Как Rust 128-битное целое число "i128" работает в 64-битной системе?

Rust имеет 128-битные целые числа, они обозначены типом данных i128u128 для беззнаковых целых):

let a: i128 = 170141183460469231731687303715884105727;

Как Rust заставляет эти значения i128 работать в 64-битной системе; например как это делает арифметику на них?

Поскольку, насколько мне известно, значение не может поместиться в один регистр процессора x86-64, компилятор как-то использует 2 регистра для одного значения i128? Или они вместо этого используют какую-то большую целочисленную структуру для их представления?

Ответ 1

Все целочисленные типы Rust скомпилированы в целые числа LLVM. Абстрактная машина LLVM допускает целые числа любой битовой ширины от 1 до 2 ^ 23 - 1. * Инструкции instructions LLVM обычно работают с целыми числами любого размера.

Очевидно, что существует не так много 8388607-битных архитектур, поэтому, когда код компилируется в машинный код, LLVM должен решить, как его реализовать. Семантика абстрактной инструкции, такой как add, определяется самой LLVM. Как правило, абстрактные инструкции, которые имеют эквивалент одной инструкции в собственном коде, будут скомпилированы с этой собственной инструкцией, в то время как те, которые не будут эмулироваться, возможно, с несколькими собственными инструкциями. Ответ маккартона демонстрирует, как LLVM компилирует как собственные, так и эмулированные инструкции.

(Это относится не только к целым числам, которые больше, чем может поддерживать собственная машина, но и к тем, которые меньше. Например, современные архитектуры могут не поддерживать собственную арифметику битов 8-, поэтому инструкция add для двух i8 может эмулироваться с более широкой инструкцией, лишние биты отбрасываются.)

Компилятор как-то использует 2 регистра для одного значения i128? Или они используют какую-то большую целочисленную структуру для их представления?

На уровне LLVM IR ответ не один: i128 помещается в один регистр, как и любой другой однозначный тип. С другой стороны, после перевода в машинный код разница между ними на самом деле не существует, поскольку структуры могут быть разложены на регистры, как целые числа. Однако при выполнении арифметики вполне вероятно, что LLVM просто загрузит все это в два регистра.


* Однако не все бэкэнды LLVM созданы равными. Этот ответ относится к x86-64. Я понимаю, что внутренняя поддержка для размеров больше 128 и не степеней двойки является пятнистой (что может частично объяснить, почему Rust предоставляет только битовые целые числа 8-, 16-, 32-, 64- и 12 8-).). Согласно est31 в Reddit, rustc реализует 128-битные целые числа в программном обеспечении при нацеливании на бэкэнд, который не поддерживает их изначально.

Ответ 2

Компилятор будет хранить их в нескольких регистрах и использовать несколько инструкций для выполнения арифметических операций с этими значениями, если это необходимо. У большинства ISA есть инструкция add-with-carry, например, x86 adc, что делает довольно эффективным целочисленное добавление /sub с расширенной точностью.

Например, учитывая

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

компилятор генерирует следующее при компиляции для x86-64 без оптимизации:
(комментарии добавлены @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

где вы можете видеть, что значение 42 хранится в rax и rcx.

(примечание редактора: соглашения о вызовах x86-64 C возвращают 128-разрядные целые числа в RDX: RAX. Но этот main вообще не возвращает значение. Все избыточное копирование происходит исключительно из-за отключения оптимизации, и что Rust фактически проверяет переполнение в режиме отладки.)

Для сравнения приведем asm для 64-разрядных целых чисел Rust на x86-64, где не требуется добавление с переносом, только один регистр или слот стека для каждого значения.

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 все еще полностью избыточен: jc (переход, если CF = 1) будет работать просто отлично.

При включенной оптимизации компилятор Rust не проверяет переполнение, поэтому + работает подобно .wrapping_add().

Ответ 3

Да, точно так же, как обрабатывались 64-битные целые числа на 32-битных машинах, или 32-битные целые на битовых машинах 16-, или даже 16- и 32-битные целые на 8-битных машинах (все еще применимо к микроконтроллеры!). Да, вы храните номер в двух регистрах, или в ячейках памяти, или где-либо еще (это не имеет значения). Сложение и вычитание тривиальны, принимая две инструкции и используя флаг переноса. Умножение требует трех умножений и некоторых сложений (для 64-разрядных микросхем обычно уже есть операция умножения 64x64-> 128, которая выводит в два регистра). Деление... требует подпрограммы и является довольно медленным (за исключением некоторых случаев, когда деление на константу может быть преобразовано в сдвиг или умножение), но оно все еще работает. Побитовые и/или/или просто должны быть сделаны на верхней и нижней половин по отдельности. Сдвиги могут быть выполнены с вращением и маскированием. И это в значительной степени охватывает вещи.

Ответ 4

Чтобы дать, возможно, более ясный пример, на x86_64, скомпилированном с флагом -O, функция

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

компилируется в

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

(В моем исходном сообщении было u128, а не i128, о котором вы спрашивали. Функция компилирует один и тот же код в любом случае, хорошая демонстрация того, что подпись и добавление без знака одинаковы на современном процессоре.)

Другой листинг дал неоптимизированный код. Его безопасно перешагнуть в отладчике, потому что он гарантирует, что вы можете установить точку останова в любом месте и проверить состояние любой переменной в любой строке программы. Его медленнее и труднее читать. Оптимизированная версия намного ближе к коду, который будет фактически работать в производстве.

Параметр a этой функции передается в паре 64-битных регистров rsi: rdi. Результат возвращается в другой паре регистров, rdx: rax. Первые две строки кода инициализируют сумму a.

Третья строка добавляет 1337 к младшему слову ввода. Если это переполнение, он несет 1 в флаге переноса CPU. Четвертая строка добавляет ноль к старшему слову ввода - плюс 1, если он переносится.

Вы можете думать об этом как о простом добавлении однозначного числа к двузначному числу

  a  b
+ 0  7
______
 

но в базе 18,446,744,073,709,551,616. Вы по-прежнему добавляете младшую цифру сначала, возможно, переносите 1 в следующий столбец, затем добавляете следующую цифру плюс перенос. Вычитание очень похоже.

Умножение должно использовать тождество (2⁶⁴a + b) (2⁶⁴c + d) = 2¹²⁸ac + 2⁶⁴ (ad + bc) + bd, где каждое из этих умножений возвращает верхнюю половину произведения в одном регистре, а нижнюю половину произведения в еще один. Некоторые из этих терминов будут отброшены, потому что биты выше 128 не укладываются в u128 и отбрасываются. Несмотря на это, для этого требуется ряд машинных инструкций. Отдел также принимает несколько шагов. Для значения со знаком умножение и деление дополнительно должны были бы преобразовать знаки операндов и результат. Эти операции не очень эффективны.

На других архитектурах это становится проще или сложнее. RISC-V определяет 128-битное расширение набора команд, хотя, насколько мне известно, никто не реализовал его в кремнии. Без этого расширения руководство по архитектуре RISC-V рекомендует условную ветвь: addi t0, t1, +imm; blt t0, t1, overflow

SPARC имеет управляющие коды, такие как управляющие флаги x86, но для их установки необходимо использовать специальную инструкцию add,cc. MIPS, с другой стороны, требует, чтобы вы проверяли, строго ли сумма двух целых чисел без знака меньше, чем один из операндов. Если это так, сложение переполнено. По крайней мере, вы можете установить другой регистр в значение бита переноса без условного перехода.