Warum kann GCC nicht davon ausgehen, dass sich std :: vector :: size in dieser Schleife nicht ändert?

14

Ich habe gegenüber einem Kollegen behauptet, dass er if (i < input.size() - 1) print(0);in dieser Schleife optimiert würde, damit er input.size()nicht in jeder Iteration gelesen wird, aber es stellt sich heraus, dass dies nicht der Fall ist!

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

Laut dem Compiler Explorer mit gcc-Optionen -O3 -fno-exceptionslesen wir tatsächlich input.size()jede Iteration und verwenden sie lea, um eine Subtraktion durchzuführen!

        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

Interessanterweise findet diese Optimierung in Rust statt. Es sieht so aus, als würde es idurch eine Variable ersetzt j, die bei jeder Iteration dekrementiert wird, und der Test i < input.size() - 1wird durch so etwas ersetzt 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);
        }
    }
}

Im Compiler-Explorer sieht die entsprechende Assembly wie folgt aus:

        cmpq    %r12, %rbx
        jae     .LBB0_4

Ich habe nachgesehen und bin mir ziemlich sicher, dass es der Zähler r12ist xs.len() - 1und rbxist. Früher gibt es ein addfür rbxund ein movaußerhalb der Schleife in r12.

Warum ist das? Es scheint, als ob GCC in der Lage ist, das zu integrieren, size()und operator[]wie es getan hat, sollte es wissen können, dass sich size()dies nicht ändert. Aber vielleicht urteilt der Optimierer von GCC, dass es sich nicht lohnt, ihn in eine Variable zu ziehen? Oder vielleicht gibt es eine andere mögliche Nebenwirkung, die dies unsicher machen würde - weiß jemand Bescheid?

Jonathan Chan
quelle
1
Auch printlnist wahrscheinlich eine komplexe Methode, der Compiler kann Probleme haben zu beweisen, dass printlnder Vektor nicht mutiert.
Mooing Duck
1
@MooingDuck: Ein weiterer Thread wäre Data Race UB. Compiler können und gehen davon aus, dass dies nicht der Fall ist. Das Problem hierbei ist der Nicht-Inline-Funktionsaufruf an cout.operator<<(). Der Compiler weiß nicht, dass diese Black-Box-Funktion keinen Verweis auf die std::vectorvon einem globalen erhält .
Peter Cordes
@PeterCordes: Sie haben Recht, dass andere Threads keine eigenständige Erklärung sind und die Komplexität von printlnoder der operator<<Schlüssel ist.
Mooing Duck
Der Compiler kennt die Semantik dieser externen Methoden nicht.
user207421

Antworten:

10

Der Nicht-Inline-Funktionsaufruf an cout.operator<<(int)ist eine Blackbox für den Optimierer (da die Bibliothek nur in C ++ geschrieben ist und der Optimierer nur einen Prototyp sieht; siehe Diskussion in den Kommentaren). Es muss davon ausgegangen werden, dass der Speicher, auf den möglicherweise eine globale Variable verweist, geändert wurde.

(Oder der std::endlAnruf. Übrigens, warum an diesem Punkt eine Spülung erzwingen, anstatt nur eine zu drucken '\n'?)

zB ist nach allem, was es weiß, std::vector<int> &inputein Verweis auf eine globale Variable, und einer dieser Funktionsaufrufe modifiziert diese globale Variable . (Oder es gibt vector<int> *ptrirgendwo eine globale Funktion oder eine Funktion, die einen Zeiger auf eine static vector<int>in einer anderen Kompilierungseinheit zurückgibt , oder auf eine andere Weise, mit der eine Funktion einen Verweis auf diesen Vektor erhalten kann, ohne dass wir einen Verweis darauf erhalten.

Wenn Sie eine lokale Variable , deren Adresse hatte noch nie gemacht worden, der Compiler kann davon ausgehen , dass nicht-Inline - Funktion Anrufe nicht mutieren könnte. Weil es für keine globale Variable eine Möglichkeit gibt, einen Zeiger auf dieses Objekt zu halten. ( Dies wird als Escape-Analyse bezeichnet. ) Aus diesem Grund kann der Compiler size_t iüber Funktionsaufrufe hinweg ein Register führen. ( int iKann einfach wegoptimiert werden, weil es von beschattet wird size_t iund nicht anderweitig verwendet wird).

vectorDies könnte auch mit einem lokalen Zeiger geschehen (dh mit den Zeigern base, end_size und end_capacity).

ISO C99 hat eine Lösung für dieses Problem : int *restrict foo. Viele C ++ - Kompilierungen unterstützen int *__restrict foodas Versprechen, dass auf den Speicher, auf den verwiesen foowird, nur über diesen Zeiger zugegriffen wird. Am nützlichsten in Funktionen, die 2 Arrays benötigen, und Sie möchten dem Compiler versprechen, dass sie sich nicht überlappen. Es kann also automatisch vektorisiert werden, ohne Code zu generieren, um dies zu überprüfen und eine Fallback-Schleife auszuführen.

Das OP kommentiert:

In Rust ist eine nicht veränderbare Referenz eine globale Garantie dafür, dass niemand den Wert mutiert, auf den Sie verweisen (entspricht C ++ restrict).

Das erklärt, warum Rust diese Optimierung durchführen kann, C ++ jedoch nicht.


Optimieren Sie Ihr C ++

Natürlich sollten Sie auto size = input.size();einmal oben in Ihrer Funktion verwenden, damit der Compiler weiß, dass es sich um eine Schleifeninvariante handelt. C ++ - Implementierungen lösen dieses Problem nicht für Sie, daher müssen Sie es selbst tun.

Möglicherweise müssen Sie auch const int *data = input.data();Lasten des Datenzeigers vom std::vector<int>"Steuerblock" abheben. Es ist bedauerlich, dass für die Optimierung sehr nicht-idiomatische Quellenänderungen erforderlich sein können.

Rust ist eine viel modernere Sprache, die entwickelt wurde, nachdem Compiler-Entwickler gelernt hatten, was in der Praxis für Compiler möglich war. Es zeigt sich auch auf andere Weise, einschließlich der portablen Darstellung einiger der coolen Dinge, die CPUs über i32.count_ones, Rotation, Bit-Scan usw. tun können . Es ist wirklich dumm, dass ISO C ++ immer noch keine dieser tragbaren Dinge verfügbar macht, außer std::bitset::count().

Peter Cordes
quelle
1
Der OP-Code hat immer noch den Test, ob der Vektor als Wert genommen wird. Obwohl GCC in diesem Fall optimieren könnte, ist dies nicht der Fall.
Walnuss
1
Der Standard definiert das Verhalten operator<<für diese Operandentypen. In Standard C ++ ist es also keine Black Box und der Compiler kann davon ausgehen, dass er das tut, was in der Dokumentation angegeben ist. Vielleicht möchten sie Bibliotheksentwickler dabei unterstützen, nicht standardmäßiges Verhalten hinzuzufügen ...
MM
2
Dem Optimierer könnte das Verhalten zugeführt werden, das der Standard vorschreibt. Mein Punkt ist, dass diese Optimierung vom Standard zugelassen wird, der Compiler-Anbieter sich jedoch dafür entscheidet, sie so zu implementieren, wie Sie diese Optimierung beschreiben und darauf verzichten
MM
2
@MM Es wurde kein zufälliges Objekt gesagt, ich sagte einen implementierungsdefinierten Vektor. Der Standard enthält nichts, was eine Implementierung daran hindert, einen implementierungsdefinierten Vektor zu haben, den der Operator << ändert, und den Zugriff auf diesen Vektor auf implementierungsdefinierte Weise zu ermöglichen. coutErmöglicht streambufdie Zuordnung eines Objekts einer benutzerdefinierten Klasse, von der abgeleitet wurde , zum Stream cout.rdbuf. Ebenso kann ein von abgeleitetes Objekt ostreamzugeordnet werden cout.tie.
Ross Ridge
2
@PeterCordes - Ich wäre nicht so sicher in Bezug auf lokale Vektoren: Sobald eine Mitgliedsfunktion aus dem Rahmen gerät, sind die Einheimischen effektiv entkommen, da der thisZeiger implizit übergeben wird. Dies könnte in der Praxis bereits beim Konstruktor geschehen. Betrachten Sie diese einfache Schleife - ich habe nur die gcc-Hauptschleife (von L34:bis jne L34) überprüft , aber sie verhält sich definitiv so, als wären die Vektorelemente entkommen (sie werden bei jeder Iteration aus dem Speicher geladen).
BeeOnRope