Mẹo cơ số 2^51 (2017)
(chosenplaintext.ca)- Phép cộng số nguyên lớn thường được xử lý bằng cách chia thành các limb 64 bit, nhưng khi phát sinh lan truyền nhớ thì khó tận dụng đầy đủ lợi thế thực thi song song của CPU hiện đại
adccủa x86 phụ thuộc vào carry flag của phép toán trước đó nên chuỗi lệnh bị tuần tự hóa, trở thành nút thắt ngay cả trên các kiến trúc có thể chạy song song nhiều lệnhaddnhư Intel Haswell- Biểu diễn cơ số 2^51 chia giá trị 256 bit thành năm chữ số cơ số 2^51 thay vì bốn chữ số cơ số 2^64, tận dụng các bit cao còn dư của mỗi limb làm không gian lưu trữ nhớ trung gian
- Cách này không loại bỏ nhớ, mà trì hoãn lan truyền qua nhiều lần cộng rồi xử lý một lần ở bước chuẩn hóa cuối cùng
- Trong benchmark đơn giản trên Haswell, ngay cả khi tính cả chi phí chuyển đổi thì từ ba phép cộng trở lên đã nhanh hơn cách cơ số 2^64, và lợi thế càng tăng khi số lần lặp nhiều hơn
Vì sao nhớ trở thành nút thắt trong phép cộng số nguyên lớn
- Phép cộng dài trên giấy thường được thực hiện từ phải sang trái, bắt đầu ở hàng đơn vị
- Vì kết quả của mỗi chữ số phụ thuộc vào nhớ chuyển sang từ chữ số bên phải
- Nếu cộng từ trái sang phải thì nhớ phát sinh về sau sẽ buộc phải sửa lại các chữ số phía trước đã tính
- Phép cộng số nguyên lớn cũng có cùng ràng buộc
- Nếu chia hai số nguyên 256 bit
xvàythành bốn limb 64 bit thì có thể cộng các limb cùng vị trí với nhau - Nếu limb thấp bị tràn thì phải chuyển giá trị 1 đó sang limb cao hơn
- Nếu chia hai số nguyên 256 bit
adccủa x86 là lệnh dùng để xử lý sự lan truyền này- Nó kiểm tra xem phép toán trước có tràn hay không rồi cộng thêm 1 nếu cần
- Phép cộng 256 bit đúng nghĩa sẽ nối tiếp từ limb thấp nhất theo thứ tự
add,adc,adc,adc
Cấu trúc khiến adc chậm trên CPU hiện đại
adcnhìn chung tốn chi phí thực thi cao hơnaddthườngadcdùng carry flag như đầu vào thứ ba nên phức tạp hơnadd- Nó cũng ít được dùng hơn
add, nên nhà thiết kế CPU có ít động lực hơn để dành diện tích chip tối ưu hiệu năng choadc
- Vấn đề lớn hơn là phụ thuộc lệnh
- Trên Intel Haswell, một lệnh
addđơn mất 1 chu kỳ để thực thi - Trong điều kiện lý tưởng, Haswell có thể thực thi tối đa 4 lệnh
addmỗi chu kỳ - Haswell có 8 cổng thực thi, trong đó 4 cổng có thể chạy
addsố nguyên
- Trên Intel Haswell, một lệnh
- Bốn lệnh
addđộc lập rất dễ chạy song song- Ngược lại, chuỗi
adckhiến mỗi lệnh phụ thuộc vào đầu ra carry flag của lệnh trước - CPU không thể song song hóa các lệnh này mà phải thực thi tuần tự
- Ngược lại, chuỗi
- Với SIMD, tổn thất còn lớn hơn
vpaddqthực hiện đồng thời bốn phép cộng 64 bit- Haswell có thể chạy hai lệnh
vpaddqmỗi chu kỳ - Nếu phải từ bỏ tính song song này để xử lý nhớ thì lợi ích hiệu năng sẽ giảm đi
Trì hoãn nhớ qua ví dụ cộng trên giấy
- Có thể trì hoãn nhớ nếu vẫn giữ giá trị vị trí thập phân nhưng mở rộng tập ký tự được phép xuất hiện ở mỗi vị trí
- Thay vì chỉ dùng
0-9, ta dùng thêmA-Zvà*, tổng cộng 37 ký tự - Nhưng bản thân hệ cơ số không phải là cơ số 37, mà vẫn giữ giá trị vị trí thập phân
- Thay vì chỉ dùng
- Khi một chữ số vượt quá 9 thì không còn cần nhớ ngay lập tức
29 + 1có thể viết là30, nhưng cũng có thể biểu diễn như2A,1K, hoặcU- Nếu mỗi chữ số của cả hai số đều đã được chuẩn hóa trong phạm vi đến 9 thì có thể hoãn nhớ trong lúc cộng
- Cách này không phải lúc nào cũng áp dụng được với mọi đầu vào
- Nếu đã có chữ số lớn sẵn như
9 + Wthì vẫn cần nhớ - Với các số đã chuẩn hóa, có thể cộng tối đa bốn số mà vẫn biểu diễn được không cần nhớ
- Nếu đã có chữ số lớn sẵn như
- Cuối cùng vẫn phải chuẩn hóa lại về biểu diễn thập phân thông thường
- Đi từ phải sang trái để tính xem mỗi chữ số chứa bao nhiêu nhóm 10
- Trừ đi chừng đó ở chữ số hiện tại và chuyển sang chữ số kế tiếp
- Điểm cốt lõi không phải là loại bỏ lan truyền nhớ, mà là lưu nó trong quá trình tính trung gian rồi lan truyền một lần ở cuối
Biểu diễn cơ số 2^51 trong máy tính
- Nếu chia giá trị 256 bit thành bốn limb cơ số 2^64 thì mỗi limb có thể mang giá trị từ 0 đến 2^64−1
- Đây là cách xem mỗi limb như một chữ số trong cơ số 2^64
- Vì không thể mở rộng phạm vi số nguyên 64 bit của phần cứng, ta giảm kích thước cơ số
- Chia giá trị 256 bit thành năm chữ số cơ số 2^51 thay vì bốn chữ số cơ số 2^64
- Mỗi limb vẫn được lưu trong số nguyên 64 bit, nhưng giá trị thực chỉ dùng 51 hoặc 52 bit
- Các bit cao còn dư trở thành không gian lưu nhớ trung gian
- Mỗi limb chứa 51 hoặc 52 bit của số gốc
- 12 hoặc 13 bit còn lại dùng để chứa phần nhớ phát sinh trong khi tính
- Kỹ thuật này trong tài liệu mật mã học được gọi là radix 2^51 representation
- Với các số đã chuẩn hóa, trong không gian 2^64 giá trị limb có thể có thì không cần lo 13 bit cao bị tràn cho đến khi đã cộng tối đa 2^13 giá trị
Limb cao nhất 52 bit và chuẩn hóa
- Limb cao nhất được cấp 52 bit
- Các limb còn lại dùng 51 bit
- Phần nhớ của limb cao nhất bị bỏ qua để giá trị vượt quá 2^256−1 sẽ được xử lý theo kiểu wrap-around
- Điều này giống cách phép cộng số nguyên unsigned kích thước cố định thông thường trong C sẽ wrap khi tràn
- Mã cộng cơ số 2^51 không dùng chuỗi
adcmà thực thi độc lập năm lệnhadd- So với cách bốn limb cơ số 2^64, số lệnh
addtăng từ 4 lên 5 - Nhưng đổi lại không còn phụ thuộc carry flag nên có thể chạy song song
- So với cách bốn limb cơ số 2^64, số lệnh
- Ở bước chuẩn hóa, các bit cao của mỗi limb được lấy ra và cộng vào limb cao hơn kế tiếp
- Dùng
shr 51để trích phần carry - Dùng
and 0x0007FFFFFFFFFFFFđể chỉ giữ lại phần dưới 51 bit - Với limb cao nhất thì dùng
and 0x000FFFFFFFFFFFFFđể dọn lại
- Dùng
- Chuẩn hóa là bước thực hiện lan truyền nhớ đã trì hoãn ở cuối cùng
- Trong các phép cộng trung gian không tạo ra phụ thuộc vào carry flag
- Cuối cùng đưa mỗi limb trở lại đúng phạm vi cho phép
Kết quả hiệu năng và mở rộng sang phép trừ
- Trong benchmark đơn giản, phép cộng cơ số 2^51 cho kết quả nhanh hơn trên CPU Haswell
- Tính cả chi phí chuyển sang và quay về từ biểu diễn cơ số 2^51
- Chỉ với ba phép cộng đã nhanh hơn phép cộng cơ số 2^64
- Số phép cộng càng nhiều thì mức tiết kiệm càng tăng
- Cùng ý tưởng này cũng có thể mở rộng sang phép trừ
- Trong phép trừ, nhớ trở thành carry âm
- Để hỗ trợ phép trừ, các limb được xem như số nguyên có dấu thay vì unsigned
- Giá trị ở mỗi chữ số có thể dương hoặc âm
- Mỗi limb có thể lưu cả carry dương lẫn carry âm
- Sự thay đổi này có cái giá của nó
- Bit cao nhất của mỗi limb phải dành làm bit dấu
- Số phép toán có thể thực hiện giữa hai lần chuẩn hóa giảm từ 2^13 xuống 2^12
- Ngay cả khi dữ liệu phải trải trên nhiều thanh ghi hơn và số phép toán tăng lên, việc giảm phụ thuộc do nhớ vẫn có thể cải thiện hiệu năng tổng thể
1 bình luận
Ý kiến trên Hacker News
Có người thắc mắc liệu có thể để limb cao nhất là 64 bit, còn bốn limb còn lại mỗi limb 48 bit hay không
Như vậy có thể tích lũy nhiều phép cộng hơn trước khi chuẩn hóa, nếu tập lệnh có tính năng hữu ích thì khi tách và chuẩn hóa cũng có thể tận dụng căn chỉnh theo word, và đặc tính overflow trông cũng giống nhau
Với thư viện số nguyên lớn tổng quát thì có thể không tối ưu; trước đây không có barrel shifter để thực hiện dịch bit tùy ý một cách hiệu quả, nên cách dùng 56 bit trong 64 bit và chừa đúng 1 byte cho carry có lẽ đã tốt hơn
Vì RISC-V không có flag, cuộc thảo luận này khá liên quan
Ví dụ nếu cả hai đều là 2^63 thì tràn ngay; có thể ổn với số học wrapping, nhưng không phù hợp trong trường hợp thông thường
AVX512, và ở một mức nào đó cả AVX2, cũng có thể triển khai phép cộng 256 bit khá hiệu quả, đồng thời có lợi thế chứa được nhiều số hơn trong thanh ghi
Kết hợp
_mm256_add_epi64, mask so sánh và mask carry; throughput có vẻ cũng tốt hơn: https://godbolt.org/z/e7zETe8xYChuyển sang phép cộng 512 bit cũng đơn giản, và khi đó mức cải thiện có lẽ còn lớn hơn
https://stackoverflow.com/questions/56852812/simd-instructio...
Trên các CPU x86 đủ mới, chẳng hạn Intel Broadwell hay AMD Ryzen, cũng có thể dùng ADX, và ngày nay có thể còn nhanh hơn ngay cả trong các tình huống như Curve25519, nơi biểu diễn radix 2^51 từng có lợi thế truyền thống
[1] https://en.wikipedia.org/wiki/Intel_ADX
Các bài liên quan gồm những luồng thảo luận cũ về radix 2^51 trick
The radix 2^51 trick - https://news.ycombinator.com/item?id=33706153 - tháng 11 năm 2022
The radix 2^51 trick (2017) - https://news.ycombinator.com/item?id=23351007 - tháng 5 năm 2020
Điểm cốt lõi là dù số phép toán nhiều hơn, nếu chúng phần lớn độc lập thì có thể chạy song song và nhanh hơn
Ngược lại, dù số phép toán ít hơn nhưng phải chạy tuần tự vì phụ thuộc dữ liệu thì có thể chậm hơn; ý tưởng này áp dụng rộng hơn rất nhiều so với phép toán số nguyên dài
Số phép cộng tăng gấp đôi, nhưng có thể giảm thời gian lan truyền carry từ tuyến tính xuống log(bits)
Phép toán carry phức tạp hơn, nhưng các phép cộng thực sự có thể được song song hóa
Tuy nhiên, ngay từ đầu đã phải chia số đầu vào thành các nhóm 5 thanh ghi, nên để có lợi tổng thể thì chẳng phải bước chia đó cũng phải song song hóa được hay sao
Nếu có thể huy động 10.000 lõi, overhead trở nên không đáng kể
Điều này cho thấy rất rõ, với người chỉ làm việc trên x86_64, rằng việc RISC-V bỏ qua carry flag không hẳn là sai
Nhận xét then chốt là miễn là tổng ở một vị trí limb cụ thể không phải toàn bit 1, carry đi ra từ vị trí đó không phụ thuộc vào carry đi vào, mà chỉ phụ thuộc vào việc phép cộng ban đầu ở vị trí đó có tạo carry hay không
Nếu tổng toàn bit 1 thì carry đi ra bằng carry đi vào
Nếu biểu diễn điều này bằng nhánh có điều kiện gần như luôn được dự đoán là not-taken, thì với giả định nhiều nhánh có điều kiện có thể được dự đoán not-taken trong cùng một chu kỳ xung, từng khối lệnh có thể chạy hoàn toàn song song
Cứ 2^64 lần thì có một lần chạy rất chậm
Trên máy 4-wide với số 4 limb thì không có lợi thế so với
adc, nhưng trên máy 8-wide với số 8 limb thì lợi ích bắt đầu lớn lênCó thể không giúp ích nhiều cho x86_64 hiện tại, nhưng có tiềm năng trên dòng Apple M, nơi M1 cũng là 8-wide, dù việc đi đường vòng có thể khó vì Arm ISA
Khi RISC-V Ascalon 8-wide của Tenstorrent ra mắt vào cuối năm nay hoặc đầu năm 2026, ta có thể kiểm chứng thực tế cùng với Ventana, Rivos, XiangShan, v.v.
Nếu có shift 1-lane nhanh thì cách này cũng hoạt động tốt hơn trên SIMD rộng; trong RISC-V nó được gọi là slideup
Hai thuật toán cộng nhiều word này không thể thay thế cho nhau và có mục đích khác nhau, nên một ISA tử tế sẽ có lệnh ADC/SBB, mà chi phí bổ sung cũng rất nhỏ
Cũng không nhất thiết cần thanh ghi flag chuyên dụng; một số ISA lưu flag carry/borrow vào thanh ghi đa dụng khi cần
Việc RISC-V không có carry không phải đặc điểm tệ nhất; tệ hơn là không có integer overflow flag
Với các chương trình tự nhận là được viết an toàn, phát hiện overflow số nguyên là bắt buộc; nếu đi vòng bằng phần mềm thì hiệu năng có thể đạt được sẽ thấp hơn nhiều so với việc đi vòng cho thiếu carry
Radix trick này cũng áp dụng cho cấu trúc dữ liệu
Có ví dụ hay trong cuốn 『Purely Functional Data Structures』 của Okasaki
Giá mà tôi đọc được bài này vài tháng trước
Tôi đã cố mã hóa/giải mã buffer theo một cơ số tùy ý, rồi quá muộn mới đi đến kết luận rằng carry có thể lan tới cuối buffer và vì thế làm thuật toán chậm đi rất nhiều
Cuối cùng lời giải cũng có điểm tương tự trick này: chia buffer thành các chunk và chừa khoảng trống để xử lý carry
Không hoàn toàn giống; tôi để lại một ít bit lãng phí, dùng thêm chút ít dung lượng lưu trữ hoặc băng thông mạng để đổi lấy việc giảm tính toán
Không biết liệu bằng cách gom carry như vậy rồi xử lý ở giai đoạn sau có thể được cả đôi đường hay không, nhưng có lẽ chỉ là mong muốn
Tôi biết theo hướng dẫn của HN thì không nên sửa tiêu đề, nhưng tôi không thích những tiêu đề câu view thổi phồng một luận điểm nhỏ thành quá rộng
Tiêu đề bài này đáng ra nên là “radix 2^51 trick để cộng song song các số nguyên 64 bit trên một số kiến trúc x86 mà không làm chậm pipeline vì phụ thuộc carry”