Come funziona il numero intero a 128 bit `i128` di Rust su un sistema a 64 bit?


128

Rust ha numeri interi a 128 bit, indicati con il tipo di dati i128(e u128per gli integri senza segno):

let a: i128 = 170141183460469231731687303715884105727;

In che modo Rust fa funzionare questi i128valori su un sistema a 64 bit; es. come fa l'aritmetica su questi?

Dal momento che, per quanto ne so, il valore non può rientrare in un registro di una CPU x86-64, il compilatore utilizza in qualche modo 2 registri per un i128valore? O stanno invece usando una sorta di grande struttura intera per rappresentarli?



54
Come funziona un numero intero a due cifre quando hai solo 10 dita?
Jörg W Mittag,

27
@JorgWMittag: Ah - il vecchio stratagemma "a due cifre con solo dieci dita". Eh-eh. Pensavi di potermi ingannare con quel vecchio, eh? Bene, amico mio, come potrebbe dirti qualsiasi alunni di seconda elementare - È a questo che servono le dita dei piedi! ( Con scuse dispiaciute a Peter Sellers ... e Lady Lytton :-)
Bob Jarvis - Reinstalla Monica il

1
FWIW la maggior parte delle macchine x86 ha alcuni registri speciali a 128 bit o più grandi per le operazioni SIMD. Vedi en.wikipedia.org/wiki/Streaming_SIMD_Extensions Modifica: in qualche modo ho perso il commento di @ eckes
Ryan1729 il

4
@ JörgWMittag Nah, gli informatici contano in binario abbassando o estendendo le singole dita. E ora, 132 anni, vado a casa ;-D
Marco13

Risposte:


141

Tutti i tipi di numeri interi di Rust vengono compilati in numeri interi LLVM . La macchina astratta LLVM consente numeri interi di qualsiasi larghezza di bit da 1 a 2 ^ 23 - 1. * Le istruzioni LLVM normalmente funzionano su numeri interi di qualsiasi dimensione.

Ovviamente, non ci sono molte architetture a 8388607 bit là fuori, quindi quando il codice viene compilato in codice macchina nativo, LLVM deve decidere come implementarlo. La semantica di un'istruzione astratta come addè definita dalla stessa LLVM. Tipicamente, istruzioni astratte che hanno un equivalente di singola istruzione nel codice nativo saranno compilate a quell'istruzione nativa, mentre quelle che non lo saranno saranno emulate, possibilmente con più istruzioni native. La risposta di mcarton dimostra come LLVM compili sia le istruzioni native che quelle emulate.

(Ciò non si applica solo ai numeri più grandi di quelli che la macchina nativa può supportare, ma anche a quelli più piccoli. Ad esempio, le architetture moderne potrebbero non supportare l'aritmetica nativa a 8 bit, quindi è possibile emulare addun'istruzione su due i8secondi con un'istruzione più ampia, i bit extra vengono scartati.)

Il compilatore utilizza in qualche modo 2 registri per un i128valore? O stanno usando una sorta di grande struttura intera per rappresentarli?

A livello di LLVM IR, la risposta è nessuna delle due: si i128inserisce in un unico registro, proprio come ogni altro tipo a valore singolo . D'altra parte, una volta tradotto in codice macchina, non c'è davvero una differenza tra i due, perché le strutture possono essere scomposte in registri proprio come numeri interi. Quando si esegue l'aritmetica, tuttavia, è una scommessa abbastanza sicura che LLVM caricherà il tutto in due registri.


* Tuttavia, non tutti i back-end LLVM sono creati uguali. Questa risposta si riferisce a x86-64. Comprendo che il supporto back-end per dimensioni superiori a 128 e non-power di due è discutibile (il che potrebbe spiegare in parte perché Rust espone solo numeri interi a 8, 16, 32, 64 e 128 bit). Secondo est31 su Reddit , rustc implementa interi a 128 bit nel software quando si prende di mira un backend che non li supporta in modo nativo.


1
Eh, mi chiedo perché sia ​​2 ^ 23 invece del più tipico 2 ^ 32 (beh, parlando in termini generali di quanto spesso compaiono quei numeri, non in termini di larghezze di bit massime degli interi supportati dai backend del compilatore ...)
Fondo La causa di Monica il

26
@NicHartley Alcune delle basi di LLVM hanno un campo in cui le sottoclassi possono archiviare i dati. Per la Typeclasse questo significa che ci sono 8 bit per memorizzare il tipo di tipo (funzione, blocco, numero intero, ...) e 24 bit per i dati della sottoclasse. La IntegerTypeclasse utilizza quindi quei 24 bit per memorizzare le dimensioni, consentendo alle istanze di adattarsi perfettamente a 32 bit!
Todd Sewell,

56

Il compilatore li memorizzerà in più registri e utilizzerà più istruzioni per eseguire l'aritmetica su tali valori, se necessario. La maggior parte degli ISA ha un'istruzione add-with-carry come quella degli x86adc che rende abbastanza efficiente eseguire add / sub interi con precisione estesa.

Ad esempio, dato

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

il compilatore genera quanto segue durante la compilazione per x86-64 senza ottimizzazione:
(commenti aggiunti da @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

dove puoi vedere che il valore 42è archiviato in raxe rcx.

(nota del redattore: le convenzioni di chiamata C x86-64 restituiscono numeri interi a 128 bit in RDX: RAX. Ma questo mainnon restituisce alcun valore. Tutta la copia ridondante deriva esclusivamente dalla disabilitazione dell'ottimizzazione e che Rust controlla effettivamente l'overflow nel debug modalità.)

Per fare un confronto, ecco l'asm per gli interi Rust a 64 bit su x86-64 dove non è necessario aggiungere-con-trasportare, solo un singolo registro o stack-slot per ogni valore.

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

Il setb / test è ancora totalmente ridondante: jc(salta se CF = 1) funzionerebbe bene.

Con l'ottimizzazione abilitata, il compilatore della ruggine non verifica overflow così +opere come .wrapping_add().


4
@Anush No, rax / rsp / ... sono registri a 64 bit. Ogni numero a 128 bit è memorizzato in due registri / posizioni di memoria, con il risultato di due aggiunte a 64 bit.
ManfP

5
@Anush: no, sta usando così tante istruzioni perché è compilato con l'ottimizzazione disabilitata. Che ci si vede molto più codice più semplice (come solo l'add / ADC) se avete compilato una funzione che ha preso due u128args e restituito un valore (come questo godbolt.org/z/6JBza0 ), invece di disabilitare l'ottimizzazione per fermare il compilatore da fare propagazione costante su argomenti di compilazione a tempo costante.
Peter Cordes,

3
@ CAD97 La modalità di rilascio utilizza l' aritmetica di wrapping ma non verifica l'overflow e il panico come la modalità di debug. Questo comportamento è stato definito da RFC 560 . Non è UB.
Trento

3
@PeterCordes: in particolare, Rust il linguaggio specifica che l'overflow non è specificato e rustc (l'unico compilatore) specifica due comportamenti tra cui scegliere: Panic o Wrap. Idealmente, Panic sarebbe usato di default. In pratica, a causa di una generazione di codice non ottimale, nella modalità di rilascio l'impostazione predefinita è Avvolgere e un obiettivo a lungo termine è passare al panico quando (se mai) la generazione del codice è "abbastanza buona" per l'uso corrente. Inoltre, tutti i tipi integrali di Rust supportano le operazioni denominate per scegliere un comportamento: controllato, avvolgimento, saturazione, ... in modo da poter sovrascrivere il comportamento selezionato in base all'operazione.
Matthieu M.,

1
@MatthieuM .: Sì, adoro i metodi wrapping vs. check vs. saturating add / sub / shift / qualunque sia sui tipi primitivi. Molto meglio del wrapping C senza segno, UB firmato costringendoti a scegliere in base a quello. Ad ogni modo, alcuni ISA potrebbero fornire un supporto efficiente per il panico, ad esempio una bandiera adesiva che è possibile controllare dopo un'intera sequenza di operazioni. (A differenza di OF o CF di x86 che vengono sovrascritti con 0 o 1.) Ad esempio la proposta ForwardCom ISA di Agner Fog ( agner.org/optimize/blog/read.php?i=421#478 ) Ma ciò limita ancora l'ottimizzazione per non effettuare mai alcun calcolo la fonte Rust non ha funzionato. : /
Peter Cordes,

30

Sì, esattamente come sono stati gestiti numeri interi a 64 bit su macchine a 32 bit, o numeri interi a 32 bit su macchine a 16 bit, o persino numeri interi a 16 e 32 bit su macchine a 8 bit (ancora applicabile ai microcontrollori! ). Sì, memorizzi il numero in due registri, o posizioni di memoria o qualsiasi altra cosa (non importa davvero). Aggiunta e sottrazione sono banali, prendendo due istruzioni e usando la bandiera carry. La moltiplicazione richiede tre moltiplicazioni e alcune aggiunte (è comune che i chip a 64 bit abbiano già un'operazione di moltiplicazione 64x64-> 128 che genera due registri). La divisione ... richiede una subroutine ed è piuttosto lenta (tranne in alcuni casi in cui la divisione per costante può essere trasformata in uno spostamento o moltiplicare), ma funziona ancora. Bitwise e / o / xor devono essere semplicemente eseguiti sulle metà superiore e inferiore separatamente. I turni possono essere eseguiti con rotazione e mascheramento. E questo praticamente copre le cose.


26

Per fornire forse un esempio più chiaro, su x86_64, compilato con il -Oflag, la funzione

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

(Il mio post originale aveva u128piuttosto che il i128tuo chiesto. La funzione compila lo stesso codice in entrambi i casi, una buona dimostrazione che l'aggiunta firmata e non firmata sono le stesse su una CPU moderna.)

L'altro elenco ha prodotto un codice non ottimizzato. È sicuro passare in un debugger, perché si assicura che sia possibile posizionare un punto di interruzione ovunque e ispezionare lo stato di qualsiasi variabile in qualsiasi riga del programma. È più lento e più difficile da leggere. La versione ottimizzata è molto più vicina al codice che verrà effettivamente eseguito in produzione.

Il parametro adi questa funzione viene passato in una coppia di registri a 64 bit, rsi: rdi. Il risultato viene restituito in un'altra coppia di registri, rdx: rax. Le prime due righe di codice inizializzano la somma in a.

La terza riga aggiunge 1337 alla parola bassa dell'input. Se questo trabocca, porta l'1 nel flag carry della CPU. La quarta riga aggiunge zero alla parola più alta dell'input, più 1 se viene trasportata.

Puoi pensare a questo come una semplice aggiunta di un numero di una cifra a un numero di due cifre

  a  b
+ 0  7
______
 

ma in base 18.446.744.073.709.551.616. Stai ancora aggiungendo prima la "cifra" più bassa, possibilmente portando un 1 nella colonna successiva, quindi aggiungendo la cifra successiva più il carry. La sottrazione è molto simile.

La moltiplicazione deve utilizzare l'identità (2⁶⁴a + b) (2⁶⁴c + d) = 2¹²⁸ac + 2⁶⁴ (ad + bc) + bd, dove ciascuna di queste moltiplicazioni restituisce la metà superiore del prodotto in un registro e la metà inferiore del prodotto in un altro. Alcuni di questi termini verranno eliminati, poiché i bit sopra il 128 ° non rientrano in a u128e vengono scartati. Anche così, questo richiede una serie di istruzioni della macchina. Anche la divisione compie diversi passaggi. Per un valore con segno, la moltiplicazione e la divisione dovrebbero inoltre convertire i segni degli operandi e il risultato. Tali operazioni non sono affatto molto efficienti.

Su altre architetture, diventa più facile o più difficile. RISC-V definisce un'estensione del set di istruzioni a 128 bit, sebbene per quanto ne sappia nessuno lo ha implementato in silicio. Senza questa estensione, il manuale di architettura RISC-V raccomanda un ramo condizionale:addi t0, t1, +imm; blt t0, t1, overflow

SPARC ha codici di controllo come i flag di controllo di x86, ma è necessario utilizzare un'istruzione speciale add,ccper impostarli. MIPS, d'altra parte, richiede di verificare se la somma di due numeri interi senza segno è strettamente inferiore a uno degli operandi. In tal caso, l'aggiunta è traboccata. Almeno sei in grado di impostare un altro registro sul valore del bit di riporto senza un ramo condizionale.


1
ultimo paragrafo: per rilevare quale dei due numeri senza segno è maggiore osservando il bit più alto di un subrisultato, è necessario un n+1risultato secondario nbit per gli input bit. cioè devi guardare il risultato, non il bit di segno del risultato della stessa larghezza. Ecco perché le condizioni del ramo senza segno x86 sono basate su CF (bit 64 o 32 del risultato logico completo), non su SF (bit 63 o 31).
Peter Cordes,

1
re: divmod: l'approccio di AArch64 è quello di fornire divisione e un'istruzione che integer x - (a*b), calcolando il resto dal dividendo, quoziente e divisore. (Ciò è utile anche per i divisori costanti che usano un inverso moltiplicativo per la parte di divisione). Non avevo letto degli ISA che univano le istruzioni div + mod in un'unica operazione divmod; è pulito.
Peter Cordes,

1
re: flags: sì, un output di flag è un secondo output che deve gestire in qualche modo OoO exec + register-renaming. Le CPU x86 lo gestiscono mantenendo alcuni bit extra con il risultato intero su cui si basa il valore FLAGS, quindi probabilmente ZF, SF e PF vengono generati al volo quando necessario. Penso che ci sia un brevetto Intel su questo. In questo modo si riduce il numero di output che devono essere tracciati separatamente di nuovo a 1. (Nelle CPU Intel, nessun utente può mai scrivere più di 1 registro intero; ad esempio mul r64è 2 uops, con il secondo che scrive la metà alta RDX).
Peter Cordes,

1
Ma per un'efficace precisione estesa, le bandiere sono molto buone. Il problema principale è senza rinominare il registro per l'esecuzione in ordine superscalare. le bandiere sono un pericolo WAW (scrivere dopo scrivere). Ovviamente, le istruzioni add-with-carry sono 3 input, e questo è anche un problema significativo da tracciare. Intel prima di Broadwell decodificato adc, sbbe cmova 2 uops ciascuno. (Haswell ha introdotto uops a 3 input per FMA, Broadwell l'ha esteso a numero intero.)
Peter Cordes,

1
Gli ISA RISC con flag di solito rendono l'impostazione della bandiera opzionale, controllata da un bit in più. ad esempio ARM e SPARC sono così. PowerPC come al solito rende tutto più complicato: ha 8 registri di codice condizione (raggruppati in un registro a 32 bit per salvare / ripristinare) in modo da poter confrontare in cc0 o in cc7 o altro. E poi i codici di condizione AND o OR insieme! Le istruzioni di branch e cmov possono scegliere quale registro CR leggere. Quindi questo ti dà la possibilità di avere più catene di dep di bandiere in volo contemporaneamente, come x86 ADCX / ADOX. alanclements.org/power%20pc.html
Peter Cordes,
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.