Perché GCC non può supporre che std :: vector :: size non cambierà in questo loop?


14

Ho affermato a un collega che if (i < input.size() - 1) print(0);sarebbe stato ottimizzato in questo ciclo in modo che input.size()non venisse letto in ogni iterazione, ma risulta che non è così!

void print(int x) {
    std::cout << x << std::endl;
}

void print_list(const std::vector<int>& input) {
    int i = 0;
    for (size_t i = 0; i < input.size(); i++) {
        print(input[i]);
        if (i < input.size() - 1) print(0);
    }
}

Secondo l' Explorer del compilatore con le opzioni gcc, in -O3 -fno-exceptionsrealtà stiamo leggendo input.size()ogni iterazione e usando leaper eseguire una sottrazione!

        movq    0(%rbp), %rdx
        movq    8(%rbp), %rax
        subq    %rdx, %rax
        sarq    $2, %rax
        leaq    -1(%rax), %rcx
        cmpq    %rbx, %rcx
        ja      .L35
        addq    $1, %rbx

È interessante notare che in Rust si verifica questa ottimizzazione. Sembra che ivenga sostituito con una variabile jche viene decrementata ad ogni iterazione e il test i < input.size() - 1viene sostituito con qualcosa del genere j > 0.

fn print(x: i32) {
    println!("{}", x);
}

pub fn print_list(xs: &Vec<i32>) {
    for (i, x) in xs.iter().enumerate() {
        print(*x);
        if i < xs.len() - 1 {
            print(0);
        }
    }
}

Nell'Explorer del compilatore l'assembly pertinente è simile al seguente:

        cmpq    %r12, %rbx
        jae     .LBB0_4

Ho controllato e sono abbastanza sicuro che r12sia xs.len() - 1ed rbxè il contatore. In precedenza c'è un addfor rbxe un movesterno del loop in r12.

Perchè è questo? Sembra che se GCC è in grado di incorporare size()e, operator[]come ha fatto, dovrebbe essere in grado di sapere che size()non cambia. Ma forse l'ottimizzatore di GCC ritiene che non valga la pena estrarlo in una variabile? O forse c'è qualche altro effetto collaterale che potrebbe rendere questo non sicuro - qualcuno lo sa?


1
Inoltre printlnè probabilmente un metodo complesso, il compilatore potrebbe avere difficoltà a provare che printlnnon muta il vettore.
Mooing Duck il

1
@MooingDuck: un altro thread sarebbe UB data-race. I compilatori possono e presumere che ciò non accada. Il problema qui è la chiamata di funzione non in linea a cout.operator<<(). Il compilatore non sa che questa funzione della scatola nera non ottiene un riferimento a std::vectorda un globale.
Peter Cordes,

@PeterCordes: hai ragione sul fatto che altri thread non sono una spiegazione autonoma e la complessità di printlno operator<<è la chiave.
Mooing Duck il

Il compilatore non conosce la semantica di questi metodi esterni.
user207421

Risposte:


10

La chiamata di funzione non in linea a cout.operator<<(int)è una scatola nera per l'ottimizzatore (perché la libreria è appena scritta in C ++ e tutto l'ottimizzatore vede è un prototipo; vedere la discussione nei commenti). Deve presumere che qualsiasi memoria che potrebbe essere indicata da una var globale sia stata modificata.

(O la std::endlchiamata. A proposito, perché forzare una scarica di cout a quel punto invece di stampare solo una '\n'?)

ad esempio, per quanto ne sa, std::vector<int> &inputè un riferimento a una variabile globale e una di quelle chiamate di funzione modifica quella var globale . (O c'è un globale vector<int> *ptrda qualche parte, o c'è una funzione che restituisce un puntatore a static vector<int>in qualche altra unità di compilazione, o in qualche altro modo in cui una funzione potrebbe ottenere un riferimento a questo vettore senza essere passato da un riferimento ad esso da noi.

Se avessi una variabile locale il cui indirizzo non era mai stato preso, il compilatore poteva supporre che le chiamate di funzione non in linea non potessero mutarlo. Perché non ci sarebbe modo per nessuna variabile globale di contenere un puntatore a questo oggetto. ( Questo si chiama Escape Analysis ). Ecco perché il compilatore può tenere size_t iun registro tra le chiamate di funzione. ( int ipuò essere semplicemente ottimizzato perché è ombreggiato size_t ie non utilizzato altrimenti).

Potrebbe fare lo stesso con un local vector(es. Per i puntatori base, end_size ed end_capacity.)

ISO C99 ha una soluzione per questo problema: int *restrict foo. Molti compilatori C ++ supportano la int *__restrict foopromessa che l'accesso alla memoria a cui si foofa riferimento è accessibile solo tramite quel puntatore. Più comunemente utile nelle funzioni che accettano 2 array e vuoi promettere al compilatore che non si sovrappongono. Quindi può auto-vettorializzare senza generare codice per verificarlo ed eseguire un loop di fallback.

I commenti del PO:

In Rust un riferimento non modificabile è una garanzia globale che nessun altro sta mutando il valore a cui si fa riferimento (equivalente a C ++ restrict)

Ciò spiega perché Rust può eseguire questa ottimizzazione, mentre C ++ non può.


Ottimizzare il tuo C ++

Ovviamente dovresti usare auto size = input.size();una volta nella parte superiore della tua funzione in modo che il compilatore sappia che è un invariante di loop. Le implementazioni C ++ non risolvono questo problema per te, quindi devi farlo da solo.

Potrebbe anche essere necessario const int *data = input.data();sollevare carichi del puntatore dati dal std::vector<int>"blocco di controllo". È un peccato che l'ottimizzazione possa richiedere cambiamenti di fonte molto non idiomatici.

Rust è un linguaggio molto più moderno, progettato dopo che gli sviluppatori di compilatori hanno appreso ciò che era possibile in pratica per i compilatori. Mostra anche in altri modi, tra cui l'esposizione portabile di alcune delle cose interessanti che le CPU possono fare tramite i32.count_ones, ruotare, scansionare bit, ecc. È davvero stupido che ISO C ++ non esponga ancora nessuna di queste portabilità, tranne std::bitset::count().


1
Il codice OP ha ancora il test se il vettore è preso per valore. Quindi, anche se GCC potrebbe ottimizzare in quel caso, non lo fa.
noce

1
Lo standard definisce il comportamento di operator<<per quei tipi di operandi; quindi in Standard C ++ non è una scatola nera e il compilatore può supporre che faccia ciò che dice la documentazione. Forse vogliono supportare gli sviluppatori di librerie aggiungendo comportamenti non standard ...
MM

2
L'ottimizzatore potrebbe essere alimentato dal comportamento che lo standard impone, il mio punto è che questa ottimizzazione è consentita dallo standard, ma il fornitore del compilatore sceglie di implementare nel modo in cui descrivi e rinuncia a questa ottimizzazione
MM

2
@MM Non ha detto oggetto casuale, ho detto un vettore definito di implementazione. Non c'è nulla nello standard che vieti a un'implementazione di avere un vettore definito di implementazione che l'operatore << modifica e che consenta l'accesso a questo vettore in un modo definito di implementazione. coutconsente a un oggetto di una classe definita dall'utente derivata streambufdi essere associato al flusso utilizzando cout.rdbuf. Allo stesso modo è ostreampossibile associare un oggetto derivato da cout.tie.
Ross Ridge,

2
@PeterCordes - Non sarei così sicuro dei vettori locali: non appena una funzione membro viene fuori linea, i locali sono effettivamente fuggiti perché il thispuntatore è passato implicitamente. Questo potrebbe accadere in pratica già dal costruttore. Considera questo semplice ciclo : ho controllato solo il ciclo principale di gcc (da L34:a jne L34), ma si sta sicuramente comportando come se i membri del vettore fossero fuggiti (caricandoli dalla memoria ogni iterazione).
BeeOnRope,
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.