rust x86-64 bigint int128 llvm-codegen

¿Cómo funciona el entero de 128 bits `i128` de Rust en un sistema de 64 bits?



x86-64 bigint (4)

Rust tiene enteros de 128 bits, estos se denotan con el tipo de datos i128 (y u128 para u128 sin signo):

let a: i128 = 170141183460469231731687303715884105727;

¿Cómo hace Rust para que estos valores de i128 funcionen en un sistema de 64 bits? Por ejemplo, ¿cómo hace aritmética en estos?

Dado que, hasta donde yo sé, el valor no puede caber en un registro de una CPU x86-64, ¿el compilador de alguna manera usa 2 registros para un valor i128 ? ¿O en su lugar están utilizando algún tipo de estructura entera grande para representarlos?


El compilador los almacenará en múltiples registros y usará múltiples instrucciones para hacer aritmética en esos valores si es necesario. La mayoría de los ISA tienen una instrucción add-with-carry como adc de x86 que hace que sea bastante eficiente hacer add / sub de enteros de precisión extendida.

Por ejemplo, dado

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

el compilador genera lo siguiente al compilar para x86-64 sin optimización:
(comentarios añadidos por @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

donde puede ver que el valor 42 se almacena en rax y rcx .

(Nota del editor: las convenciones de llamadas x86-64 C devuelven enteros de 128 bits en RDX: RAX. Pero este main no devuelve ningún valor. Toda la copia redundante es puramente por la desactivación de la optimización, y ese Rust realmente comprueba el desbordamiento en modo de depuración.)

A modo de comparación, aquí está el asm para números enteros Rust de 64 bits en x86-64 donde no se necesita agregar con acarreo, solo un registro único o ranura de pila para cada valor.

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

El setb / test sigue siendo totalmente redundante: jc (salto si CF = 1) funcionaría bien.

Con la optimización habilitada, el compilador Rust no comprueba el desbordamiento, por lo que + funciona como .wrapping_add() .


Para proporcionar quizás un ejemplo más claro, en x86_64, compilado con el indicador -O , la función

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

compila a

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

(Mi publicación original tenía u128 lugar del i128 que preguntaste. La función compila el mismo código de cualquier manera, una buena demostración de que la adición firmada y sin firmar es la misma en una CPU moderna).

El otro listado produjo código no optimizado. Es seguro avanzar en un depurador, porque se asegura de que pueda poner un punto de interrupción en cualquier lugar e inspeccionar el estado de cualquier variable en cualquier línea del programa. Es más lento y más difícil de leer. La versión optimizada está mucho más cerca del código que realmente se ejecutará en producción.

El parámetro a de esta función se pasa en un par de registros de 64 bits, rsi: rdi. El resultado se devuelve en otro par de registros, rdx: rax. Las dos primeras líneas de código inicializan la suma a a .

La tercera línea agrega 1337 a la palabra baja de la entrada. Si esto se desborda, lleva el 1 en el indicador de acarreo de la CPU. La cuarta línea agrega cero a la palabra alta de la entrada, más el 1 si se transfirió.

Puede pensar en esto como una simple adición de un número de un dígito a un número de dos dígitos

a b + 0 7 ______  

pero en la base 18,446,744,073,709,551,616. Todavía está agregando el "dígito" más bajo primero, posiblemente llevando un 1 a la siguiente columna, luego agregando el siguiente dígito más el acarreo. La resta es muy similar.

La multiplicación debe usar la identidad (2⁶⁴a + b) (2⁶⁴c + d) = 2¹²⁸ac + 2⁶⁴ (ad + bc) + bd, donde cada una de estas multiplicaciones devuelve la mitad superior del producto en un registro y la mitad inferior del producto en otro. Algunos de esos términos se eliminarán, porque los bits por encima del 128 no caben en un u128 y se descartan. Aun así, esto requiere una serie de instrucciones de la máquina. La división también toma varios pasos. Para un valor con signo, la multiplicación y la división necesitarían además convertir los signos de los operandos y el resultado. Esas operaciones no son muy eficientes en absoluto.

En otras arquitecturas, se vuelve más fácil o más difícil. RISC-V define una extensión de conjunto de instrucciones de 128 bits, aunque que yo sepa nadie lo ha implementado en silicio. Sin esta extensión, el manual de arquitectura RISC-V recomienda una rama condicional: addi t0, t1, +imm; blt t0, t1, overflow addi t0, t1, +imm; blt t0, t1, overflow

SPARC tiene códigos de control como los indicadores de control de x86, pero debe usar una instrucción especial, add,cc , para configurarlos. MIPS, por otro lado, requiere que verifique si la suma de dos enteros sin signo es estrictamente menor que uno de los operandos. Si es así, la adición se desbordó. Al menos puede establecer otro registro al valor del bit de acarreo sin una rama condicional.


Sí, de la misma manera que se manejaron enteros de 64 bits en máquinas de 32 bits, o enteros de 32 bits en máquinas de 16 bits, o incluso enteros de 16 y 32 bits en máquinas de 8 bits (¡todavía aplicable a microcontroladores! ) Sí, almacena el número en dos registros, o ubicaciones de memoria, o lo que sea (realmente no importa). La suma y la resta son triviales, toman dos instrucciones y usan la bandera de acarreo. La multiplicación requiere tres multiplicaciones y algunas adiciones (es común que los chips de 64 bits ya tengan una operación de multiplicación de 64x64-> 128 que genera dos registros). La división ... requiere una subrutina y es bastante lenta (excepto en algunos casos donde la división por una constante puede transformarse en un turno o una multiplicación), pero aún funciona. Bitwise y / o / xor simplemente tienen que hacerse en las mitades superior e inferior por separado. Los cambios se pueden lograr con rotación y enmascaramiento. Y eso prácticamente cubre las cosas.


Todos los tipos enteros de Rust se compilan en enteros LLVM . La máquina abstracta LLVM permite enteros de cualquier ancho de bit de 1 a 2 ^ 23 - 1. * Las instructions LLVM generalmente funcionan en enteros de cualquier tamaño.

Obviamente, no hay muchas arquitecturas de 8388607 bits, así que cuando el código se compila en código de máquina nativo, LLVM tiene que decidir cómo implementarlo. La semántica de una instrucción abstracta como add está definida por el propio LLVM. Por lo general, las instrucciones abstractas que tienen un equivalente de instrucción única en código nativo se compilarán a esa instrucción nativa, mientras que las que no se emularán, posiblemente con múltiples instrucciones nativas. La respuesta de mcarton demuestra cómo LLVM compila tanto las instrucciones nativas como las emuladas.

(Esto no solo se aplica a los enteros que son más grandes de lo que la máquina nativa puede admitir, sino también a los que son más pequeños. Por ejemplo, las arquitecturas modernas pueden no admitir la aritmética nativa de 8 bits, por lo que una instrucción add en dos i8 s puede ser emulado con una instrucción más amplia, los bits adicionales descartados).

¿El compilador de alguna manera usa 2 registros para un valor de i128 ? ¿O están usando algún tipo de estructura entera grande para representarlos?

En el nivel de LLVM IR, la respuesta es ninguna: i128 cabe en un único registro, al igual que cualquier otro tipo de valor único . Por otro lado, una vez traducido al código de máquina, no hay realmente una diferencia entre los dos, porque las estructuras pueden descomponerse en registros al igual que los enteros. Sin embargo, cuando se hace aritmética, es una apuesta bastante segura que LLVM simplemente cargará todo en dos registros.

* Sin embargo, no todos los backends LLVM se crean de la misma manera. Esta respuesta se relaciona con x86-64. Entiendo que el soporte de back-end para tamaños mayores de 128 y no potencias de dos es irregular (lo que puede explicar en parte por qué Rust solo expone enteros de 8, 16, 32, 64 y 128 bits). Según est31 en Reddit , rustc implementa números enteros de 128 bits en el software cuando apunta a un backend que no los admite de forma nativa.