Perché il compilatore non può (o non può) ottimizzare un ciclo di addizione prevedibile in una moltiplicazione?


133

Questa è una domanda che mi è venuta in mente mentre leggevo la brillante risposta di Mysticial alla domanda: perché è più veloce elaborare un array ordinato che un array non ordinato ?

Contesto per i tipi coinvolti:

const unsigned arraySize = 32768;
int data[arraySize];
long long sum = 0;

Nella sua risposta spiega che Intel Compiler (ICC) ottimizza questo:

for (int i = 0; i < 100000; ++i)
    for (int c = 0; c < arraySize; ++c)
        if (data[c] >= 128)
            sum += data[c];

... in qualcosa di equivalente a questo:

for (int c = 0; c < arraySize; ++c)
    if (data[c] >= 128)
        for (int i = 0; i < 100000; ++i)
            sum += data[c];

L'ottimizzatore sta riconoscendo che questi sono equivalenti e pertanto scambia i loop , spostando il ramo all'esterno del loop interno. Molto intelligente!

Ma perché non lo fa?

for (int c = 0; c < arraySize; ++c)
    if (data[c] >= 128)
        sum += 100000 * data[c];

Speriamo che Mysticial (o chiunque altro) possa dare una risposta altrettanto brillante. Non ho mai appreso delle ottimizzazioni discusse in quell'altra domanda prima, quindi sono davvero grato per questo.


14
È qualcosa che probabilmente solo Intel conosce. Non so quale ordine esegua i suoi passaggi di ottimizzazione. E a quanto pare, non esegue un passaggio loop-crollante dopo l'interscambio loop.
Mistico il

7
Questa ottimizzazione è valida solo se i valori contenuti nell'array di dati sono immutabili. Ad esempio, se la memoria è mappata su un dispositivo di input / output ogni volta che leggi i dati [0] produrrà un valore diverso ...
Thomas CG de Vilhena,

2
Che tipo di dati è questo, intero o in virgola mobile? L'aggiunta ripetuta in virgola mobile fornisce risultati molto diversi dalla moltiplicazione.
Ben Voigt,

6
@Thomas: Se i dati fossero volatile, allora l'interscambio di loop sarebbe anche un'ottimizzazione non valida.
Ben Voigt,

3
GNAT (compilatore Ada con GCC 4.6) non commuterà i loop su O3, ma se i loop vengono commutati, lo convertirà in una moltiplicazione.
prosfilaes,

Risposte:


105

Il compilatore non può generalmente trasformarsi

for (int c = 0; c < arraySize; ++c)
    if (data[c] >= 128)
        for (int i = 0; i < 100000; ++i)
            sum += data[c];

in

for (int c = 0; c < arraySize; ++c)
    if (data[c] >= 128)
        sum += 100000 * data[c];

perché il secondo potrebbe portare a un overflow di numeri interi con segno dove il primo no. Anche con un comportamento avvolgente garantito per il trabocco di numeri interi di complemento con segno due, cambierebbe il risultato (se data[c]è 30000, il prodotto diventerebbe -1294967296per i tipici 32 bit intcon avvolgimento, mentre 100000 volte aggiungendo 30000 a sum, non trabocca, aumenta sumdi 3000000000). Si noti che lo stesso vale per quantità non firmate, con numeri diversi, l'overflow di in 100000 * data[c]genere introduce un modulo di riduzione 2^32che non deve apparire nel risultato finale.

Potrebbe trasformarlo in

for (int c = 0; c < arraySize; ++c)
    if (data[c] >= 128)
        sum += 100000LL * data[c];  // resp. 100000ull

tuttavia, se, come al solito, long longè sufficientemente più grande di int.

Perché non lo fa, non posso dirlo, immagino sia ciò che Mysticial ha detto , "apparentemente, non esegue un passaggio loop-collasso dopo loop-interscambio".

Si noti che lo stesso ciclo di interscambio non è generalmente valido (per numeri interi con segno), poiché

for (int c = 0; c < arraySize; ++c)
    if (condition(data[c]))
        for (int i = 0; i < 100000; ++i)
            sum += data[c];

può portare a overflow dove

for (int i = 0; i < 100000; ++i)
    for (int c = 0; c < arraySize; ++c)
        if (condition(data[c]))
            sum += data[c];

no. Qui è kosher, poiché la condizione garantisce data[c]che tutto ciò che viene aggiunto abbia lo stesso segno, quindi se uno trabocca, entrambi lo fanno.

Non sarei troppo sicuro che il compilatore ne abbia tenuto conto, però (@Mysticial, potresti provare con una condizione simile data[c] & 0x80o così che può essere vera per valori positivi e negativi?). Ho fatto in modo che i compilatori facessero ottimizzazioni non valide (per esempio, un paio di anni fa, avevo una ICC (11.0, iirc) che utilizzava una conversione int-to-double firmata a 32 bit in 1.0/ncui si ntrovava una unsigned int. Era circa due volte più veloce di quella di gcc output. Ma sbagliato, molti valori erano più grandi di 2^31, oops.).


4
Ricordo una versione del compilatore MPW che ha aggiunto un'opzione per consentire frame stack maggiori di 32K [le versioni precedenti erano limitate usando @ A7 + indirizzamento int16 per le variabili locali]. Ha funzionato perfettamente per frame di stack inferiori a 32 K o oltre 64 K, ma per un frame di stack ADD.W A6,$A00040 K avrebbe usato , dimenticando che le operazioni con i registri degli indirizzi estendono la parola a 32 bit prima dell'aggiunta. Ci è voluto un po 'per la risoluzione dei problemi, poiché l'unica cosa che il codice ha fatto tra quello ADDe la prossima volta che ha saltato A6 dallo stack è stato ripristinare i registri del chiamante che ha salvato in quel frame ...
supercat

3
... e l'unico registro a cui si è preoccupato del chiamante era l'indirizzo [costante di tempo di caricamento] di un array statico. Il compilatore sapeva che l'indirizzo dell'array era stato salvato in un registro in modo da poterlo ottimizzare in base a quello, ma il debugger conosceva semplicemente l'indirizzo di una costante. Quindi, prima di un'affermazione, MyArray[0] = 4;ho potuto controllare l'indirizzo MyArraye vedere quella posizione prima e dopo l'esecuzione dell'affermazione; non cambierebbe. Il codice era qualcosa di simile move.B @A3,#4e A3 avrebbe dovuto indicare sempre MyArrayogni volta che quell'istruzione veniva eseguita, ma non lo fece. Divertimento.
supercat

allora perché clang esegue questo tipo di ottimizzazione?
Jason S,

Il compilatore potrebbe eseguire tale riscrittura nelle sue rappresentazioni intermedie interne, perché è autorizzato ad avere un comportamento meno indefinito nelle sue rappresentazioni intermedie interne.
user253751

48

Questa risposta non si applica al caso specifico collegato, ma si applica al titolo della domanda e può essere interessante per i futuri lettori:

A causa della precisione finita, l'aggiunta ripetuta in virgola mobile non equivale alla moltiplicazione . Tener conto di:

float const step = 1e-15;
float const init = 1;
long int const count = 1000000000;

float result1 = init;
for( int i = 0; i < count; ++i ) result1 += step;

float result2 = init;
result2 += step * count;

cout << (result1 - result2);

dimostrazione


10
Questa non è una risposta alla domanda posta. Nonostante le informazioni interessanti (e assolutamente da sapere per qualsiasi programmatore C / C ++), questo non è un forum e non appartiene a questo.
orlp,

30
@nightcracker: l'obiettivo dichiarato di StackOverflow è quello di creare una libreria di risposte ricercabili utili per i futuri utenti. E questa è una risposta alla domanda posta ... succede così che ci sono alcune informazioni non dichiarate che rendono questa risposta non applicabile per il poster originale. Potrebbe ancora applicarsi ad altri con la stessa domanda.
Ben Voigt,

12
E ' potrebbe essere una risposta alla domanda del titolo , ma non la domanda, no.
orlp,

7
Come ho detto, sono informazioni interessanti . Eppure mi sembra ancora sbagliato che nota bene la migliore risposta alla domanda non risponda alla domanda così com'è, ora . Questo semplicemente non è il motivo per cui il compilatore Intel ha deciso di non ottimizzare, basta.
orlp,

4
@nightcracker: Anche a me sembra sbagliato che questa sia la risposta migliore. Spero che qualcuno pubblichi una risposta davvero buona per il caso intero che supera questo in punteggio. Sfortunatamente, non penso che ci sia una risposta per "impossibile" per il caso intero, perché la trasformazione sarebbe legale, quindi ci rimane "perché non lo fa", che in realtà si infrange sul " "stretta ragione", perché localizzata in una particolare versione del compilatore. La domanda a cui ho risposto è la più importante, IMO.
Ben Voigt,

6

Il compilatore contiene vari passaggi che esegue l'ottimizzazione. Di solito in ogni passaggio viene eseguita un'ottimizzazione delle istruzioni o ottimizzazioni del ciclo. Al momento non esiste un modello che ottimizzi il corpo del loop in base alle intestazioni del loop. Questo è difficile da rilevare e meno comune.

L'ottimizzazione che è stata fatta è stata il movimento del codice invariante in loop. Questo può essere fatto usando una serie di tecniche.


4

Bene, immagino che alcuni compilatori potrebbero fare questo tipo di ottimizzazione, supponendo che stiamo parlando di aritmetica integer.

Allo stesso tempo, alcuni compilatori potrebbero rifiutarsi di farlo perché la sostituzione dell'aggiunta ripetitiva con la moltiplicazione potrebbe modificare il comportamento di overflow del codice. Per i tipi di numeri interi senza segno, non dovrebbe fare la differenza poiché il loro comportamento di overflow è completamente specificato dalla lingua. Ma per quelli firmati, potrebbe (probabilmente non sulla piattaforma di complemento di 2 però). È vero che l'overflow firmato in realtà porta a un comportamento indefinito in C, il che significa che dovrebbe essere perfettamente OK ignorare del tutto la semantica di overflow, ma non tutti i compilatori sono abbastanza coraggiosi da farlo. Spesso attinge molte critiche dalla folla "C è solo un linguaggio di assemblaggio di livello superiore". (Ricordi cosa è successo quando GCC ha introdotto ottimizzazioni basate sulla semantica aliasing rigorosa?)

Storicamente, GCC si è mostrato come un compilatore che ha le carte in regola per compiere passi così drastici, ma altri compilatori potrebbero preferire attenersi al comportamento "inteso dall'utente" percepito anche se non definito dal linguaggio.


Preferirei sapere se dipendo accidentalmente da un comportamento indefinito, ma immagino che il compilatore non abbia modo di saperlo poiché l'overflow sarebbe un problema di runtime: /
jhabbott

2
@jhabbott: se si verifica l'overflow, si verifica un comportamento indefinito. Se il comportamento è definito è sconosciuto fino al momento dell'esecuzione (supponendo che i numeri vengano immessi in fase di esecuzione): P.
orlp,

3

Lo fa ora - almeno, clang fa :

long long add_100k_signed(int *data, int arraySize)
{
    long long sum = 0;

    for (int c = 0; c < arraySize; ++c)
        if (data[c] >= 128)
            for (int i = 0; i < 100000; ++i)
                sum += data[c];
    return sum;
}

compila da -O1 a

add_100k_signed:                        # @add_100k_signed
        test    esi, esi
        jle     .LBB0_1
        mov     r9d, esi
        xor     r8d, r8d
        xor     esi, esi
        xor     eax, eax
.LBB0_4:                                # =>This Inner Loop Header: Depth=1
        movsxd  rdx, dword ptr [rdi + 4*rsi]
        imul    rcx, rdx, 100000
        cmp     rdx, 127
        cmovle  rcx, r8
        add     rax, rcx
        add     rsi, 1
        cmp     r9, rsi
        jne     .LBB0_4
        ret
.LBB0_1:
        xor     eax, eax
        ret

L'overflow di numeri interi non ha nulla a che fare con esso; se c'è un overflow di numeri interi che causa un comportamento indefinito, può accadere in entrambi i casi. Ecco lo stesso tipo di funzione che utilizza intinvece dilong :

int add_100k_signed(int *data, int arraySize)
{
    int sum = 0;

    for (int c = 0; c < arraySize; ++c)
        if (data[c] >= 128)
            for (int i = 0; i < 100000; ++i)
                sum += data[c];
    return sum;
}

compila da -O1 a

add_100k_signed:                        # @add_100k_signed
        test    esi, esi
        jle     .LBB0_1
        mov     r9d, esi
        xor     r8d, r8d
        xor     esi, esi
        xor     eax, eax
.LBB0_4:                                # =>This Inner Loop Header: Depth=1
        mov     edx, dword ptr [rdi + 4*rsi]
        imul    ecx, edx, 100000
        cmp     edx, 127
        cmovle  ecx, r8d
        add     eax, ecx
        add     rsi, 1
        cmp     r9, rsi
        jne     .LBB0_4
        ret
.LBB0_1:
        xor     eax, eax
        ret

2

C'è un ostacolo concettuale a questo tipo di ottimizzazione. Gli autori di compilatori dedicano molto impegno alla riduzione della forza , ad esempio sostituendo le moltiplicazioni con aggiunte e spostamenti. Si abituano a pensare che le moltiplicazioni siano cattive. Quindi un caso in cui uno dovrebbe andare dall'altra parte è sorprendente e controintuitivo. Quindi nessuno pensa di implementarlo.


3
Sostituire un loop con un calcolo a forma chiusa è anche una riduzione della forza, no?
Ben Voigt,

Formalmente, sì, suppongo, ma non ho mai sentito nessuno parlarne in quel modo. (Tuttavia, sono un po 'fuori moda in letteratura.)
Zwol,

1

Le persone che sviluppano e mantengono i compilatori hanno una quantità limitata di tempo ed energia da spendere per il loro lavoro, quindi generalmente vogliono concentrarsi su ciò a cui i loro utenti tengono di più: trasformare un codice ben scritto in un codice veloce. Non vogliono passare il tempo a cercare modi per trasformare il codice stupido in codice veloce: ecco a cosa serve la revisione del codice. In un linguaggio di alto livello, potrebbe esserci un codice "sciocco" che esprime un'idea importante, facendo valere il tempo degli sviluppatori per renderlo così veloce, ad esempio la deforestazione abbreviata e la fusione del flusso consentono ai programmi Haskell strutturati attorno a determinati tipi di pigrizia ha prodotto strutture di dati da compilare in loop ristretti che non allocano memoria. Ma questo tipo di incentivo semplicemente non si applica alla trasformazione dell'aggiunta in loop in moltiplicazione. Se vuoi che sia veloce,

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.