L'operatore logico AND ( &&
) utilizza la valutazione del cortocircuito, il che significa che il secondo test viene eseguito solo se il primo confronto viene considerato vero. Questa è spesso esattamente la semantica richiesta. Ad esempio, considera il seguente codice:
if ((p != nullptr) && (p->first > 0))
È necessario assicurarsi che il puntatore sia diverso da null prima di dereferenziarlo. Se questa non fosse una valutazione di cortocircuito, avresti un comportamento indefinito perché dovresti dereferenziare un puntatore nullo.
È anche possibile che la valutazione del corto circuito produca un aumento delle prestazioni nei casi in cui la valutazione delle condizioni è un processo costoso. Per esempio:
if ((DoLengthyCheck1(p) && (DoLengthyCheck2(p))
Se DoLengthyCheck1
fallisce, non ha senso chiamare DoLengthyCheck2
.
Tuttavia, nel binario risultante, un'operazione di cortocircuito si traduce spesso in due rami, poiché questo è il modo più semplice per il compilatore di conservare queste semantiche. (Ecco perché, dall'altro lato della medaglia, la valutazione del corto circuito a volte può inibire il potenziale di ottimizzazione.) Puoi vederlo guardando la porzione pertinente di codice oggetto generato per la tua if
dichiarazione da GCC 5.4:
movzx r13d, WORD PTR [rbp+rcx*2]
movzx eax, WORD PTR [rbx+rcx*2]
cmp r13w, 478 ; (curr[i] < 479)
ja .L5
cmp ax, 478 ; (l[i + shift] < 479)
ja .L5
add r8d, 1 ; nontopOverlap++
Qui puoi vedere i due confronti ( cmp
istruzioni) qui, ciascuno seguito da un salto / ramo condizionato separato ( ja
o salta se sopra).
È una regola empirica generale che i rami siano lenti e che pertanto debbano essere evitati in anelli stretti. Questo è vero su praticamente tutti i processori x86, dall'umile 8088 (i cui tempi di recupero lenti e la coda di prefetch estremamente ridotta [paragonabile a una cache di istruzioni], unita alla totale mancanza di previsione dei rami, significavano che i rami presi richiedevano il dump della cache ) alle moderne implementazioni (le cui lunghe condotte rendono le filiali errate altrettanto costose). Nota il piccolo avvertimento in cui sono scivolato lì. I processori moderni dal Pentium Pro dispongono di avanzati motori di previsione delle filiali progettati per ridurre al minimo il costo delle filiali. Se è possibile prevedere correttamente la direzione della filiale, il costo è minimo. Il più delle volte funziona bene, ma se ti imbatti in casi patologici in cui il predittore di ramo non è dalla tua parte,il tuo codice può diventare estremamente lento . Questo è presumibilmente dove sei, dal momento che dici che l'array non è ordinato.
Dici che i benchmark hanno confermato che la sostituzione di &&
con a *
rende il codice notevolmente più veloce. Il motivo è evidente quando si confronta la parte pertinente del codice oggetto:
movzx r13d, WORD PTR [rbp+rcx*2]
movzx eax, WORD PTR [rbx+rcx*2]
xor r15d, r15d ; (curr[i] < 479)
cmp r13w, 478
setbe r15b
xor r14d, r14d ; (l[i + shift] < 479)
cmp ax, 478
setbe r14b
imul r14d, r15d ; meld results of the two comparisons
cmp r14d, 1 ; nontopOverlap++
sbb r8d, -1
È un po 'controintuitivo che questo potrebbe essere più veloce, poiché ci sono più istruzioni qui, ma è così che a volte funziona l'ottimizzazione. Si vedono gli stessi confronti ( cmp
) fatti qui, ma ora ciascuno è preceduto da un xor
e seguito da un setbe
. XOR è solo un trucco standard per cancellare un registro. Il setbe
è un'istruzione x86 che imposta un bit in base al valore di un flag, e viene spesso utilizzato per implementare il codice di rami. Qui setbe
è l'inverso di ja
. Imposta il registro di destinazione su 1 se il confronto era inferiore o uguale (poiché il registro era pre-azzerato, altrimenti sarebbe 0), mentre ja
ramificato se il confronto era superiore. Una volta che questi due valori sono stati ottenuti in r15b
er14b
registri, si moltiplicano insieme usando imul
. La moltiplicazione era tradizionalmente un'operazione relativamente lenta, ma è dannatamente veloce sui processori moderni e questo sarà particolarmente veloce, perché moltiplica solo due valori di dimensioni di byte.
Avresti potuto facilmente sostituire la moltiplicazione con l'operatore AND bit per bit ( &
), che non esegue la valutazione del corto circuito. Ciò rende il codice molto più chiaro ed è un modello generalmente riconosciuto dai compilatori. Ma quando lo fai con il tuo codice e lo compili con GCC 5.4, continua ad emettere il primo ramo:
movzx r13d, WORD PTR [rbp+rcx*2]
movzx eax, WORD PTR [rbx+rcx*2]
cmp r13w, 478 ; (curr[i] < 479)
ja .L4
cmp ax, 478 ; (l[i + shift] < 479)
setbe r14b
cmp r14d, 1 ; nontopOverlap++
sbb r8d, -1
Non vi è alcun motivo tecnico per cui è stato necessario emettere il codice in questo modo, ma per qualche motivo, la sua euristica interna gli sta dicendo che questo è più veloce. Probabilmente sarebbe più veloce se il predittore del ramo fosse dalla tua parte, ma probabilmente sarà più lento se la previsione del ramo fallisce più spesso di quanto non riesca.
Le nuove generazioni del compilatore (e altri compilatori, come Clang) conoscono questa regola e talvolta la useranno per generare lo stesso codice che avresti cercato ottimizzando a mano. Vedo regolarmente Clang tradurre le &&
espressioni nello stesso codice che sarebbe stato emesso se l'avessi usato &
. Quello che segue è l'output rilevante di GCC 6.2 con il tuo codice usando l' &&
operatore normale :
movzx r13d, WORD PTR [rbp+rcx*2]
movzx eax, WORD PTR [rbx+rcx*2]
cmp r13d, 478 ; (curr[i] < 479)
jg .L7
xor r14d, r14d ; (l[i + shift] < 479)
cmp eax, 478
setle r14b
add esi, r14d ; nontopOverlap++
Si noti come intelligente questo è! Sta usando le condizioni firmate ( jg
e setle
) anziché le condizioni non firmate ( ja
e setbe
), ma questo non è importante. Puoi vedere che fa ancora il confronto e la diramazione per la prima condizione come la versione precedente e usa le stesse setCC
istruzioni per generare codice branchless per la seconda condizione, ma è diventato molto più efficiente nel modo in cui fa l'incremento . Invece di fare un secondo confronto ridondante per impostare i flag per sbb
un'operazione, utilizza la conoscenza che r14d
sarà 1 o 0 per aggiungere semplicemente incondizionatamente questo valore nontopOverlap
. Se r14d
è 0, l'aggiunta è no-op; in caso contrario, aggiunge 1, esattamente come dovrebbe fare.
GCC 6.2 produce effettivamente un codice più efficiente quando si utilizza l' &&
operatore di cortocircuito rispetto &
all'operatore bit a bit :
movzx r13d, WORD PTR [rbp+rcx*2]
movzx eax, WORD PTR [rbx+rcx*2]
cmp r13d, 478 ; (curr[i] < 479)
jg .L6
cmp eax, 478 ; (l[i + shift] < 479)
setle r14b
cmp r14b, 1 ; nontopOverlap++
sbb esi, -1
Il ramo e l'insieme condizionale sono ancora lì, ma ora ritorna al modo meno intelligente di incrementare nontopOverlap
. Questa è una lezione importante sul perché dovresti stare attento quando cerchi di ingannare il tuo compilatore!
Ma se con i benchmark puoi provare che il codice di branching è in realtà più lento, allora può pagare per provare a risolvere il tuo compilatore. Devi solo farlo con un'attenta ispezione dello smontaggio e prepararti a rivalutare le tue decisioni quando esegui l'upgrade a una versione successiva del compilatore. Ad esempio, il codice che hai potrebbe essere riscritto come:
nontopOverlap += ((curr[i] < 479) & (l[i + shift] < 479));
Non c'è alcuna if
dichiarazione qui, e la stragrande maggioranza dei compilatori non penserà mai di emettere codice di ramificazione per questo. GCC non fa eccezione; tutte le versioni generano qualcosa di simile al seguente:
movzx r14d, WORD PTR [rbp+rcx*2]
movzx eax, WORD PTR [rbx+rcx*2]
cmp r14d, 478 ; (curr[i] < 479)
setle r15b
xor r13d, r13d ; (l[i + shift] < 479)
cmp eax, 478
setle r13b
and r13d, r15d ; meld results of the two comparisons
add esi, r13d ; nontopOverlap++
Se hai seguito gli esempi precedenti, questo dovrebbe sembrare molto familiare. Entrambi i confronti sono fatti in modo branchless, i risultati intermedi and
Ed insieme, e quindi questo risultato (che sarà 0 o 1) è add
ED a nontopOverlap
. Se vuoi il codice senza rami, questo ti garantirà virtualmente di averlo.
GCC 7 è diventato ancora più intelligente. Ora genera un codice praticamente identico (tranne un leggero riarrangiamento delle istruzioni) per il trucco di cui sopra come il codice originale. Quindi, la risposta alla tua domanda, "Perché il compilatore si comporta in questo modo?" , probabilmente perché non sono perfetti! Tentano di utilizzare l'euristica per generare il codice più ottimale possibile, ma non sempre prendono le decisioni migliori. Ma almeno possono diventare più intelligenti nel tempo!
Un modo di guardare a questa situazione è che il codice di ramificazione ha le migliori prestazioni nel migliore dei casi . Se la previsione del ramo ha esito positivo, saltare le operazioni non necessarie comporterà un tempo di esecuzione leggermente più veloce. Tuttavia, il codice branchless ha le migliori prestazioni nel caso peggiore . Se la previsione del ramo fallisce, l'esecuzione di alcune istruzioni aggiuntive necessarie per evitare un ramo sarà sicuramente più veloce di un ramo non previsto. Anche il compilatore più intelligente e intelligente avrà difficoltà a fare questa scelta.
E per la tua domanda se questo è qualcosa che i programmatori devono fare attenzione, la risposta è quasi certamente no, tranne in alcuni hot loop che stai cercando di accelerare tramite le micro-ottimizzazioni. Quindi, ti siedi con lo smontaggio e trovi il modo di modificarlo. E, come ho detto prima, sii pronto a rivisitare quelle decisioni quando esegui l'aggiornamento a una versione più recente del compilatore, perché potrebbe fare qualcosa di stupido con il tuo codice complicato o potrebbe aver cambiato la sua euristica di ottimizzazione abbastanza da poter tornare indietro all'utilizzo del codice originale. Commenta a fondo!