ทริกฐาน 2^51 (2017)
(chosenplaintext.ca)- การบวกจำนวนเต็มขนาดใหญ่มักทำโดยแบ่งออกเป็น limb ขนาด 64 บิต แต่เมื่อเกิดการแพร่กระจายของตัวทด จะใช้ประโยชน์จากการประมวลผลแบบขนานของ CPU สมัยใหม่ได้ไม่เต็มที่
adcของ x86 พึ่งพา carry flag จากการดำเนินการก่อนหน้า ทำให้เชนคำสั่งถูกทำให้เป็นลำดับต่อเนื่อง จึงกลายเป็นคอขวดแม้บนสถาปัตยกรรมอย่าง Intel Haswell ที่สามารถรันaddหลายคำสั่งแบบขนานได้- การแทนค่าแบบ radix 2^51 แบ่งค่า 256 บิตออกเป็นหลักฐาน 2^51 จำนวนห้าหลัก แทนที่จะเป็นหลักฐาน 2^64 จำนวนสี่หลัก และใช้บิตส่วนบนที่เหลือของแต่ละ limb เป็นพื้นที่เก็บตัวทดระหว่างทาง
- ไม่ใช่วิธีที่กำจัดตัวทด แต่เป็นการ หน่วงการแพร่กระจาย ระหว่างการบวกหลายครั้ง แล้วค่อยจัดการทั้งหมดพร้อมกันในขั้นตอน normalization สุดท้าย
- ในเบนช์มาร์กง่าย ๆ บน Haswell แม้รวมต้นทุนการแปลงแล้ว ก็เร็วกว่า radix 2^64 ตั้งแต่ การบวกสามครั้ง เป็นต้นไป และยิ่งจำนวนรอบเพิ่มขึ้น ประโยชน์ก็ยิ่งมากขึ้น
ทำไมตัวทดจึงเป็นคอขวดในการบวกจำนวนเต็มขนาดใหญ่
- การบวกยาวบนกระดาษทำจากหลักหน่วย โดยไล่จากขวาไปซ้าย
- เพราะผลลัพธ์ของแต่ละหลักขึ้นอยู่กับ ตัวทด ที่ส่งมาจากหลักทางขวา
- หากบวกจากซ้ายก่อน ตัวทดที่เกิดขึ้นทีหลังอาจทำให้ต้องกลับไปแก้ผลลัพธ์ของหลักหน้าที่คำนวณไปแล้ว
- การบวกจำนวนเต็มขนาดใหญ่ก็มีข้อจำกัดแบบเดียวกัน
- หากแบ่งจำนวนเต็ม 256 บิต
xและyออกเป็น limb ขนาด 64 บิตสี่ตัว ก็สามารถบวก limb ที่อยู่ตำแหน่งเดียวกันเข้าด้วยกันได้ - ถ้า limb ต่ำเกิด overflow จะต้องส่งค่า 1 นั้นต่อไปยัง limb ที่สูงกว่า
- หากแบ่งจำนวนเต็ม 256 บิต
adcของ x86 คือคำสั่งที่จัดการการแพร่กระจายนี้- มันดูว่าการดำเนินการก่อนหน้า overflow หรือไม่ และบวก 1 เพิ่มเมื่อจำเป็น
- การบวก 256 บิตที่ถูกต้องจึงเรียงจาก limb ต่ำสุดเป็น
add,adc,adc,adc
โครงสร้างที่ทำให้ adc ช้าบน CPU สมัยใหม่
- โดยทั่วไป
adcมีต้นทุนการรันสูงกว่าaddปกติadcใช้อินพุตที่สามคือ carry flag จึงซับซ้อนกว่าadd- เนื่องจากถูกใช้น้อยกว่า
addผู้ออกแบบ CPU จึงมีแรงจูงใจน้อยกว่าในการทุ่มพื้นที่ชิปเพื่อปรับประสิทธิภาพadc
- ปัญหาที่ใหญ่กว่าคือ การพึ่งพากันของคำสั่ง
- บน Intel Haswell คำสั่ง
addเดี่ยวใช้เวลา execute 1 cycle - ภายใต้เงื่อนไขที่เหมาะสม Haswell สามารถ execute
addได้สูงสุด 4 คำสั่งต่อ cycle - Haswell มี execution port 8 พอร์ต และในนั้นมี 4 พอร์ตที่สามารถ execute
addจำนวนเต็มได้
- บน Intel Haswell คำสั่ง
addอิสระสี่คำสั่งมักรันแบบขนานได้ง่าย- ในทางกลับกัน เชน
adcแต่ละคำสั่งพึ่งพาเอาต์พุต carry flag ของคำสั่งก่อนหน้า - CPU ไม่สามารถทำให้คำสั่งเหล่านี้ขนานกันได้ และต้อง execute ตามลำดับ
- ในทางกลับกัน เชน
- ใน SIMD การสูญเสียยิ่งมากขึ้น
vpaddqทำการบวก 64 บิตสี่รายการพร้อมกัน- Haswell สามารถ execute
vpaddqได้สองคำสั่งต่อ cycle - หากต้องทิ้งความขนานนี้เพื่อจัดการตัวทด ประโยชน์ด้านประสิทธิภาพก็ลดลง
การหน่วงตัวทดเมื่อมองผ่านการบวกบนกระดาษ
- หากยังคงค่าประจำหลักแบบฐาน 10 ไว้ แต่ขยายชุดอักขระที่ใส่ในแต่ละหลักได้ ก็สามารถหน่วงตัวทดได้
- ใช้อักขระทั้งหมด 37 ตัว โดยเพิ่ม
A-Zและ*แทนที่จะมีแค่0-9ตามปกติ - แต่ตัวฐานเองไม่ใช่ฐาน 37 ยังยังคงเป็น ค่าประจำหลักแบบฐาน 10
- ใช้อักขระทั้งหมด 37 ตัว โดยเพิ่ม
- เมื่อหลักหนึ่งเกิน 9 ก็ไม่จำเป็นต้องทดทันที
29 + 1เขียนเป็น30ได้ แต่ก็แทนได้เป็น2A,1KหรือUเช่นกัน- หากแต่ละหลักของตัวเลขสองจำนวนถูก normalize ให้อยู่ไม่เกิน 9 อยู่แล้ว ก็สามารถเลื่อนการทดระหว่างการบวกออกไปได้
- ไม่ได้ใช้ได้กับอินพุตทุกแบบเสมอไป
- หากมีค่าหลักที่ใหญ่อยู่แล้ว เช่น
9 + Wก็จำเป็นต้องทด - สำหรับตัวเลขที่ normalize แล้ว สามารถบวกได้สูงสุดสี่จำนวนโดยไม่ต้องทด
- หากมีค่าหลักที่ใหญ่อยู่แล้ว เช่น
- ท้ายที่สุดต้อง normalize กลับเป็นการแทนค่าแบบฐาน 10 ปกติ
- คำนวณจากด้านขวาว่าแต่ละหลักมีค่า 10 อยู่กี่ชุด
- ลบจำนวนนั้นออกจากหลักปัจจุบัน แล้วส่งต่อไปยังหลักถัดไป
- แก่นสำคัญไม่ใช่การกำจัดการแพร่กระจายของตัวทด แต่คือการ เก็บไว้ระหว่างการคำนวณกลางทาง แล้วค่อยแพร่กระจายครั้งเดียวตอนท้าย
การแทนค่า radix 2^51 ในคอมพิวเตอร์
- หากแบ่งค่า 256 บิตออกเป็น limb ฐาน 2^64 สี่ตัว แต่ละ limb จะมีค่าได้ตั้งแต่ 0 ถึง 2^64−1
- นี่คือการมองแต่ละ limb เป็น หลักของฐาน 2^64
- เนื่องจากไม่สามารถขยายช่วงจำนวนเต็ม 64 บิตของฮาร์ดแวร์ได้ จึงลดขนาดของฐานลง
- แบ่งค่า 256 บิตออกเป็น หลักฐาน 2^51 ห้าหลัก แทนที่จะเป็นหลักฐาน 2^64 สี่หลัก
- แต่ละ limb ยังคงเก็บเป็นจำนวนเต็ม 64 บิต แต่ค่าจริงใช้เพียง 51 บิตหรือ 52 บิต
- บิตส่วนบนที่เหลือกลายเป็นพื้นที่เก็บตัวทดระหว่างทาง
- แต่ละ limb เก็บ 51 บิตหรือ 52 บิตของตัวเลขเดิม
- อีก 12 บิตหรือ 13 บิตที่เหลือใช้บรรจุตัวทดที่เกิดขึ้นระหว่างการคำนวณ
- เทคนิคนี้ในวรรณกรรมด้านคริปโตกราฟีเรียกว่า radix 2^51 representation
- หากเป็นจำนวนที่ normalize แล้ว ภายในค่าที่เป็นไปได้ 2^64 ค่าของ limb จะยังไม่ต้องกังวลเรื่อง overflow ของ 13 บิตบน จนกว่าจะบวกรวมกันถึง 2^13 ค่า
limb สูงสุด 52 บิตและการ normalize
- limb สูงสุดจัดสรรไว้ 52 บิต
- limb อื่น ๆ ใช้ 51 บิต
- ตัวทดของ limb สูงสุดจะถูกละทิ้ง เพื่อให้กรณีที่เกิน 2^256−1 ถูกจัดการแบบวนกลับ
- เหมือนกับวิธีที่การบวกจำนวนเต็ม unsigned ขนาดปกติใน C วนกลับเมื่อ overflow
- โค้ดบวก radix 2^51 ไม่ใช้เชน
adcแต่ executeaddห้าคำสั่งอย่างเป็นอิสระ- จำนวน
addเพิ่มจาก 4 เป็น 5 เมื่อเทียบกับวิธี limb ฐาน 2^64 สี่ตัว - แต่ไม่มีการพึ่งพา carry flag จึงรันแบบขนานได้
- จำนวน
- ในขั้นตอน normalization จะดึงบิตส่วนบนของแต่ละ limb ออกมาแล้วบวกเข้ากับ limb ถัดไปที่สูงกว่า
- ใช้
shr 51เพื่อดึงส่วน carry ออกมา - ใช้
and 0x0007FFFFFFFFFFFFเพื่อเหลือไว้เฉพาะ 51 บิตล่าง - limb สูงสุดจัดระเบียบด้วย
and 0x000FFFFFFFFFFFFF
- ใช้
- normalization คือขั้นตอนสุดท้ายที่ทำการแพร่กระจายตัวทดที่หน่วงไว้
- ระหว่างการบวกกลางทางจะไม่สร้างการพึ่งพา carry flag
- สุดท้ายปรับแต่ละ limb ให้กลับเข้าไปอยู่ในช่วงที่อนุญาต
ผลด้านประสิทธิภาพและการขยายไปสู่การลบ
- ในเบนช์มาร์กง่าย ๆ การบวก radix 2^51 ให้ผลเร็วกว่าเมื่อรันบน CPU Haswell
- รวมต้นทุนการแปลงเข้าและออกจากการแทนค่า radix 2^51 แล้ว
- แค่ การบวกสามครั้ง ก็เร็วกว่า radix 2^64 แล้ว
- ยิ่งจำนวนครั้งของการบวกเพิ่มขึ้น ผลประหยัดก็เพิ่มตามไปด้วย
- แนวคิดเดียวกันนี้ขยายไปใช้กับการลบได้ด้วย
- ในการลบ ตัวทดจะกลายเป็น negative carry
- หากต้องรองรับการลบ ต้องปฏิบัติกับ limb เหมือนเป็นจำนวนเต็ม signed ไม่ใช่ unsigned
- ค่าประจำแต่ละหลักอาจเป็นบวกหรือลบได้
- แต่ละ limb สามารถเก็บได้ทั้ง carry บวกและ carry ลบ
- การเปลี่ยนแปลงนี้มีต้นทุนตามมา
- บิตสูงสุดของแต่ละ limb ถูกสงวนไว้เป็นบิตเครื่องหมาย
- จำนวนการดำเนินการที่ทำได้ระหว่างการ normalize ลดจาก 2^13 เหลือ 2^12
- แม้จะต้องแบ่งข้อมูลลงในรีจิสเตอร์มากขึ้นและจำนวนการดำเนินการเพิ่มขึ้น แต่หากลด การพึ่งพาตัวทด ได้ ประสิทธิภาพโดยรวมก็อาจดีขึ้น
1 ความคิดเห็น
ความคิดเห็นบน Hacker News
สงสัยว่าทำไม่ได้หรือ ถ้าให้ limb ระดับบนสุดเป็น 64 บิต และ limb ที่เหลืออีกสี่ตัวเป็นตัวละ 48 บิต
จะสะสมการบวกได้มากขึ้นก่อน normalize และถ้าชุดคำสั่งมีฟีเจอร์ที่เป็นประโยชน์ ก็อาจใช้ประโยชน์จากการจัดแนว word ตอนแยกและ normalize ได้ แถมลักษณะ overflow ก็ดูเหมือนเดิม
สำหรับไลบรารีจำนวนเต็มขนาดใหญ่แบบทั่วไป อาจไม่ใช่ตัวเลือกที่ดีที่สุด และในอดีตคงไม่มี barrel shifter ที่ทำ arbitrary bit shift ได้มีประสิทธิภาพ จึงน่าจะดีกว่าที่จะใช้ 56 บิตจาก 64 บิต โดยเหลือไว้พอดี 1 ไบต์สำหรับ carry
RISC-V ไม่มี flag ดังนั้นประเด็นนี้จึงค่อนข้างเกี่ยวข้อง
เช่น ถ้าทั้งคู่เป็น 2^63 ก็ล้นทันที และแม้อาจใช้ได้กับ arithmetic แบบ wrapping แต่ไม่ถูกต้องในกรณีทั่วไป
ด้วย AVX512 และในระดับหนึ่ง AVX2 ก็สามารถทำ การบวก 256 บิต ได้ค่อนข้างมีประสิทธิภาพ และยังมีข้อดีที่ใส่ตัวเลขในรีจิสเตอร์ได้มากขึ้นด้วย
ใช้การผสม
_mm256_add_epi64, comparison mask และ carry mask โดย throughput ก็ดูดีกว่า: https://godbolt.org/z/e7zETe8xYการเปลี่ยนเป็นการบวก 512 บิตก็ไม่ยาก และตอนนั้นส่วนที่ดีขึ้นน่าจะมากกว่า
https://stackoverflow.com/questions/56852812/simd-instructio...
บน x86 CPU ที่ใหม่พอ เช่น Intel Broadwell หรือ AMD Ryzen ก็อาจใช้ ADX ได้ และในสถานการณ์อย่าง Curve25519 ที่การแทนค่า radix 2^51 เคยได้เปรียบแบบดั้งเดิม ทุกวันนี้อาจเร็วกว่าได้
[1] https://en.wikipedia.org/wiki/Intel_ADX
บทความที่เกี่ยวข้องคือเธรดเก่า ๆ เรื่อง radix 2^51 trick
The radix 2^51 trick - https://news.ycombinator.com/item?id=33706153 - พฤศจิกายน 2022
The radix 2^51 trick (2017) - https://news.ycombinator.com/item?id=23351007 - พฤษภาคม 2020
ประเด็นสำคัญคือ แม้จำนวน operation จะมากกว่า แต่ถ้าส่วนใหญ่เป็นอิสระต่อกัน ก็สามารถรันแบบขนานและอาจเร็วกว่าได้
ในทางกลับกัน ต่อให้จำนวน operation น้อยกว่า แต่ถ้าต้องรันแบบอนุกรมเพราะ data dependency ก็อาจช้ากว่าได้ และแนวคิดนี้ใช้ได้กว้างไกลกว่าการคำนวณจำนวนเต็มยาวมาก
จำนวนการบวกจะเพิ่มเป็นสองเท่า แต่สามารถลดเวลาการ propagate carry จากเชิงเส้นเป็น log(bits) ได้
การทำ carry ซับซ้อนกว่า แต่สามารถ parallelize การบวกจริงได้
แต่ตั้งแต่แรกก็ต้องแบ่งตัวเลขอินพุตเป็น ชุดรีจิสเตอร์ 5 ตัว อยู่แล้ว ถ้าอยากได้ประโยชน์โดยรวม การแบ่งนั้นก็ควร parallelize ได้ด้วยไม่ใช่หรือ
ถ้าใช้ 10,000 คอร์ ได้ overhead ก็ถือว่าน้อยจนมองข้ามได้
แสดงให้เห็นได้ดีมากว่าคนที่ทำงานกับ x86_64 อย่างเดียว เห็นว่าการที่ RISC-V ตัด carry flag ออกไปนั้นไม่ได้ผิด
insight สำคัญคือ ตราบใดที่ผลรวมในตำแหน่ง limb หนึ่งไม่ได้เป็น 1 ทั้งหมด carry ที่ออกจากตำแหน่งนั้นจะไม่ขึ้นกับ carry ที่เข้ามา แต่ขึ้นกับว่าการบวกดั้งเดิมในตำแหน่งนั้นสร้าง carry หรือไม่เท่านั้น
ถ้าผลรวมเป็น 1 ทั้งหมด carry ที่ออกไปจะเท่ากับ carry ที่เข้ามา
ถ้าแสดงสิ่งนี้เป็น conditional branch ที่ถูกทำนายว่า not-taken แทบเสมอ ภายใต้สมมติฐานว่าสามารถทำนาย conditional branch หลายตัวเป็น not-taken ได้ใน clock cycle เดียวกัน แต่ละบล็อกคำสั่งก็สามารถรันขนานกันได้เต็มที่
ทุก ๆ 2^64 ครั้งจะมีครั้งหนึ่งที่รันช้ามาก
สำหรับเครื่อง 4-wide กับตัวเลข 4-limb จะไม่ได้เปรียบกว่า
adcแต่ถ้าเป็นเครื่อง 8-wide กับตัวเลข 8-limb ก็เริ่มได้ประโยชน์มากขึ้นใน x86_64 ปัจจุบันอาจไม่ได้ช่วยมากนัก แต่ใน Apple M series ที่ M1 ก็เป็น 8-wide มีความเป็นไปได้ และอาจทำทางอ้อมได้ยากเพราะ Arm ISA
ถ้า Tenstorrent 8-wide RISC-V Ascalon ออกมาช่วงปลายปีนี้หรือต้นปี 2026 ก็คงได้ตรวจสอบจริงพร้อมกับ Ventana, Rivos, XiangShan และรายอื่น ๆ
ถ้ามี 1-lane shift ที่เร็ว ก็จะทำงานได้ดีขึ้นใน SIMD กว้าง ๆ ด้วย และใน RISC-V เรียกสิ่งนี้ว่า slideup
อัลกอริทึมการบวกหลาย word ทั้งสองแบบแทนที่กันไม่ได้และมี use case ต่างกัน ดังนั้น ISA ที่ดีควรมีคำสั่ง ADC/SBB และต้นทุนเพิ่มก็เล็กน้อย
ไม่จำเป็นต้องมี flag register เฉพาะด้วยซ้ำ บาง ISA จะเก็บ flag carry/borrow ไว้ในรีจิสเตอร์ทั่วไปเมื่อจำเป็น
การไม่มี carry ใน RISC-V ไม่ใช่คุณสมบัติที่แย่ที่สุด สิ่งที่แย่กว่าคือไม่มี integer overflow flag
โปรแกรมที่อ้างว่าเขียนอย่างปลอดภัยจำเป็นต้องตรวจจับ integer overflow แต่ถ้าเลี่ยงด้วยซอฟต์แวร์ จะทำให้ประสิทธิภาพที่ทำได้ลดลงยิ่งกว่าการเลี่ยงการไม่มี carry มาก
radix trick นี้นำไปใช้กับโครงสร้างข้อมูลได้ด้วย
มีตัวอย่างที่ดีในหนังสือ 『Purely Functional Data Structures』 ของ Okasaki
ถ้าได้เห็นบทความนี้เมื่อไม่กี่เดือนก่อนก็คงดี
ตอนพยายาม encode/decode buffer ด้วย radix ใด ๆ ก็ตาม ผมมาถึงข้อสรุปช้าเกินไปว่า carry อาจ propagate ไปถึงท้าย buffer ได้ และนั่นทำให้อัลกอริทึมช้าลงมาก
สุดท้ายวิธีแก้ก็มีส่วนคล้าย trick นี้ คือแบ่ง buffer เป็น chunk และเหลือ พื้นที่เผื่อ ไว้จัดการ carry
ไม่ได้เหมือนกันเป๊ะ ๆ โดยใช้บิตที่เสียเปล่าเล็กน้อย แลกกับการใช้พื้นที่เก็บข้อมูลหรือ bandwidth เครือข่ายเพิ่มขึ้นนิดเดียว เพื่อลดการคำนวณ
สงสัยว่าถ้ารวบ carry แบบนี้ไว้แล้วค่อยแก้ในขั้นตอนหลัง จะได้ทั้งสองทางหรือเปล่า แต่อาจเป็นแค่ความหวังก็ได้
รู้ว่า guideline ของ HN บอกว่าอย่าแก้ชื่อเรื่อง แต่ไม่ชอบ ชื่อพาดหัวเรียกคลิก ที่ขยายข้ออ้างเล็ก ๆ ให้กว้างเกินไป
ชื่อบทความนี้ควรเป็นประมาณ “radix 2^51 trick สำหรับบวกจำนวนเต็ม 64 บิตแบบขนานโดยไม่ทำให้ pipeline ช้าลงเพราะ carry dependency บนสถาปัตยกรรม x86 บางรุ่น”