Як працює 128-розрядний цілий `i128` Руста на 64-бітній системі?


128

Іржа має 128-бітні цілі числа, вони позначаються типом даних i128u128для непідписаних вкладишів):

let a: i128 = 170141183460469231731687303715884105727;

Як Rust змушує ці i128значення працювати в 64-бітній системі; наприклад, як це робить арифметику на них?

Оскільки, наскільки я знаю, значення не може вміститися в одному реєстрі процесора x86-64, чи компілятор якось використовує 2 регістри для одного i128значення? Або вони замість цього використовують якусь велику цілу структуру для їх представлення?



54
Як працює двоцифрове ціле число, якщо у вас всього 10 пальців?
Йорг W Міттаг

27
@JorgWMittag: Ах - старий "двоцифровий номер із лише десятьма пальцями". Хе-хе. Думав, ти можеш обдурити мене тим старим, так? Ну, мій друже, як міг тобі сказати будь-який другокласник - ЦЕ для чого пальці ніг! ( Маючи вибачення перед Пітером Селлером ... та леді Лайттон :-)
Боб Джарвіс -

1
Більшість машин FWIW мають x86 спеціальні 128-бітні або більші регістри для SIMD-операцій. Дивіться en.wikipedia.org/wiki/Streaming_SIMD_Extensions Редагувати: я якось пропустив коментар @ eckes
Ryan1729

4
@ JörgWMittag Не, комп'ютерні підрахунки в двійковій формі, опускаючи або розгинаючи окремі пальці. А тепер, 132 роки, я йду додому ;-D
Marco13

Відповіді:


141

Усі цілі типи Руста складені в цілі числа LLVM . Абстрактна машина LLVM дозволяє цілі числа будь-якої ширини бітів від 1 до 2 ^ 23 - 1. * Інструкції LLVM зазвичай працюють на цілі числа будь-якого розміру.

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

(Це стосується не лише цілих чисел, які можуть підтримувати вбудована машина, але й тих, які менші. Наприклад, сучасні архітектури можуть не підтримувати нативну 8-бітну арифметику, тому може бути емуляція addінструкції щодо двох i8s при більш широкій інструкції додаткові біти відкидаються.)

Чи компілятор якось використовує 2 регістри для одного i128значення? Або вони використовують якусь велику цілу структуру для їх представлення?

На рівні ІР LLVM відповідь не є жодним: i128вписується в єдиний реєстр, як і будь-який інший однозначний тип . З іншого боку, щойно переведений на машинний код, насправді різниці між ними не існує, тому що структури можуть бути розкладені на регістри так само, як цілі числа. Однак, виконуючи арифметику, це досить безпечна ставка, що LLVM просто завантажить всю справу в два регістри.


* Однак, не всі пакети LLVM створюються рівними. Ця відповідь стосується x86-64. Я розумію, що підтримка бекенда для розмірів, більших за 128, та двох не потужностей, є плямистими (що частково може пояснити, чому Руст виставляє лише 8, 16-, 32-, 64- та 128-бітні цілі числа). За даними est31 про Reddit , rustc реалізує 128-бітні цілі числа в програмному забезпеченні під час націлювання на бекенд, який не підтримує їх на самому світі.


1
Так, мені цікаво, чому це 2 ^ 23 замість більш типових 2 ^ 32 (ну, якщо говорити широко з точки зору того, як часто з'являються ці числа, а не з точки зору максимальної бітової ширини цілих чисел, підтримуваних компілятором…)
Фонд Позов Моніки

26
@NicHartley Деякі базові класи LLVM мають поле, де підкласи можуть зберігати дані. Для Typeкласу це означає, що є 8 біт для зберігання типу типу (функція, блок, ціле число, ...) та 24 біти для даних підкласу. Потім IntegerTypeклас використовує ці 24 біти для зберігання розміру, що дозволяє екземплярам акуратно вписатись у 32 біти!
Тодд Сьюелл

56

Компілятор буде зберігати їх у декількох регістрах і використовувати численні інструкції, щоб виконати арифметику цих значень, якщо потрібно. Більшість ISA мають інструкцію щодо додаткового перенесення, як x86,adc що робить її досить ефективною для додавання / підрозділу з цілою цілою чисельністю.

Наприклад, дано

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

Постановка / тест все ще є надлишковим: jc(стрибнути, якщо CF = 1) буде добре працювати.

При включеній оптимізації, компілятор Іржа не перевіряє переповнення так +працює як .wrapping_add().


4
@Anush Ні, rax / rsp / ... - це 64-бітні регістри. Кожне 128-бітове число зберігається у двох місцях реєстрації / пам'яті, що призводить до двох 64-бітових доповнень.
ManfP

5
@Anush: ні, він просто використовує стільки інструкцій, оскільки він складений з відключеною оптимізацією. Ви побачили б набагато простіший код (як-от тільки add / adc), якщо ви склали функцію, яка взяла два u128аргументи та повернула значення (наприклад, цей godbolt.org/z/6JBza0 ), замість того, щоб вимкнути оптимізацію, щоб зупинити компілятор не робити константа-розповсюдження на аргументи постійного збирання та часу.
Пітер Кордес

3
@ CAD97 Реліз випуску використовує обертову арифметику, але не перевіряє наявність переповнення та паніки, як це робить режим налагодження. Така поведінка була визначена RFC 560 . Це не UB.
trentcl

3
@PeterCordes: Зокрема, мова Rust визначає, що переповнення не визначено, а rustc (єдиний компілятор) визначає два способи поведінки: Panic або Wrap. В ідеалі Panic буде використовуватися за замовчуванням. На практиці, завдяки неоптимальному генеруванню коду, у режимі випуску за замовчуванням використовується Wrap, а довгострокова мета - перейти на Panic, коли (якщо взагалі колись) генерування коду буде "достатньо" для використання в основному режимі. Крім того, всі типи інтегрального типу Rust підтримують названі операції, щоб вибрати поведінку: перевірено, загортання, насичення, ... так що ви можете переосмислити вибрану поведінку за кожну операцію.
Матьє М.

1
@MatthieuM .: Так, я люблю обгортання проти перевіреного порівняно з насичуючим методом add / sub / shift / будь-яких методів на примітивних типах. Так набагато краще, ніж обгортання C без підписання, UB підписало, що змусило вас вибирати на основі цього. У будь-якому випадку, деякі ISA можуть забезпечити ефективну підтримку паніки, наприклад, липкий прапор, який ви можете перевірити після цілої послідовності операцій. (На відміну від x86 OF або CF, які перезаписані 0 або 1.), наприклад, запропонований Agner Fog ForwardCom ISA ( agner.org/optimize/blog/read.php?i=421#478 ), але це все ще обмежує оптимізацію, щоб ніколи не робити жодного розрахунку джерело Іржі не зробив. : /
Пітер Кордес

30

Так, так само, як оброблялися 64-бітні цілі числа на 32-бітних машинах, або 32-бітні цілі числа на 16-бітних машинах, або навіть 16- і 32-бітні цілі числа на 8-бітних машинах (все ще застосовні до мікроконтролерів! ). Так, ви зберігаєте номер у двох регістрах чи місцях пам'яті, або будь-якому іншому (це не має значення). Додавання та віднімання тривіально, беручи дві інструкції та використовуючи прапор перенесення. Для множення потрібні три множення та деякі доповнення (для 64-бітових чіпів звичайно вже операція множення 64x64-> 128, яка виводиться на два регістри). Ділення ... вимагає підпрограми і є досить повільним (за винятком випадків, коли ділення на константу може бути перетворене на зсув або множення), але воно все одно працює. Побітові та / або / xor повинні бути виконані на верхній і нижній половинах окремо. Зсуви можна здійснити за допомогою обертання та маскування. І це в значній мірі охоплює речі.


26

Щоб надати більш чіткий приклад, на 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 в переносному процесорі. Четвертий рядок додає нуль до високого слова вводу — плюс 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, з іншого боку, вимагає перевірити, чи сума двох непідписаних цілих чисел суворо менша, ніж один із операндів. Якщо так, додаток переповнилося. Принаймні, ви можете встановити інший регістр для значення переносу без умовної гілки.


1
останній абзац: Щоб виявити, яке з двох безпідписаних чисел більше, переглядаючи високий біт subрезультату, вам потрібен n+1трохи підсумковий результат для nвведення бітів. тобто потрібно дивитись на виконання, а не на біт знаку результату однакової ширини. Ось чому умови безпідписної гілки x86 засновані на CF (біт 64 або 32 повного логічного результату), а не SF (біт 63 або 31).
Пітер Кордес

1
re: divmod: Підхід AArch64 полягає у наданні поділу та інструкції, яка робить ціле число x - (a*b), обчислюючи залишки з дивіденду, коефіцієнта та дільника. (Це корисно навіть для постійних дільників, що використовують мультиплікативний зворотний для частини поділу). Я не читав про ISA, які з'єднують інструкції div + mod в одну операцію divmod; це акуратно.
Пітер Кордес

1
re: flags: так, вихід прапора - це другий вихід, з яким OoO exec + перейменування реєстру має якось оброблятись. Процесори x86 обробляють це, зберігаючи кілька зайвих бітів з цілим результатом, на якому базується значення FLAGS, тому, ймовірно, ZF, SF і PF генеруються на льоту при необхідності. Я думаю, що щодо цього є патент Intel. Таким чином, це зменшує кількість виходів, які необхідно відстежувати окремо назад до 1. (У процесорах Intel жоден взагалі не може записати більше 1 цілого регістру; наприклад mul r64, 2 уп, а другий пише верхню RDX).
Пітер Кордес

1
Але для ефективної розширеної точності прапори дуже хороші. Основна проблема - без перейменування реєстру для надскалярного виконання порядку. прапори - небезпека WAW (писати після запису). Звичайно, інструкції щодо доповнення - це 3-вхідні дані, і це також є суттєвою проблемою. Intel , перш ніж Бродуелла декодируется adc, sbbі cmov2 микрооперации кожен. (Haswell представив 3-вхідні Uops для FMA, Бродвелл розширив це на ціле число.)
Пітер Кордес,

1
RISC ISA з прапорами зазвичай встановлюють прапор необов'язково, керуючи додатковим бітом. наприклад, ARM та SPARC такі. PowerPC, як завжди, ускладнює все: він має 8 регістрів коду стану (упаковані разом в один 32-розрядний реєстр для збереження / відновлення), щоб ви могли порівнювати в cc0 або cc7 чи будь-що інше. І тоді разом І або АБО-коди умов разом! Вказівки відділення та cmov можуть вибрати, який регістр CR читати. Таким чином, це дає вам можливість мати декілька ланцюгів для виведення прапор одночасно під час польоту, як x86 ADCX / ADOX. alanclements.org/power%20pc.html
Пітер Кордес
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.