Perché c'è un grande impatto sulle prestazioni quando si esegue il loop su un array con 240 o più elementi?


230

Durante l'esecuzione di un ciclo di somma su un array in Rust, ho notato un enorme calo delle prestazioni quando CAPACITY> = 240. CAPACITY= 239 è circa 80 volte più veloce.

Esiste un'ottimizzazione speciale della compilazione che Rust sta eseguendo per array "brevi"?

Compilato con rustc -C opt-level=3.

use std::time::Instant;

const CAPACITY: usize = 240;
const IN_LOOPS: usize = 500000;

fn main() {
    let mut arr = [0; CAPACITY];
    for i in 0..CAPACITY {
        arr[i] = i;
    }
    let mut sum = 0;
    let now = Instant::now();
    for _ in 0..IN_LOOPS {
        let mut s = 0;
        for i in 0..arr.len() {
            s += arr[i];
        }
        sum += s;
    }
    println!("sum:{} time:{:?}", sum, now.elapsed());
}


4
Forse con 240 stai traboccando una linea di cache della CPU? In tal caso, i risultati sarebbero molto specifici della CPU.
rodrigo

11
Riprodotto qui . Ora sto indovinando che ha qualcosa a che fare con lo svolgersi del loop.
rodrigo,

Risposte:


355

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 usizes, 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 240in 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 paddqistruzioni simili sono istruzioni SIMD che consentono di sommare più valori in parallelo. Inoltre, due registri SIMD a 16 byte ( xmm0e 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 paddqthroughput è 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 = 240sembra 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 sumun 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_LOOPSaCAPACITY + 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.


2
@ lukas-kalbertodt grazie per l'ottima risposta! ora capisco anche perché il codice originale che si aggiornava sumdirettamente su un non locale sfunzionava molto più lentamente. for i in 0..arr.len() { sum += arr[i]; }
Guy Korland,

4
@LukasKalbertodt Qualcosa sta succedendo in LLVM attivando AVX2 non dovrebbe fare la differenza. Riproposto anche nella ruggine
Mgetz,

4
@Mgetz Interessante! Ma non mi sembra troppo folle rendere tale soglia dipendente dalle istruzioni SIMD disponibili, poiché ciò determina in definitiva il numero di istruzioni in un ciclo completamente non srotolato. Ma purtroppo non posso dirlo con certezza. Sarebbe bello avere un deviatore LLVM che rispondesse a questo.
Lukas Kalbertodt,

7
Perché il compilatore o LLVM non comprendono che l'intero calcolo può essere effettuato in fase di compilazione? Mi sarei aspettato di avere il risultato del ciclo hardcoded. O è l'uso di Instantimpedirlo?
Nome non creativo

4
@JosephGarvin: suppongo sia perché lo srotolamento completo avviene per consentire al passaggio di ottimizzazione successivo di vederlo. Ricorda che l'ottimizzazione dei compilatori si preoccupa ancora della compilazione rapida, oltre a rendere efficiente l'asm, quindi devono limitare la complessità del caso peggiore di qualsiasi analisi che fanno in modo che non ci vogliono ore / giorni per compilare un brutto codice sorgente con loop complicati . Ma sì, questa è ovviamente un'ottimizzazione mancata per dimensioni> = 240. Mi chiedo se non ottimizzare intenzionalmente i loop all'interno dei loop sia intenzionale per evitare di rompere semplici benchmark? Probabilmente no, ma forse.
Peter Cordes,

30

Oltre alla risposta di Lukas, se si desidera utilizzare un iteratore, provare questo:

const CAPACITY: usize = 240;
const IN_LOOPS: usize = 500000;

pub fn bar() -> usize {
    (0..CAPACITY).sum::<usize>() * IN_LOOPS
}

Grazie @ Chris Morgan per il suggerimento sul modello di gamma.

L' assemblaggio ottimizzato è abbastanza buono:

example::bar:
        movabs  rax, 14340000000
        ret

3
O meglio ancora, (0..CAPACITY).sum::<usize>() * IN_LOOPSche produce lo stesso risultato.
Chris Morgan,

11
Spiegherei davvero che l'assembly non sta effettivamente eseguendo il calcolo, ma LLVM ha pre-calcolato la risposta in questo caso.
Josep,

Sono un po 'sorpreso del fatto che rustcmanchi l'occasione per ridurre questa forza. In questo specifico contesto, tuttavia, questo sembra essere un ciclo di temporizzazione e si desidera deliberatamente che non venga ottimizzato. Il punto è ripetere il calcolo quel numero di volte da zero e dividerlo per il numero di ripetizioni. In C, il linguaggio (non ufficiale) per questo è dichiarare il contatore del ciclo come volatile, ad esempio, il contatore BogoMIPS nel kernel Linux. C'è un modo per raggiungere questo obiettivo in Rust? Potrebbe esserci, ma non lo so. Chiamare un esterno fnpotrebbe aiutare.
Davislor,

1
@Davislor: volatileimpone che la memoria sia sincronizzata. Applicandolo al contatore del loop si forza solo l'effettivo ricaricamento / memorizzazione del valore del contatore del loop. Non influisce direttamente sul corpo del loop. Ecco perché un modo migliore per usarlo è normalmente quello di assegnare il risultato importante effettivo volatile int sinko qualcosa dopo il loop (se c'è una dipendenza da loop) o ogni iterazione, per consentire al compilatore di ottimizzare il contatore di loop come vuole ma forzarlo per materializzare il risultato desiderato in un registro in modo che possa memorizzarlo.
Peter Cordes,

1
@Davislor: Penso che Rust abbia inline asta sintassi qualcosa come GNU C. Puoi usare inline asm per forzare il compilatore a materializzare un valore in un registro senza forzarlo a memorizzarlo. Usarlo sul risultato di ogni iterazione di loop può impedire che si ottimizzi. (Ma anche dalla vettorializzazione automatica se non stai attento). es. "Escape" e "Clobber" equivalenti in MSVC spiegano 2 macro (mentre chiedono come portarli su MSVC che non è realmente possibile) e si collegano ai discorsi di Chandler Carruth dove mostra il loro uso.
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.