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

128

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

let a: i128 = 170141183460469231731687303715884105727;

¿Cómo hace Rust para que estos i128valores 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 i128valor? ¿O en su lugar están utilizando algún tipo de estructura entera grande para representarlos?

ruohola
fuente
54
¿Cómo funciona un número entero de dos dígitos cuando solo tienes 10 dedos?
Jörg W Mittag
27
@JorgWMittag: Ah, la vieja estratagema de "dos dígitos con solo diez dedos". Je je. Pensé que podrías engañarme con ese viejo, ¿eh? Bueno, amigo mío, como cualquier niño de segundo grado podría decirte: ¡PARA eso están los dedos de los pies! ( Con disculpas abyectas a Peter Sellers ... y Lady Lytton :-)
Bob Jarvis - Restablece a Monica el
1
FWIW la mayoría de las máquinas x86 tienen algunos registros especiales de 128 bits o más grandes para operaciones SIMD. Ver en.wikipedia.org/wiki/Streaming_SIMD_Extensions Editar: de alguna manera me perdí el comentario de @ eckes
Ryan1729
44
@ JörgWMittag Nah, los informáticos cuentan en binario bajando o extendiendo los dedos individuales. Y ahora, 132, me voy a casa ;-D
Marco13

Respuestas:

141

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 instrucciones LLVM generalmente funcionan en enteros de cualquier tamaño.

Obviamente, no hay muchas arquitecturas de 8388607 bits, por lo 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 la adddefine 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 se puede emular una addinstrucción en dos i8s con una instrucción más amplia, los bits adicionales se descartan).

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

En el nivel de LLVM IR, la respuesta es ninguna: i128cabe 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 enteros de 128 bits en el software cuando se dirige a un back-end que no los admite de forma nativa.

trentcl
fuente
1
Eh, me pregunto por qué es 2 ^ 23 en lugar de la más típica de 2 ^ 32 (bueno, hablando en términos generales en cuanto a la frecuencia con que aparecen los números, no en términos de anchuras máximas de bits de enteros con el apoyo de backends compilador ...)
Fondo Demanda de Mónica
26
@NicHartley Algunas de las clases base de LLVM tienen un campo donde las subclases pueden almacenar datos. Para la Typeclase, esto significa que hay 8 bits para almacenar qué tipo de tipo es (función, bloque, entero, ...) y 24 bits para datos de subclase. ¡La IntegerTypeclase usa esos 24 bits para almacenar el tamaño, permitiendo que las instancias encajen perfectamente en 32 bits!
Todd Sewell el
56

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 x86adc que hace que sea bastante eficiente hacer add / sub de entero 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 agregados 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 42está almacenado en raxy rcx.

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

A modo de comparación, aquí está el asm para los 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(saltar si CF = 1) funcionaría bien.

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

mcarton
fuente
44
@Anush No, rax / rsp / ... son registros de 64 bits. Cada número de 128 bits se almacena en dos registros / ubicaciones de memoria, lo que da como resultado las dos adiciones de 64 bits.
ManfP
55
@Anush: no, solo está usando tantas instrucciones porque está compilado con la optimización deshabilitada. Veías mucho código más simple (como simplemente el complemento / ADC) cuando se ha compilado una función que tuvo dos u128argumentos y devuelve un valor (como este godbolt.org/z/6JBza0 ), en lugar de deshabilitar la optimización para detener el compilador de hacer propagación constante en argumentos de tiempo de compilación constante.
Peter Cordes el
3
@ El modo de lanzamiento CAD97 utiliza aritmética de ajuste, pero no comprueba el desbordamiento y el pánico como lo hace el modo de depuración. Este comportamiento fue definido por RFC 560 . No es UB.
trentcl
3
@PeterCordes: específicamente, Rust the language especifica que el desbordamiento no está especificado, y rustc (el único compilador) especifica dos comportamientos para elegir: Panic o Wrap. Idealmente, Panic se usaría por defecto. En la práctica, debido a una generación de código subóptima, en el modo Release el valor predeterminado es Wrap, y un objetivo a largo plazo es pasar a Pánico cuando (si alguna vez) la generación de código es "lo suficientemente buena" para el uso general. Además, todos los tipos integrales de Rust admiten operaciones con nombre para elegir un comportamiento: verificado, ajuste, saturación, ... para que pueda anular el comportamiento seleccionado en función de cada operación.
Matthieu M.
1
@MatthieuM .: Sí, me encanta la envoltura versus marcada versus saturada agregar / sub / shift / cualquier método en tipos primitivos. Mucho mejor que la envoltura de C sin firmar, UB firmó obligándote a elegir en función de eso. De todos modos, algunos ISA podrían proporcionar un soporte eficiente para Panic, por ejemplo, una bandera adhesiva que puede verificar después de una secuencia completa de operaciones. (A diferencia de x86 OF o CF, que se sobrescriben con 0 o 1.) por ejemplo, ForwardCom ISA propuesto por Agner Fog ( agner.org/optimize/blog/read.php?i=421#478 ) Pero eso todavía limita la optimización para que nunca haga ningún cálculo la fuente de Rust no lo hizo. : /
Peter Cordes
30

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 cambio 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.

hobbs
fuente
26

Para proporcionar quizás un ejemplo más claro, en x86_64, compilado con la -Obandera, 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 u128más de lo i128que pediste. La función compila el mismo código de cualquier manera, una buena demostración de que la adición firmada y no firmada es la misma en una CPU moderna).

La otra lista 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 ade 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 descartarán, porque los bits por encima del 128 no caben en u128ay 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 la 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

SPARC tiene códigos de control como los indicadores de control de x86, pero debe usar una instrucción especial add,ccpara 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.

Davislor
fuente
1
último párrafo: para detectar cuál de los dos números sin signo es mayor al observar el bit alto de un subresultado, necesita un n+1sub resultado secundario para nlas entradas de bit. es decir, debe observar la ejecución, no el bit de signo del resultado del mismo ancho. Es por eso que las condiciones de bifurcación sin signo x86 se basan en CF (bit 64 o 32 del resultado lógico completo), no SF (bit 63 o 31).
Peter Cordes
1
El enfoque de re: divmod: AArch64 es proporcionar división y una instrucción que haga un número entero x - (a*b), calculando el resto del dividendo, el cociente y el divisor. (Eso es útil incluso para divisores constantes que usan un inverso multiplicativo para la parte de división). No había leído sobre los ISA que fusionan las instrucciones div + mod en una sola operación divmod; está muy bien.
Peter Cordes
1
re: flags: yes, una salida de flag es una segunda salida que OoO exec + register-renaming tiene que manejar de alguna manera. Las CPU x86 lo manejan manteniendo algunos bits adicionales con el resultado entero en el que se basa el valor FLAGS, por lo que probablemente ZF, SF y PF se generan sobre la marcha cuando sea necesario. Creo que hay una patente de Intel sobre esto. Por lo tanto, eso reduce el número de salidas que deben rastrearse por separado de nuevo a 1. (En las CPU Intel, ningún uop puede escribir más de 1 registro entero; por ejemplo, mul r64es 2 uops, y el segundo escribe la mitad alta del RDX).
Peter Cordes
1
Pero para una precisión extendida eficiente, las banderas son muy buenas. El problema principal es sin cambiar el nombre del registro para la ejecución en orden superescalar. Las banderas son un peligro WAW (escribir después de escribir). Por supuesto, las instrucciones de agregar con llevar son de 3 entradas, y ese también es un problema importante para rastrear. Intel antes de Broadwell decodificó adc, sbby cmova 2 uops cada uno. (Haswell introdujo uops de 3 entradas para FMA, Broadwell extendió eso a entero.)
Peter Cordes
1
Los ISA RISC con banderas generalmente hacen que la configuración de la bandera sea opcional, controlada por un bit adicional. por ejemplo, ARM y SPARC son así. PowerPC, como de costumbre, hace que todo sea más complicado: tiene 8 registros de código de condición (agrupados en un registro de 32 bits para guardar / restaurar) para que pueda comparar con cc0 o cc7 o lo que sea. ¡Y luego los códigos de condición AND u OR juntos! Las instrucciones de bifurcación y cmov pueden elegir qué registro CR leer. Por lo tanto, esto le brinda la capacidad de tener múltiples cadenas de depósito de banderas en vuelo a la vez, como x86 ADCX / ADOX. alanclements.org/power%20pc.html
Peter Cordes