Riepilogo : sotto 240, LLVM srotola completamente il circuito interno e ciò fa notare che può ottimizzare il ciclo di ripetizione, infrangendo il benchmark.
Hai trovato una soglia magica al di sopra della quale LLVM smette di eseguire determinate ottimizzazioni . La soglia è di 8 byte * 240 = 1920 byte (l'array è un array di usize
s, quindi la lunghezza viene moltiplicata per 8 byte, presupponendo CPU x86-64). In questo benchmark, una specifica ottimizzazione, eseguita solo per la lunghezza 239, è responsabile dell'enorme differenza di velocità. Ma iniziamo lentamente:
(Tutto il codice in questa risposta è compilato con -C opt-level=3
)
pub fn foo() -> usize {
let arr = [0; 240];
let mut s = 0;
for i in 0..arr.len() {
s += arr[i];
}
s
}
Questo semplice codice produrrà all'incirca l'assemblaggio che ci si aspetterebbe: un ciclo che somma elementi. Tuttavia, se si cambia 240
in 239
, l'assieme emesso differisce molto. Guardalo su Godbolt Compiler Explorer . Ecco una piccola parte dell'assemblaggio:
movdqa xmm1, xmmword ptr [rsp + 32]
movdqa xmm0, xmmword ptr [rsp + 48]
paddq xmm1, xmmword ptr [rsp]
paddq xmm0, xmmword ptr [rsp + 16]
paddq xmm1, xmmword ptr [rsp + 64]
; more stuff omitted here ...
paddq xmm0, xmmword ptr [rsp + 1840]
paddq xmm1, xmmword ptr [rsp + 1856]
paddq xmm0, xmmword ptr [rsp + 1872]
paddq xmm0, xmm1
pshufd xmm1, xmm0, 78
paddq xmm1, xmm0
Questo è ciò che si chiama loop svolgendo : LLVM incolla un po 'di tempo il corpo del loop per evitare di dover eseguire tutte quelle "istruzioni di gestione del loop", ovvero aumentare la variabile del loop, verificare se il loop è terminato e saltare all'inizio del loop .
Nel caso ti stia chiedendo: le paddq
istruzioni simili sono istruzioni SIMD che consentono di sommare più valori in parallelo. Inoltre, due registri SIMD a 16 byte ( xmm0
e xmm1
) vengono utilizzati in parallelo in modo che il parallelismo a livello di istruzione della CPU possa sostanzialmente eseguire due di queste istruzioni contemporaneamente. Dopotutto, sono indipendenti l'uno dall'altro. Alla fine, entrambi i registri vengono sommati e quindi riassunti in orizzontale fino al risultato scalare.
Le moderne CPU x86 tradizionali (non Atom a bassa potenza) possono davvero fare 2 carichi vettoriali per clock quando colpiscono nella cache L1d e il paddq
throughput è anche almeno 2 per clock, con 1 latenza di ciclo sulla maggior parte delle CPU. Vedi https://agner.org/optimize/ e anche queste domande e risposte sugli accumulatori multipli per nascondere la latenza (di FP FMA per un prodotto punto) e il collo di bottiglia sul throughput.
LLVM srotola alcuni piccoli anelli quando non si svolge completamente e utilizza ancora accumulatori multipli. Di solito, i colli di bottiglia della larghezza di banda del front-end e della latenza del back-end non rappresentano un grosso problema per i loop generati da LLVM anche senza lo srotolamento completo.
Ma lo srotolamento ad anello non è responsabile di una differenza di prestazioni del fattore 80! Almeno non eseguire lo srotolamento da solo. Diamo un'occhiata al codice di benchmark effettivo, che inserisce un loop all'interno di un altro:
const CAPACITY: usize = 239;
const IN_LOOPS: usize = 500000;
pub fn foo() -> usize {
let mut arr = [0; CAPACITY];
for i in 0..CAPACITY {
arr[i] = i;
}
let mut sum = 0;
for _ in 0..IN_LOOPS {
let mut s = 0;
for i in 0..arr.len() {
s += arr[i];
}
sum += s;
}
sum
}
( Su Godbolt Compiler Explorer )
L'assemblaggio per CAPACITY = 240
sembra normale: due anelli nidificati. (All'inizio della funzione c'è un po 'di codice solo per l'inizializzazione, che ignoreremo.) Per 239, tuttavia, sembra molto diverso! Vediamo che il ciclo di inizializzazione e il ciclo interno si sono srotolati: finora previsti.
La differenza importante è che per 239, LLVM è stata in grado di capire che il risultato del loop interno non dipende dal loop esterno! Di conseguenza, LLVM emette un codice che sostanzialmente esegue prima solo il ciclo interno (calcolando la somma) e quindi simula il ciclo esterno sommando sum
un mucchio di volte!
Innanzitutto vediamo quasi lo stesso assieme di cui sopra (l'assieme che rappresenta il circuito interno). Successivamente vediamo questo (ho commentato per spiegare l'assemblea; i commenti con *
sono particolarmente importanti):
; at the start of the function, `rbx` was set to 0
movq rax, xmm1 ; result of SIMD summing up stored in `rax`
add rax, 711 ; add up missing terms from loop unrolling
mov ecx, 500000 ; * init loop variable outer loop
.LBB0_1:
add rbx, rax ; * rbx += rax
add rcx, -1 ; * decrement loop variable
jne .LBB0_1 ; * if loop variable != 0 jump to LBB0_1
mov rax, rbx ; move rbx (the sum) back to rax
; two unimportant instructions omitted
ret ; the return value is stored in `rax`
Come puoi vedere qui, il risultato del loop interno viene preso, sommato tutte le volte che il loop esterno sarebbe corso e quindi restituito. LLVM può eseguire questa ottimizzazione solo perché ha capito che il circuito interno è indipendente da quello esterno.
Ciò significa che il runtime cambia da CAPACITY * IN_LOOPS
aCAPACITY + IN_LOOPS
. E questo è responsabile dell'enorme differenza di prestazioni.
Una nota aggiuntiva: puoi fare qualcosa al riguardo? Non proprio. LLVM deve avere soglie magiche che senza di esse l'ottimizzazione di LLVM potrebbe richiedere un'eternità per completare un determinato codice. Ma possiamo anche concordare sul fatto che questo codice era altamente artificiale. In pratica, dubito che si verificherebbe una differenza così grande. In questi casi, la differenza dovuta allo srotolamento a ciclo completo non è nemmeno il fattore 2. Quindi non c'è bisogno di preoccuparsi di casi d'uso reali.
Come ultima nota sul codice Rust idiomatico: arr.iter().sum()
è un modo migliore per riassumere tutti gli elementi di un array. E la modifica di questo nel secondo esempio non comporta differenze notevoli nell'assemblaggio emesso. Dovresti usare versioni brevi e idiomatiche, a meno che tu non abbia misurato che questo danneggia le prestazioni.