การค้นหาไบนารีแบบไร้สาขาที่เร็วที่สุด
(mhdm.dev)sb_lower_boundคงอินเทอร์เฟซแบบเดียวกับstd::lower_boundไว้ และเมื่อสาขาการเปรียบเทียบถูกคอมไพล์เป็น conditional move (cmov) จะแสดงผลลัพธ์ที่เร็วกว่า binary search ทั่วไปได้สูงสุด 2 เท่า- ผลลัพธ์การเปรียบเทียบของ binary search มักเกิด branch prediction miss บ่อย เพราะไม่สามารถรู้ตำแหน่งที่จะค้นหาได้ล่วงหน้า และบน x86 ตัวเลือก
clang -mllvm -x86-cmov-converter=falseช่วยลดปัญหานี้ได้ - การใช้งานนี้ลด
lengthลงครึ่งหนึ่งในแต่ละลูป และอัปเดตเฉพาะfirstตามผลการเปรียบเทียบ จึงลดจำนวนคำสั่งลง โดยในช่วง2^k <= n < 2^(k+1)จะเปรียบเทียบk+1ครั้งเสมอ - ใน benchmark ของ
clang -cmovเวลาเฉลี่ยในการรันคือstd::lower_bound61.30ns,sb_lower_bound33.24ns,bb_lower_bound32.73ns และค่า geometric mean ก็แตกต่างมากเช่นกัน คือ 39.17ns, 19.81ns, 21.33ns ตามลำดับ - ในการค้นหาสตริง 8 ไบต์ที่ฟังก์ชันเปรียบเทียบช้า มีบางกรณีที่
std::lower_boundนำอยู่เล็กน้อย และสำหรับอาร์เรย์ขนาดใหญ่ เวอร์ชันดัดแปลงที่เพิ่ม prefetching เร็วกว่าstd::lower_boundโดยเฉลี่ยประมาณ 2.3 เท่า
โครงสร้างพื้นฐานของ sb_lower_bound
sb_lower_boundเป็นฟังก์ชัน C++ ในรูปแบบเดียวกับstd::lower_bound- อินพุตคือ
first,last,value,comp - ค่าที่คืนคือ iterator ของตำแหน่งแรกที่การเปรียบเทียบล้มเหลว และถ้าองค์ประกอบทั้งหมดตรงตามเงื่อนไขจะคืน
last
- อินพุตคือ
- ลูปหลักลด
lengthลงครึ่งหนึ่ง และเลื่อนfirstไปข้างหน้าเฉพาะเมื่อcomp(first[length], value)เป็นจริง - ในที่นี้ “branchless” ไม่ได้หมายความว่า
ifหายไป แต่หมายถึงกรณีที่ifนั้นถูกคอมไพล์เป็นคำสั่ง conditional move เช่นcmovแทน conditional jump - ใน
clangหากใช้ตัวเลือก-mllvm -x86-cmov-converter=falseรูปแบบนี้อาจถูกคอมไพล์เป็น conditional move ได้
จุดที่ std::lower_bound ช้าลง
- binary search ทั่วไปจะเปรียบเทียบองค์ประกอบตรงกลางกับ
valueแล้วเลือกช่วงซ้ายหรือขวา - ในสถานการณ์ที่ไม่รู้ตำแหน่งของเป้าหมายการค้นหา
if (comp(first[half], value))มักกลายเป็น สาขาที่คาดเดายาก - CPU ใช้ branch prediction เพื่อรันคำสั่งถัดไปล่วงหน้า แต่ถ้าคาดเดาผิดก็ต้องทิ้งงานที่ทำไปแล้ว
- หากใช้ conditional move จะเลือกค่าตามผลการเปรียบเทียบได้ พร้อมลด conditional jump
clang -cmovสามารถเปลี่ยนif/elseบางส่วนของstd::lower_boundให้เป็น conditional move ได้เช่นกัน ทำให้เร็วขึ้นประมาณ 25%gccไม่มีตัวเลือกที่ดีในการบังคับ conditional move ในสถานการณ์เดียวกัน และปัจจุบันsb_lower_boundก็ไม่ได้ปล่อยโค้ดแบบ branchless โดยไม่ขึ้นกับระดับการปรับแต่ง
การค้นหา “ที่เหมาะที่สุด” ในมุมมองจำนวนครั้งการเปรียบเทียบ
- “ที่เหมาะที่สุด” ในที่นี้หมายถึง binary search ที่มี จำนวนครั้งการเปรียบเทียบ น้อยที่สุด
- ในลิสต์ขนาด
nผลลัพธ์ที่เป็นไปได้ของstd::lower_boundมีn+1แบบ คือ ตำแหน่งองค์ประกอบnตำแหน่งรวมกับตำแหน่งท้ายอีก 1 ตำแหน่ง - หากขนาดลิสต์เป็น
2^k - 1ผลลัพธ์ที่เป็นไปได้คือ2^kแบบ และการเปรียบเทียบแต่ละครั้งให้ข้อมูล 1 บิตจริง/เท็จ ดังนั้นจำนวนครั้งการเปรียบเทียบที่เหมาะที่สุดคือkครั้ง - ในกรณี “nice” ที่ความยาวเป็น
2^k - 1สามารถค้นหาแบบเหมาะที่สุดได้ด้วยลูปที่สั้นมาก - หากความยาวไม่ตรง อาจเกิด การเข้าถึงนอกช่วง ได้ เช่น เมื่อ
valueเป็น 4 ใน[0, 1, 2, 3, 4, 5]
ลักษณะประสิทธิภาพและข้อจำกัดของ sb_lower_bound
- เมื่อ
sb_lower_boundแบ่งช่วงที่มีความยาวเป็นจำนวนคู่ แม้ผลการเปรียบเทียบจะเป็นจริง แต่ในบางกรณีอาจข้ามองค์ประกอบไปไม่มากพอ - ในช่วง
2^k <= n < 2^(k+1)จะเปรียบเทียบk+1ครั้งเสมอ - ในช่วงเดียวกัน
std::lower_boundจะเปรียบเทียบkครั้งหรือk+1ครั้ง และโดยเฉลี่ยประมาณlog2(n+1)ครั้ง - จำนวนครั้งการเปรียบเทียบอาจมากกว่า แต่จำนวนคำสั่งในลูปน้อยกว่ามาก ทำให้เวลาในการรันโดยรวมเร็วกว่า
- หากฟังก์ชันเปรียบเทียบช้ามาก ความแตกต่างระหว่าง
k+1ครั้งกับlog2(n+1)ครั้งอาจส่งผลต่อประสิทธิภาพได้ - หากต้องการบังคับ conditional move ใน
gccมีวิธีใช้ inline assembly เฉพาะ x86 เพื่อใช้cmovแต่แบบเรียบง่ายจะเพิ่มจำนวนคำสั่ง และทางเลือกอื่นต้องเขียน assembly แยกตามชนิดข้อมูล
เวอร์ชันดัดแปลงที่เร็วกว่า bb_lower_bound
bb_lower_boundแบ่งช่วงด้วยวิธีอื่นจนกว่าความยาวจะอยู่ในรูป2^k - 1จากนั้นจึงค้นหาด้วยลูปที่สองซึ่งเร็วกว่าlength & (length + 1)ใช้ตรวจว่าความยาวอยู่ในรูป11..1หรือก็คือ2^k - 1หรือไม่- สำหรับความยาวที่ไม่ปกติ จะใช้ค่า MAGIC อย่าง
auto step = length / 8 * 6 + 1เพื่อเข้าใกล้ช่วง “nice” อย่างรวดเร็ว - โดยทั่วไป
stepควรมากกว่าหรือเท่ากับlength / 2เพื่อให้เข้าสู่ลูปเร็วได้บ่อย แต่ถ้าใกล้lengthมากเกินไปก็จะเสียข้อดีของ binary search - เพราะมี
breakทำให้bb_lower_boundกลายเป็นรูปแบบที่มีสาขา - วิธีใช้ตารางที่คำนวณ
stepที่เร็วที่สุดสำหรับทุกความยาวไว้ล่วงหน้า ยังเป็นเส้นทางที่ยังไม่ได้สำรวจ
การใช้งานแบบ branchless สมบูรณ์ไม่ได้เร็วกว่า
- บนเครื่อง 64 บิต ลูปของ
sb_lower_boundทำซ้ำสูงสุด 64 ครั้ง ดังนั้นสามารถสร้างเวอร์ชัน “branchless สมบูรณ์” ที่ตัดแม้แต่การตรวจlengthออก โดยใช้switchและ fall-through ที่ตั้งใจไว้ - วิธีนี้มีโครงสร้างเป็นการกระโดดไปยังตำแหน่งโค้ดตามจำนวนครั้งการเปรียบเทียบที่ต้องใช้ด้วย
std::bit_width(length) - ประสิทธิภาพจริงไม่ได้เร็วกว่า
- CPU x86 รุ่นใหม่จัดการสาขาที่คาดเดาได้ เช่น เงื่อนไขลูป ได้ดี ดังนั้นจึงไม่มีประโยชน์จากการตัดการตรวจ
length - ยังตัดสินว่าลูปทั่วไปดีกว่าในแง่ที่หลีกเลี่ยง template, macro และการคัดลอก-แก้ไข 64 เคสได้ด้วย
ผล benchmark
- ผลลัพธ์ตาม
clang -cmovในเวลาเฉลี่ยในการรัน (ns) มีดังนี้std::lower_: 61.30branchless_lower_: 43.43asm_lower_: 54.32sb_lower_: 33.24sbm_lower_: 35.54bb_lower_: 32.73
- ค่า geometric mean ของเวลาในการรัน (ns) ก็ต่ำที่สุดที่
sb_lower_std::lower_: 39.17branchless_lower_: 25.14asm_lower_: 31.21sb_lower_: 19.81sbm_lower_: 20.91bb_lower_: 21.33
sbm_lower_boundเป็นเวอร์ชันดัดแปลงที่ใช้รูปแบบfirst += comp(first[length], value) * (length + rem)แทนifเพื่อชักนำให้gccสร้าง conditional move- การปรับแต่งนี้อาจหายไปใน
gccเวอร์ชันถัดไป จึงต้องมีคอมเมนต์และความระมัดระวัง - คำสั่ง benchmark ใช้
g++-10,clang++-10,clang++-10 -mllvm -x86-cmov-converter=falseและเพิ่ม-march=haswell - การใช้
-march=nativeหรือไม่ระบุ-marchไม่ได้ส่งผลต่ออันดับมากนัก และการทดสอบทำบน Intel i7 Kaby Lake
การวัด branch prediction miss
- การรัน
clangปกติที่วัดด้วยperfบันทึก branches ประมาณ 6.94 พันล้านครั้ง และ branch-misses ประมาณ 1.20 พันล้านครั้ง โดยอัตรา branch-misses อยู่ที่ 17.34% - การรัน
clang -cmovบันทึก branches ประมาณ 4.07 พันล้านครั้ง และ branch-misses ประมาณ 35.95 ล้านครั้ง โดยอัตรา branch-misses ลดลงเหลือ 0.88% -cmovกำจัดสาขาประมาณ 2.9 พันล้านรายการและ branch miss ประมาณ 1.2 พันล้านรายการ- สาขาที่ถูกกำจัดเป็นสาขาที่เคยคาดเดาพลาดด้วยความน่าจะเป็นประมาณ 41%
- ค่านี้ใกล้กับ 50% ที่คาดได้จากสาขาที่คาดเดาไม่ได้โดยสิ้นเชิง
เมื่อฟังก์ชันเปรียบเทียบช้า ผลลัพธ์จะต่างออกไป
- เพื่อดูสถานการณ์ที่ฟังก์ชันเปรียบเทียบช้ากว่า จึงทดสอบการค้นหาสตริง 8 ไบต์
- ในเวลาเฉลี่ยในการรัน (ns)
std::lower_boundเร็วกว่าsb_lower_boundเล็กน้อยหรือใกล้เคียงกันgcc:std::lower_160.01,sb_lower_165.66clang:std::lower_157.71,sb_lower_162.68,bb_lower_157.22clang -cmov:std::lower_156.06,sb_lower_164.71,bb_lower_157.48
- ในกรณีนี้
std::lower_boundเร็วกว่าsb_lower_boundเพียงเล็กน้อยแต่สม่ำเสมอ - ไลบรารีอาจมุ่งให้ได้ประสิทธิภาพดีที่สุดด้วยการใช้
sb_lower_boundเมื่อทำงานกับ primitive type โดยตรง และใช้std::lower_boundในกรณีอื่น
ความแตกต่างที่เห็นใน assembly
- hot loop ของ
std::lower_boundในclang -cmovมี conditional move เช่นcmova,cmovbeแต่ใช้หลายคำสั่งในการอัปเดตความยาวและตำแหน่ง - hot loop ของ
sb_lower_boundคำนวณความยาวครึ่งหนึ่ง เศษ และพอยน์เตอร์ที่จะเลื่อน จากนั้นอัปเดตfirstด้วยcmova - assembly ของ
branchless_lower_boundสั้นและสะอาดมาก แต่ในการทดสอบประสิทธิภาพsb_lower_boundให้ผลดีกว่าด้วย overhead ที่ต่ำกว่า
อัปเดต: sb_lower_bound ที่สั้นลง
- หลังจากคอมเมนต์ของ ผู้เขียน orlp.net สามารถ refactor
sb_lower_boundเพื่อลดจำนวนคำสั่ง assembly ใน hot loop จาก 9 คำสั่งเหลือ 8 คำสั่งได้ - แก่นสำคัญคือ
length - halfเท่ากับhalf + length % 2 - รูปแบบที่ refactor แล้วจะคำนวณ
half = length / 2และถ้าการเปรียบเทียบเป็นจริงจะทำfirst += length - halfจากนั้นอัปเดตlength = half - ใน
clang -cmovเวลาเฉลี่ยในการรันดีขึ้นเล็กน้อยจากประมาณ 33ns เป็นประมาณ 32ns
สำหรับอาร์เรย์ขนาดใหญ่ prefetching ได้ผล
- prefetching ที่เสนอในคอมเมนต์เป็นวิธีนำหน่วยความจำที่ต้องใช้เข้ามาในแคช L1/L2 ล่วงหน้า เพื่อลด latency เมื่อเข้าถึงจริง
- ตัวอย่าง latency คือ L1 ประมาณ 4 cycles, L2 ประมาณ 12 cycles, L3 ประมาณ 40 cycles, หน่วยความจำประมาณ 200 cycles
- ทั้ง
gccและclangรองรับ__builtin_prefetch() - หาก prefetch ตำแหน่ง
length / 4จะมี 1 ใน 2 รายการที่สูญเปล่า และหากเพิ่มจนถึงlength / 8จะมี 5 ใน 6 รายการที่สูญเปล่า - การคำนวณตำแหน่ง prefetch และการเรียกเองก็มี overhead และใน hot loop ที่ทำให้สั้นแล้ว ต้นทุนนี้มีความสำคัญ
- กลยุทธ์ prefetch หลายแบบไม่ได้ช่วยสำหรับอาร์เรย์ต่ำกว่า 256KB
- ตั้งแต่ 256KB ขึ้นไป
sbp_lower_boundที่เพิ่ม prefetching ปรับปรุงเวลาเฉลี่ยในการรันจากประมาณ 32ns เป็นประมาณ 26ns ในการทดสอบสูงสุดประมาณ 4 ล้าน entry หรือ 16MB - ในการทดสอบที่ขยายภายหลังไปถึงประมาณ 128 ล้าน entry หรือ 512MB เวอร์ชัน prefetch เร็วกว่า
std::lower_boundตามเวลาเฉลี่ยประมาณ 2.3 เท่า- เกณฑ์เปรียบเทียบคือ
std::lower_boundประมาณ 161ns และเวอร์ชัน prefetch ประมาณ 71ns
- เกณฑ์เปรียบเทียบคือ
ข้อสังเกตและทางเลือกสำหรับชุดข้อมูลขนาดใหญ่
- เมื่อมีขนาดใหญ่มาก
std::lower_boundแบบ branchless ที่clang -cmovสร้างขึ้นช้ากว่าเวอร์ชันที่มีสาขา - CPU รุ่นใหม่สามารถตามสาขาที่คาดเดาไว้ พร้อมโหลดหน่วยความจำและทำ speculative execution ซึ่งทำหน้าที่คล้าย prefetch ได้ในทางปฏิบัติ
sbpm_lower_boundเป็นเวอร์ชันที่เพิ่ม prefetching ให้กับsbm_lower_boundและชักนำให้gccสร้างโค้ด branchless ด้วยการคูณบูลีน- มีการกระโดดของกราฟประสิทธิภาพในช่วง 1 ล้านถึง 10 ล้านองค์ประกอบ จึงมีช่องให้สร้างการใช้งานที่เร็วกว่าในเชิงทฤษฎี
- อย่างไรก็ตาม โค้ด prefetching ซับซ้อนขึ้นเรื่อย ๆ และมี magic constant เพิ่มขึ้น จึงประเมินว่ายิ่งซับซ้อนมาก โอกาสที่จะ contribute เข้า
gcc/libstdc++หรือllvm/libc++ก็ยิ่งต่ำ - ทางเลือกที่ฝ่าข้อจำกัดของ
std::lower_boundคือ Eytzinger Binary Search ซึ่งจัดเรียงอาร์เรย์อินพุตใหม่เป็นรูป heap ของค่ากลางแบบไบนารี เพื่อให้การค้นหาเป็นมิตรต่อแคช - ในการทดสอบ int 16-ary tree ของ Sergey Slotin at CppCon 2022 ได้ผลลัพธ์เร็วกว่า
std::lower_bound7 ถึง 15 เท่า
โค้ดและเงื่อนไขการใช้งาน
- หากการค้นหาหรือการเปรียบเทียบเป็นส่วนที่ช้าที่สุดของโปรแกรม และโปรเซสเซอร์คาดเดาผลการเปรียบเทียบได้ยาก สามารถลองใช้ตัวเลือก
-mllvm -x86-cmov-converter=falseของclangบน x86 ได้ - หากต้องการ binary search ที่เร็วขึ้น สามารถลองใช้
sb_lower_boundได้ และในgccsbm_lower_boundก็เป็นอีกตัวเลือกหนึ่ง - โค้ดเผยแพร่ภายใต้ไลเซนส์ MIT
- สามารถดูโค้ดและ benchmark ได้ที่ github.com/mh-dm/sb_lower_bound/
1 ความคิดเห็น
ความคิดเห็นจาก Hacker News
ทุกครั้งที่เห็นคนพยายาม ตัด branch ออก ผมก็สงสัยว่าพวกเขารู้ไหมว่าโครงสร้างที่ branch prediction ผิดพลาดแล้วทำให้ pipeline ยาว ๆ หยุดชะงักนั้นไม่ใช่องค์ประกอบที่จำเป็นของสถาปัตยกรรม CPU
เหตุผลที่ pipeline ยาวก็เพราะมีการวิเคราะห์และแปลงจำนวนมากก่อนการ execute ทันที ซึ่งส่วนใหญ่สามารถทำไว้ล่วงหน้าได้ เพราะไม่ได้เป็นอัลกอริทึมที่มี dependency ของ state สูงนัก
CPU Transmeta Crusoe ทำงานในลักษณะนี้ และเราสามารถจินตนาการถึงโลกที่ไม่ต้องกังวลเรื่อง branch ได้
ถ้ามองให้ลึกขึ้น ทุก operation ก็คือ branch ที่ดูสถานะของบิตแล้วเปลี่ยนผลลัพธ์ แต่ branch เฉพาะที่ภายใน ALU แบบนี้ไม่ใช่ branch บน pipeline หลัก จึงไม่ทำร้ายประสิทธิภาพมากนัก
จำได้ว่าตอนนั้นเคยบอก srk ด้วยว่า การเลือกใช้ metric ระหว่าง IPC กับ throughput มีผลต่อการมองว่าอะไรดีหรือไม่ดี
ฝั่ง IPC มองว่าถ้าทำ IPC ให้สูงขึ้น ฝั่ง process ก็จะเพิ่ม clock ให้ ทุกคนก็ชนะ ส่วนฝั่ง throughput ใช้วิธีคิดที่สมจริงกว่า คือกฎของ Moore ตายแล้ว และถ้าหมุน silicon ให้เร็วขึ้นมันก็ละลาย ดังนั้นฝ่ายที่ออกแบบ ISA อย่างชาญฉลาดจะชนะ
ตลอด 20 ปีที่ผ่านมา ทั้งสองฝั่งต่างก็มีทั้งความสำเร็จและความผิดหวัง และน่าสนใจที่ช่วงนี้ RISC-V กำลังพาเรากลับมาตั้งคำถามแบบนี้ในสถาปัตยกรรม CPU อีกครั้ง
ยังเป็นจุดที่ดีในการติดตามว่าแนวคิด superscalar สมัยใหม่ถูกเพิ่มเข้ามาอย่างไรบนฐานความยืดหยุ่นของ instruction set และระยะยาวผมคิดว่าฝั่งนี้น่าจะชนะ
การแปลงของ Transmeta ไม่ได้ทำให้ต้นทุนของ branch หายไป
ผมจำได้ว่า Linus ที่เคยทำงานที่ Transmeta พูดไว้ในเธรด comp.arch ทำนองว่า “หน้าที่ของ CPU คือทำให้เกิด cache miss ให้เร็วที่สุดเท่าที่จะทำได้”
forced cache miss นั้นมีอยู่จริง และไม่มี JIT ใดลบมันได้
ในโลกจริง แม้จะมี cache ขนาดมหึมาอย่างตอนนี้ ก็ยังหลีกเลี่ยง capacity miss ไม่ได้
Itanium เองก็มองว่าสามารถกำจัดต้นทุนของ branch ได้ด้วย static analysis แต่ลองนึกดูว่าผลลัพธ์เป็นอย่างไร
อยากให้โปรแกรมเมอร์อ่านหนังสือสถาปัตยกรรมคอมพิวเตอร์สักหน่อย ก่อนจะสรุปอย่างมั่นใจว่าสามารถทำสิ่งที่ดีกว่า processor ยุคใหม่ได้ง่าย ๆ
ผมคิดว่าพวกเขาประเมินขนาดของความพยายามทางปัญญาที่ใส่ไว้ใน processor ปัจจุบันต่ำไปอย่างน้อยประมาณ 7 หลัก
หนึ่งในนั้นคือ input data ที่ถูกประมวลผล
binary search เป็นกรณีแบบนั้นพอดี เพราะ compiler ไม่รู้ว่าผลลัพธ์จะถูกพบที่ตำแหน่งไหน
อีกอย่างคือ microarchitecture โดยเฉพาะลำดับชั้น cache และการจัดวาง execution unit
ถ้าเปลี่ยนไปใช้ ISA ที่มีคำสั่งคล้าย micro-operation ของ CPU ปัจจุบัน ก็ต้อง compile ใหม่สำหรับทุก microarchitecture
แต่ประเด็นนี้ในทางเทคนิคแก้ได้ด้วย OS JIT แบบที่ GPU ปัจจุบันทำ คือแจกจ่ายโปรแกรมเป็นรูปแบบ bytecode (DXBC, SPIR-V, NVPTX) แล้วให้ user-mode GPU driver recompile เป็นคำสั่ง hardware จริง
ตัวแปรที่ใหญ่กว่านั้นคือ CPU thread อื่น ๆ กำลังรันโค้ดที่ไม่อาจรู้ได้
แม้จะตัด hyperthreading ออกเพื่อทำให้ core เป็นอิสระต่อกัน ก็ยังเหลือทรัพยากรที่แชร์กันทั้งชิปอยู่ดี เช่น L3 cache, หน่วยความจำภายนอก, bandwidth ของ I/O, พลังงาน และความร้อน
ถ้านิยามทุกอย่างใหม่ว่าเป็น Branch™ ก็จะรวมสิ่งที่ไม่ใช่ branch จริง ๆ เข้าไปด้วย และ Branch™ บางส่วนก็สามารถคำนวณล่วงหน้าได้
แต่ branch removal ที่พูดกันทั่วไปน่าจะหมายถึงกรณีที่เส้นทางการคำนวณแยกออกจริง ๆ ในโค้ดอย่าง if/else ไม่ใช่หรือ
แม้ในโลกแบบนั้น การ optimize ที่มีประโยชน์ก็ยังเป็นไปได้ แต่จะจำกัดอยู่กับ Branch™ ที่พยายามคำนวณผลลัพธ์หลายอนาคตพร้อมกัน
ทุกครั้งที่มี operation ที่ทำได้อย่างอิสระ ก็เกิดโอกาสที่จะ execute พร้อมกันได้เท่านั้น
ไม่ได้หมายถึงแค่ decode, fetch, execute
ถ้ามี ALU กับ shifter ที่เป็นอิสระกัน ก็สามารถ shift ระหว่างที่กำลัง add ได้ และถ้ามี adder กับ multiplier เฉพาะทาง ก็ไม่มีเหตุผลที่จะลองทำทั้งสองอย่างพร้อมกันไม่ได้
นั่นหมายความว่าเราจะอยากให้หลาย instruction อยู่ระหว่างดำเนินการพร้อมกัน และต้องสามารถ fetch กับ decode instruction ได้เร็วกว่าอัตราการประมวลผล
อีกทั้งยังนำไปสู่สถานการณ์ตามธรรมชาติที่อยาก reorder เพื่อไม่ให้คำสั่ง Add จำนวน N คำสั่งไปบดบัง Shift ที่เป็นอิสระ
คุณอาจคิดว่าโครงสร้างปัจจุบันซับซ้อนเกินความจำเป็น และอาจไม่ได้ผิดก็ได้
แต่ก็มีงานวิศวกรรมมหาศาลถูกทุ่มลงไปเพื่อสร้างโครงสร้างปัจจุบัน ดังนั้นถ้าคิดว่าหากไม่ใช้วิธีนี้จะทำให้เร็วขึ้นได้มาก ก็ควรขุดลึกลงไปดูว่าข้ออ้างนั้นแม่นยำแค่ไหน
ตรงส่วนที่ว่า “ถ้ามี ภาษา bare-metal ที่สะอาดและเร็วสำหรับเขียนทั้งหมดนี้ก็คงดี…” ผู้เขียนใส่เชิงอรรถ “BUT RUST..” และ “BUT ZIG..” ไว้ แต่ก็สงสัยว่า Nim จะเป็นอย่างไร
ดูเหมือนจะมีการใช้งาน
lowerBoundในไลบรารีเนทีฟ: https://github.com/nim-lang/Nim/blob/version-2-0/lib/pure/al...ถ้าพูดอย่างเคร่งครัด มันไม่ใช่ภาษา “bare-metal” แต่คอมไพล์เป็น C หรือ C++ ได้ ดังนั้นน่าจะน่าสนใจถ้าดูว่ามันคอมไพล์ออกมาเป็นโค้ดแบบไหนที่นี่
และก็สงสัยด้วยว่า C มีปัญหาอะไร
TigerBeetle ใช้ การใช้งานแบบไม่มี branch ของตัวเอง: https://github.com/tigerbeetle/tigerbeetle/blob/e996abcf7154...
นี่แหละคือกรณีใช้งานที่ทำให้ต้องมี C++ templates
C ไม่สะอาด
ยังไม่แน่ใจว่านี่ใช่
lower_boundอยู่หรือเปล่าอาจเป็นเพราะอ่านโค้ดผิดก็ได้ แต่ดูเหมือนว่าเมื่อมีค่าซ้ำ มันจะคืนรายการที่ตรงกันรายการใดก็ได้ ไม่ใช่รายการแรกสุดที่ตรงกัน
ถ้าฟังก์ชันเปรียบเทียบกำลังมองหาคำนำหน้าของสตริงหนึ่ง ๆ เพื่อทำ autocomplete แม้ในลิสต์ที่ไม่มีค่าซ้ำ ก็อาจมีหลายรายการที่ตรงกันได้ และตอนนั้นเราต้องการรายการแรกสุดในลิสต์
อยากรู้ว่าทำไมถึงมองว่าไม่ใช่
อยากให้บล็อกทุกบทความเริ่มแบบบทความนี้: “คุณคงยุ่งอยู่ งั้นเข้าเรื่องเลยครับ นี่คือ การใช้งาน binary search ใน C++ ที่เร็วที่สุด ทั่วไปที่สุด และเรียบง่ายที่สุด”
ไลบรารีมาตรฐานของ Zig ไม่ได้เรียก C++ เพื่อทำ binary search
binary search ปัจจุบันอยู่ที่นี่: https://github.com/ziglang/zig/blob/b835fd90cef1447904d3b009...
ยังไม่ค่อยเข้าใจ
ปัญหาของ binary search กับ branch ไม่ใช่ ตัว branch เอง แต่คือก่อนจะเปรียบเทียบเสร็จ เรายังไม่รู้ว่าควรดึงตำแหน่งหน่วยความจำถัดไปตรงไหนในอาร์เรย์
ไม่ว่าจะใช้ branch หรือใช้อะไรอย่างอื่น ประเด็นสุดท้ายคือเราต้องการให้โปรเซสเซอร์ทำอะไร
มันมี data dependency
ก่อนจะอ่านดัชนีกลาง เราไม่รู้ว่าจะค้นหาช่วงบนหรือช่วงล่าง
เราอาจเดาแล้วออกคำสั่งอ่านทั้งสองฝั่งได้ ซึ่งจะช่วยแก้ dependency แต่ก็เพิ่มทราฟฟิกหน่วยความจำ
ประเด็นสำคัญคือการแลกเปลี่ยนแบบนี้คุ้มไหม และการแค่ลบ branch ออกไม่ใช่คำตอบ
มีพูดถึงตอนท้ายบทความ: https://mhdm.dev/posts/sb_lower_bound/#prefetching
ดังนั้น binary search ที่เร็วขึ้นจริง ๆ จึงใช้ การจัดวางอาร์เรย์แบบ Eytzinger: https://algorithmica.org/en/eytzinger
บนโปรเซสเซอร์ Cascade Lake ของผม
-mllvm -x86-cmov-converter=falseทำให้ประสิทธิภาพของ binary search ลดลงเกือบครึ่งตัวเลขคือจำนวน nanosecond ต่อ bsearch บนอาร์เรย์
uint32ขนาด 100MBclang 15.0.7 ดูเหมือนจะแย่กว่า gcc 13.2.1 มากในการปรับโค้ดเฉพาะกรณีนี้ให้เหมาะสม
ดู assembly ได้ที่นี่: https://godbolt.org/z/cbx5Kdjs6
assembly ของ gcc ดูสะอาดกว่ามาก
100MB ใหญ่พอที่เวอร์ชันมี branch จะออกมาดีกว่าเล็กน้อย แต่ไม่ใช่เพราะมันดีกว่า หากเป็นเพราะลักษณะของ speculative execution บน x86
มีใครรู้ไหมว่าลิงก์ “BUT RUST” เดิมควรจะไปที่ไหน?
ดูเหมือนมันจะพังไปแล้วเพราะไม่ได้ตรึงเวอร์ชันไว้ และไม่แน่ว่าอาจตั้งใจจะลิงก์ไปกลางคอมเมนต์เอกสารของ
starts_withหรือเปล่าlet mid = left + size / 2;[1] https://web.archive.org/web/20230602210213/https://doc.rust-...
ตั้งใจจะลิงก์ไปยัง อิมพลีเมนเทชันการค้นหาแบบไบนารี ของ Rust
อัปเดตเป็น https://doc.rust-lang.org/1.71.1/src/core/slice/mod.rs.html#... แล้ว
น่าสนใจที่ผลลัพธ์ไม่คงเดิมเมื่อใช้ฟังก์ชันเปรียบเทียบ
compที่ซับซ้อนกว่าในบทความ ผู้เขียนนึกถึงสถานการณ์การค้นหาแบบไบนารีที่ค่อนข้างสมจริง ซึ่งฟังก์ชันเปรียบเทียบช้า เช่น ID, หมายเลขโทรศัพท์, บัญชี, คีย์เวิร์ด จึงทดสอบการค้นหาสตริงขนาด 8 ไบต์
ในกรณีนี้
std::lower_boundเร็วกว่าsb_lower_boundเพียงเล็กน้อยแต่สม่ำเสมอ และบอกว่าหากต้องการให้ได้ประสิทธิภาพดีที่สุดเสมอ ไลบรารีควรใช้sb_lower_boundเมื่อจัดการชนิดข้อมูลพื้นฐานโดยตรง และใช้std::lower_boundในกรณีอื่นอยากเห็นการวิเคราะห์ตรงนี้
ถ้าเป็นข้อมูลและอินพุตแบบสุ่มจริง ๆ การทำนายน่าจะผิดประมาณครึ่งหนึ่ง
วิธีแบบ CMOV จะติดอยู่เพราะมี data dependency หลังฟังก์ชันเปรียบเทียบ
โดยเฉลี่ยแล้ว วิธีแบบมี branch จะทำการเปรียบเทียบได้สองครั้งพร้อมกัน ส่วน CMOV ทำได้ครั้งเดียว ดังนั้นเมื่อเวลาที่ใช้ในการเปรียบเทียบมากกว่า penalty จากการทำนาย branch ผิด ก็น่าจะมีจุดที่ผลพลิกกลับ
สิ่งที่เคยลองทำแบบคร่าว ๆ ด้วย SIMD เมื่อก่อน เร็วกว่า
std::lower_bound3 เท่า จนกว่าจะไปติดที่แบนด์วิดท์หน่วยความจำ: https://github.com/matthewkolbe/ThinkingInSimd/tree/main/alg...สมมติว่าเป็นสุ่มล้วน ๆ แต่ถ้าสตริง 8 ไบต์เหล่านี้ไม่ใช่ข้อมูลบริสุทธิ์ ตัว branch predictor สมัยใหม่ก็อาจทำผลงานได้ดีกว่า
cmovได้ง่าย ๆดูเหมือนว่าแอตทริบิวต์
unpredictableตอนนี้จะมีผลต่อ cmov transformation pass แล้วอ้างอิง ณ วันที่ 1 มิถุนายน ดังนั้นน่าจะเข้าไปอยู่ใน clang 17/18: https://reviews.llvm.org/D118118