Perché GCC utilizza la moltiplicazione per uno strano numero nell'implementazione della divisione di numeri interi?


228

Ho letto dive muloperazioni di assemblaggio e ho deciso di vederle in azione scrivendo un semplice programma in C:

File division.c

#include <stdlib.h>
#include <stdio.h>

int main()
{
    size_t i = 9;
    size_t j = i / 5;
    printf("%zu\n",j);
    return 0;
}

E quindi generare il codice del linguaggio assembly con:

gcc -S division.c -O0 -masm=intel

Ma guardando il division.sfile generato , non contiene alcuna operazione div! Invece, fa una sorta di magia nera con spostamento dei bit e numeri magici. Ecco uno snippet di codice che calcola i/5:

mov     rax, QWORD PTR [rbp-16]   ; Move i (=9) to RAX
movabs  rdx, -3689348814741910323 ; Move some magic number to RDX (?)
mul     rdx                       ; Multiply 9 by magic number
mov     rax, rdx                  ; Take only the upper 64 bits of the result
shr     rax, 2                    ; Shift these bits 2 places to the right (?)
mov     QWORD PTR [rbp-8], rax    ; Magically, RAX contains 9/5=1 now, 
                                  ; so we can assign it to j

Cosa sta succedendo qui? Perché GCC non usa affatto il div? Come genera questo numero magico e perché funziona tutto?


29
gcc ottimizza le divisioni per costanti, prova le divisioni per 2,3,4,5,6,7,8 e molto probabilmente vedrai un codice molto diverso per ogni caso.
Jabberwocky,

28
Nota: il numero magico si -3689348814741910323converte in CCCCCCCCCCCCCCCDquasi uint64_to (circa 2 ^ 64) * 4/5.
chux - Ripristina Monica il

32
@qiubit: il compilatore non genererà perversamente codice inefficiente solo perché l'ottimizzazione è disabilitata. Una banale "ottimizzazione" che non comporta il riordino del codice o l'eliminazione delle variabili verrà eseguita indipendentemente, ad esempio. In sostanza, una singola istruzione sorgente si tradurrà nel codice più efficiente per quell'operazione in isolamento. L'ottimizzazione del compilatore tiene conto del codice circostante anziché solo della singola istruzione.
Clifford,

20
Leggi questo fantastico articolo: Labor of Division
Jester

9
Alcuni compilatori in realtà saranno perversamente generare il codice inefficiente perché l'ottimizzazione è disabilitato. In particolare, lo faranno per semplificare il debug, come la possibilità di impostare punti di interruzione su singole righe di codice. GCC è, infatti, piuttosto insolito in quanto non ha una vera modalità "nessuna ottimizzazione", poiché molte delle sue ottimizzazioni sono costantemente attivate. Questo è un esempio di dove puoi vederlo con GCC. Clang, d'altra parte, e MSVC, potranno emettere divun'istruzione a -O0. (cc @ clifford)
Cody Grey

Risposte:


169

La divisione integer è una delle operazioni aritmetiche più lente che è possibile eseguire su un processore moderno, con latenza fino a dozzine di cicli e throughput scadente. (Per x86, consultare le tabelle di istruzioni della nebbia di Agner e la guida ai microarchi ).

Se conosci il divisore in anticipo, puoi evitare la divisione sostituendola con una serie di altre operazioni (moltiplicazioni, aggiunte e turni) che hanno l'effetto equivalente. Anche se sono necessarie diverse operazioni, spesso è ancora molto più veloce della divisione intera stessa.

L'implementazione /dell'operatore C in questo modo invece che con una sequenza multi-istruzione che coinvolge divè solo il modo predefinito di GCC di fare divisione per costanti. Non richiede ottimizzazione tra le operazioni e non cambia nulla nemmeno per il debug. (L'utilizzo -Osper codice di piccole dimensioni comporta tuttavia l'utilizzo di GCC div.) L'uso di un inverso moltiplicativo anziché di divisione è come usare leainvece di muleadd

Di conseguenza, si tende a vedere divo idivnell'output solo se il divisore non è noto al momento della compilazione.

Per informazioni su come il compilatore genera queste sequenze, oltre al codice che consente di generarle da soli (quasi certamente inutili a meno che non si stia lavorando con un compilatore braindead ), vedere libdivide .


5
Non sono sicuro che sia corretto raggruppare operazioni FP e numeri interi in un confronto di velocità, @fuz. Forse Sneftel dovrebbe dire che la divisione è l' operazione intera più lenta che puoi eseguire su un processore moderno? Inoltre, nei commenti sono stati forniti alcuni collegamenti ad ulteriori spiegazioni di questa "magia". Pensi che sarebbe opportuno raccogliere nella tua risposta per la visibilità? 1 , 2 , 3
Cody Grey

1
Perché la sequenza delle operazioni è funzionalmente identica ... questo è sempre un requisito, anche a -O3. Il compilatore deve creare un codice che dia risultati corretti per tutti i possibili valori di input. Questo cambia solo in virgola mobile con -ffast-mathAFAIK e non ci sono ottimizzazioni di numeri interi "pericolose". (Con l'ottimizzazione abilitata, il compilatore potrebbe essere in grado di provare qualcosa sul possibile intervallo di valori che gli consente di utilizzare qualcosa che funziona solo per numeri interi con segno non negativo per esempio.)
Peter Cordes

6
La vera risposta è che gcc -O0 trasforma ancora il codice attraverso rappresentazioni interne come parte della trasformazione di C in codice macchina . Accade semplicemente che gli inversori moltiplicativi modulari sono abilitati di default anche a -O0(ma non con-Os ). Altri compilatori (come clang) useranno DIV per costanti non di potenza di 2 a -O0. correlati: Penso di aver incluso un paragrafo su questo nella mia risposta di Asm scritta a mano congettura di Collatz
Peter Cordes

6
@PeterCordes E sì, penso che GCC (e molti altri compilatori) abbiano dimenticato di elaborare una buona logica per "quali tipi di ottimizzazioni si applicano quando l'ottimizzazione è disabilitata". Avendo trascorso la maggior parte della giornata a rintracciare un oscuro bug codegen, al momento sono un po 'seccato per questo.
Sneftel,

9
@Sneftel: Probabilmente è solo perché il numero di sviluppatori di applicazioni che si lamentano attivamente con gli sviluppatori del compilatore per l'esecuzione del codice più veloce del previsto è relativamente piccolo.
dan04

121

Dividere per 5 equivale a moltiplicare 1/5, che è di nuovo lo stesso che moltiplicare per 4/5 e spostare a destra 2 bit. Il valore in questione è CCCCCCCCCCCCCCCDin esadecimale, che è la rappresentazione binaria di 4/5 se posizionata dopo un punto esadecimale (cioè il binario per quattro quinti è 0.110011001100ricorrente - vedi sotto per il perché). Penso che puoi prenderlo da qui! Potresti voler controllare l' aritmetica in virgola fissa (anche se nota che è arrotondato a un numero intero alla fine.

Per quanto riguarda il motivo, la moltiplicazione è più veloce della divisione e quando il divisore è fisso, questo è un percorso più veloce.

Consulta la moltiplicazione reciproca, un tutorial per una descrizione dettagliata di come funziona, spiegando in termini di punto fisso. Mostra come funziona l'algoritmo per trovare il reciproco e come gestire la divisione e il modulo firmati.

Consideriamo per un minuto perché 0.CCCCCCCC...(hex) o 0.110011001100...binario è 4/5. Dividi la rappresentazione binaria per 4 (sposta a destra di 2 posizioni) e otterremo 0.001100110011...quale per banale ispezione si può aggiungere l'originale da ottenere 0.111111111111..., che è ovviamente uguale a 1, allo stesso modo 0.9999999...in decimale è uguale a uno. Pertanto, sappiamo che x + x/4 = 1, quindi 5x/4 = 1,x=4/5 . Questo viene quindi rappresentato come CCCCCCCCCCCCDin esadecimale per arrotondamento (poiché la cifra binaria oltre l'ultimo presente sarebbe a 1).


2
@ user2357112 sentiti libero di pubblicare la tua risposta, ma non sono d'accordo. Puoi pensare alla moltiplicazione come una moltiplicazione di 64,0 bit per 0,64 bit che fornisce una risposta in virgola fissa a 128 bit, di cui vengono scartati i 64 bit più bassi, quindi una divisione per 4 (come faccio notare nel primo paragrafo). Potresti essere in grado di trovare una risposta aritmetica modulare alternativa che spieghi ugualmente bene i movimenti dei bit, ma sono abbastanza sicuro che funzioni come una spiegazione.
circa

6
Il valore è in realtà "CCCCCCCCCCCCCCCD" L'ultima D è importante, si assicura che quando il risultato viene troncato, le divisioni esatte escano con la risposta giusta.
plugwash

4
Non importa. Non ho visto che stanno prendendo i 64 bit superiori del risultato della moltiplicazione a 128 bit; non è qualcosa che puoi fare nella maggior parte delle lingue, quindi inizialmente non avevo realizzato che stesse accadendo. Questa risposta sarebbe molto migliorata da una menzione esplicita di come prendere i 64 bit superiori del risultato a 128 bit equivale a moltiplicare per un numero a virgola fissa e arrotondare per difetto. (Inoltre, sarebbe utile spiegare perché deve essere 4/5 invece di 1/5 e perché dobbiamo arrotondare 4/5 su invece di giù.)
user2357112 supporta Monica il

2
Afaict dovresti capire quanto è necessario un errore per lanciare una divisione di 5 verso l'alto attraverso un arrotondamento, quindi confrontalo con l'errore peggiore nella tua caclulazione. Presumibilmente gli sviluppatori di gcc lo hanno fatto e hanno concluso che darà sempre i risultati corretti.
lavaggio:

3
In realtà probabilmente dovrai solo controllare i 5 valori di input più alti possibili, se quelli arrotondati correttamente anche tutto il resto dovrebbe.
lavaggio:

60

In generale, la moltiplicazione è molto più rapida della divisione. Quindi, se riusciamo a cavarcela con il moltiplicarsi per il reciproco, possiamo invece accelerare significativamente la divisione per una costante

Una ruga è che non possiamo rappresentare esattamente il reciproco (a meno che la divisione non fosse per una potenza di due, ma in quel caso di solito possiamo semplicemente convertire la divisione in un po 'di spostamento). Quindi, per garantire risposte corrette, dobbiamo stare attenti che l'errore nel nostro reciproco non causi errori nel nostro risultato finale.

-3689348814741910323 è 0xCCCCCCCCCCCCCCCD che ha un valore di poco più di 4/5 espresso in 0,64 punti fissi.

Quando moltiplichiamo un numero intero a 64 bit per un numero a virgola fissa di 0,64 otteniamo un risultato a 64,64. Tronciamo il valore a un numero intero a 64 bit (arrotondandolo effettivamente a zero) e quindi eseguiamo un ulteriore spostamento che si divide per quattro e di nuovo tronca Osservando il livello di bit è chiaro che possiamo trattare entrambi i troncamenti come un singolo troncamento.

Questo ci dà chiaramente almeno un'approssimazione della divisione per 5 ma ci dà una risposta esatta arrotondata correttamente verso lo zero?

Per ottenere una risposta esatta, l'errore deve essere abbastanza piccolo da non spingere la risposta oltre un limite di arrotondamento.

La risposta esatta a una divisione per 5 avrà sempre una parte frazionaria di 0, 1/5, 2/5, 3/5 o 4/5. Pertanto, un errore positivo inferiore a 1/5 del risultato moltiplicato e spostato non spingerà mai il risultato oltre un limite di arrotondamento.

L'errore nella nostra costante è (1/5) * 2 -64 . Il valore di i è inferiore a 2 64, quindi l'errore dopo la moltiplicazione è inferiore a 1/5. Dopo la divisione per 4 l'errore è inferiore a (1/5) * 2 −2 .

(1/5) * 2 −2 <1/5 quindi la risposta sarà sempre uguale a fare una divisione esatta e arrotondare verso zero.


Purtroppo questo non funziona per tutti i divisori.

Se proviamo a rappresentare 4/7 come un numero a virgola fissa di 0,64 con arrotondamento da zero, si finisce con un errore di (6/7) * 2 -64 . Dopo aver moltiplicato per un valore i di poco inferiore a 2 64 , si finisce con un errore di poco inferiore a 6/7 e dopo aver diviso per quattro si finisce con un errore di poco inferiore a 1,5 / 7 che è maggiore di 1/7.

Quindi per implementare correttamente il divison per 7 dobbiamo moltiplicare per un numero di punti fissi di 0,65. Possiamo implementarlo moltiplicando per i 64 bit inferiori del nostro numero in virgola fissa, quindi aggiungendo il numero originale (questo potrebbe traboccare nel bit di riporto), quindi facendo una rotazione in riporto.


8
Questa risposta ha trasformato le inversioni moltiplicative modulari da "matematica che sembra più complicata di quanto io voglia prendere il tempo per" in qualcosa di sensato. +1 per la versione di facile comprensione. Non ho mai avuto bisogno di fare altro che usare solo costanti generate dal compilatore, quindi ho solo sfogliato altri articoli che spiegano la matematica.
Peter Cordes,

2
Non vedo nulla a che fare con l'aritmetica modulare nel codice. Non so da dove altri commentatori lo ottengano.
lavaggio:

3
È modulo 2 ^ n, come tutta la matematica dei numeri interi in un registro. en.wikipedia.org/wiki/…
Peter Cordes

4
Le inversioni moltiplicative modulari di @PeterCordes sono utilizzate per la divisione esatta, poiché non sono utili per la divisione generale
harold

4
Moltiplicazione @PeterCordes per reciproco a virgola fissa? Non so come lo chiamano tutti ma probabilmente lo chiamerei così, è abbastanza descrittivo
harold il

12

Ecco il link a un documento di un algoritmo che produce i valori e il codice che vedo con Visual Studio (nella maggior parte dei casi) e che presumo sia ancora usato in GCC per la divisione di un intero variabile per un intero costante.

http://gmplib.org/~tege/divcnst-pldi94.pdf

Nell'articolo, un uword ha N bit, un udword ha 2N bit, n = numeratore = dividendo, d = denominatore = divisore, ℓ è inizialmente impostato su ceil (log2 (d)), shpre è pre-shift (usato prima di moltiplicare ) = e = numero di zero zero finali in d, shpost è post-shift (usato dopo moltiplicare), prec è precisione = N - e = N - shpre. L'obiettivo è ottimizzare il calcolo di n / d usando un pre-turno, moltiplicare e post-turno.

Scorri verso il basso fino alla figura 6.2, che definisce come viene generato un moltiplicatore udword (la dimensione massima è N + 1 bit), ma non spiega chiaramente il processo. Spiegherò di seguito.

La Figura 4.2 e la Figura 6.2 mostrano come il moltiplicatore può essere ridotto a un N bit o meno moltiplicatore per la maggior parte dei divisori. L'equazione 4.5 spiega come è stata derivata la formula utilizzata per gestire i moltiplicatori di bit N + 1 nelle figure 4.1 e 4.2.

Nel caso del moderno X86 e di altri processori, il tempo di moltiplicazione è fisso, quindi il pre-shift non aiuta su questi processori, ma aiuta comunque a ridurre il moltiplicatore da N + 1 bit a N bit. Non so se GCC o Visual Studio abbiano eliminato il pre-shift per gli obiettivi X86.

Tornando alla Figura 6.2. Il numeratore (dividendo) per mlow e mhigh può essere maggiore di un udword solo quando denominatore (divisore)> 2 ^ (N-1) (quando ℓ == N => mlow = 2 ^ (2N)), in questo caso il la sostituzione ottimizzata per n / d è un confronto (se n> = d, q = 1, altrimenti q = 0), quindi non viene generato alcun moltiplicatore. I valori iniziali di mlow e mhigh saranno N + 1 bit e due divisioni udword / uword possono essere utilizzate per produrre ciascun valore N + 1 bit (mlow o mhigh). Usando X86 in modalità 64 bit come esempio:

; upper 8 bytes of dividend = 2^(ℓ) = (upper part of 2^(N+ℓ))
; lower 8 bytes of dividend for mlow  = 0
; lower 8 bytes of dividend for mhigh = 2^(N+ℓ-prec) = 2^(ℓ+shpre) = 2^(ℓ+e)
dividend  dq    2 dup(?)        ;16 byte dividend
divisor   dq    1 dup(?)        ; 8 byte divisor

; ...
        mov     rcx,divisor
        mov     rdx,0
        mov     rax,dividend+8     ;upper 8 bytes of dividend
        div     rcx                ;after div, rax == 1
        mov     rax,dividend       ;lower 8 bytes of dividend
        div     rcx
        mov     rdx,1              ;rdx:rax = N+1 bit value = 65 bit value

Puoi provarlo con GCC. Hai già visto come viene gestita j = i / 5. Dai un'occhiata a come viene gestita j = i / 7 (che dovrebbe essere il caso del moltiplicatore N + 1 bit).

Sulla maggior parte dei processori attuali, moltiplicare ha una tempistica fissa, quindi non è necessario un pre-turno. Per X86, il risultato finale è una sequenza di due istruzioni per la maggior parte dei divisori e una sequenza di cinque istruzioni per divisori come 7 (per emulare un moltiplicatore N + 1 bit come mostrato nell'equazione 4.5 e nella figura 4.2 del file pdf). Esempio codice X86-64:

;       rax = dividend, rbx = 64 bit (or less) multiplier, rcx = post shift count
;       two instruction sequence for most divisors:

        mul     rbx                     ;rdx = upper 64 bits of product
        shr     rdx,cl                  ;rdx = quotient
;
;       five instruction sequence for divisors like 7
;       to emulate 65 bit multiplier (rbx = lower 64 bits of multiplier)

        mul     rbx                     ;rdx = upper 64 bits of product
        sub     rbx,rdx                 ;rbx -= rdx
        shr     rbx,1                   ;rbx >>= 1
        add     rdx,rbx                 ;rdx = upper 64 bits of corrected product
        shr     rdx,cl                  ;rdx = quotient
;       ...

Quel documento descrive l'implementazione in gcc, quindi penso che sia un presupposto sicuro che lo stesso algo sia ancora usato.
Peter Cordes,

Quel documento datato 1994 descrive l'implementazione in gcc, quindi c'è stato tempo per gcc di aggiornare il suo algoritmo. Nel caso in cui altri non abbiano il tempo di controllare per vedere cosa significano i 94 in quell'URL.
Ed Grimm,

0

Risponderò da una prospettiva leggermente diversa: perché è permesso farlo.

C e C ++ sono definiti rispetto a una macchina astratta. Il compilatore trasforma questo programma in termini di macchina astratta in macchina concreta seguendo la regola as-if .

  • Al compilatore è consentito apportare QUALSIASI modifica purché non cambi il comportamento osservabile come specificato dalla macchina astratta. Non ci sono aspettative ragionevoli che il compilatore trasformerà il tuo codice nel modo più semplice possibile (anche quando molti programmatori C lo ritengono). Di solito, lo fa perché il compilatore vuole ottimizzare le prestazioni rispetto all'approccio diretto (come discusso nelle altre risposte a lungo).
  • Se in ogni caso il compilatore "ottimizza" un programma corretto su qualcosa che ha un comportamento osservabile diverso, si tratta di un bug del compilatore.
  • Qualsiasi comportamento indefinito nel nostro codice (overflow intero con segno è un classico esempio) e questo contratto è nullo.
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.