Perché un ciclo semplice è ottimizzato quando il limite è 959 ma non 960?


131

Considera questo semplice ciclo:

float f(float x[]) {
  float p = 1.0;
  for (int i = 0; i < 959; i++)
    p += 1;
  return p;
}

Se compili con gcc 7 (istantanea) o clang (tronco) con -march=core-avx2 -Ofastte ottieni qualcosa di molto simile a.

.LCPI0_0:
        .long   1148190720              # float 960
f:                                      # @f
        vmovss  xmm0, dword ptr [rip + .LCPI0_0] # xmm0 = mem[0],zero,zero,zero
        ret

In altre parole, imposta semplicemente la risposta su 960 senza loop.

Tuttavia, se si modifica il codice in:

float f(float x[]) {
  float p = 1.0;
  for (int i = 0; i < 960; i++)
    p += 1;
  return p;
}

L'assembly prodotto esegue effettivamente la somma del ciclo? Ad esempio clang dà:

.LCPI0_0:
        .long   1065353216              # float 1
.LCPI0_1:
        .long   1086324736              # float 6
f:                                      # @f
        vmovss  xmm0, dword ptr [rip + .LCPI0_0] # xmm0 = mem[0],zero,zero,zero
        vxorps  ymm1, ymm1, ymm1
        mov     eax, 960
        vbroadcastss    ymm2, dword ptr [rip + .LCPI0_1]
        vxorps  ymm3, ymm3, ymm3
        vxorps  ymm4, ymm4, ymm4
.LBB0_1:                                # =>This Inner Loop Header: Depth=1
        vaddps  ymm0, ymm0, ymm2
        vaddps  ymm1, ymm1, ymm2
        vaddps  ymm3, ymm3, ymm2
        vaddps  ymm4, ymm4, ymm2
        add     eax, -192
        jne     .LBB0_1
        vaddps  ymm0, ymm1, ymm0
        vaddps  ymm0, ymm3, ymm0
        vaddps  ymm0, ymm4, ymm0
        vextractf128    xmm1, ymm0, 1
        vaddps  ymm0, ymm0, ymm1
        vpermilpd       xmm1, xmm0, 1   # xmm1 = xmm0[1,0]
        vaddps  ymm0, ymm0, ymm1
        vhaddps ymm0, ymm0, ymm0
        vzeroupper
        ret

Perché è questo e perché è esattamente lo stesso per clang e gcc?


Il limite per lo stesso ciclo se si sostituisce floatcon doubleè 479. Questo è lo stesso per gcc e clang di nuovo.

Aggiornamento 1

Si scopre che gcc 7 (snapshot) e clang (trunk) si comportano in modo molto diverso. clang ottimizza i loop per tutti i limiti inferiori a 960, per quanto posso dire. gcc d'altra parte è sensibile al valore esatto e non ha un limite superiore. Ad esempio, non ottimizza il ciclo quando il limite è 200 (così come molti altri valori) ma lo fa quando il limite è 202 e 20002 (così come molti altri valori).


3
Ciò che Sulthan probabilmente significa è che 1) il compilatore srotola il ciclo e 2) una volta srotolato, vede che le operazioni di somma possono essere raggruppate in una. Se il ciclo non viene srotolato, le operazioni non possono essere raggruppate.
Jean-François Fabre

3
Avere un numero dispari di loop rende lo srotolamento più complicato, le ultime iterazioni devono essere fatte in modo speciale. Potrebbe essere sufficiente per portare l'ottimizzatore in una modalità in cui non è più in grado di riconoscere il collegamento. È abbastanza probabile, prima deve aggiungere il codice per il caso speciale e quindi dovrebbe rimuoverlo di nuovo. L'uso dell'ottimizzatore tra le orecchie è sempre il migliore :)
Hans Passant,

3
@HansPassant È inoltre ottimizzato per qualsiasi numero inferiore a 959.
eleanora

6
Di solito questo non si farebbe con l'eliminazione della variabile di induzione, invece di srotolare una quantità folle? Lo srotolamento di un fattore 959 è pazzo.
Harold,

4
@eleanora Ho giocato con quel compilatore Explorer e sembra che valga la pena tenere presente quanto segue (parlando solo dell'istantanea gcc): Se il conteggio dei loop è un multiplo di 4 e almeno 72, il loop non viene srotolato (o meglio, srotolato da un fattore 4); in caso contrario, l'intero loop viene sostituito da una costante, anche se il conteggio dei loop è 2000000001. Il mio sospetto: ottimizzazione prematura (come in, un "ehi, un multiplo di 4, prematuro che va bene per lo srotolamento" che blocca l'ulteriore ottimizzazione rispetto a un più approfondito "Qual è il problema con questo ciclo?")
Hagen von Eitzen,

Risposte:


88

TL; DR

Per impostazione predefinita, l'istantanea corrente GCC 7 si comporta in modo incoerente, mentre le versioni precedenti hanno un limite predefinito dovuto PARAM_MAX_COMPLETELY_PEEL_TIMES, che è 16. Può essere ignorato dalla riga di comando.

La logica del limite è quella di impedire lo svolgimento di un loop troppo aggressivo, che può essere un'arma a doppio taglio .

Versione GCC <= 6.3.0

L'opzione di ottimizzazione pertinente per GCC è -fpeel-loops, che è abilitata indirettamente insieme a flag -Ofast(l'enfasi è mia):

Cicli di peeling per i quali vi sono informazioni sufficienti per non ottenere molto (dal feedback del profilo o dall'analisi statica ). Attiva anche il peeling del loop completo (ovvero la rimozione completa dei loop con un numero costante di iterazioni ).

Abilitato con -O3e / o -fprofile-use.

Maggiori dettagli possono essere ottenuti aggiungendo -fdump-tree-cunroll:

$ head test.c.151t.cunroll 

;; Function f (f, funcdef_no=0, decl_uid=1919, cgraph_uid=0, symbol_order=0)

Not peeling: upper bound is known so can unroll completely

Il messaggio proviene da /gcc/tree-ssa-loop-ivcanon.c:

if (maxiter >= 0 && maxiter <= npeel)
    {
      if (dump_file)
        fprintf (dump_file, "Not peeling: upper bound is known so can "
         "unroll completely\n");
      return false;
    }

quindi la try_peel_loopfunzione ritorna false.

È possibile raggiungere un output più dettagliato con -fdump-tree-cunroll-details:

Loop 1 iterates 959 times.
Loop 1 iterates at most 959 times.
Not unrolling loop 1 (--param max-completely-peeled-times limit reached).
Not peeling: upper bound is known so can unroll completely

È possibile modificare i limiti plaing con max-completely-peeled-insns=ne max-completely-peel-times=nparams:

max-completely-peeled-insns

Il numero massimo di insn di un loop completamente pelato.

max-completely-peel-times

Il numero massimo di iterazioni di un loop per essere adatto per il peeling completo.

Per ulteriori informazioni sugli insn, puoi fare riferimento al Manuale interno di GCC .

Ad esempio, se si compila con le seguenti opzioni:

-march=core-avx2 -Ofast --param max-completely-peeled-insns=1000 --param max-completely-peel-times=1000

quindi il codice si trasforma in:

f:
        vmovss  xmm0, DWORD PTR .LC0[rip]
        ret
.LC0:
        .long   1148207104

fragore

Non sono sicuro di cosa faccia effettivamente Clang e come modificarne i limiti, ma come ho osservato, potresti costringerlo a valutare il valore finale contrassegnando il loop con unroll pragma e lo rimuoverà completamente:

#pragma unroll
for (int i = 0; i < 960; i++)
    p++;

risulta in:

.LCPI0_0:
        .long   1148207104              # float 961
f:                                      # @f
        vmovss  xmm0, dword ptr [rip + .LCPI0_0] # xmm0 = mem[0],zero,zero,zero
        ret

Grazie per questa bella risposta. Come altri hanno sottolineato, gcc sembra essere sensibile alla dimensione esatta del limite. Ad esempio, non riesce a eliminare il loop per 912 godbolt.org/g/EQJHvT . Che cosa dice fdump-tree-cunroll-details in quel caso?
eleanora,

In effetti anche 200 hanno questo problema. Questo è tutto in un'istantanea di gcc 7 fornita da godbolt. godbolt.org/g/Vg3SVs Questo non si applica affatto al clang.
eleanora,

13
Spieghi la meccanica del peeling, ma non quale sia la rilevanza del 960 o perché ci sia persino un limite
MM

1
@MM: il comportamento di peeling è completamente diverso tra GCC 6.3.0 e l'ultimo snaphost. Nel caso del primo, sospetto fortemente che il limite codificato sia imposto da PARAM_MAX_COMPLETELY_PEEL_TIMESparam, che è definito /gcc/params.def:321con il valore 16.
Grzegorz Szpetkowski

14
Potresti voler menzionare il motivo per cui GCC si limita deliberatamente in questo modo. In particolare, se srotoli i tuoi loop in modo troppo aggressivo, il binario diventa più grande e hai meno probabilità di adattarsi alla cache L1. I fallimenti della cache sono potenzialmente piuttosto costosi rispetto al salvataggio di alcuni salti condizionali, presupponendo una buona previsione del ramo (che avrai, per un ciclo tipico).
Kevin,

19

Dopo aver letto il commento di Sulthan, immagino che:

  1. Il compilatore svolge completamente il ciclo se il contatore del ciclo è costante (e non troppo alto)

  2. Una volta srotolato, il compilatore vede che le operazioni di somma possono essere raggruppate in una sola.

Se il ciclo non viene srotolato per qualche motivo (qui: genererebbe troppe istruzioni con 1000), le operazioni non possono essere raggruppate.

Il compilatore potrebbe vedere che lo srotolamento di 1000 istruzioni equivale a una singola aggiunta, ma i passaggi 1 e 2 sopra descritti sono due ottimizzazioni separate, quindi non può correre il "rischio" di srotolare, non sapendo se le operazioni possono essere raggruppate (esempio: non è possibile raggruppare una chiamata di funzione).

Nota: questo è un caso angolare: chi usa un ciclo per aggiungere di nuovo la stessa cosa? In tal caso, non fare affidamento sul compilatore possibile srotolare / ottimizzare; scrivere direttamente l'operazione corretta in un'istruzione.


1
allora puoi concentrarti su quella not too highparte? Voglio dire perché il rischio non c'è in caso di 100? Ho indovinato qualcosa ... nel mio commento sopra ... può essere la ragione?
user2736738

Penso che il compilatore non sia consapevole dell'imprecisione in virgola mobile che potrebbe innescare. Immagino sia solo un limite di dimensioni delle istruzioni. Hai a max-unrolled-insnsfiancomax-unrolled-times
Jean-François Fabre

Ah, era una specie di mio pensiero o supposizione ... vorrei avere un ragionamento più chiaro.
user2736738

5
È interessante notare che se si modifica floatin an int, il compilatore gcc è in grado di ridurre il ciclo del ciclo indipendentemente dal conteggio delle iterazioni, grazie alle ottimizzazioni della variabile di induzione ( -fivopts). Ma quelli non sembrano funzionare per floats.
Tavian Barnes,

1
@CortAmmon Giusto, e ricordo di aver letto alcune persone che erano sorprese e sconvolte dal fatto che GCC utilizza MPFR per calcolare con precisione numeri molto grandi, dando risultati piuttosto diversi rispetto alle equivalenti operazioni in virgola mobile che avrebbero accumulato errori e perdite di precisione. Dimostra che molte persone calcolano il virgola mobile nel modo sbagliato.
Zan Lynx,

12

Ottima domanda!

Sembra che tu abbia raggiunto un limite al numero di iterazioni o operazioni che il compilatore tenta di incorporare quando semplifica il codice. Come documentato da Grzegorz Szpetkowski, esistono modi specifici del compilatore per modificare questi limiti con pragmi o opzioni della riga di comando.

Puoi anche giocare con Godbolt's Compiler Explorer per confrontare il modo in cui i diversi compilatori e opzioni influiscono sul codice generato: gcc 6.2e icc 17comunque incorporano il codice per 960, mentre clang 3.9non lo fa (con la configurazione Godbolt predefinita, in realtà smette di allineare a 73).


Ho modificato la domanda per chiarire le versioni di gcc e clang che stavo usando. Vedi godbolt.org/g/FfwWjL . Sto usando -Fast per esempio.
eleanora,
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.