Số nguyên 128 bit của Rust `i128` hoạt động như thế nào trên hệ thống 64 bit?


128

Rust có số nguyên 128 bit, các số nguyên này được biểu thị bằng kiểu dữ liệu i128(và u128cho các số nguyên không dấu):

let a: i128 = 170141183460469231731687303715884105727;

Làm thế nào để Rust làm cho các i128giá trị này hoạt động trên hệ thống 64 bit; ví dụ như làm thế nào để số học trên những?

Vì, theo như tôi biết, giá trị không thể vừa trong một thanh ghi của CPU x86-64, trình biên dịch bằng cách nào đó có sử dụng 2 thanh ghi cho một i128giá trị không? Hoặc thay vào đó họ sử dụng một số loại cấu trúc số nguyên lớn để đại diện cho họ?



54
Làm thế nào để một số nguyên hai chữ số hoạt động khi bạn chỉ có 10 ngón tay?
Jörg W Mittag

27
@JorgWMittag: Ah - mánh "số hai chữ số" cũ chỉ bằng mười ngón tay. Heh heh. Nghĩ rằng bạn có thể đánh lừa tôi với cái cũ, eh? Chà, bạn của tôi, như bất kỳ học sinh lớp hai nào cũng có thể nói với bạn - ĐÓ là những ngón chân dùng để làm gì! ( Với lời từ chối lời xin lỗi tới Peter Sellers ... và Lady Lytton :-)
Bob Jarvis - Tái lập lại

1
FWIW hầu hết các máy x86 có một số thanh ghi đặc biệt 128 bit hoặc lớn hơn cho các hoạt động SIMD. Xem en.wikipedia.org/wiki/Streaming_SIMD_Extensions Chỉnh sửa: Tôi bằng cách nào đó đã bỏ lỡ nhận xét của @ eckes
Ryan1729

4
@ JörgWMittag Nah, các nhà khoa học máy tính đếm nhị phân bằng cách hạ thấp hoặc mở rộng các ngón tay riêng lẻ. Và bây giờ, 132, tôi sẽ về nhà ;-D
Marco13

Câu trả lời:


141

Tất cả các kiểu số nguyên của Rust được biên dịch thành các số nguyên LLVM . Máy trừu tượng LLVM cho phép các số nguyên có độ rộng bit bất kỳ từ 1 đến 2 ^ 23 - 1. * Các lệnh LLVM thường hoạt động trên các số nguyên có kích thước bất kỳ.

Rõ ràng, không có nhiều kiến ​​trúc 8388607 bit ngoài đó, vì vậy khi mã được biên dịch thành mã máy gốc, LLVM phải quyết định cách triển khai nó. Các ngữ nghĩa của một lệnh trừu tượng như addđược định nghĩa bởi chính LLVM. Thông thường, các hướng dẫn trừu tượng có một lệnh tương đương trong mã gốc sẽ được biên dịch theo hướng dẫn gốc đó, trong khi các hướng dẫn không được mô phỏng, có thể có nhiều lệnh gốc. Câu trả lời của mcarton cho thấy cách LLVM biên dịch cả các hướng dẫn gốc và mô phỏng.

(Điều này không chỉ áp dụng cho các số nguyên lớn hơn máy gốc có thể hỗ trợ mà còn áp dụng cho các số nguyên nhỏ hơn. Ví dụ, các kiến ​​trúc hiện đại có thể không hỗ trợ số học 8 bit gốc, do đó, một addlệnh trên hai i8giây có thể được mô phỏng với một lệnh rộng hơn, các bit thừa bị loại bỏ.)

Trình biên dịch bằng cách nào đó sử dụng 2 thanh ghi cho một i128giá trị? Hay họ đang sử dụng một số loại cấu trúc số nguyên lớn để đại diện cho họ?

Ở cấp độ LLVM IR, câu trả lời là không: i128phù hợp với một thanh ghi, giống như mọi loại giá trị đơn khác . Mặt khác, một khi được dịch sang mã máy, thực sự không có sự khác biệt giữa hai loại này, bởi vì các cấu trúc có thể được phân tách thành các thanh ghi giống như các số nguyên. Tuy nhiên, khi thực hiện số học, đặt cược khá an toàn rằng LLVM sẽ chỉ tải toàn bộ vào hai thanh ghi.


* Tuy nhiên, không phải tất cả các phụ trợ LLVM đều được tạo như nhau. Câu trả lời này liên quan đến x86-64. Tôi hiểu rằng hỗ trợ phụ trợ cho các kích thước lớn hơn 128 và không có quyền hạn của hai là không chính xác (điều này có thể giải thích một phần lý do tại sao Rust chỉ hiển thị các số nguyên 8-, 16-, 32-, 64- và 128 bit). Theo est31 trên Reddit , Rustc thực hiện các số nguyên 128 bit trong phần mềm khi nhắm mục tiêu vào một phụ trợ không hỗ trợ chúng nguyên bản.


1
Huh, tôi tự hỏi tại sao lại là 2 ^ 23 thay vì 2 ^ 32 điển hình hơn (nói rộng ra về mức độ thường xuyên của những con số đó, không phải về độ rộng bit tối đa của số nguyên được hỗ trợ bởi phụ trợ trình biên dịch ...)
Fund Vụ kiện của Monica

26
@NicHartley Một số cơ sở của LLVM có một trường nơi các lớp con có thể lưu trữ dữ liệu. Đối với Typelớp, điều này có nghĩa là có 8 bit để lưu trữ loại đó là gì (hàm, khối, số nguyên, ...) và 24 bit cho dữ liệu của lớp con. Sau IntegerTypeđó, lớp sử dụng 24 bit đó để lưu trữ kích thước, cho phép các thể hiện vừa vặn trong 32 bit!
Todd Sewell

56

Trình biên dịch sẽ lưu trữ chúng trong nhiều thanh ghi và sử dụng nhiều hướng dẫn để thực hiện số học trên các giá trị đó nếu cần. Hầu hết các ISA đều có hướng dẫn bổ sung mang theo như x86,adc điều này làm cho việc thêm / phụ số nguyên có độ chính xác mở rộng khá hiệu quả.

Ví dụ, được đưa ra

fn main() {
    let a = 42u128;
    let b = a + 1337;
}

trình biên dịch tạo ra các mục sau khi biên dịch cho x86-64 mà không tối ưu hóa:
(các bình luận được thêm bởi @PeterCordes)

playground::main:
    sub rsp, 56
    mov qword ptr [rsp + 32], 0
    mov qword ptr [rsp + 24], 42         # store 128-bit 0:42 on the stack
                                         # little-endian = low half at lower address

    mov rax, qword ptr [rsp + 24]
    mov rcx, qword ptr [rsp + 32]        # reload it to registers

    add rax, 1337                        # add 1337 to the low half
    adc rcx, 0                           # propagate carry to the high half. 1337u128 >> 64 = 0

    setb    dl                           # save carry-out (setb is an alias for setc)
    mov rsi, rax
    test    dl, 1                        # check carry-out (to detect overflow)
    mov qword ptr [rsp + 16], rax        # store the low half result
    mov qword ptr [rsp + 8], rsi         # store another copy of the low half
    mov qword ptr [rsp], rcx             # store the high half
                             # These are temporary copies of the halves; probably the high half at lower address isn't intentional
    jne .LBB8_2                       # jump if 128-bit add overflowed (to another not-shown block of code after the ret, I think)

    mov rax, qword ptr [rsp + 16]
    mov qword ptr [rsp + 40], rax     # copy low half to RSP+40
    mov rcx, qword ptr [rsp]
    mov qword ptr [rsp + 48], rcx     # copy high half to RSP+48
                  # This is the actual b, in normal little-endian order, forming a u128 at RSP+40
    add rsp, 56
    ret                               # with retval in EAX/RAX = low half result

nơi bạn có thể thấy rằng giá trị 42được lưu trữ trong raxrcx.

(lưu ý của biên tập viên: x86-64 Các quy ước gọi C trả về số nguyên 128 bit trong RDX: RAX. Nhưng điều này mainhoàn toàn không trả về giá trị. chế độ.)

Để so sánh, đây là mã asm cho các số nguyên Rust 64 bit trên x86-64 trong đó không cần thêm tiện ích mang theo, chỉ cần một thanh ghi hoặc khe ngăn xếp cho mỗi giá trị.

playground::main:
    sub rsp, 24
    mov qword ptr [rsp + 8], 42           # store
    mov rax, qword ptr [rsp + 8]          # reload
    add rax, 1337                         # add
    setb    cl
    test    cl, 1                         # check for carry-out (overflow)
    mov qword ptr [rsp], rax              # store the result
    jne .LBB8_2                           # branch on non-zero carry-out

    mov rax, qword ptr [rsp]              # reload the result
    mov qword ptr [rsp + 16], rax         # and copy it (to b)
    add rsp, 24
    ret

.LBB8_2:
    call panic function because of integer overflow

Setb / test vẫn hoàn toàn dư thừa: jc(nhảy nếu CF = 1) sẽ hoạt động tốt.

Khi tối ưu hóa được kích hoạt, trình biên dịch Rust không kiểm tra tràn vì vậy +hoạt động như thế nào .wrapping_add().


4
@Anush Không, rax / rsp / ... là các thanh ghi 64 bit. Mỗi số 128 bit được lưu trữ ở hai vị trí thanh ghi / bộ nhớ, dẫn đến hai bổ sung 64 bit.
ManfP

5
@Anush: không, nó chỉ sử dụng rất nhiều hướng dẫn bởi vì nó được biên dịch với tối ưu hóa bị vô hiệu hóa. Bạn sẽ thấy mã đơn giản hơn nhiều (như chỉ là add / adc) nếu bạn đã biên dịch một hàm lấy hai đối số u128và trả về một giá trị (như godbolt.org/z/6JBza0 ), thay vì vô hiệu hóa tối ưu hóa để ngăn chặn trình biên dịch thực hiện truyền liên tục trên các đối số biên dịch thời gian biên dịch.
Peter Cordes

3
@ CAD97 Chế độ phát hành sử dụng gói số học nhưng không kiểm tra tràn và hoảng loạn như chế độ gỡ lỗi. Hành vi này được xác định bởi RFC 560 . Đó không phải là UB.
trentcl

3
@PeterCordes: Cụ thể, Rust ngôn ngữ chỉ định rằng tràn là không xác định và Rustc (trình biên dịch duy nhất) chỉ định hai hành vi để chọn: Panic hoặc Wrap. Lý tưởng nhất là Panic sẽ được sử dụng theo mặc định. Trong thực tế, do tạo mã tối ưu phụ, trong chế độ Phát hành, mặc định là Gói và mục tiêu dài hạn là chuyển sang Panic khi (nếu có) việc tạo mã là "đủ tốt" để sử dụng chính. Ngoài ra, tất cả các loại tích phân Rust đều hỗ trợ các hoạt động được đặt tên để chọn một hành vi: đã kiểm tra, gói, bão hòa, ... để bạn có thể ghi đè hành vi đã chọn trên cơ sở mỗi hoạt động.
Matthieu M.

1
@MatthieuM.: Có, tôi thích gói so với kiểm tra so với bão hòa add / sub / shift / bất kỳ phương thức nào trên các kiểu nguyên thủy. Vì vậy, tốt hơn nhiều so với gói C không dấu, UB đã ký buộc bạn phải chọn dựa trên đó. Dù sao, một số ISA có thể cung cấp hỗ trợ hiệu quả cho Panic, ví dụ: cờ dính mà bạn có thể kiểm tra sau toàn bộ chuỗi hoạt động. (Không giống như OF hoặc CF của x86 được ghi đè bằng 0 hoặc 1.), ví dụ: ForwardCom ISA được đề xuất của Agner Fog ( agner.org/optizes/blog/read.php?i=421#478 ) nguồn Rust đã không làm. : /
Peter Cordes

30

Có, giống như cách sử dụng số nguyên 64 bit trên máy 32 bit hoặc số nguyên 32 bit trên máy 16 bit hoặc thậm chí số nguyên 16 và 32 bit trên máy 8 bit (vẫn áp dụng cho vi điều khiển! ). Có, bạn lưu trữ số trong hai thanh ghi hoặc vị trí bộ nhớ hoặc bất cứ thứ gì (nó không thực sự quan trọng). Phép cộng và phép trừ là tầm thường, thực hiện hai hướng dẫn và sử dụng cờ mang. Phép nhân đòi hỏi ba bội số và một số bổ sung (phổ biến là các chip 64 bit đã có hoạt động nhân 64x64-> 128 tạo ra hai thanh ghi). Bộ phận ... yêu cầu một chương trình con và khá chậm (ngoại trừ trong một số trường hợp, phép chia theo hằng số có thể được chuyển thành ca hoặc nhân), nhưng nó vẫn hoạt động. Bitwise và / hoặc / xor chỉ phải được thực hiện ở hai nửa trên và dưới một cách riêng biệt. Ca có thể được thực hiện với xoay và mặt nạ. Và điều đó bao gồm khá nhiều thứ.


26

Để cung cấp một ví dụ rõ ràng hơn, trên x86_64, được biên dịch với -Ocờ, hàm

pub fn leet(a : i128) -> i128 {
    a + 1337
}

biên dịch thành

example::leet:
  mov rdx, rsi
  mov rax, rdi
  add rax, 1337
  adc rdx, 0
  ret

(Bài viết gốc của tôi có u128chứ không phải là i128bạn đã hỏi về. Hàm này biên dịch cùng một mã, một minh chứng tốt cho thấy bổ sung có chữ ký và không dấu là giống nhau trên CPU hiện đại.)

Các danh sách khác sản xuất mã không tối ưu hóa. Sẽ an toàn khi bước qua trình gỡ lỗi, bởi vì nó đảm bảo bạn có thể đặt điểm dừng ở bất kỳ đâu và kiểm tra trạng thái của bất kỳ biến nào tại bất kỳ dòng nào của chương trình. Nó chậm hơn và khó đọc hơn. Phiên bản tối ưu hóa gần hơn với mã sẽ thực sự chạy trong sản xuất.

Tham số acủa hàm này được truyền trong một cặp thanh ghi 64 bit, rsi: rdi. Kết quả được trả về trong một cặp thanh ghi khác, rdx: rax. Hai dòng mã đầu tiên khởi tạo tổng thành a.

Dòng thứ ba thêm 1337 vào từ thấp của đầu vào. Nếu điều này tràn ra, nó mang số 1 trong cờ mang của CPU. Dòng thứ tư thêm 0 vào từ cao của đầu vào cộng với 1 nếu nó được thực hiện.

Bạn có thể nghĩ về điều này như việc thêm đơn giản một số có một chữ số vào số có hai chữ số

  a  b
+ 0  7
______
 

nhưng trong căn cứ 18.446.744.073.709.551.616. Trước tiên, bạn vẫn đang thêm chữ số thấp nhất, có thể mang số 1 vào cột tiếp theo, sau đó thêm chữ số tiếp theo cộng với chữ số mang. Phép trừ rất giống nhau.

Phép nhân phải sử dụng danh tính (2⁶⁴a + b) (2⁶⁴c + d) = 2¹²⁸ac + 2⁶⁴ (quảng cáo + bc) + bd, trong đó mỗi phép nhân này trả về nửa trên của sản phẩm trong một thanh ghi và nửa dưới của sản phẩm trong khác. Một số thuật ngữ đó sẽ bị loại bỏ, bởi vì các bit trên 128 không phù hợp với a u128và bị loại bỏ. Mặc dù vậy, điều này cần một số hướng dẫn máy. Bộ phận cũng mất vài bước. Đối với một giá trị đã ký, phép nhân và phép chia sẽ cần phải chuyển đổi các dấu của toán hạng và kết quả. Những hoạt động đó không hiệu quả lắm.

Trên các kiến ​​trúc khác, nó trở nên dễ dàng hơn hoặc khó hơn. RISC-V định nghĩa một phần mở rộng tập lệnh 128 bit, mặc dù theo hiểu biết của tôi thì không ai thực hiện nó trong silicon. Không có phần mở rộng này, hướng dẫn kiến ​​trúc RISC-V đề xuất một nhánh có điều kiện:addi t0, t1, +imm; blt t0, t1, overflow

SPARC có các mã điều khiển như các cờ điều khiển của x86, nhưng bạn phải sử dụng một lệnh đặc biệt add,cc, để đặt chúng. Mặt khác, MIPS yêu cầu bạn kiểm tra xem tổng của hai số nguyên không dấu có đúng hơn một trong các toán hạng hay không. Nếu vậy, bổ sung tràn. Ít nhất bạn có thể đặt một thanh ghi khác thành giá trị của bit carry mà không cần nhánh có điều kiện.


1
đoạn cuối: Để phát hiện số nào trong hai số không dấu lớn hơn bằng cách nhìn vào bit cao của subkết quả, bạn cần một n+1kết quả phụ nbit cho đầu vào bit. tức là bạn cần nhìn vào phần thực hiện, không phải bit dấu của kết quả cùng chiều rộng. Đó là lý do tại sao các điều kiện nhánh không dấu x86 dựa trên CF (bit 64 hoặc 32 của kết quả logic đầy đủ), chứ không phải SF (bit 63 hoặc 31).
Peter Cordes

1
re: divmod: Cách tiếp cận của AArch64 là cung cấp phép chia và hướng dẫn thực hiện số nguyên x - (a*b), tính toán phần còn lại từ cổ tức, thương số và ước số. (Điều đó hữu ích ngay cả đối với các ước số không đổi bằng cách sử dụng nghịch đảo nhân cho phần chia). Tôi đã không đọc về ISA kết hợp các hướng dẫn div + mod vào một hoạt động divmod duy nhất; đó là gọn gàng.
Peter Cordes

1
re: flags: yes, đầu ra cờ là đầu ra thứ 2 mà OoO exec + đổi tên đăng ký phải xử lý bằng cách nào đó. CPU x86 xử lý nó bằng cách giữ thêm một vài bit với kết quả số nguyên mà giá trị FLAGS dựa trên, do đó, có lẽ ZF, SF và PF được tạo khi đang cần. Tôi nghĩ rằng có một bằng sáng chế của Intel về điều này. Vì vậy, việc giảm số lượng đầu ra phải được theo dõi riêng biệt trở lại 1. (Trong CPU Intel, không có uop nào có thể ghi nhiều hơn 1 thanh ghi số nguyên, ví dụ: mul r642 uops, với cái thứ 2 viết nửa cao RDX).
Peter Cordes

1
Nhưng để có độ chính xác mở rộng hiệu quả, cờ rất tốt. Vấn đề chính là không có đăng ký đổi tên để thực hiện theo thứ tự superscalar. cờ là một mối nguy WAW (viết sau khi viết). Tất nhiên, hướng dẫn bổ sung mang theo là 3 đầu vào và đó cũng là một vấn đề quan trọng cần theo dõi. Intel trước Broadwell giải mã adc, sbbcmovtới 2 UOPs mỗi. (Haswell đã giới thiệu các vòng 3 đầu vào cho FMA, Broadwell đã mở rộng thành số nguyên.)
Peter Cordes

1
ISA RISC với cờ thường làm cho cài đặt cờ tùy chọn, được điều khiển bởi một bit phụ. ví dụ ARM và SPARC giống như thế này. PowerPC như thường lệ khiến mọi thứ trở nên phức tạp hơn: nó có 8 thanh ghi mã điều kiện (được đóng gói cùng nhau thành một thanh ghi 32 bit để lưu / khôi phục) để bạn có thể so sánh thành cc0 hoặc cc7 hoặc bất cứ thứ gì. Và sau đó VÀ hoặc HOẶC mã điều kiện với nhau! Hướng dẫn chi nhánh và cmov có thể chọn đăng ký CR để đọc. Vì vậy, điều này cung cấp cho bạn khả năng có nhiều cờ dep chuỗi trong cùng một lúc, như x86 ADCX / ADOX. alanclements.org/power%20pc.html
Peter Cordes
Khi sử dụng trang web của chúng tôi, bạn xác nhận rằng bạn đã đọc và hiểu Chính sách cookieChính sách bảo mật của chúng tôi.
Licensed under cc by-sa 3.0 with attribution required.