Ecco un esempio del mondo reale: il punto fisso si moltiplica sui vecchi compilatori.
Questi non solo sono utili su dispositivi senza virgola mobile, brillano anche quando si tratta di precisione, poiché offrono 32 bit di precisione con un errore prevedibile (il galleggiante ha solo 23 bit ed è più difficile prevedere la perdita di precisione). cioè precisione assoluta uniforme su tutta la gamma, anziché precisione relativa quasi uniforme ( float
).
I compilatori moderni ottimizzano bene questo esempio a virgola fissa, quindi per esempi più moderni che richiedono ancora un codice specifico del compilatore, vedere
C non ha un operatore a moltiplicazione completa (risultato 2N-bit dagli ingressi N-bit). Il solito modo per esprimerlo in C è quello di trasmettere gli input al tipo più ampio e sperare che il compilatore riconosca che i bit superiori degli input non sono interessanti:
// on a 32-bit machine, int can hold 32-bit fixed-point integers.
int inline FixedPointMul (int a, int b)
{
long long a_long = a; // cast to 64 bit.
long long product = a_long * b; // perform multiplication
return (int) (product >> 16); // shift by the fixed point bias
}
Il problema con questo codice è che facciamo qualcosa che non può essere espresso direttamente nel linguaggio C. Vogliamo moltiplicare due numeri a 32 bit e ottenere un risultato a 64 bit di cui restituiamo il medio a 32 bit. Tuttavia, in C questa moltiplicazione non esiste. Tutto quello che puoi fare è promuovere gli interi a 64 bit e fare una moltiplicazione 64 * 64 = 64.
x86 (e ARM, MIPS e altri) possono comunque fare il moltiplicarsi in una singola istruzione. Alcuni compilatori erano soliti ignorare questo fatto e generare codice che chiama una funzione di libreria di runtime per fare il moltiplicare. Lo spostamento di 16 viene anche spesso eseguito da una routine di libreria (anche l'x86 può fare tali spostamenti).
Quindi rimaniamo con una o due chiamate in libreria solo per un moltiplicarsi. Ciò ha gravi conseguenze. Non solo lo spostamento è più lento, i registri devono essere preservati attraverso le chiamate di funzione e non aiuta nemmeno a allineare e srotolare il codice.
Se si riscrive lo stesso codice nell'assemblatore (inline) è possibile ottenere un aumento di velocità significativo.
Inoltre: l'utilizzo di ASM non è il modo migliore per risolvere il problema. La maggior parte dei compilatori consente di utilizzare alcune istruzioni assembler in forma intrinseca se non è possibile esprimerle in C. Ad esempio il compilatore VS.NET2008 espone il mul 32 * 32 = 64 bit come __emul e lo spostamento a 64 bit come __ll_rshift.
Utilizzando intrinseci è possibile riscrivere la funzione in modo che il compilatore C abbia la possibilità di capire cosa sta succedendo. Ciò consente di incorporare il codice, allocare i registri, eliminare anche la sottoespressione comune e la propagazione costante. In questo modo otterrai un enorme miglioramento delle prestazioni rispetto al codice assembler scritto a mano.
Per riferimento: il risultato finale per il mul punto fisso per il compilatore VS.NET è:
int inline FixedPointMul (int a, int b)
{
return (int) __ll_rshift(__emul(a,b),16);
}
La differenza di prestazioni delle divisioni in punti fissi è ancora maggiore. Ho avuto miglioramenti fino al fattore 10 per il codice a virgola fissa della divisione scrivendo un paio di righe asm.
L'uso di Visual C ++ 2013 fornisce lo stesso codice assembly in entrambi i modi.
gcc4.1 del 2007 ottimizza anche la versione C pura. (L'esploratore del compilatore Godbolt non ha alcuna versione precedente di gcc installata, ma presumibilmente anche le versioni GCC più vecchie potrebbero farlo senza intrinsechi.)
Vedi source + asm per x86 (32-bit) e ARM sull'esploratore del compilatore Godbolt . (Sfortunatamente non ha compilatori abbastanza vecchi da produrre codice errato dalla semplice versione in puro C.)
Le moderne CPU possono fare cose che C non ha operatori per niente , come popcnt
o bit-scan per trovare il primo o l'ultimo bit impostato . (POSIX ha una ffs()
funzione, ma la sua semantica non corrisponde a x86 bsf
/ bsr
. Vedi https://en.wikipedia.org/wiki/Find_first_set ).
Alcuni compilatori a volte riconoscono un ciclo che conta il numero di bit impostati in un numero intero e lo compila in popcnt
un'istruzione (se abilitato in fase di compilazione), ma è molto più affidabile da usare __builtin_popcnt
in GNU C o su x86 se sei solo targeting hardware con SSE4.2: _mm_popcnt_u32
da<immintrin.h>
.
O in C ++, assegnare a std::bitset<32>
e utilizzare .count()
. (Questo è un caso in cui il linguaggio ha trovato un modo per esporre in modo portabile un'implementazione ottimizzata di popcount attraverso la libreria standard, in un modo che si compili sempre in qualcosa di corretto e può trarre vantaggio da qualunque cosa il target supporti.) Vedi anche https : //en.wikipedia.org/wiki/Hamming_weight#Language_support .
Allo stesso modo, ntohl
può essere compilato in bswap
(scambio di byte x86 a 32 bit per la conversione endian) su alcune implementazioni C che lo hanno.
Un'altra area importante per intrinseche o scritte a mano è la vettorializzazione manuale con istruzioni SIMD. I compilatori non sono male con semplici loop come dst[i] += src[i] * 10.0;
, ma spesso fanno male o non si auto-vettorizzano affatto quando le cose si complicano. Ad esempio, è improbabile che tu ottenga qualcosa di simile Come implementare atoi usando SIMD? generato automaticamente dal compilatore dal codice scalare.