อัลกอริทึม SIMD ที่ออกแบบตั้งแต่ต้น
(mcyoung.xyz)- โค้ดค base64 vb64 ที่สร้างด้วย
std::simdของ Rust จะกลายเป็นโค้ด SIMD ที่เร็วและพกพาได้ก็ต่อเมื่อออกแบบการจัดวางข้อมูลและลำดับการคำนวณใหม่ให้เหมือนวงจร แทนที่จะเวกเตอร์ไรซ์ลูปเชิงขั้นตอนแบบเดิมตรง ๆ - การปรับแต่งหลักอยู่ที่การลด stall ที่เกิดจาก branch และการเข้าถึงหน่วยความจำ โดยสร้างโครงสร้างแบบ branchless ที่ทำงานเหมือนกันไม่ขึ้นกับอินพุต ด้วย comparison, mask, select และ shuffle
- ในการถอดรหัส base64 จะสร้าง perfect hash โดยใช้
byte >> 4และการปรับแก้สำหรับ/เพื่อแปลงอักขระ ASCII เป็น sextet แล้วใช้ lookup table ภายในเวกเตอร์ SIMD ร่วมกับ shuffle เพื่อหา offset - เมื่อต้องแพ็ก sextet ขนาด 6 บิตสี่ตัวให้เป็นสามไบต์ จะขยาย lane เป็น
u16แล้ว shift จากนั้นแยก low/high byte และรวมชิ้นส่วนไบต์ของ lane ข้างเคียงด้วยrotate_lanes_leftและ OR - ในเบนช์มาร์ก หลังใช้ชุด
-Zbuild-std,-Ctarget-cpu=native,N = 32และปรับแต่งการโหลด remainder แล้ว แสดง ประสิทธิภาพราว 2 เท่า เมื่อเทียบกับ implementation base64 baseline บน crates.io ในเกือบทุกช่วง
พื้นฐานทางกายภาพที่ทำให้ต้องใช้ SIMD
- การเพิ่มประสิทธิภาพคอมพิวเตอร์ไม่ได้เชื่อมโยงกับวิทยาการคอมพิวเตอร์เชิงทฤษฎีเท่านั้น แต่เกี่ยวข้องโดยตรงกับ ข้อจำกัดทางกายภาพ
- ณ ปี 2023 Moore’s law ยังดูเหมือนจะคงอยู่ แต่ในช่วง 15 ปีที่ผ่านมา ผลของ Dennard scaling พังทลายลง ทำให้ทรานซิสเตอร์ที่หนาแน่นขึ้นนำไปสู่ความหนาแน่นการใช้พลังงานที่สูงขึ้น
- หลังจากการเพิ่มความถี่สัญญาณนาฬิกาต่อไปทำได้ยาก ตั้งแต่ต้นทศวรรษ 2000 ทิศทางหลักของการเพิ่มประสิทธิภาพจึงย้ายไปสู่การใช้คอร์จำนวนมากขึ้น
- มัลติเธรดต้องอาศัยความร่วมมือระหว่างคอร์ จึงมีต้นทุนการซิงโครไนซ์ และ control flow เช่น jump, virtual call, synchronization ทำให้เกิด stall
- สาเหตุหลักของ stall มีสองอย่าง
- branch: control flow อย่าง
if, ลูป, การเรียกฟังก์ชัน, การคืนค่าจากฟังก์ชัน,switchของ C - งานหน่วยความจำ: load/store โดยเฉพาะการเข้าถึงที่ไม่เป็นมิตรกับแคช
- branch: control flow อย่าง
โค้ดเชิงขั้นตอนและ instruction-level parallelism
- คอร์ CPU สมัยใหม่ไม่ได้รันโค้ดทีละบรรทัด แต่จะออกคำสั่งการคำนวณที่ไม่ขึ้นต่อกันพร้อมกัน
- การคำนวณที่ไม่ขึ้นต่อกัน เช่น
a = x + yและb = x ^ yสามารถใช้วงจร add และ xor พร้อมกันได้ - วิธีนี้คือ instruction-level parallelism และ dependency ที่ขัดขวางสิ่งนี้เรียกว่า data hazard
- ยิ่ง CPU สามารถทำให้ functional unit อิ่มตัวได้ดีเท่าไร ก็ยิ่งประมวลผลการคำนวณได้มากขึ้นต่อหน่วยเวลา
- branch ต้องรอให้คำนวณเงื่อนไขเสร็จก่อนจึงจะดึงคำสั่งถัดไปได้ ส่วนงานหน่วยความจำต้องรอให้ข้อมูลเดินทางมาถึง CPU ทางกายภาพ จึงเกิด stall
- GPU จัดการภาพในรูปแบบพิกเซลแบบเวกเตอร์และทำงานที่มี locality สูงจำนวนมาก จึงใกล้เคียงกับเครื่อง SIMD ที่ออกแบบมาให้เหมาะกับการประมวลผลแบบ batch และ control flow ที่จำกัด
- SIMD คือ single instruction, multiple data เป็นวิธีที่คำสั่งหนึ่งคำสั่งทำการคำนวณแบบขนานบน data lane หลายตัว
วิธีคิดแบบ lane
- SIMD และ vector มักถูกใช้ในความหมายเดียวกัน และหน่วยพื้นฐานของคำสั่ง SIMD คือ vector ซึ่งเป็นอาร์เรย์ตัวเลขขนาดคงที่
- องค์ประกอบแต่ละตัวของ vector เรียกว่า lane
- เวกเตอร์ SIMD ต้องอยู่ในรีจิสเตอร์ได้ จึงมักมีขนาดเล็ก
- ความกว้างเวกเตอร์สูงสุดของสภาพแวดล้อมตัวอย่างคือ 256 บิต
- เท่ากับ 32 ไบต์ของ
u8x32หรือ double 4 ตัวของf64x8
- แม้เป็นเวกเตอร์ขนาดเล็ก หากลดภาระการทำให้ pipeline อิ่มตัวได้ 4 เท่า ก็อาจนำไปสู่การปรับปรุง latency ได้ในสัดส่วนนั้น
divide and conquer ที่เห็นจาก popcnt
- การคำนวณเวกเตอร์ที่ง่ายที่สุดคือ bitwise and/or/xor
- จำนวนเต็มทั่วไปก็สามารถมองเป็นเวกเตอร์ของ lane ขนาด 1 บิตได้จากมุมมองของ bitwise operation
- จากมุมมองนี้
i32เทียบเท่ากับi1x32
- จากมุมมองนี้
popcntคือการคำนวณที่นับจำนวนบิต 1 ในจำนวนเต็ม และหากมองi32เป็นi1x32ก็ถือเป็นการคำนวณแบบ reduce- implementation แบบง่ายที่ดึง 32 บิตออกมาเป็นอาร์เรย์แล้วบวกกันอาจสร้างโค้ดที่แย่
- วิธีที่ดีกว่าคือเพิ่มความกว้างของ lane ไปพร้อมกับรวมผล โดยบวกบิตคู่ที่อยู่ติดกัน แล้วบวกคู่ของคู่ต่อไปเรื่อย ๆ
- แยกบิตตำแหน่งคู่/คี่ด้วย mask
0x55555555,0xaaaaaaaa - ใช้ shift เพื่อจัด lane ให้ตรงกันแล้วบวก
- จากนั้นทำซ้ำในหน่วย 2 บิต, 4 บิต, 8 บิต, 16 บิต
- แยกบิตตำแหน่งคู่/คี่ด้วย mask
- implementation นี้ไม่ได้ถูกปรับให้เป็นคำสั่ง
popcntแต่จะเป็นโค้ดที่เล็กและเร็วบนระบบที่ไม่มีคำสั่งดังกล่าว - สามารถนำไปใช้กับ
u64ได้โดยเพิ่มขั้นตอน reduction อีกหนึ่งขั้นเท่านั้น และไม่จำเป็นต้องใช้การบวกu64ทั้งตัว - แนวทาง divide and conquer เช่นนี้เป็นแพตเทิร์นสำคัญของการเขียนโปรแกรม SIMD
เครื่องมือหลักของชุดคำสั่ง SIMD
- เวกเตอร์ SIMD จริงให้ความหมายที่ซับซ้อนกว่า scalar และฟีเจอร์สำหรับทดแทน control flow ที่ช้ามีความสำคัญเป็นพิเศษ
- คำสั่งที่ใช้ได้ขึ้นอยู่กับสถาปัตยกรรมอย่างมาก
- คอร์ประสิทธิภาพสูงจำนวนมากของ x86 implement AVX2
- AVX2 ให้เวกเตอร์
ymmขนาด 256 บิต - ตัวรีจิสเตอร์เองไม่มีจำนวน lane กำกับอยู่ แต่คำสั่งจะกำหนดวิธีตีความ lane
- ตัวอย่างเช่น
vpaddbตีความymmเป็นi8x32
- การคำนวณที่โดยทั่วไปใช้ได้มีดังนี้
- bitwise operation: ความกว้างของ lane เป็น 1 บิตโดยนัยเสมอ
- lane-wise arithmetic: การบวก ลบ คูณ หาร integer shift, min/max ฯลฯ
- lane-wise comparison: สร้าง mask vector เช่น
m[i] = a[i] < b[i] - select: ใช้ mask เพื่อเลือกค่าจากเวกเตอร์สองตัวแยกตาม lane
- shuffle/swizzle: มองเวกเตอร์หนึ่งเป็นเหมือน lookup table แล้วจัดเรียง lane ใหม่ด้วย index vector
- true/false ของ mask vector มักใช้รูปแบบบิต all-ones หรือ all-zeros
- comparison และ select เป็นเครื่องมือหลักที่ช่วยให้โค้ด SIMD คงสถานะ branchless
- โค้ด branchless ทำงานเหมือนกันโดยไม่ขึ้นกับอินพุต และทิ้งผลลัพธ์ที่ไม่จำเป็นด้วยคุณสมบัติอย่าง
x * 0 = 0,a ^ b ^ a = b
ใช้ shuffle เพื่อจัดตำแหน่งข้อมูล
- shuffle เป็นเครื่องมือหลักใน SIMD ที่ทำให้ข้อมูลมาอยู่ใน “ตำแหน่งที่ถูกต้อง”
- broadcast หรือ splat สร้างเวกเตอร์ที่ทุก lane มี scalar เดียวกัน และสามารถแสดงได้ด้วย index shuffle แบบ
[0, 0, ...] - interleave หรือ zip/pack คือการวาง lane ของเวกเตอร์สองตัว
a,bสลับกันc = [a[0], b[0], a[1], b[1], ...]- สามารถ implement ด้วย shuffle2 ได้
- deinterleave หรือ unzip/unpack คือสิ่งตรงข้ามของ interleave
- rotate หมุน lane ในรูป
b[i] = a[(i + j) % n]และสิ่งนี้ก็เป็น shuffle เช่นกัน - ในการเขียนโปรแกรม SIMD มักต้องตีความและจัดวางบล็อกข้อมูลที่ใหญ่กว่าจำนวนเต็มใหม่ให้เป็นบล็อกเล็ก ๆ หลายขนาด
intrinsics, target feature, portable SIMD
- การดำเนินการที่ใช้ได้ใน SIMD จะแตกต่างกันไปตามสถาปัตยกรรมและ instruction set extension
- x86 อาจมีการดำเนินการที่ ARM ไม่มี และแม้แต่ภายในผู้ผลิตเดียวกันก็อาจมีส่วนขยายที่มีเฉพาะในชิปเซิร์ฟเวอร์ระดับสูง เช่น Intel AVX-512
- toolchain ทำให้ส่วนขยายเหล่านี้เป็นนามธรรมทั่วไปในรูปของ target feature
lscpuบน Linux แสดง feature ที่ CPU รับรู้- LLVM จะเลือกคำสั่งต่างกันตามการตั้งค่า feature
- ต้องมี
+avx2LLVM จึงจะสร้างโค้ดที่ใช้ymmได้
-march=nativeหรือ-Ctarget-cpu=nativeสามารถสร้างโค้ดที่ดีให้เหมาะกับเครื่องที่ใช้ build ได้ แต่ความสามารถในการพกพาไปยังโปรเซสเซอร์อื่นอาจลดลง- runtime feature detection คือวิธีตรวจสอบความสามารถที่ CPU รองรับ แล้วตัดสินใจว่าจะเรียกฟังก์ชันเวอร์ชันใด ใช้ในโค้ดที่เผยแพร่ไปยังอุปกรณ์หลากหลาย เช่น ไลบรารีเข้ารหัส
- โค้ด SIMD ใน C++ มักใช้ intrinsics เช่น
_mm256_cvtps_epu32- แทนการดำเนินการระดับต่ำของ instruction set เฉพาะ
- ไม่จำเป็นต้อง map เป็นคำสั่งเดียวเสมอไป
- compiler สามารถทำการรวม กำจัดส่วนซ้ำ และปรับแต่งการเลือกคำสั่งได้
- หากต้องเขียนโค้ดคล้ายกันซ้ำ ๆ สำหรับหลาย instruction set ข้อดีด้านการบำรุงรักษาเมื่อเทียบกับ assembly อาจไม่มากนัก
- ไลบรารี portable SIMD เป็นแนวทางที่จัดการการเลือกคำสั่งบางส่วนในระดับไลบรารี และปล่อยส่วนที่เหลือให้ compiler
- การ implement
vb64เป็นการทดลองเพื่อดูว่า portable SIMD ของ Rust สร้างโค้ดที่แข่งขันได้หรือไม่
เปลี่ยนการ decode base64 เป็น SIMD
- base64 เป็นวิธี encode ข้อมูลไบนารีใด ๆ ให้เป็น ASCII
- มอง byte string ขาเข้าเป็น bit vector แล้วแบ่งเป็น chunk ขนาด 6 บิตที่เรียกว่า sextet
- ค่า sextet จะถูก map เป็นอักขระต่อไปนี้
0..25→'A'..'Z'26..51→'a'..'z'52..61→'0'..'9'62→+63→/
- base64 มีหลายรูปแบบย่อย แต่ความซับซ้อนส่วนใหญ่เหมือนกัน
- มีสองประเด็นที่ต้องระวัง
- base64 เป็นรูปแบบที่บิตภายในไบต์เป็น big endian
- ความยาวอินพุตอาจหารด้วย 4 ไม่ลงตัว โดยหลักแล้วจะใช้ padding
=ให้เป็นจำนวนเท่าของ 4 แต่ก็สามารถจัดการข้อความที่ padding ไม่ถูกต้องได้
- decoded length คำนวณโดยเอา
input / 4 * 3แล้วบวกความยาวส่วนที่เหลือตามinput % 4
การ refactor พื้นฐานเพื่อไปสู่ branchless
- decoder base64 แบบเรียบง่ายมี branch หลายจุด
- loop ที่ไล่ตาม chunk ของอินพุต
- loop ของ byte ภายใน chunk
matchตามอักขระ ASCIIreturn Errเมื่อเกิดข้อผิดพลาดmatchภายในdecoded_len- ความเป็นไปได้ที่จะเรียก
Vec::extend_from_sliceและ allocator
- แนวทางการ optimize คือ ลบ branch ทั้งหมด
matchของdecoded_lenmap ค่าinput % 4คือ0, 1, 2, 3เป็น0, 1, 1, 2- หากเปลี่ยนเป็น
mod4 - mod4 / 2ก็จะเป็นเวอร์ชัน branchless - LLVM สามารถพับ
matchเดิมให้เป็น switch table ได้ แต่ในบริเวณนี้ การเข้าถึงหน่วยความจำที่ไม่จำเป็นทำให้ประสิทธิภาพลดลง
แยก loop ที่ร้อนที่สุดออกมา
- จุดแข็งของ SIMD คือการประมวลผลข้อมูลจำนวนมากในครั้งเดียว ทำให้ unroll loop ได้แรงและทำให้ใกล้เคียง branchless
- เป้าหมายของ hot loop คืออ่านได้สูงสุด 4 ไบต์ สร้างผล decode ได้สูงสุด 3 ไบต์ และแจ้งได้ด้วยว่ามี syntax error หรือไม่
- มีข้อเท็จจริงที่นำมาใช้ได้สามอย่าง
- ความยาวเอาต์พุตคำนวณได้ด้วย
decoded_len()แบบ branchless - มอง base64 ที่ไม่ถูกต้องเป็นเส้นทางที่พบได้น้อยมาก และหากต้องการตำแหน่งข้อผิดพลาดก็สามารถสแกนซ้ำภายหลังได้
- ใน base64 ตัว
Aมีค่าเป็น 0 ดังนั้นการ padding truncated chunk ด้วยAจะไม่ทำให้ค่าเปลี่ยน
- ความยาวเอาต์พุตคำนวณได้ด้วย
decode_hot()ถูกแยกออกมาเป็นรูปแบบที่ประมวลผลไบต์อินพุตสี่ตัว แล้วคืนผลลัพธ์ที่ decode แล้วพร้อม bool ว่าสำเร็จหรือไม่- หากคืน bool แยกต่างหากแทน
Option<[u8; 3]>จะทำให้ง่ายต่อการลบ branchif !okในภายหลัง - ในเวอร์ชัน SIMD รับ
Simd<u8, 4>เป็นอินพุต และตั้งเอาต์พุตเป็นSimd<u8, 4>ให้ตรงกับจำนวน lane ที่เป็น power-of-two ด้วย- เอาต์พุตที่ต้องใช้จริงคือ 3 ไบต์
- lane สุดท้ายไม่ถูกใช้งาน
วิธีเปลี่ยน ASCII เป็น sextet
matchที่เปลี่ยนอักขระ ASCII เป็น sextet ส่วนใหญ่สามารถเขียนในรูปbyte - Cได้'A'..'Z'→byte - 'A''a'..'z'→byte - 'a' + 26'0'..'9'→byte - '0' + 52'+'→byte - '+' + 62'/'→byte - '/' + 63
- สร้าง vector offset แยกตาม lane แล้วทำ
ascii - offsetsก็พอ - แนวทางแรกคือ compare-and-select
- สร้าง mask สำหรับ
A-Z,a-z,0-9,+,/ - lane ที่ไม่มี mask ใดถูกเลือกจะถือว่า invalid
- splat offset ที่สอดคล้องกับแต่ละ mask แล้วรวมด้วย OR
- สร้าง mask สำหรับ
- วิธีนี้สร้างโค้ดที่สง่างามและแข่งขันได้ แต่ต้องเปรียบเทียบรวม 8 ครั้ง และมีค่าที่ยังมีชีวิตอยู่จำนวนมาก ทำให้เกิด register pressure ได้
SIMD hash table และ perfect hash
- ช่วง byte ของ
A-Z,a-z,0-9คือ0x41..0x5b,0x61..0x7b,0x30..0x3aตามลำดับ และมี high nibble ต่างกัน +และ/คือ0x2b,0x2fดังนั้นแค่byte >> 4ก็แยกแยะได้เกือบทั้งหมด- กรณี
/หากลบออกหนึ่ง จะกลายเป็น perfect hash สำหรับช่วงดังกล่าว - การ map ของ
(byte >> 4) - (byte == '/')เป็นดังนี้A-Z→ 4 หรือ 5a-z→ 6 หรือ 70-9→ 3+→ 2/→ 1
- ค่านี้มีขนาดเล็ก จึงใส่ offset lookup table ไว้ใน SIMD vector แล้ว lookup ด้วย shuffle ได้
- แนวคิด perfect hash นี้เสนอโดยผู้ใช้ไม่ระบุตัวตนใน GitHub issue
Simd::swizzle_dyn()มีข้อจำกัดว่า index array และความยาวของ lookup table ต้องเท่ากัน- ในวิธี perfect hash กระบวนการคำนวณ sextet ไม่ได้ให้ validation มาเป็นผลข้างเคียง จึงตรวจสอบความถูกต้องของ byte ด้วย exact bloom filter จาก GitHub issue เดียวกัน
- ตัวอย่าง implementation อยู่ที่ simd.rs ของ vb64
การ pack sextet สี่ตัวเป็นสามไบต์
- ขั้นตอนรวม sextet ขนาด 6 บิตสี่ตัวให้เป็นสามไบต์นั้นยากกว่า
- หากตั้ง sextet อินพุตตัวใดตัวหนึ่งเป็น all-ones แล้วดูว่าบิตย้ายไปที่ใดในเอาต์พุต ก็จะติดตามความสัมพันธ์ของการจัดวางได้
- shuffle ระดับ byte อย่างเดียวไม่เพียงพอ
- เป้าหมายที่ต้องย้ายเป็นชิ้นส่วนของไบต์
- ใช้ shift อย่างเดียวก็ยังไม่พอ
- บิตที่ถูก overshift ต้องย้ายไปยัง lane ที่อยู่ติดกัน
- วิธีแก้คือ ทำให้ lane ใหญ่ขึ้น
- cast
sextetsเป็น vectoru16แล้ว shift แยกตาม laneinput[0]shift 2 บิตinput[1]shift 4 บิตinput[2]shift 6 บิตinput[3]shift 8 บิตเพื่อปรับตำแหน่ง
- แยก vector ของ low byte และ high byte จากผลลัพธ์ของ shift
- ใช้
hi.rotate_lanes_left::<1>()จัดชิ้นส่วนฝั่ง high byte ให้ตรงกับ lane ข้างเคียง แล้วรวมด้วยlo | hi_rotated - วิธีนี้ใช้ hardware primitive อย่างจริงจัง ทำให้โค้ดสั้นและมีประสิทธิภาพ
การขยายจำนวน lane และการกำจัด garbage lane
Simd<u8, 4>มีขนาดเล็กกว่าเวกเตอร์รีจิสเตอร์ขั้นต่ำ 128 บิตของ x86 ด้วยซ้ำ จึงทำให้decode_hot()เป็น generic ตามจำนวน laneN- ใช้ข้อจำกัด
LaneCount<N>: SupportedLaneCountเพื่อรับประกันจำนวน lane แบบ power-of-two ขนาดเล็ก - lookup table และ shift table สร้างเวกเตอร์ที่เป็นแพตเทิร์นซ้ำด้วย helper
tiled() - ในกรณี
N = 4แค่ละเว้นค่า garbage ใน lane สุดท้ายก็พอ แต่เมื่อNใหญ่ขึ้น garbage จะปนอยู่ใน every fourth lane - เพื่อกำจัดสิ่งนี้จึงใช้ shuffle
- ความสัมพันธ์ที่ต้องการคือ
shuffled[i] = output[i + i / 3] - ลบ garbage lane โดยข้ามทุก ๆ อินเด็กซ์ที่สี่
- ส่วนที่ overflow เป็น 1/4 ด้านบนของเวกเตอร์เอาต์พุตสุดท้าย จึงละเว้นได้
- ความสัมพันธ์ที่ต้องการคือ
- ด้วยวิธีนี้
decode_hot::<32>()จะสามารถถอดรหัส base64 byte จำนวน 32 ตัวแบบขนานได้
การปรับ outer loop ให้เหมาะสม
decode()ก็ถูกเปลี่ยนให้เป็น generic ตามจำนวน lane ภายในNด้วย- ต้นทุนที่ยังเหลืออยู่มีดังนี้
- branch เปรียบเทียบความยาวของ
for chunks in ... memcpyแบบ variable-length ของ[T]::copy_from_slice- branch
okในแต่ละ loop iteration - ความเป็นไปได้ในการเรียก allocator ของ
Vec::extend_from_sliceและmemcpyอีกชุดหนึ่ง
- branch เปรียบเทียบความยาวของ
- เนื่องจากทราบความยาวเอาต์พุตอยู่แล้ว จึงจองพื้นที่ล่วงหน้าด้วย
out.reserve(final_len + N / 4) - นอกจากนี้ยังเผื่อพื้นที่ slop ไว้ เพื่อทำ full SIMD store แทน variable-length memcpy
- แต่ละ iteration จะเขียน SIMD vector ทั้งก้อน และการเขียนครั้งถัดไปจะเลื่อนไป
3/4 * Nเพื่อเขียนทับ garbage byte ก่อนหน้า - garbage byte สุดท้ายจะไม่ถูกรวมอยู่ใน
Vec::set_len()สุดท้าย จึงถูกจัดการเหมือนถูกลบไปแล้ว - แม้จะ early return เพราะ
if !okแต่ยังไม่ได้ commit ด้วยset_len()ดังนั้นoutจึงยังคงอยู่ในสถานะที่ไม่ถูกแก้ไข
เลื่อนการจัดการข้อผิดพลาดออกนอก hot loop
- ไม่ return ด้วย
if !okในทุก iteration แต่สะสมด้วยerror |= !ok - ตรวจสอบว่ามีข้อผิดพลาดหรือไม่เพียงครั้งเดียวก่อน
set_len()สุดท้าย - ภายใต้สมมติฐานว่า base64 blob ส่วนใหญ่ valid เส้นทางข้อผิดพลาดจึงถูกดันออกไปนอก hot loop
- แม้จะมี syntax error การคำนวณ SIMD หลังจากนั้นก็จะไม่ misbehave แบบสุ่ม ดังนั้น garbage write จะไม่ถูก commit และหายไป
- หลังจากนั้น การเรียกอย่าง
Vec::push()อาจเขียนทับพื้นที่บัฟเฟอร์เดียวกันได้
unroll and jam และการจัดการ remainder
- ใช้ unroll and jam เพื่อลด variable-length memcpy ของ
copy_from_slice - แบ่งลูปออกเป็นสองส่วน
- hot vectorized loop: ประมวลผลอินพุตความยาว
Nเสมอ - cold remainder part: ประมวลผลอินพุต
i < Nไม่เกินหนึ่งครั้ง
- hot vectorized loop: ประมวลผลอินพุตความยาว
- ใช้
Iterator::chunks_exact()ของ Rust เพื่อทำ hand-rolled unroll-and-jam - ใน hot loop เรียก
Simd::from_slice()เพื่อทำ load ขนาดเท่าเวกเตอร์เพียงครั้งเดียว - bounds check จะอยู่ในรูปแบบที่คอมไพเลอร์กำจัดได้ง่าย
เบนช์มาร์กและการปรับ manual loading ให้เหมาะสม
- เบนช์มาร์กถอดรหัสข้อความตั้งแต่ความยาว 0 จนถึงประมาณ 200 หรือ 500 ไบต์ และเปรียบเทียบกับ implementation base64 baseline บน crates.io
- ตัวเลือกคอมไพล์ใช้
-Zbuild-stdและ-Ctarget-cpu=native - ผลการจูนพบว่า
N = 32ดีที่สุด และใช้ YMM register หนึ่งตัวต่อ hot loop iteration - ตอนแรกชนะ baseline แต่เกิดความผันผวนของประสิทธิภาพในรูปแบบ heartbeat ที่สัมพันธ์อย่างมากกับ
data.len() % 32 - หลังตรวจสอบ assembly จึงสรุปว่า
copy_from_sliceน่าจะถูก inline/unroll เป็น byte-wise load loop - ลองใช้
Simd::gather_or()ด้วย แต่สร้าง assembly ที่แย่กว่า จึงไม่ใช้ - แทนที่จะใช้วิธีนั้น ได้เขียนฟังก์ชัน manual loading สำหรับข้อมูล variable-length
- hot part ทำ
u128load ซึ่งเป็น scalar load ขนาดใหญ่เท่าที่ทำได้ในลูป - LLVM ลด 16-byte chunk ลงเป็น XMM load
- remainder ใช้ load แบบซ้อนทับกันด้วย
u64,u32,u8
- hot part ทำ
- เมื่ออ่าน 15 ไบต์ จะอ่าน
u64ที่pและu64ที่p + 7ให้ซ้อนทับกัน 1 ไบต์ แล้วรวมด้วย OR - สำหรับ 4~7 ไบต์ ใช้
u32load ที่ซ้อนทับกัน - สำหรับ 1~3 ไบต์ อ่านจาก
p,p + len/2,p + len - 1ซึ่งอาจ load byte บางส่วนซ้ำ แต่ช่วยลดจำนวน branch - หลังใช้ loading code ใหม่ variance ลดลงมาก และแสดง ประสิทธิภาพ 2 เท่า เมื่อเทียบกับ baseline ในแทบทุกช่วง
encoding และ web-safe base64
- ฟังก์ชัน encoding แค่ต้อง implement
encode_hot()ที่ทำการคำนวณของdecode_hot()แบบย้อนกลับ - perfect hash ที่ใช้ในการถอดรหัสไม่เหมาะกับ encoding จึงต้องใช้ hash ใหม่
- โค้ด loading/storing รอบ encoder ก็แตกต่างจาก decoder เล็กน้อย
vb64ยัง implement routine สำหรับ encoding ที่มีประสิทธิภาพด้วย- web-safe base64 เป็นรูปแบบดัดแปลงที่เปลี่ยน
+และ/เป็น-และ_ - การสร้าง perfect hash สำหรับ web-safe base64 ยุ่งยากกว่า และอาจต้องใช้วิธีอย่าง
(byte >> 4) - (byte == '_' ? '_' : 0)เป็นต้น vb64ยังไม่รองรับ web-safe base64
สรุป
vb64ระบุว่าไม่ได้เป็นไลบรารีที่พยายามแก้คอขวดสำคัญ และไม่ทราบว่าที่ใดมี base64 decoding เป็นคอขวดจริง ๆ- โค้ด branchless มักจะเกินความจำเป็น แต่ช่วยให้เข้าใจว่าคอมไพเลอร์ทำอะไรให้ได้และทำอะไรให้ไม่ได้
std::simdของ Rust โดยรวมถือว่าดีและสร้างโค้ดที่ยอดเยี่ยม- แม้จะมี rough edge บางอย่างที่อยากให้แก้เพื่อทำให้โค้ด SIMD ง่ายขึ้น แต่ประเมินว่าพอใจกับผลลัพธ์ของงานในปัจจุบัน
- SIMD และการปรับประสิทธิภาพเป็นหัวข้อซับซ้อนที่ต้องใช้ทริกและความรู้ด้านฮาร์ดแวร์จำนวนมาก และหลายส่วนในนั้นไม่ได้ถูกจัดทำเป็นเอกสาร
1 ความคิดเห็น
ความคิดเห็นจาก Hacker News
เห็นการใช้งาน portable SIMD จริง ๆ แล้วน่าสนใจ และพอลองทำเบนช์มาร์กซ้ำบนระบบ Zen 3 ก็ได้ความเร็วเพิ่มขึ้นเท่าเดิม
บน M1 MacBook Pro เมื่อความยาวอินพุต 110 ไบต์ ประสิทธิภาพเริ่มดีขึ้น 1.4 เท่าแล้วค่อย ๆ เพิ่มไปถึง 2 เท่า แม้จะต่ำกว่า x86_64 แต่ก็ดูเหมือนบรรลุเป้าหมายแล้ว
อย่างไรก็ตาม เมื่อดูโค้ดแล้ว ก็ยืนยันประสบการณ์ของผมว่า Rust มี ergonomics ที่ค่อนข้างแย่ ในงานที่เกี่ยวกับ SIMD และ pointer รวมถึง performance engineering ในภาพกว้าง
ถึงอย่างนั้น portable SIMD ของ Rust ก็ยังไม่ใช่เรื่องที่น่าพูดถึงนักเมื่อเทียบกับ C++ และถ้าจะลงไปทำงานกับพื้นที่ไบต์ดิบ pointer และการจัดการบัฟเฟอร์ ก็ต้องคุ้นกับ
Pin,MaybeUninitเป็นต้นportable_simdและallocator_apiอยู่ในสถานะไม่เสถียรมาหลายปีแล้ว แถมกำแพงในการเริ่มใช้ก็สูงและยังดูขัดมือกว่าเดิม ซึ่งส่วนใหญ่เป็นการออกแบบโดยตั้งใจแต่ก็ไม่มีอะไรห้ามไม่ให้สร้าง abstraction ที่ใช้ง่ายขึ้นภายในโปรแกรมของตัวเอง หรือใช้ crate จากบุคคลที่สาม
SSE intrinsic ของ C++ แย่กว่ามาก ทั้ง underscore ก็ดูรกตา และชื่อก็จำยาก
ผมเคยพยายามทำ implementation แบบ C++ คลาสสิกให้ดีที่สุดแล้ว แต่พอมีคนทำเวอร์ชัน SIMD มาให้ที่ เร็วกว่า 10 เท่าขึ้นไป บางครั้งก็น่าทึ่งจริง ๆ
แต่โค้ดแบบนี้ portability ต่ำกว่า
อยากให้ auto-vectorization ของคอมไพเลอร์ดีขึ้นกว่านี้ และอยากให้มีการรองรับระดับภาษา เช่น annotation ที่อนุญาตให้จัดลำดับบาง operation ใหม่ได้เฉพาะจุด
คอมไพเลอร์ไม่สามารถแก้ข้อมูลแทนเราได้นอกบริบทที่เฉพาะจุดมาก ๆ ทำให้ auto-vectorization ยากจริง ๆ
เช่นใน
for(double v: vec) sum+=vการบวก floating point ไม่เป็น associative ดังนั้นการบวกค่าตามลำดับ กับวิธี SIMD ที่บวกทีละช่วงห่าง 8 ค่าแล้วค่อยรวมส่วนที่เหลือ จึงไม่เหมือนกันจากมุมมองคอมไพเลอร์ อาจดูเหมือนเป็น optimization ที่ชัดเจน แต่ตราบใดที่เราไม่ได้บอกให้ผ่อนปรนการรับประกันบางอย่าง มันก็จะให้ความสำคัญกับ การรับประกัน semantics แบบลำดับ มากกว่า optimization
เลยยุ่งเหยิงขึ้น และอย่างที่ janwas ว่า สำหรับ hot path ผมคิดว่าควรใช้ไลบรารี โดยเฉพาะอย่าง Google Highway หรือ Intel ISPC จะดีกว่า
คือพยายามให้มีประสิทธิภาพและ portable เท่าที่ทำได้ แต่เมื่อจำเป็นก็ทำให้การเขียนโปรแกรมเฉพาะ target ทำได้ง่าย
auto-vectorization นั้นคอมไพเลอร์ FORTRAN ทำได้ดีกว่าอย่างชัดเจน เพราะไม่อนุญาต aliasing
C++ ถูกถ่วงไว้เพราะต้องตาม memory model ของ C
CUDA คือ C++ ที่ออกแบบมาสำหรับ GPU ซึ่งเป็นเครื่อง SIMD ขั้นสุดของยุคนี้ และ ROCm ก็แทบจะเป็น CUDA สำหรับ AMD
ส่วนตัวผมชอบ C++AMP ของ Microsoft เพราะคิดว่าเริ่มต้นได้ง่ายที่สุด
แต่น่าเสียดายที่ท้ายที่สุดมันไม่ได้ติดตลาด
อีกอย่าง ถ้าใช้ ไลบรารี wrapper สำหรับ SIMD จริง ๆ แล้วก็ทำให้ portable ได้ค่อนข้างดี
หมายเหตุเล็กน้อยคือ คอมไพเลอร์ไม่สามารถ optimize implementation ของ
popcountนั้นให้เป็นคำสั่งเดียวได้ แต่ implementation แบบอื่นทำได้แน่นอนว่าค่อนข้างจุกจิก: https://godbolt.org/z/T69KxWWW8
มีการบอกว่า
_mm256_cvtps_epu32แทน operation ระดับต่ำของชุดคำสั่งเฉพาะ และอธิบายว่าเป็น float-to-int cast ของ AVX2 แต่คำสั่งนั้นอยู่ใน AVX-512AVX2 ไม่มี float-to-int cast และใน AVX1 ผลลัพธ์จำนวนเต็มเป็น signed โดยคำสั่งคือ
_mm256_cvtps_epi32อยากรู้ว่าจะเทียบกับ fastbase64[0] แล้วเป็นอย่างไร
บทความยอดเยี่ยม และดีใจที่ได้เห็นเนื้อหาแบบนี้ออนไลน์ แต่คงยากที่จะเห็นด้วยกับความมองโลกในแง่ดีของผู้เขียนที่มีต่อ ไลบรารี portable SIMD
[0]: https://github.com/lemire/fastbase64
ผมคิดว่า ISPC ดีกว่าการเอา SIMD ไปแปะเพิ่มให้ C++ หรือ Rust เฉย ๆ
มันรองรับ dynamic dispatch ด้วย ซึ่งเป็นฟีเจอร์ที่ถ้าต้อง implement เองจะเจ็บปวดมาก
แบบนั้นจึงจะเรียกกลับไปยัง C++ แบบ inline ได้ ใช้ template และ class ในโค้ด SIMD ได้ และ inline โค้ด SIMD หลายส่วนเข้าด้วยกันได้
ผมเห็นด้วยว่า implementation ของ dynamic dispatch ทำยาก แต่ Highway จัดการส่วนนั้นให้แล้ว
เป็นบทความที่ยอดเยี่ยม และทำให้รู้สึกแรงมากว่า “ฉันคงไม่มีทางฉลาดได้ขนาดนี้”
คล้ายกับที่คนทั่วไปไม่ได้เป็น software engineer หรือนักฟิสิกส์
ถ้าตั้งใจศึกษาอย่างจริงจังสักไม่กี่เดือน ก็น่าจะทำได้ในระดับใกล้เคียงกัน
สุดท้ายมันเป็นเรื่องของความสนใจและความจำเป็น
ผมเองก็สลับไปทำ performance optimization หรือ bare-metal engineering ที่ใกล้ระบบมากขึ้นในโปรเจกต์ส่วนตัวอยู่บ้าง แต่ก็อยากให้งานประจำต้องใช้มากกว่านี้
เพียงแต่สิ่งที่งานส่วนใหญ่ในอุตสาหกรรมต้องการไม่ใช่ด้านนั้น
ไม่ใช่ Python แบบ idiomatic แต่เป็นการแก้ทุกอย่างด้วย numpy
สนุกและเรียนรู้ความฉลาดแบบนี้ได้ และหลายส่วนของบทความจะรู้สึกเป็นธรรมชาติมากในวิธีคิดเพื่อแก้ปัญหาด้วยภาษาเหล่านั้น
เมื่อเวลาผ่านไป คุณจะเริ่มมองปัญหาในรูปแบบนั้น
เป็นบทความที่น่าสนใจ
ในตัวอย่างแรกช่วงต้น ผู้เขียนบอกว่าการใช้งาน
popcntที่ไม่ได้ทำเวกเตอร์ไรซ์จะสร้าง “โค้ดที่แย่จนน่าขันอย่างตรงไปตรงมา” แต่ถ้าใช้โหมด release กับ CPU เป้าหมายแบบ native ดูเหมือนว่าฟังก์ชันนั้นจะถูก เวกเตอร์ไรซ์ ได้ค่อนข้างดีhttps://godbolt.org/z/WE1Eq65jY
pub fn popcnt(mut x: u32) -> u32 { x.count_ones() }ซึ่งคอมไพล์เป็น
popcnt eax, edi; retสำหรับบิตเวกเตอร์ขนาดใหญ่ การใช้งาน AVX2 อาจเร็วกว่า
POPCNTดู “Faster Population Counts Using AVX2 Instructions”: https://academic.oup.com/comjnl/article/61/1/111/3852071
32 บิตยังไม่ใหญ่พอ และโค้ดที่ Rust สร้างขึ้นนั้นแย่จนน่าขันจริง ๆ
popcntเมื่อไม่นานมานี้ผมเขียนโค้ดที่ต้องนับจำนวนบิตในมาสก์ของผลลัพธ์จากการคำนวณแบบเวกเตอร์ และกรณีนี้ถูกแปลงเป็น
popcntได้ดีhttps://godbolt.org/z/zT9Whcnco
เพราะมีส่วนอย่าง “นี่เหมือนคำถามหลอกนะ… ก็แค่ add ไม่ใช่เหรอ?” เลยทำให้โดยทั่วไปเราอยากกำหนดเป้าหมายเป็น ตัวแทนเวกเตอร์ระดับกลาง แล้วปล่อยให้คอมไพเลอร์ตัดสินรายละเอียดเอง
ตัวอย่างเช่น ชิป Haswell มีหน่วยประมวลผลทศนิยมลอยตัวหลายตัวต่อคอร์ และ CPU สามารถรันการคำนวณทศนิยมลอยตัวแบบไปป์ไลน์ได้พร้อมกันมากกว่าหนึ่งรายการ แต่ในบรรดานั้น คำสั่ง
addทำได้เพียงตัวเดียวถ้ามีการบวกจำนวนมากที่ไม่ขึ้นกับผลลัพธ์ก่อนหน้าและสามารถหลีกเลี่ยง latency ได้ ก็สามารถส่งคำสั่ง fused multiply-add ที่มีเทอมการคูณเป็น 1 ไปพร้อมกัน เพื่อเพิ่ม throughput ของการบวกเป็นสองเท่าได้
คำสั่งนั้นสามารถรันพร้อมกับการบวกทศนิยมลอยตัวแบบเวกเตอร์ทั่วไปได้