Erzwingen der Anweisungsreihenfolge in C ++


111

Angenommen, ich habe eine Reihe von Anweisungen, die ich in einer festen Reihenfolge ausführen möchte. Ich möchte g ++ mit Optimierungsstufe 2 verwenden, damit einige Anweisungen neu angeordnet werden können. Welche Werkzeuge hat man, um eine bestimmte Reihenfolge von Anweisungen durchzusetzen?

Betrachten Sie das folgende Beispiel.

using Clock = std::chrono::high_resolution_clock;

auto t1 = Clock::now(); // Statement 1
foo();                  // Statement 2
auto t2 = Clock::now(); // Statement 3

auto elapsedTime = t2 - t1;

In diesem Beispiel ist es wichtig, dass die Anweisungen 1-3 in der angegebenen Reihenfolge ausgeführt werden. Kann der Compiler jedoch nicht denken, dass Anweisung 2 unabhängig von 1 und 3 ist, und den Code wie folgt ausführen?

using Clock=std::chrono::high_resolution_clock;

foo();                  // Statement 2
auto t1 = Clock::now(); // Statement 1
auto t2 = Clock::now(); // Statement 3

auto elapsedTime = t2 - t1;

34
Wenn der Compiler glaubt, dass sie unabhängig sind, wenn sie es nicht sind, ist der Compiler defekt und Sie sollten einen besseren Compiler verwenden.
David Schwartz


1
könnte __sync_synchronize()hilfreich sein?
vsz

3
@HowardHinnant: Die semantische Potenz von Standard C würde sich enorm verbessern, wenn eine solche Direktive definiert würde und wenn die Aliasing-Regeln angepasst würden, um Lesevorgänge auszunehmen, die nach einer zuvor geschriebenen Datenbarriere durchgeführt wurden.
Supercat

4
@DavidSchwartz In diesem Fall geht es darum, die fooAusführungszeit zu messen , die der Compiler bei der Neuordnung ignorieren darf, genauso wie er die Beobachtung von einem anderen Thread ignorieren darf.
CodesInChaos

Antworten:


100

Ich möchte versuchen, eine etwas umfassendere Antwort zu geben, nachdem dies mit dem C ++ - Standardkomitee besprochen wurde. Ich bin nicht nur Mitglied des C ++ - Komitees, sondern auch Entwickler der LLVM- und Clang-Compiler.

Grundsätzlich gibt es keine Möglichkeit, eine Barriere oder eine Operation in der Sequenz zu verwenden, um diese Transformationen zu erreichen. Das grundlegende Problem besteht darin, dass die Betriebssemantik einer ganzzahligen Addition vollständig bekannt ist der Implementierung . Es kann sie simulieren, es weiß, dass sie von korrekten Programmen nicht beobachtet werden können, und es ist immer frei, sie zu bewegen.

Wir könnten versuchen, dies zu verhindern, aber es hätte äußerst negative Ergebnisse und würde letztendlich scheitern.

Die einzige Möglichkeit, dies im Compiler zu verhindern, besteht darin, ihm mitzuteilen, dass alle diese grundlegenden Operationen beobachtbar sind. Das Problem ist, dass dies dann die überwiegende Mehrheit der Compiler-Optimierungen ausschließen würde. Innerhalb des Compilers haben wir im Wesentlichen keine guten Mechanismen, um zu modellieren, dass das Timing beobachtbar ist, aber sonst nichts. Wir haben nicht einmal ein gutes Modell dafür, welche Operationen Zeit brauchen . Nimmt die Konvertierung einer vorzeichenlosen 32-Bit-Ganzzahl in eine vorzeichenlose 64-Bit-Ganzzahl beispielsweise Zeit in Anspruch? Auf x86-64 dauert es keine Zeit, auf anderen Architekturen dauert es jedoch nicht null. Hier gibt es keine allgemein korrekte Antwort.

Aber selbst wenn es uns durch einige Heldentaten gelingt, den Compiler daran zu hindern, diese Operationen neu zu ordnen, gibt es keine Garantie dafür, dass dies ausreicht. Überlegen Sie sich eine gültige und konforme Methode zum Ausführen Ihres C ++ - Programms auf einem x86-Computer: DynamoRIO. Dies ist ein System, das den Maschinencode des Programms dynamisch auswertet. Eine Sache, die es tun kann, sind Online-Optimierungen, und es ist sogar in der Lage, den gesamten Bereich grundlegender arithmetischer Anweisungen außerhalb des Timings spekulativ auszuführen. Und dieses Verhalten ist nicht nur bei dynamischen Evaluatoren zu beobachten. Die tatsächliche x86-CPU spekuliert auch (eine viel geringere Anzahl von) Anweisungen und ordnet sie dynamisch neu an.

Die wesentliche Erkenntnis ist, dass die Tatsache, dass Arithmetik nicht beobachtbar ist (selbst auf der Timing-Ebene), die Schichten des Computers durchdringt. Dies gilt für den Compiler, die Laufzeit und häufig sogar für die Hardware. Das Erzwingen der Beobachtbarkeit würde sowohl den Compiler als auch die Hardware dramatisch einschränken.

Aber all dies sollte nicht dazu führen, dass Sie die Hoffnung verlieren. Wenn Sie die Ausführung grundlegender mathematischer Operationen zeitlich festlegen möchten, haben wir gut untersuchte Techniken studiert, die zuverlässig funktionieren. Typischerweise werden diese beim Micro-Benchmarking verwendet . Ich habe auf der CppCon2015 einen Vortrag darüber gehalten: https://youtu.be/nXaxk27zwlk

Die dort gezeigten Techniken werden auch von verschiedenen Micro-Benchmark-Bibliotheken wie Googles bereitgestellt: https://github.com/google/benchmark#preventing-optimization

Der Schlüssel zu diesen Techniken besteht darin, sich auf die Daten zu konzentrieren. Sie machen die Eingabe in die Berechnung für den Optimierer undurchsichtig und das Ergebnis der Berechnung für den Optimierer undurchsichtig. Sobald Sie das getan haben, können Sie es zuverlässig zeitlich festlegen. Schauen wir uns eine realistische Version des Beispiels in der ursprünglichen Frage an, wobei die Definition für foodie Implementierung vollständig sichtbar ist. Ich habe auch eine (nicht portable) Version DoNotOptimizeaus der Google Benchmark-Bibliothek extrahiert, die Sie hier finden: https://github.com/google/benchmark/blob/master/include/benchmark/benchmark_api.h#L208

#include <chrono>

template <class T>
__attribute__((always_inline)) inline void DoNotOptimize(const T &value) {
  asm volatile("" : "+m"(const_cast<T &>(value)));
}

// The compiler has full knowledge of the implementation.
static int foo(int x) { return x * 2; }

auto time_foo() {
  using Clock = std::chrono::high_resolution_clock;

  auto input = 42;

  auto t1 = Clock::now();         // Statement 1
  DoNotOptimize(input);
  auto output = foo(input);       // Statement 2
  DoNotOptimize(output);
  auto t2 = Clock::now();         // Statement 3

  return t2 - t1;
}

Hier stellen wir sicher, dass die Eingabedaten und die Ausgabedaten um die Berechnung herum als nicht optimierbar markiert werden foound nur um diese Markierungen herum die berechneten Timings. Da Sie Daten verwenden, um die Berechnung zu fixieren, bleibt diese garantiert zwischen den beiden Zeitpunkten, und dennoch kann die Berechnung selbst optimiert werden. Die resultierende x86-64-Assembly, die durch einen kürzlich erstellten Build von Clang / LLVM generiert wurde, lautet:

% ./bin/clang++ -std=c++14 -c -S -o - so.cpp -O3
        .text
        .file   "so.cpp"
        .globl  _Z8time_foov
        .p2align        4, 0x90
        .type   _Z8time_foov,@function
_Z8time_foov:                           # @_Z8time_foov
        .cfi_startproc
# BB#0:                                 # %entry
        pushq   %rbx
.Ltmp0:
        .cfi_def_cfa_offset 16
        subq    $16, %rsp
.Ltmp1:
        .cfi_def_cfa_offset 32
.Ltmp2:
        .cfi_offset %rbx, -16
        movl    $42, 8(%rsp)
        callq   _ZNSt6chrono3_V212system_clock3nowEv
        movq    %rax, %rbx
        #APP
        #NO_APP
        movl    8(%rsp), %eax
        addl    %eax, %eax              # This is "foo"!
        movl    %eax, 12(%rsp)
        #APP
        #NO_APP
        callq   _ZNSt6chrono3_V212system_clock3nowEv
        subq    %rbx, %rax
        addq    $16, %rsp
        popq    %rbx
        retq
.Lfunc_end0:
        .size   _Z8time_foov, .Lfunc_end0-_Z8time_foov
        .cfi_endproc


        .ident  "clang version 3.9.0 (trunk 273389) (llvm/trunk 273380)"
        .section        ".note.GNU-stack","",@progbits

Hier können Sie sehen, wie der Compiler den Aufruf auf foo(input)einen einzelnen Befehl optimiert addl %eax, %eax, ohne ihn jedoch außerhalb des Timings zu verschieben oder ihn trotz der konstanten Eingabe vollständig zu eliminieren.

Ich hoffe, dies hilft, und das C ++ - Standardkomitee prüft die Möglichkeit, APIs ähnlich wie DoNotOptimizehier zu standardisieren .


1
Vielen Dank für Ihre Antwort. Ich habe es als die neue beste Antwort markiert. Ich hätte das früher tun können, aber ich habe diese Stackoverflow-Seite seit vielen Monaten nicht mehr gelesen. Ich bin sehr daran interessiert, mit dem Clang-Compiler C ++ - Programme zu erstellen. Unter anderem gefällt mir, dass man in Clang Unicode-Zeichen in Variablennamen verwenden kann. Ich denke, ich werde weitere Fragen zu Clang on Stackoverflow stellen.
S2108887

5
Ich verstehe zwar, wie dies verhindert, dass foo vollständig wegoptimiert wird, aber können Sie etwas näher erläutern, warum dies verhindert, dass die Aufrufe Clock::now()relativ zu foo () neu angeordnet werden? Muss der Optimierer dies annehmen DoNotOptimizeund Clock::now()Zugriff auf einen gemeinsamen globalen Status haben und diesen ändern, der ihn wiederum an den Ein- und Ausgang binden würde? Oder verlassen Sie sich auf einige aktuelle Einschränkungen der Implementierung des Optimierers?
MikeMB

2
DoNotOptimizeIn diesem Beispiel handelt es sich um ein synthetisch "beobachtbares" Ereignis. Es ist, als würde eine sichtbare Ausgabe mit der Darstellung der Eingabe auf ein Terminal gedruckt. Da das Lesen der Uhr auch beobachtbar ist (Sie beobachten, wie die Zeit vergeht), können sie nicht neu angeordnet werden, ohne das beobachtbare Verhalten des Programms zu ändern.
Chandler Carruth

1
Ich bin immer noch nicht ganz klar mit dem Konzept "beobachtbar". Wenn die fooFunktion einige Operationen wie das Lesen von einem Socket ausführt, der für eine Weile blockiert sein kann, zählt dies eine beobachtbare Operation? Und da reades sich nicht um eine "völlig bekannte" Operation handelt (richtig?), Wird der Code in Ordnung bleiben?
Ravenisadesk

"Das grundlegende Problem besteht darin, dass die operationelle Semantik einer ganzzahligen Addition der Implementierung vollständig bekannt ist." Aber es scheint mir, dass das Problem nicht die Semantik der Ganzzahladdition ist, sondern die Semantik des Aufrufs der Funktion foo (). Woher weiß es, dass foo () und clock () nicht interagieren, es sei denn, foo () befindet sich in derselben Kompilierungseinheit?
Dave

59

Zusammenfassung:

Es scheint keine garantierte Möglichkeit zu geben, eine Neuordnung zu verhindern. Solange jedoch die Optimierung der Verbindungszeit / des gesamten Programms nicht aktiviert ist, scheint es eine gute Wahl zu sein , die aufgerufene Funktion in einer separaten Kompilierungseinheit zu lokalisieren . (Zumindest bei GCC, obwohl die Logik vermuten lässt, dass dies auch bei anderen Compilern wahrscheinlich ist.) Dies geht zu Lasten des Funktionsaufrufs. Inline-Code befindet sich per Definition in derselben Kompilierungseinheit und kann neu angeordnet werden.

Ursprüngliche Antwort:

GCC ordnet die Aufrufe unter -O2-Optimierung neu an:

#include <chrono>
static int foo(int x)    // 'static' or not here doesn't affect ordering.
{
    return x*2;
}
int fred(int x)
{
    auto t1 = std::chrono::high_resolution_clock::now();
    int y = foo(x);
    auto t2 = std::chrono::high_resolution_clock::now();
    return y;
}

GCC 5.3.0:

g++ -S --std=c++11 -O0 fred.cpp ::

_ZL3fooi:
        pushq   %rbp
        movq    %rsp, %rbp
        movl    %ecx, 16(%rbp)
        movl    16(%rbp), %eax
        addl    %eax, %eax
        popq    %rbp
        ret
_Z4fredi:
        pushq   %rbp
        movq    %rsp, %rbp
        subq    $64, %rsp
        movl    %ecx, 16(%rbp)
        call    _ZNSt6chrono3_V212system_clock3nowEv
        movq    %rax, -16(%rbp)
        movl    16(%rbp), %ecx
        call    _ZL3fooi
        movl    %eax, -4(%rbp)
        call    _ZNSt6chrono3_V212system_clock3nowEv
        movq    %rax, -32(%rbp)
        movl    -4(%rbp), %eax
        addq    $64, %rsp
        popq    %rbp
        ret

Aber:

g++ -S --std=c++11 -O2 fred.cpp ::

_Z4fredi:
        pushq   %rbx
        subq    $32, %rsp
        movl    %ecx, %ebx
        call    _ZNSt6chrono3_V212system_clock3nowEv
        call    _ZNSt6chrono3_V212system_clock3nowEv
        leal    (%rbx,%rbx), %eax
        addq    $32, %rsp
        popq    %rbx
        ret

Nun mit foo () als externe Funktion:

#include <chrono>
int foo(int x);
int fred(int x)
{
    auto t1 = std::chrono::high_resolution_clock::now();
    int y = foo(x);
    auto t2 = std::chrono::high_resolution_clock::now();
    return y;
}

g++ -S --std=c++11 -O2 fred.cpp ::

_Z4fredi:
        pushq   %rbx
        subq    $32, %rsp
        movl    %ecx, %ebx
        call    _ZNSt6chrono3_V212system_clock3nowEv
        movl    %ebx, %ecx
        call    _Z3fooi
        movl    %eax, %ebx
        call    _ZNSt6chrono3_V212system_clock3nowEv
        movl    %ebx, %eax
        addq    $32, %rsp
        popq    %rbx
        ret

ABER wenn dies mit -flto verknüpft ist (Link-Time-Optimierung):

0000000100401710 <main>:
   100401710:   53                      push   %rbx
   100401711:   48 83 ec 20             sub    $0x20,%rsp
   100401715:   89 cb                   mov    %ecx,%ebx
   100401717:   e8 e4 ff ff ff          callq  100401700 <__main>
   10040171c:   e8 bf f9 ff ff          callq  1004010e0 <_ZNSt6chrono3_V212system_clock3nowEv>
   100401721:   e8 ba f9 ff ff          callq  1004010e0 <_ZNSt6chrono3_V212system_clock3nowEv>
   100401726:   8d 04 1b                lea    (%rbx,%rbx,1),%eax
   100401729:   48 83 c4 20             add    $0x20,%rsp
   10040172d:   5b                      pop    %rbx
   10040172e:   c3                      retq

3
MSVC und ICC auch. Clang ist der einzige, der die ursprüngliche Sequenz beizubehalten scheint.
Cody Gray

3
Sie verwenden t1 und t2 nirgendwo, so dass es denken kann, dass das Ergebnis verworfen werden kann und ordnen Sie den Code neu
phuclv

3
@Niall - Ich kann nichts Konkreteres anbieten, aber ich denke, mein Kommentar spielt auf den zugrunde liegenden Grund an: Der Compiler weiß, dass foo () jetzt () nicht beeinflussen kann, und umgekehrt, und auch die Neuordnung. Verschiedene Experimente mit externen Scope-Funktionen und Daten scheinen dies zu bestätigen. Dazu gehört, dass statisches foo () von einer Dateibereichsvariablen N abhängt. Wenn N als statisch deklariert wird, erfolgt eine Neuordnung, während es als nicht statisch deklariert wird (dh für andere Kompilierungseinheiten sichtbar ist und daher möglicherweise Nebenwirkungen von Externe Funktionen wie now ()) werden nicht neu angeordnet.
Jeremy

3
@ Lưu Vĩnh Phúc: Nur dass die Anrufe selbst nicht erledigt werden. Ich vermute erneut, dass dies daran liegt, dass der Compiler nicht weiß, welche Nebenwirkungen er haben könnte - aber er weiß , dass diese Nebenwirkungen das Verhalten von foo () nicht beeinflussen können.
Jeremy

3
Und noch ein letzter Hinweis: Die Angabe von -flto (Link-Time-Optimierung) führt zu einer Neuordnung, auch in ansonsten nicht neu geordneten Fällen.
Jeremy

20

Die Neuordnung kann vom Compiler oder vom Prozessor vorgenommen werden.

Die meisten Compiler bieten eine plattformspezifische Methode an, um eine Neuordnung von Lese- / Schreibanweisungen zu verhindern. Auf gcc ist dies

asm volatile("" ::: "memory");

( Weitere Informationen hier )

Beachten Sie, dass dies nur indirekt Neuordnungsvorgänge verhindert, solange diese von den Lese- / Schreibvorgängen abhängen.

In der Praxis habe ich noch kein System gesehen, bei dem der Systemaufruf Clock::now()den gleichen Effekt hat wie eine solche Barriere. Sie können die resultierende Baugruppe überprüfen, um sicherzugehen.

Es ist jedoch nicht ungewöhnlich, dass die zu testende Funktion während der Kompilierungszeit ausgewertet wird. Um eine "realistische" Ausführung zu erzwingen, müssen Sie möglicherweise Eingaben für foo()E / A oder einen volatileLesevorgang ableiten .


Eine andere Möglichkeit wäre, das Inlining für zu deaktivieren foo()- dies ist wiederum compilerspezifisch und normalerweise nicht portierbar, hätte aber den gleichen Effekt.

Auf gcc wäre das __attribute__ ((noinline))


@ Ruslan wirft ein grundlegendes Problem auf: Wie realistisch ist diese Messung?

Die Ausführungszeit wird von vielen Faktoren beeinflusst: Eine ist die tatsächliche Hardware, auf der wir ausgeführt werden, die andere ist der gleichzeitige Zugriff auf gemeinsam genutzte Ressourcen wie Cache-, Speicher-, Festplatten- und CPU-Kerne.

Was wir normalerweise tun, um vergleichbare Timings zu erhalten: Stellen Sie sicher, dass sie mit einer geringen Fehlerquote reproduzierbar sind . Das macht sie etwas künstlich.

Die Ausführungsleistung von "Hot Cache" und "Cold Cache" kann sich leicht um eine Größenordnung unterscheiden - aber in Wirklichkeit wird es etwas dazwischen sein ("lauwarm"?)


2
Ihr Hack mit asmbeeinflusst die Ausführungszeit der Anweisungen zwischen Timer-Aufrufen: Der Code nach dem Speicher-Clobber muss alle Variablen aus dem Speicher neu laden.
Ruslan

@ Ruslan: Ihr Hack, nicht meiner. Es gibt verschiedene Reinigungsstufen, und so etwas ist für reproduzierbare Ergebnisse unvermeidlich.
Peterchen

2
Beachten Sie, dass der Hack mit 'asm' nur als Barriere für Operationen dient, die den Speicher berühren, und das OP an mehr als dem interessiert ist. Siehe meine Antwort für weitere Details.
Chandler Carruth

11

Die C ++ - Sprache definiert auf verschiedene Weise, was beobachtet werden kann.

Wenn foo()nichts beobachtbar ist, kann es vollständig beseitigt werden. Wenn foo()nur eine Berechnung, die Werte im "lokalen" Zustand speichert (sei es auf dem Stapel oder irgendwo in einem Objekt) und der Compiler nachweisen kann, dass kein sicher abgeleiteter Zeiger in den Clock::now()Code gelangen kann, gibt es keine beobachtbaren Konsequenzen für Bewegen derClock::now() Anrufe.

Wenn foo()mit einer Feile oder dem Display interagiert, und der Compiler nicht nachweisen kann , dass Clock::now()tut nicht interact mit der Datei oder dem Display, dann Nachbestellung kann nicht getan werden, weil die Interaktion mit einer Datei oder einem Display beobachtbares Verhalten ist.

Während Sie compilerspezifische Hacks verwenden können, um zu erzwingen, dass sich Code nicht bewegt (wie bei der Inline-Assembly), besteht ein anderer Ansatz darin, zu versuchen, Ihren Compiler zu überlisten.

Erstellen Sie eine dynamisch geladene Bibliothek. Laden Sie es vor dem betreffenden Code.

Diese Bibliothek enthüllt eines:

namespace details {
  void execute( void(*)(void*), void *);
}

und verpackt es so:

template<class F>
void execute( F f ) {
  struct bundle_t {
    F f;
  } bundle = {std::forward<F>(f)};

  auto tmp_f = [](void* ptr)->void {
    auto* pb = static_cast<bundle_t*>(ptr);
    (pb->f)();
  };
  details::execute( tmp_f, &bundle );
}

Das packt ein nulläres Lambda und verwendet die dynamische Bibliothek, um es in einem Kontext auszuführen, den der Compiler nicht verstehen kann.

In der dynamischen Bibliothek machen wir:

void details::execute( void(*f)(void*), void *p) {
  f(p);
}

das ist ziemlich einfach.

Um die Aufrufe an neu zu ordnen execute, muss es die dynamische Bibliothek verstehen, die es beim Kompilieren Ihres Testcodes nicht kann.

Es kann immer noch foo()s ohne Nebenwirkungen eliminieren , aber Sie gewinnen einige, Sie verlieren einige.


19
"Ein anderer Ansatz besteht darin, zu versuchen, Ihren Compiler zu überlisten." Wenn dieser Satz kein Zeichen dafür ist, dass er das Kaninchenloch hinuntergegangen ist, weiß ich nicht, was es ist. :-)
Cody Gray

1
Ich denke, es könnte hilfreich sein zu beachten, dass die Zeit, die ein Codeblock zur Ausführung benötigt, nicht als "beobachtbares" Verhalten angesehen wird, das Compiler beibehalten müssen . Wenn die Zeit zum Ausführen eines Codeblocks "beobachtbar" wäre, wären keine Formen der Leistungsoptimierung zulässig. Während es für C und C ++ hilfreich wäre, eine "Kausalitätsbarriere" zu definieren, die es erforderlich machen würde, dass ein Compiler die Ausführung von Code nach der Barriere zurückhält, bis alle Nebenwirkungen von vor der Barriere durch den generierten Code behandelt wurden [code which will sicherstellen, dass die Daten vollständig ...
supercat haben

1
..., die über Hardware-Caches verbreitet werden, müssten dazu hardwarespezifische Mittel verwenden, aber ein hardwarespezifisches Mittel zum Warten, bis alle veröffentlichten Schreibvorgänge abgeschlossen sind, wäre ohne eine Barriereanweisung nutzlos, um sicherzustellen, dass alle ausstehenden Schreibvorgänge vom Compiler verfolgt werden muss auf der Hardware veröffentlicht werden, bevor die Hardware aufgefordert wird, sicherzustellen, dass alle veröffentlichten Schreibvorgänge abgeschlossen sind.] Ich kenne keine Möglichkeit, dies in einer der beiden Sprachen zu tun, ohne einen Dummy- volatileZugriff zu verwenden oder externen Code aufzurufen.
Supercat

4

Nein, das kann es nicht. Gemäß dem C ++ - Standard [intro.execution]:

14 Jede mit einem vollständigen Ausdruck verbundene Wertberechnung und Nebenwirkung wird vor jeder mit dem nächsten zu bewertenden vollständigen Ausdruck verbundenen Wertberechnung und Nebenwirkung sequenziert.

Ein vollständiger Ausdruck ist im Grunde eine Anweisung, die durch ein Semikolon abgeschlossen wird. Wie Sie sehen können, schreibt die obige Regel vor, dass Anweisungen in der richtigen Reihenfolge ausgeführt werden müssen. Es ist innerhalb von Aussagen , dass der Compiler mehr freien Lauf gelassen wird (dh es unter gewissen Umständen ist erlaubt Ausdrücke auszuwerten , die eine Aussage in Befehle anders als von links nach rechts oder irgendetwas anderes spezifische bilden).

Beachten Sie, dass die Bedingungen für die Anwendung der Als-ob-Regel hier nicht erfüllt sind. Es ist unangemessen zu glauben, dass jeder Compiler nachweisen kann , dass das Neuordnen von Aufrufen zum Abrufen der Systemzeit das beobachtbare Programmverhalten nicht beeinflusst. Wenn es einen Umstand gäbe, unter dem zwei Aufrufe zum Abrufen der Zeit neu angeordnet werden könnten, ohne das beobachtete Verhalten zu ändern, wäre es äußerst ineffizient, tatsächlich einen Compiler zu erstellen, der ein Programm mit ausreichendem Verständnis analysiert, um dies mit Sicherheit ableiten zu können.


12
Es gibt immer noch die Als-ob-Regel
MM

18
Der As -if-Regel- Compiler kann alles tun, um Code zu erstellen, solange er das beobachtbare Verhalten nicht ändert. Der Zeitpunkt der Ausführung ist nicht beobachtbar. So kann es beliebige Codezeilen neu anordnen, solange das Ergebnis gleich ist (die meisten Compiler tun vernünftige Dinge und ordnen Zeitaufrufe nicht neu, aber es ist nicht erforderlich)
Revolver_Ocelot

6
Der Zeitpunkt der Ausführung ist nicht beobachtbar. Das ist ziemlich seltsam. Aus praktischer, nicht technischer Sicht ist der Ausführungszeitpunkt (auch als "Leistung" bezeichnet) sehr gut beobachtbar.
Frédéric Hamidi

3
Hängt davon ab, wie Sie die Zeit messen. Es ist nicht möglich, die Anzahl der Taktzyklen zu messen, die zur Ausführung eines Code-Körpers in Standard-C ++ benötigt werden.
Peter

3
@dba Du mischst ein paar Dinge zusammen. Der Linker kann keine Win16-Anwendungen mehr generieren, das stimmt, aber das liegt daran, dass die Unterstützung für das Generieren dieser Art von Binärdateien entfernt wurde. WIn16-Apps verwenden nicht das PE-Format. Dies bedeutet nicht, dass entweder der Compiler oder der Linker über spezielle Kenntnisse der API-Funktionen verfügen. Das andere Problem betrifft die Laufzeitbibliothek. Es ist absolut kein Problem, die neueste Version von MSVC dazu zu bringen, eine Binärdatei zu generieren, die unter NT 4 ausgeführt wird. Ich habe es geschafft. Das Problem tritt auf, sobald Sie versuchen, eine Verknüpfung in der CRT herzustellen, die nicht verfügbare Funktionen aufruft.
Cody Gray

2

Nein.

Manchmal können Anweisungen nach der "Als-ob" -Regel neu angeordnet werden. Dies liegt nicht daran, dass sie logisch unabhängig voneinander sind, sondern daran, dass diese Unabhängigkeit eine solche Neuordnung ermöglicht, ohne die Semantik des Programms zu ändern.

Das Verschieben eines Systemaufrufs, der die aktuelle Zeit erhält, erfüllt diese Bedingung offensichtlich nicht. Ein Compiler, der dies wissentlich oder unwissentlich tut, ist nicht konform und wirklich albern.

Im Allgemeinen würde ich nicht erwarten, dass ein Ausdruck, der zu einem Systemaufruf führt, selbst von einem aggressiv optimierenden Compiler "hinterfragt" wird. Es weiß einfach nicht genug darüber, was dieser Systemaufruf bewirkt.


5
Ich bin damit einverstanden, dass es albern wäre, aber ich würde es nicht als nicht konform bezeichnen . Der Compiler kann wissen, was der Systemaufruf eines konkreten Systems genau bewirkt und ob er Nebenwirkungen hat. Ich würde erwarten, dass Compiler einen solchen Aufruf nicht neu anordnen, nur um den allgemeinen Anwendungsfall abzudecken, was eine bessere Benutzererfahrung ermöglicht, nicht weil der Standard dies verbietet.
Revolver_Ocelot

4
@Revolver_Ocelot: Optimierungen, die die Semantik des Programms ändern (okay, für die Elision von Kopien speichern), entsprechen nicht dem Standard, unabhängig davon, ob Sie zustimmen oder nicht.
Leichtigkeitsrennen im Orbit

6
Im trivialen Fall von int x = 0; clock(); x = y*2; clock();gibt es keine definierten Möglichkeiten für die clock()Interaktion des Codes mit dem Status von x. Nach dem C ++ - Standard muss es nicht wissen, was es clock()tut - es könnte den Stapel untersuchen (und feststellen, wann die Berechnung erfolgt), aber das ist nicht das Problem von C ++ .
Yakk - Adam Nevraumont

5
Um Yakks Punkt weiter zu führen: Es ist wahr, dass eine Neuordnung der Systemaufrufe, so dass das Ergebnis des ersten t2und des zweiten zugewiesen wird, t1nicht konform und albern wäre, wenn diese Werte verwendet werden. Was diese Antwort vermisst, ist das Ein konformer Compiler kann manchmal anderen Code während eines Systemaufrufs neu anordnen. In diesem Fall kann es, sofern es weiß, was es foo()tut (zum Beispiel, weil es es eingefügt hat) und daher (lose gesagt) eine reine Funktion ist, es bewegen.
Steve Jessop

1
Dies liegt wiederum daran, dass es keine Garantie dafür gibt, dass die tatsächliche Implementierung (wenn auch nicht die abstrakte Maschine) y*yvor dem Systemaufruf nicht spekulativ berechnet wird, nur zum Spaß. Es gibt auch keine Garantie dafür, dass die tatsächliche Implementierung das Ergebnis dieser spekulativen Berechnung später an keinem beliebigen Punkt xverwendet und daher zwischen den Aufrufen von nichts unternimmt clock(). Das Gleiche gilt für alles, was eine Inline-Funktion footut, vorausgesetzt, sie hat keine Nebenwirkungen und kann nicht von dem Zustand abhängen, durch den sie geändert werden könnte clock().
Steve Jessop

0

noinline Funktion + Blackbox für Inline-Assembly + vollständige Datenabhängigkeiten

Dies basiert auf https://stackoverflow.com/a/38025837/895245, aber weil ich keine klare Rechtfertigung dafür gesehen habe, warum die::now() nicht nachbestellt werden kann, wäre ich lieber paranoid und würde es zusammen mit dem in eine Noinline-Funktion einfügen asm.

Auf diese Weise bin ich mir ziemlich sicher, dass die Neuordnung nicht stattfinden kann, da die noinline"Bindungen" die::now und die Datenabhängigkeit .

main.cpp

#include <chrono>
#include <iostream>
#include <string>

// noinline ensures that the ::now() cannot be split from the __asm__
template <class T>
__attribute__((noinline)) auto get_clock(T& value) {
    // Make the compiler think we actually use / modify the value.
    // It can't "see" what is going on inside the assembly string.
    __asm__ __volatile__ ("" : "+g" (value));
    return std::chrono::high_resolution_clock::now();
}

template <class T>
static T foo(T niters) {
    T result = 42;
    for (T i = 0; i < niters; ++i) {
        result = (result * result) - (3 * result) + 1;
    }
    return result;
}

int main(int argc, char **argv) {
    unsigned long long input;
    if (argc > 1) {
        input = std::stoull(argv[1], NULL, 0);
    } else {
        input = 1;
    }

    // Must come before because it could modify input
    // which is passed as a reference.
    auto t1 = get_clock(input);
    auto output = foo(input);
    // Must come after as it could use the output.
    auto t2 = get_clock(output);
    std::cout << "output " << output << std::endl;
    std::cout << "time (ns) "
              << std::chrono::duration_cast<std::chrono::nanoseconds>(t2 - t1).count()
              << std::endl;
}

GitHub stromaufwärts .

Kompilieren und ausführen:

g++ -ggdb3 -O3 -std=c++14 -Wall -Wextra -pedantic -o main.out main.cpp
./main.out 1000
./main.out 10000
./main.out 100000

Der einzige kleine Nachteil dieser Methode ist, dass wir callqeiner inlineMethode eine zusätzliche Anweisung hinzufügen . objdump -CDzeigt, dass mainenthält:

    11b5:       e8 26 03 00 00          callq  14e0 <auto get_clock<unsigned long long>(unsigned long long&)>
    11ba:       48 8b 34 24             mov    (%rsp),%rsi
    11be:       48 89 c5                mov    %rax,%rbp
    11c1:       b8 2a 00 00 00          mov    $0x2a,%eax
    11c6:       48 85 f6                test   %rsi,%rsi
    11c9:       74 1a                   je     11e5 <main+0x65>
    11cb:       31 d2                   xor    %edx,%edx
    11cd:       0f 1f 00                nopl   (%rax)
    11d0:       48 8d 48 fd             lea    -0x3(%rax),%rcx
    11d4:       48 83 c2 01             add    $0x1,%rdx
    11d8:       48 0f af c1             imul   %rcx,%rax
    11dc:       48 83 c0 01             add    $0x1,%rax
    11e0:       48 39 d6                cmp    %rdx,%rsi
    11e3:       75 eb                   jne    11d0 <main+0x50>
    11e5:       48 89 df                mov    %rbx,%rdi
    11e8:       48 89 44 24 08          mov    %rax,0x8(%rsp)
    11ed:       e8 ee 02 00 00          callq  14e0 <auto get_clock<unsigned long long>(unsigned long long&)>

so sehen wir, dass foodas inline war, aber get_clocknicht war und es umgibt.

get_clock selbst ist jedoch äußerst effizient und besteht aus einer für einen einzelnen Blattaufruf optimierten Anweisung, die nicht einmal den Stapel berührt:

00000000000014e0 <auto get_clock<unsigned long long>(unsigned long long&)>:
    14e0:       e9 5b fb ff ff          jmpq   1040 <std::chrono::_V2::system_clock::now()@plt>

Da die Taktgenauigkeit selbst begrenzt ist, halte ich es für unwahrscheinlich, dass Sie die Timing-Effekte eines zusätzlichen Objekts bemerken jmpq. Beachten Sie, dass eine callunabhängig davon erforderlich ist, da sie ::now()sich in einer gemeinsam genutzten Bibliothek befindet.

Aufruf ::now()von einer Inline-Assembly mit einer Datenabhängigkeit

Dies wäre die effizienteste Lösung, die möglich wäre, und sogar das jmpqoben erwähnte Extra zu überwinden .

Dies ist leider äußerst schwierig, wie unter: printf in erweitertem Inline-ASM aufrufen

Wenn Ihre Zeitmessung jedoch direkt in der Inline-Montage ohne Anruf durchgeführt werden kann, kann diese Technik verwendet werden. Dies ist beispielsweise bei gem5 magischen Instrumentierungsanweisungen , x86 RDTSC (nicht sicher, ob dies nicht mehr repräsentativ ist) und möglicherweise anderen Leistungsindikatoren der Fall .

Verwandte Themen:

Getestet mit GCC 8.3.0, Ubuntu 19.04.


1
Sie normalerweise nicht brauchen , um mit einem Spill / Neuladen zu erzwingen "+m", indem "+r"eine viel effizientere Art und Weise der Compiler einen Wert materialisieren zu machen und dann übernehmen die Variablen verändert haben.
Peter Cordes
Durch die Nutzung unserer Website bestätigen Sie, dass Sie unsere Cookie-Richtlinie und Datenschutzrichtlinie gelesen und verstanden haben.
Licensed under cc by-sa 3.0 with attribution required.