Warum kann (oder nicht) der Compiler eine vorhersagbare Additionsschleife nicht in eine Multiplikation optimieren?

133

Dies ist eine Frage, die mir beim Lesen der brillanten Antwort von Mysticial auf die Frage in den Sinn kam : Warum ist es schneller, ein sortiertes Array zu verarbeiten als ein unsortiertes Array ?

Kontext für die beteiligten Typen:

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

In seiner Antwort erklärt er, dass der Intel Compiler (ICC) dies optimiert:

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

... in etwas Äquivalentes dazu:

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

Der Optimierer erkennt, dass diese äquivalent sind, und tauscht daher die Schleifen aus , wodurch der Zweig außerhalb der inneren Schleife bewegt wird. Sehr schlau!

Aber warum macht es das nicht?

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

Hoffentlich kann Mysticial (oder sonst jemand) eine ebenso brillante Antwort geben. Ich habe noch nie etwas über die Optimierungen erfahren, die in dieser anderen Frage besprochen wurden, deshalb bin ich wirklich dankbar dafür.

Jhabbott
quelle
14
Das weiß wahrscheinlich nur Intel. Ich weiß nicht, in welcher Reihenfolge die Optimierungsdurchläufe ausgeführt werden. Und anscheinend wird nach dem Schleifenaustausch kein Durchlauf-Kollaps ausgeführt.
Mysticial
7
Diese Optimierung ist nur gültig, wenn die im Datenarray enthaltenen Werte unveränderlich sind. Wenn beispielsweise der Speicher jedes Mal , wenn Sie Daten lesen [0], einem Eingabe- / Ausgabegerät zugeordnet ist, wird ein anderer Wert erzeugt ...
Thomas CG de Vilhena
2
Welcher Datentyp ist dies, Ganzzahl oder Gleitkomma? Wiederholte Addition im Gleitkomma ergibt sehr unterschiedliche Ergebnisse aus der Multiplikation.
Ben Voigt
6
@Thomas: Wenn die Daten volatilewären, wäre der Schleifenaustausch ebenfalls eine ungültige Optimierung.
Ben Voigt
3
GNAT (Ada-Compiler mit GCC 4.6) schaltet die Schleifen bei O3 nicht um, aber wenn die Schleifen geschaltet werden, konvertiert es sie in eine Multiplikation.
Prosfilaes

Antworten:

105

Der Compiler kann im Allgemeinen nicht transformieren

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];

weil das letztere zu einem Überlauf von vorzeichenbehafteten ganzen Zahlen führen könnte, während das erstere dies nicht tut. Selbst bei einem garantierten Wrap-Around-Verhalten für den Überlauf von vorzeichenbehafteten Zweierkomplement-Ganzzahlen würde sich das Ergebnis ändern (wenn data[c]30000 ist, würde das Produkt -1294967296für die typischen 32-Bit- ints mit Wrap-Around werden, während 100000-mal 30000 hinzugefügt sumwürden, wenn dies der Fall wäre läuft nicht über, erhöht sumum 3000000000). Beachten Sie, dass das Gleiche für vorzeichenlose Mengen mit unterschiedlichen Zahlen gilt. Ein Überlauf von 100000 * data[c]würde normalerweise ein Reduktionsmodul einführen 2^32, das nicht im Endergebnis erscheinen darf.

Es könnte es in verwandeln

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

wenn jedoch wie üblich long longausreichend größer ist als int.

Warum es das nicht tut, kann ich nicht sagen, ich denke, es ist das, was Mysticial gesagt hat: "Anscheinend führt es nach dem Schleifenaustausch keinen durchlaufenden Durchlauf durch."

Beachten Sie, dass der Schleifenaustausch selbst nicht allgemein gültig ist (für vorzeichenbehaftete Ganzzahlen), da

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

kann wo zum Überlauf führen

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

würde nicht. Hier ist es koscher, da die Bedingung sicherstellt, data[c]dass alle hinzugefügten Zeichen das gleiche Vorzeichen haben. Wenn also einer überläuft, tun dies beide.

Ich wäre mir jedoch nicht sicher, ob der Compiler dies berücksichtigt (@Mysticial, könnten Sie es mit einer Bedingung wie data[c] & 0x80oder so versuchen , die für positive und negative Werte zutreffen kann?). Ich ließ Compiler ungültige Optimierungen vornehmen (zum Beispiel hatte ich vor ein paar Jahren einen ICC (11.0, iirc), der eine signierte 32-Bit-Int-zu-Double-Konvertierung verwendete, 1.0/nbei nder eine unsigned int. War ungefähr doppelt so schnell wie die von gcc Ausgabe. Aber falsch, viele Werte waren größer als 2^31, oops.).

Daniel Fischer
quelle
4
Ich erinnere mich an eine Version des MPW-Compilers, die eine Option hinzugefügt hat, um Stapelrahmen größer als 32 KB zuzulassen [frühere Versionen wurden mit @ A7 + int16-Adressierung für lokale Variablen eingeschränkt]. Es hat alles richtig gemacht für Stapelrahmen unter 32 KB oder über 64 KB, aber für einen Stapelrahmen mit 40 KB würde es verwendet ADD.W A6,$A000, wobei vergessen würde , dass Wortoperationen mit Adressregistern das Wort vor dem Hinzufügen auf 32 Bit verlängern. Die Fehlerbehebung dauerte eine Weile, da der Code zwischen diesem ADDund dem nächsten Mal, als A6 vom Stapel genommen wurde, nur die Wiederherstellung der
Anruferregister
3
... und das einzige Register, um das sich der Anrufer kümmerte, war die Adresse [Ladezeitkonstante] eines statischen Arrays. Der Compiler wusste, dass die Adresse des Arrays in einem Register gespeichert war, damit es darauf basierend optimiert werden konnte, aber der Debugger kannte einfach die Adresse einer Konstanten. Daher konnte MyArray[0] = 4;ich vor einer Anweisung die Adresse von überprüfen MyArrayund diesen Speicherort vor und nach der Ausführung der Anweisung überprüfen . es würde sich nicht ändern. Code war so etwas wie move.B @A3,#4und A3 sollte immer auf MyArrayjedes Mal zeigen, wenn diese Anweisung ausgeführt wurde, aber das tat es nicht. Spaß.
Supercat
Warum führt Clang diese Art der Optimierung durch?
Jason S
Der Compiler könnte dieses Umschreiben in seinen internen Zwischendarstellungen durchführen, da er in seinen internen Zwischendarstellungen weniger undefiniertes Verhalten aufweisen darf.
user253751
48

Diese Antwort gilt nicht für den jeweiligen verknüpften Fall, sie gilt jedoch für den Fragentitel und kann für zukünftige Leser interessant sein:

Aufgrund der endlichen Genauigkeit ist eine wiederholte Gleitkommaaddition nicht gleichbedeutend mit einer Multiplikation . Erwägen:

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);

Demo

Ben Voigt
quelle
10
Dies ist keine Antwort auf die gestellte Frage. Trotz interessanter Informationen (und ein Muss für jeden C / C ++ - Programmierer) ist dies kein Forum und gehört nicht hierher.
Orlp
30
@nightcracker: Das erklärte Ziel von StackOverflow ist es, eine durchsuchbare Bibliothek mit Antworten zu erstellen, die für zukünftige Benutzer nützlich sind. Und dies ist eine Antwort auf die gestellte Frage ... es kommt einfach so vor, dass es einige nicht angegebene Informationen gibt, die dazu führen, dass diese Antwort nicht für das Originalplakat gilt. Es kann immer noch für andere mit der gleichen Frage gelten.
Ben Voigt
12
Es ist könnte eine Antwort auf die Frage sein , Titel , nicht aber die Frage, nein.
orlp
7
Wie gesagt, es sind interessante Informationen. Dennoch scheint es mir immer noch falsch, dass nota bene die oberste Antwort auf die Frage die Frage in ihrer jetzigen Form nicht beantwortet . Dies ist einfach nicht der Grund, warum der Intel Compiler beschlossen hat, nicht zu optimieren, Basta.
Orlp
4
@nightcracker: Es scheint mir auch falsch, dass dies die beste Antwort ist. Ich hoffe, dass jemand eine wirklich gute Antwort für den ganzzahligen Fall veröffentlicht, der diese in der Punktzahl übertrifft. Leider glaube ich nicht, dass es für den ganzzahligen Fall eine Antwort auf "kann nicht" gibt, da die Transformation legal wäre. Wir bleiben also bei "warum nicht", was tatsächlich gegen das "nicht" verstößt. zu lokalisierter "enger Grund, weil er einer bestimmten Compilerversion eigen ist. Die Frage, die ich beantwortet habe, ist die wichtigere, IMO.
Ben Voigt
6

Der Compiler enthält verschiedene Durchgänge, die die Optimierung durchführen. Normalerweise wird in jedem Durchgang entweder eine Optimierung von Anweisungen oder eine Schleifenoptimierung durchgeführt. Derzeit gibt es kein Modell, das eine Optimierung des Schleifenkörpers basierend auf den Schleifenüberschriften durchführt. Dies ist schwer zu erkennen und seltener.

Die Optimierung, die durchgeführt wurde, war eine schleifeninvariante Codebewegung. Dies kann unter Verwendung einer Reihe von Techniken erfolgen.

Ritter
quelle
4

Nun, ich würde vermuten, dass einige Compiler diese Art der Optimierung durchführen könnten, vorausgesetzt, wir sprechen von Integer Arithmetics.

Gleichzeitig lehnen einige Compiler dies möglicherweise ab, da das Ersetzen der wiederholten Addition durch Multiplikation das Überlaufverhalten des Codes ändern kann. Bei vorzeichenlosen Ganzzahltypen sollte dies keinen Unterschied machen, da ihr Überlaufverhalten vollständig von der Sprache festgelegt wird. Aber für signierte könnte es sein (wahrscheinlich nicht auf der 2er-Komplement-Plattform). Es ist wahr, dass ein signierter Überlauf tatsächlich zu undefiniertem Verhalten in C führt, was bedeutet, dass es vollkommen in Ordnung sein sollte, diese Überlaufsemantik insgesamt zu ignorieren, aber nicht alle Compiler sind mutig genug, dies zu tun. Es wird oft von der Menge "C ist nur eine übergeordnete Assemblersprache" kritisiert. (Erinnern Sie sich, was passiert ist, als GCC Optimierungen basierend auf Strict-Aliasing-Semantik eingeführt hat?)

In der Vergangenheit hat sich GCC als Compiler erwiesen, der das Zeug dazu hat, solch drastische Schritte zu unternehmen. Andere Compiler ziehen es jedoch möglicherweise vor, sich an das wahrgenommene "vom Benutzer beabsichtigte" Verhalten zu halten, selbst wenn es nicht durch die Sprache definiert ist.

Ameise
quelle
Ich würde es vorziehen zu wissen, ob ich versehentlich von undefiniertem Verhalten abhängig bin, aber ich denke, der Compiler hat keine Möglichkeit zu wissen, da der Überlauf ein Laufzeitproblem sein würde: /
jhabbott
2
@jhabbott: iff der Überlauf auftritt, dann gibt es nicht definiertes Verhalten. Ob das Verhalten definiert ist, ist bis zur Laufzeit unbekannt (vorausgesetzt, die Zahlen werden zur Laufzeit eingegeben): P.
Orlp
3

Das tut es jetzt - zumindest tut es das Klirren :

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;
}

Kompiliert mit -O1 bis

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

Ein ganzzahliger Überlauf hat nichts damit zu tun. Wenn es einen Ganzzahlüberlauf gibt, der undefiniertes Verhalten verursacht, kann dies in beiden Fällen passieren. Hier ist die gleiche Art von Funktion, die intanstelle von verwendet wirdlong :

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;
}

Kompiliert mit -O1 bis

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
Jason S.
quelle
2

Es gibt eine konzeptionelle Barriere für diese Art der Optimierung. Compiler-Autoren geben sich viel Mühe mit der Reduzierung der Stärke - zum Beispiel, indem sie Multiplikationen durch Additionen und Verschiebungen ersetzen. Sie gewöhnen sich daran zu denken, dass Multiplikationen schlecht sind. Ein Fall, in dem man in die andere Richtung gehen sollte, ist überraschend und nicht intuitiv. Also denkt niemand daran, es umzusetzen.

zwol
quelle
3
Das Ersetzen einer Schleife durch eine geschlossene Berechnung ist auch eine Verringerung der Festigkeit, nicht wahr?
Ben Voigt
Formal, ja, nehme ich an, aber ich habe noch nie jemanden so darüber reden hören. (Ich bin allerdings etwas veraltet in der Literatur.)
zwol
1

Die Leute, die Compiler entwickeln und warten, haben nur eine begrenzte Zeit und Energie für ihre Arbeit. Daher möchten sie sich im Allgemeinen auf das konzentrieren, was ihren Benutzern am wichtigsten ist: gut geschriebenen Code in schnellen Code umzuwandeln. Sie möchten ihre Zeit nicht damit verbringen, nach Wegen zu suchen, dummen Code in schnellen Code umzuwandeln - dafür ist die Codeüberprüfung gedacht. In einer Hochsprache kann es "albernen" Code geben, der eine wichtige Idee zum Ausdruck bringt, sodass sich die Zeit der Entwickler lohnt, dies schnell zu machen. Beispielsweise ermöglichen die Abkürzung der Entwaldung und die Fusion von Streams Haskell-Programme, die um bestimmte Arten von Faulheit herum strukturiert sind erzeugte Datenstrukturen, die in engen Schleifen kompiliert werden sollen, die keinen Speicher zuweisen. Diese Art von Anreiz gilt jedoch einfach nicht für die Umwandlung von Schleifenaddition in Multiplikation. Wenn Sie möchten, dass es schnell geht,

dfeuer
quelle