เร็วกว่า C {n} เท่า
(owen.cafe)- แม้จะเป็นลูป C ขนาดเล็ก ผลลัพธ์จากคอมไพเลอร์ก็ไม่ได้ดีที่สุดเสมอไป และจากการปรับแต่งแอสเซมบลี x86_64 ด้วยมือ พบว่าเวอร์ชันที่ ตัด conditional branch ออก เร็วกว่าผลลัพธ์ของ clang 6.73 เท่า
- ฟังก์ชันเป้าหมายประมวลผลสตริงโดยให้
's'เป็น +1,'p'เป็น -1, และ'\0'เป็นจุดสิ้นสุด โดยผลลัพธ์จาก clang 16 แบ่ง flow นี้ออกเป็น conditional branch 3 จุด - หลังจากเปลี่ยนลำดับ branch, จัดวาง basic block ใหม่ และแทน jump ด้วย arithmetic เวลาในการรันลดลงจาก 3.23 วินาทีเหลือ 2.87 วินาที และในขั้นนี้ทำความเร็วได้เท่ากับ GCC 12
- เวอร์ชันที่เร็วที่สุดใช้
cmoveเพื่อเลือกค่าที่จะบวกต่ออักขระเป็น 0, 1 หรือ -1 แล้วทำaddเสมอ ได้ผลที่ 0.48 วินาที และ throughput 1.94GiB/s - benchmark ทำบน AMD Ryzen 5 5625U และ Linux 6.1.33 โดยประมวลผลลิสต์อักขระสุ่ม
'p'/'s'จำนวน 1 ล้านตัว 1000 รอบ และใช้ผลลัพธ์ที่ดีที่สุดจากการรันหลายครั้ง
ฟังก์ชันทดลองและผลลัพธ์จากคอมไพเลอร์
- ฟังก์ชันเป้าหมายเพิ่มตัวชี้สตริงทีละตัว และอัปเดตจำนวนเต็ม
resตามอักขระ's':res += 1'p':res -= 1'\0': คืนค่าres- อักขระอื่น: ไม่เปลี่ยนแปลง
- เริ่มต้นจากความคาดหวังว่า เนื่องจากฟังก์ชันมีขนาดเล็ก gcc หรือ clang น่าจะปรับให้เหมาะสมได้ค่อนข้างดี หรืออาจถึงขั้นดีที่สุด
- แอสเซมบลีเริ่มต้นที่ clang สร้าง แบ่งกรณีทั้งสี่ออกเป็น conditional branch 3 จุด (
je,je,jne)- เริ่มด้วย
res = 0 - อ่านอักขระแล้วตรวจสอบก่อนว่าเป็น
'\0'หรือไม่ - จากนั้นค่อยเปรียบเทียบกับ
'p','s'
- เริ่มด้วย
- ผลลัพธ์เริ่มต้นของ clang
- เวลาในการรัน: 3.23 วินาที
- throughput: 295.26MiB/s
- GCC สร้างโค้ดมากกว่าเล็กน้อย แต่เร็วกว่าเล็กน้อย
ตรวจสอบอักขระที่พบบ่อยก่อนเงื่อนไขสิ้นสุดที่พบได้น้อย
- ลูปจะจบก็ต่อเมื่อเจออักขระ null terminator
'\0'และในฟังก์ชันนี้ null terminator จะปรากฏได้มากที่สุดเพียงครั้งเดียว - ผลลัพธ์จาก clang ตรวจสอบ
'\0'เป็นอย่างแรก ทำให้ทุกครั้งที่เจออักขระ'p'และ's'ต้องตรวจสอบเงื่อนไขสิ้นสุดก่อน - การแก้ด้วยมือครั้งแรกคือเปลี่ยนลำดับการเปรียบเทียบให้ ตรวจสอบ
'p'และ's'ก่อน - ผลลัพธ์
- เวลาในการรัน: 3.10 วินาที
- ความเร็วเพิ่มขึ้น: 1.04 เท่า
- throughput: 307.64MiB/s
จัดวาง basic block ใหม่และลด jump
- กรณีที่พบบ่อยทั้งสองคือ
'p'และ's'ต่างก็ jump กลับไปที่จุดเริ่มลูป ดังนั้นสามารถลด branch ได้ด้วยการวางบล็อกหนึ่งไว้เหนือลูป - หากวางบล็อก
's'ไว้ก่อนลูปทันที หลังประมวลผล's'แล้วจะไหลเข้าสู่ลูปได้โดยไม่ต้องมี jump แยก - แต่แลกกับการต้อง jump เข้าลูปหนึ่งครั้งตอนเริ่มฟังก์ชัน เพื่อข้ามบล็อก
's'- jump ตอนเริ่มฟังก์ชันเกิดขึ้นเพียงครั้งเดียว
- ส่วนอักขระ
's'อาจพบหลายครั้ง จึงถือเป็น trade-off ที่ยอมรับได้
- ผลลัพธ์
- เวลาในการรัน: 2.98 วินาที
- ความเร็วเพิ่มขึ้นโดยรวม: 1.08 เท่า
- throughput: 320.02MiB/s
ใช้ arithmetic เพื่อตัด unconditional jump หนึ่งจุด
- ใช้ arithmetic เพื่อลบ unconditional
jmpที่กลับไปลูปในบล็อกp: - การลดค่า 1 ครั้งสามารถให้ผลเดียวกันได้ด้วย
sub eax, 2แล้วตามด้วยinc eaxจึงทำให้หลังประมวลผล'p'แล้วไหลต่อไปยังบล็อก's' - วิธีนี้ตัด คำสั่ง branch ออกไปได้อีกหนึ่งจุด
- ผลลัพธ์
- เวลาในการรัน: 2.87 วินาที
- ความเร็วเพิ่มขึ้นโดยรวม: 1.12 เท่า
- throughput: 332.29MiB/s
- ประสิทธิภาพ ณ จุดนี้เท่ากับโค้ดที่ GCC 12 สร้าง
- โค้ด GCC 12 ก็รันใน 2.87 วินาที
- เวอร์ชันเขียนด้วยมือมี 13 คำสั่ง
- ผลลัพธ์จาก GCC มี 19 คำสั่ง
- โค้ด GCC ดูเหมือนจะ unroll ลูปและนำ case block บางส่วนกลับมาใช้ซ้ำ
แทน conditional branch ด้วย cmove
- หาก conditional branch เป็นคอขวด ก็สามารถตัด conditional branch เองออกได้โดยไม่ต้องพึ่ง branch predictor
- เวอร์ชันที่เร็วที่สุดใช้
cmoveหรือ conditional move เมื่อเท่ากัน - กฎการทำงานเรียบง่าย
- ค่าเริ่มต้นคือ 0
- ถ้าอักขระปัจจุบันเป็น
's'ให้เป็น 1 - ถ้าอักขระปัจจุบันเป็น
'p'ให้เป็น -1 - ในแต่ละรอบ ให้นำค่าที่เลือกได้ไปบวกกับ
resเสมอ
- วิธีนี้ตัดลูกศรจำนวนมากออกจาก control flow graph
- ผลลัพธ์
- เวลาในการรัน: 0.48 วินาที
- ความเร็วเพิ่มขึ้นโดยรวม: 6.73 เท่า
- throughput: 1.94GiB/s
- ในแอสเซมบลีของลูป C แบบแน่นที่เขียนเอง การปรับแต่งที่คอมไพเลอร์ไม่ได้ทำอัตโนมัติสามารถเพิ่มความเร็วได้ในระดับ 6 เท่า
ความพยายามประหยัด register และการทดลองเพิ่มเติมที่ไม่สำเร็จ
- ยังได้ลองเวอร์ชันที่ใช้
seteของ x86_64 เพื่อตั้งค่า register ขนาด 1 ไบต์เป็น 0 หรือ 1 แบบมีเงื่อนไข - เวอร์ชันนี้ตัดการใช้
r8dออก แต่ช้ากว่าเวอร์ชันที่ใช้เฉพาะcmov - ผลลัพธ์
- เวลาในการรัน: 0.51 วินาที
- ความเร็วเพิ่มขึ้นโดยรวม: 6.33 เท่า
- throughput: 1.83GiB/s
- การใช้ register น้อยลง หรือใช้การคำนวณ 8 บิตแทน 32 บิต ไม่ได้ทำให้เร็วขึ้น
- การทดลองเพิ่มเติมก็ทำให้ประสิทธิภาพลดลงเช่นกัน
- unroll ลูปของเวอร์ชันที่ดีที่สุด: ช้าลง
- จัดตำแหน่งจุดเริ่มลูปให้อยู่บนขอบเขต 16 ไบต์: ช้าลง
- ใน GNU assembler หากใส่
.align <bytes>ไว้หน้าป้ายกำกับ จะสามารถแทรกnopได้
สภาพแวดล้อม benchmark และโค้ด
- รายการโค้ดอยู่บน GitHub
- สภาพแวดล้อม benchmark
- OS: Linux 6.1.33
- CPU: AMD Ryzen 5 5625U with Radeon Graphics
- CPU family 25, 6 คอร์, 2 เธรดต่อคอร์, 1 ซ็อกเก็ต
- clang: 16.0.1
- gcc: 12.2.0
- เวอร์ชัน C ถูกคอมไพล์ด้วย
-march=nativeเพื่อให้สร้างโค้ดที่เหมาะกับ CPU เฉพาะได้ - benchmark ใช้ลิสต์อักขระ 1 ล้านตัวที่ประกอบด้วย
'p'และ's'แบบสุ่ม- ฟังก์ชันแต่ละเวอร์ชันประมวลผลลิสต์นี้ 1000 รอบ
- แต่ละเวอร์ชันถูกรันหลายครั้ง แล้วเลือกผลลัพธ์ที่ดีที่สุด
- มีบทความต่อเนื่องลิงก์ไปยัง part two
1 ความคิดเห็น
ความเห็นจาก Hacker News
ข้อสรุปที่ถูกต้องไม่ใช่ แอสเซมบลีเขียนมือเร็วกกว่า C 6 เท่า แต่ใกล้เคียงกับ การกระโดดคำสั่งอาจช้ากว่าการคำนวณแบบมีเงื่อนไขมาก มากกว่า
ใน C เอง ถ้าไม่ใช้
switchแล้วจัดการด้วยifแค่หนึ่งหรือสองอัน ก็ให้ผลแบบเดียวกันได้ง่าย ๆ พอเปลี่ยนฟังก์ชัน C ให้เป็น ถ้าsก็เพิ่ม, ถ้าpก็ลด, ถ้า\0ก็จบ เร็วขึ้น 5.5 เท่า และในการรันตัวอย่างลดจาก 3.58 วินาทีเหลือ 0.65 วินาทีอย่างที่คนอื่นพูดกัน หลังจากปรับอินพุตแล้วก็ยังสามารถทำอัลกอริทึมให้เป็นเวกเตอร์ได้ด้วย ฉันมองนี่เป็นแบบฝึกหัดเชิงการสอน และหวังจริง ๆ ว่าคนจะไม่ไล่ลงไปเขียนแอสเซมบลีโดยไม่มีเหตุผลที่ดีพอ
Linus ก็เคยเขียนยาวไว้ก่อนหน้านี้เหมือนกันว่า
cmovไม่ค่อยมีประโยชน์กับ branch ที่คาดเดาได้: https://yarchive.net/comp/linux/cmov.htmlgcc (Ubuntu 9.4.0-1ubuntu1~20.04.1) 9.4.0ทั้งloneกับltwoใช้เวลาประมาณ 3.58 วินาทีเท่ากันswitchเป็นifหลายอันจะเร็วกว่าตลอดไหม และตั้งแต่จำนวนกรณีประมาณไหนswitchจะเริ่มเร็วกว่า ถ้ามันสม่ำเสมอ ก็น่าจะเป็นเรื่องที่ควรเข้าไปอยู่ใน compiler optimizationมองว่าโค้ดเดิมไม่ได้เขียนมาให้เป็นมิตรกับคอมไพเลอร์เท่าไร ถ้าเขียนแบบ
result += *s == 's'; result -= *s == 'p';คอมไพเลอร์จะสร้าง โค้ดsete/cmovแบบไร้ branch ที่เหมาะสมให้ และความเร็วจะออกมาแทบเท่ากับแอสเซมบลีที่ปรับแต่งในบทความแต่ก็ยังไม่ทำ loop unrolling หรือ vectorization ถ้าส่งขนาดสตริงแยกเข้ามาแล้ววนโดยรู้
sizeคอมไพเลอร์จะรู้ขนาดลูป จึง unroll ได้ และถ้าเป็นไปได้ก็จะใช้คำสั่ง AVX-512 ด้วย สำหรับอินพุตใหญ่จะเร็วกว่ามาก แต่ขี้เกียจทำ benchmark เอง ถ้าเป็นโปรแกรมเมอร์ C ที่ไม่ติดตามความยาวสตริงก็เชิญตามสบาย แต่ฉันว่าไม่ควรทำแบบนั้นจริง ๆ: https://godbolt.org/z/rde51zMd8เวอร์ชันนั้นทำได้ 3.88GiB/s ตั้งใจไม่พาไปถึงขั้น vectorization เพื่อให้ขอบเขตปัญหายังเล็ก และอยากโชว์ทิปกับลูกเล่นแอสเซมบลีในบทความ ยังพอมีช่องให้เขียนต่อภายหลังเรื่อง padding สตริงอินพุตและทำอัลกอริทึมให้เป็นเวกเตอร์
/* DON’T REFACTOR THIS FOR READABILITY IT WILL SLOW DOWN */{.overflowChecks:off.}แล้ววนinputโดยถ้า's' == cก็เพิ่ม ถ้า'p' == cก็ลดบน Apple M1 เร็วขึ้นประมาณ 5 เท่า และถ้าเปิดตรวจ overflow ไว้ ความเร็วจะมากกว่าเวอร์ชัน C พื้นฐานได้แค่ราว 2 เท่าเท่านั้น การรู้แพตเทิร์นที่ดีสำหรับชักนำ SIMD optimization ไว้ก็มีประโยชน์เสมอ
ถ้ามองจากฝั่งที่เกือบเป็นผู้เชี่ยวชาญด้าน optimization ฉันคงแก้ปัญหานี้คนละแบบไปเลย บนเครื่องฉัน C เวอร์ชันแรกทำได้ 389MB ต่อวินาที และถ้าแอสเซมบลีในบทความให้ผลดีขึ้น 6.2 เท่าเท่ากัน ก็จะได้ราว 2.4GB ต่อวินาที
สำหรับบัฟเฟอร์ยาว ๆ เวอร์ชัน C++ นี้บนเครื่องฉันเกิน 24GB/s: https://gist.github.com/Const-me/3ade77faad47f0fbb0538965ae7...
โดยไม่ต้องเขียนแอสเซมบลี ใช้แค่ AVX2 intrinsic ก็เร็วกว่าเวอร์ชันเดิม 61 เท่า
movemaskกับpopcntเพื่อทำเวกเตอร์ให้กับ prologue ได้ แทนการเก็บตัวนับไว้ในรีจิสเตอร์ymmยังเป็นโค้ดที่ไม่ได้ทดสอบเลยจึงต้อง benchmark แต่ก็ดูเหมือนจะทำได้ด้วยการสร้าง mask ของ
s,p,\0แล้วใช้tzcnt,bzhiนับบิตก่อนถึงท้ายสตริงstd::experimental::simdได้ไหม: https://en.cppreference.com/w/cpp/experimental/simdโค้ดนี้ดูเหมาะกับ SIMD มากจริง ๆ ถ้าเปลี่ยน prototype ให้รับความยาวแบบระบุชัดได้ ก็จะอ่านและประมวลผลทีละ 16 ไบต์ได้ง่าย
เอาผลการเปรียบเทียบมาบวกและลบตรง ๆ ได้เลย และแค่เรียก
strlen()ตอนเริ่มฟังก์ชันเพื่อเอาความยาวแบบ explicit ก็น่าจะคุ้มแล้วลองทำ implementation แบบ vectorized บน RISC-V อย่างเร็ว ๆ ดูแล้ว ใช้
rvvอ่านสตริง หา位置ของ\0แล้วนับจำนวนsกับpด้วยvcpopบน Mangopi MQ Pro (C906,
rv64gc+rvv 0.7.1, ความยาวเวกเตอร์ 128 บิต)switchได้ 0.19 Bytes/Cycle, C implementation แบบ table ได้ 0.17 Bytes/Cycle, ส่วนrvvได้ 1.57 Bytes/Cycle และหลังประมาณ 30KiB จะลดลงไปที่ 1.35 ถ้าจัด pointer ให้ตรงหน้า page และให้vlไม่ใหญ่กว่าขนาด page ก็ทำได้ถึง 2/1.7 Bytes/Cyclervvมีฟีเจอร์นี้อยู่ ไม่อย่างนั้นอาจพังได้เมื่อ null byte อยู่ชิดก่อนจุดสิ้นสุดของหน่วยความจำที่ถูกจัดสรรนี่น่าจะเป็น คุณสมบัติเฉพาะของสถาปัตยกรรม x86 ค่าใช้จ่ายของการไม่ทำ branch มันถูกมาก เลยทำให้ branch ดูแพงเมื่อเทียบกัน: https://wordsandbuttons.online/challenge_your_performance_in...
แต่บนโปรเซสเซอร์แบบอื่นอาจไม่เป็นแบบนั้น: https://wordsandbuttons.online/using_logical_operators_for_l...
คำถามที่ใหญ่กว่าคือ โดยทั่วไปแล้วทำไมต้องใช้ C ถ้าจะจูนด้วยมือให้รันได้ดีที่สุดบนฮาร์ดแวร์เฉพาะตัวหนึ่ง C ก็คือเครื่องมือที่ผิด และคุณต้องการ assembly กับระบบ macro ที่ดี เป้าหมายดั้งเดิมของ C คือทำให้โค้ดระดับระบบย้ายจากแพลตฟอร์มหนึ่งไปอีกแพลตฟอร์มหนึ่งได้ง่ายขึ้น และการสูญเสียประสิทธิภาพระหว่างทางก็เป็นสิ่งที่คาดไว้อยู่แล้ว มันคล้ายกับการเขียนกลอนเป็น Esperanto เพื่อให้แปลอัตโนมัติไปเป็นภาษาที่ต้องการ แทนที่จะเขียนบทกวีภาษาฮินดีแล้วแปลเป็นภาษาอูรดู คุณจะไม่ได้บทกวีชั้นยอดสองบท แต่จะได้งานแปลคุณภาพต่ำสองชิ้นอย่างรวดเร็ว และนั่นคือบทบาทของ C
ถ้าคอมไพล์ด้วย FDO/PGO ก็มีโอกาสสูงที่จะเกิดการจัดเรียง branch และ block ใหม่ เพราะถ้าไม่มี FDO คอมไพเลอร์จะไม่รู้ว่าแต่ละ branch ถูกเลือกบ่อยแค่ไหน ในบางกรณี FDO ยังอาจเปิดใช้
cmovได้ด้วยแต่
cmovจะดีกว่าtest/jumpแบบปกติหรือไม่ ขึ้นอยู่มากว่าการทำนาย branch ทำได้แม่นแค่ไหน และโดยทั่วไปcmovจะทำงานดีกว่าเมื่อ branch คาดเดาได้ยากมาก ถ้าcmovทำให้เร็วขึ้น 6 เท่า ก็เดาได้ว่าข้อมูลทดสอบน่าจะเป็นสตริงสุ่มที่เกือบทั้งหมดมีแต่sกับpไม่ได้ผิดอะไร แต่ก็เป็นการอาศัยคุณสมบัติของข้อมูลที่ไม่ได้พูดถึงมาปรับ benchmark ให้เฉพาะทาง เลยทำให้บทความดูชวนให้เข้าใจผิดนิดหน่อยมันสุ่มเลือก
's'หรือ'p'และอักขระที่เป็นไปได้ก็มีแค่'s','p', กับ null terminator เท่านั้น ถ้ารู้คุณสมบัติของอินพุตนี้ ก็ทำ optimization แบบฉลาดเกินไปอย่างresult += (1 | *s++) - 'r';ได้เหมือนกัน มันเป็นโค้ดที่เจ้าเล่ห์มาก แต่ก็แสดงประเด็นเรื่องการใช้คุณสมบัติของข้อมูลได้ชัดเจน'\0'ได้มากสุดครั้งเดียวเพราะฟังก์ชันจะคืนค่าทันที แต่ตัวอักษรอื่นอาจเจอได้หลายครั้ง ข้อมูลนี้ดูเหมือนเป็นสิ่งที่คอมไพเลอร์เข้าถึงได้แม้ไม่มี PGOแน่นอนว่า PGO ช่วยได้ และบนเครื่องผมมันได้ 2.80 วินาที ซึ่งดีกว่าโค้ดท้ายส่วน
Rearranging blocksอินพุตถูกอธิบายไว้ในBenchmarking setupและก็อยู่ใน repository ด้วย: https://github.com/414owen/blog-code/blob/master/01-six-time...ในภาค 2 ที่ลิงก์ไว้ท้ายบทความ ผู้เขียนทำโค้ด C ให้เร็วที่สุดจนชนะ assembly ทั้งหมดในบทความนี้ ผมไม่เคยบอกว่าการเขียน assembly เป็นความคิดที่ดีเสมอไป แค่มองว่าการ optimize และการแกะความหมายผลลัพธ์จากคอมไพเลอร์เป็นโจทย์ที่น่าสนใจและเป็นโอกาสเรียนรู้ที่ดี
ผมคิดว่าน่าจะทำให้เร็วกว่าในบทความและบทความต่อเนื่องได้ แต่ก็ต้องแลกกับการเจาะจงกรณีที่สตริงประกอบด้วยแค่
's'และ'p'ตัว benchmark เองก็ทดสอบเฉพาะสตริงที่มีแต่
's'กับ'p'อยู่แล้ว เลยมองว่ายุติธรรม ประเด็นหลักคือ เราอยากเพิ่มresทีละ 1 เมื่ออักขระถัดไปเป็นsแต่res += c - 'r'ใช้ไม่ได้ เพราะแม้กับsจะได้ 1 แต่กับpจะได้ -2 อย่างไรก็ตาม ถ้ามอง'p' - 'r'เป็นจำนวนเต็มแบบ unsigned มันจะ underflow และตั้งค่า carry flag จากนั้นadcของ x64 จะบวกรีจิสเตอร์สองตัวพร้อม carry flag ได้ ดังนั้นจึงแทนcmp, cmovสองชุดด้วยsub, adcชุดเดียวได้ เวอร์ชันนี้เร็วกว่าเวอร์ชัน C ในบทความต่อ 1.08 เท่า และเร็วกว่า x64-7 เดิม 1.66 เท่า แน่นอนว่าถ้าใช้ SWAR/SIMD ก็ยังน่าจะปรับได้อีกเป็นแนวทางที่น่าสนใจ ผมน่าจะเขียนให้ชัดว่า assembly ที่ค่อนข้างเรียบง่ายใน
02-the-same-speed-as-c/loop-5.x64.sคือเวอร์ชันที่เร็วที่สุดที่ผมมีบนเครื่องผม
loop-5.x64.sใช้เวลา 0.244 วินาที ส่วน implementation ข้างบนได้ 0.422 วินาที ผมเองก็ไม่แน่ใจนักว่าทำไมถึงต่างกันแบบนี้ เพราะดูเผิน ๆ implementation ข้างบนเหมือนจะเร็วกว่า ดังนั้นควร benchmark บนฮาร์ดแวร์ที่จะใช้รันจริงเสมอถ้าจะให้ง่ายกว่านั้น ก็รวมค่าของสมาชิกทุกตัวใน array ก่อน แล้วค่อยลบ
'p' * lenทิ้งตอนท้าย แล้วหารด้วย('s' - 'p')เพื่อหาจำนวนsจำนวนpก็คือlen - s_countขั้นรวมค่าเริ่มต้นก็ vectorize ได้ง่ายเช่นกัน ถ้าไม่ได้พลาดอะไร มันก็น่าจะทำงานได้ ปัญหาเดียวคือความเป็นไปได้ที่ผลรวมสะสมจะ overflow ผมไม่ค่อยมีแรงจูงใจจะ benchmark เอง แก้ไข: ผมลืมไปว่าตอนเจอ
sค่ามันลดลง ดังนั้นผลลัพธ์สุดท้ายคือp_count - s_countstrlen()น่าจะถูก implement มาให้เร็วพอสมควรอยู่แล้ว และถ้ารู้ขนาดบัฟเฟอร์ คอมไพเลอร์ก็สามารถ auto-vectorize ลูปด้านในได้จริง ๆ แล้ว โค้ด
len = strlen(buf)แล้วตามด้วยลูปforที่บวก(buf[i] == 's') - (buf[i] == 'p')สามารถ auto-vectorize ได้: https://gcc.godbolt.org/z/qYfadPYoqเมื่อก่อนผมเคยเขียน Common Lisp UTF-8 decoder สำหรับ SBCL มีตัวถอดรหัสในตัวอยู่แล้ว เลยทำเป็นแบบฝึกหัด
ถ้าไม่นับ optimization ง่าย ๆ ที่เห็นชัดอยู่แล้ว เกือบทุกการปรับปรุงประสิทธิภาพมาจากการจัดโครงสร้างโค้ดให้คอมไพเลอร์สร้างคำสั่ง
cmov*แทน branchตัวถอดรหัส UTF-8 มักถูกรันกับอินพุตที่เป็น ASCII ล้วน เลยอยากรู้ว่าคุณ benchmark ด้วยอินพุตแบบไหน