Was machen Compiler mit Verzweigungen zur Kompilierzeit?

Was machen Compiler mit Verzweigungen zur Kompilierzeit?

TL;DR

Es gibt mehrere Möglichkeiten, um abhängig von einem Vorlagenparameter ein unterschiedliches Laufzeitverhalten zu erhalten. Leistung sollte hier nicht Ihr Hauptanliegen sein, aber Flexibilität und Wartbarkeit sollten es sein. In allen Fällen werden die verschiedenen dünnen Wrapper und konstanten bedingten Ausdrücke auf jedem anständigen Compiler für Release-Builds wegoptimiert. Unten eine kleine Zusammenfassung mit den verschiedenen Kompromissen (inspiriert von dieser Antwort von @AndyProwl).

Laufzeit wenn

Ihre erste Lösung ist die einfache Laufzeit if :

template<class T>
T numeric_procedure(const T& x)
{
    if (std::is_integral<T>::value) {
        // valid code for integral types
    } else {
        // valid code for non-integral types,
        // must ALSO compile for integral types
    }
}

Es ist einfach und effektiv:Jeder anständige Compiler optimiert den toten Zweig weg.

Es gibt mehrere Nachteile:

  • Auf einigen Plattformen (MSVC) führt ein konstanter bedingter Ausdruck zu einer falschen Compiler-Warnung, die Sie dann ignorieren oder stumm schalten müssen.
  • Aber schlimmer noch, auf allen konformen Plattformen, beide Zweige von if/else -Anweisung muss tatsächlich für alle Typen T kompiliert werden , auch wenn bekannt ist, dass einer der Zweige nicht besetzt ist. Wenn T je nach Art unterschiedliche Elementtypen enthält, erhalten Sie einen Compiler-Fehler, sobald Sie versuchen, darauf zuzugreifen.

Tag-Versand

Ihr zweiter Ansatz ist als Tag-Dispatching bekannt:

template<class T>
T numeric_procedure_impl(const T& x, std::false_type)
{
    // valid code for non-integral types,
    // CAN contain code that is invalid for integral types
}    

template<class T>
T numeric_procedure_impl(const T& x, std::true_type)
{
    // valid code for integral types
}

template<class T>
T numeric_procedure(const T& x)
{
    return numeric_procedure_impl(x, std::is_integral<T>());
}

Es funktioniert gut, ohne Laufzeit-Overhead:das temporäre std::is_integral<T>() und der Aufruf der einzeiligen Hilfsfunktion werden beide auf jeder anständigen Plattform optimiert.

Der Hauptnachteil (kleiner IMO) ist, dass Sie einige Boilerplates mit 3 statt 1 Funktion haben.

SFINAE

Eng verwandt mit dem Tag-Dispatching ist SFINAE (Ersetzungsfehler ist kein Fehler)

template<class T, class = typename std::enable_if<!std::is_integral<T>::value>::type>
T numeric_procedure(const T& x)
{
    // valid code for non-integral types,
    // CAN contain code that is invalid for integral types
}    

template<class T, class = typename std::enable_if<std::is_integral<T>::value>::type>
T numeric_procedure(const T& x)
{
    // valid code for integral types
}

Dies hat den gleichen Effekt wie das Tag-Dispatching, funktioniert aber etwas anders. Anstatt den Argumentabzug zu verwenden, um die richtige Hilfsüberladung auszuwählen, manipuliert es direkt den Überladungssatz für Ihre Hauptfunktion.

Der Nachteil ist, dass es ein anfälliger und kniffliger Weg sein kann, wenn Sie nicht genau wissen, was der gesamte Überladungssatz ist (z. B. könnte ADL bei vorlagenintensivem Code mehr Überladungen aus zugehörigen Namespaces ziehen, an die Sie nicht gedacht haben ). Und im Vergleich zum Tag-Dispatching ist die Auswahl auf der Grundlage von etwas anderem als einer binären Entscheidung viel komplizierter.

Teilspezialisierung

Ein anderer Ansatz besteht darin, einen Klassen-Template-Helfer mit einem Funktionsanwendungsoperator zu verwenden und ihn teilweise zu spezialisieren

template<class T, bool> 
struct numeric_functor;

template<class T>
struct numeric_functor<T, false>
{
    T operator()(T const& x) const
    {
        // valid code for non-integral types,
        // CAN contain code that is invalid for integral types
    }
};

template<class T>
struct numeric_functor<T, true>
{
    T operator()(T const& x) const
    {
        // valid code for integral types
    }
};

template<class T>
T numeric_procedure(T const& x)
{
    return numeric_functor<T, std::is_integral<T>::value>()(x);
}

Dies ist wahrscheinlich der flexibelste Ansatz, wenn Sie eine feinkörnige Kontrolle und minimale Codeduplizierung wünschen (z. B. wenn Sie sich auch auf Größe und/oder Ausrichtung spezialisieren möchten, aber beispielsweise nur für Fließkommatypen). Der durch partielle Template-Spezialisierung gegebene Musterabgleich ist für solche fortgeschrittenen Probleme ideal geeignet. Wie beim Tag-Dispatching werden die Hilfsfunktoren von jedem anständigen Compiler wegoptimiert.

Der Hauptnachteil ist der etwas größere Textbaustein, wenn Sie sich nur auf eine einzelne binäre Bedingung spezialisieren möchten.

If constexpr (C++1z-Vorschlag)

Dies ist ein Neustart von gescheiterten früheren Vorschlägen für static if (das in der Programmiersprache D verwendet wird)

template<class T>
T numeric_procedure(const T& x)
{
    if constexpr (std::is_integral<T>::value) {
        // valid code for integral types
    } else {
        // valid code for non-integral types,
        // CAN contain code that is invalid for integral types
    }
}

Wie bei Ihrer Laufzeit if , alles ist an einem Ort, aber der Hauptvorteil hier ist, dass die else Verzweigung wird vom Compiler vollständig gelöscht, wenn bekannt ist, dass sie nicht genommen wird. Ein großer Vorteil ist, dass Sie den gesamten Code lokal halten und keine kleinen Hilfsfunktionen wie beim Tag-Dispatching oder der partiellen Template-Spezialisierung verwenden müssen.

Concepts-Lite (C++1z-Vorschlag)

Concepts-Lite ist eine anstehende technische Spezifikation das Teil der nächsten großen C++-Version sein soll (C++1z, mit z==7 als beste Schätzung).

template<Non_integral T>
T numeric_procedure(const T& x)
{
    // valid code for non-integral types,
    // CAN contain code that is invalid for integral types
}    

template<Integral T>
T numeric_procedure(const T& x)
{
    // valid code for integral types
}

Dieser Ansatz ersetzt den class oder typename Schlüsselwort innerhalb des template< > Klammern mit einem Konzeptnamen, der die Typfamilie beschreibt, für die der Code funktionieren soll. Es kann als Verallgemeinerung der Tag-Dispatching- und SFINAE-Techniken angesehen werden. Einige Compiler (gcc, Clang) bieten experimentelle Unterstützung für diese Funktion. Das Lite-Adjektiv bezieht sich auf den gescheiterten C++11-Vorschlag von Concepts.


Beachten Sie, dass obwohl der Optimierer Der Compiler ist möglicherweise in der Lage, statisch bekannte Tests und nicht erreichbare Zweige aus dem generierten Code zu entfernen muss noch in der Lage sein, jeden Zweig zu kompilieren.

Das heißt:

int foo() {
  #if 0
    return std::cout << "this isn't going to work\n";
  #else
    return 1;
  #endif
}

wird gut funktionieren, weil der Präprozessor den toten Zweig entfernt, bevor der Compiler ihn sieht, aber:

int foo() {
  if (std::is_integral<double>::value) {
    return std::cout << "this isn't going to work\n";
  } else {
    return 1;
  }
}

Gewohnheit. Obwohl der Optimierer den ersten Zweig verwerfen kann, wird er dennoch nicht kompiliert. Hier verwenden Sie enable_if und SFINAE-Hilfe, weil Sie den gültigen (kompilierbaren) Code auswählen können, und den ungültigen (nicht kompilierbaren) Code. Fehler beim Kompilieren ist kein Fehler.


Zur Beantwortung der Titelfrage, wie Compiler mit if(false) umgehen :

Sie optimieren konstante Verzweigungsbedingungen (und den toten Code)

Der Sprachstandard wird natürlich nicht vorausgesetzt Compiler nicht schrecklich sein, aber die C++-Implementierungen, die die Leute tatsächlich verwenden, sind auf diese Weise nicht schrecklich. (Das gilt auch für die meisten C-Implementierungen, außer vielleicht sehr vereinfachten, nicht optimierenden wie tinycc.)

Einer der Hauptgründe, warum C++ um if(something) herum entwickelt wurde anstelle des #ifdef SOMETHING des C-Präprozessors ist, dass sie gleich effizient sind. Viele C++-Features (wie constexpr ) wurde erst hinzugefügt, nachdem die Compiler bereits die notwendigen Optimierungen (Inlining + konstante Ausbreitung) implementiert hatten. (Der Grund, warum wir all die Fallstricke und Fallstricke des undefinierten Verhaltens von C und C++ in Kauf nehmen, ist die Leistung, insbesondere bei modernen Compilern, die aggressiv unter der Annahme optimieren, dass kein UB vorhanden ist. Das Sprachdesign verursacht normalerweise keine unnötigen Leistungskosten.)

Aber wenn Ihnen die Leistung im Debug-Modus wichtig ist, kann die Auswahl je nach Compiler relevant sein. (z. B. für ein Spiel oder ein anderes Programm mit Echtzeitanforderungen, damit ein Debug-Build überhaupt testbar ist).

z.B. clang++ -O0 ("debug mode") wertet immer noch ein if(constexpr_function()) aus zur Kompilierzeit und behandelt es wie if(false) oder if(true) . Einige andere Compiler werten nur zur Kompilierzeit aus, wenn sie dazu gezwungen werden (durch Template-Matching).

Für if(false) fallen keine Leistungskosten an mit aktivierter Optimierung. (Abgesehen von Fehlern bei verpassten Optimierungen, die davon abhängen können, wie früh im Kompilierprozess die Bedingung auf „false“ aufgelöst werden kann, und die Eliminierung von totem Code kann sie entfernen, bevor der Compiler „daran denkt“, Stapelplatz für seine Variablen zu reservieren, oder dass die Funktion kann kein Blatt sein oder was auch immer.)

Jeder nicht schreckliche Compiler kann toten Code hinter einer Compile-Time-Constant-Bedingung wegoptimieren (Wikipedia:Dead Code Elimination). Dies ist Teil der grundlegenden Erwartungen, die Menschen an eine C++-Implementierung haben, damit sie in der realen Welt verwendet werden kann. es ist eine der grundlegendsten Optimierungen und alle Compiler in der Praxis machen es für einfache Fälle wie constexpr .

Häufig werden durch Konstantenpropagation (insbesondere nach Inlining) Bedingungen zu Kompilierzeitkonstanten, auch wenn dies in der Quelle nicht offensichtlich der Fall war. Einer der offensichtlicheren Fälle ist die Wegoptimierung des Vergleichs bei den ersten Iterationen eines for (int i=0 ; i<n ; i++) so kann es sich in eine normale asm-Schleife mit einer bedingten Verzweigung am Ende verwandeln (wie ein do{}while Schleife in C++), wenn n konstant oder nachweisbar > 0 ist . (Ja, echte Compiler führen Wertbereichsoptimierungen durch, nicht nur Konstanten Ausbreitung.)

Einige Compiler, wie gcc und clang, entfernen toten Code innerhalb eines if(false) auch im "Debug"-Modus , auf der minimalen Optimierungsebene, die erforderlich ist, damit sie die Programmlogik durch ihre internen arch-neutralen Darstellungen transformieren und schließlich asm ausgeben können. (Aber der Debug-Modus deaktiviert jede Art von Konstantenweitergabe für Variablen, die nicht als const deklariert sind oder constexpr in der Quelle.)

Einige Compiler tun dies nur, wenn die Optimierung aktiviert ist; MSVC zum Beispiel mag es wirklich, bei der Übersetzung von C++ in Asm im Debug-Modus wörtlich zu sein, und erstellt tatsächlich eine Null in einem Register und verzweigt darauf, ob es Null ist oder nicht, für if(false) .

Für den gcc-Debug-Modus (-O0 ), constexpr Funktionen sind nicht eingebettet, wenn sie es nicht sein müssen. (An manchen Stellen erfordert die Sprache eine Konstante, wie eine Array-Größe innerhalb einer Struktur. GNU C++ unterstützt C99-VLAs, entscheidet sich jedoch dafür, eine constexpr-Funktion einzufügen, anstatt tatsächlich ein VLA im Debug-Modus zu erstellen.)

Aber keine Funktion constexpr s werden zur Kompilierzeit ausgewertet, nicht im Speicher gespeichert und getestet.

Aber um es noch einmal zu wiederholen, auf jeder Ebene der Optimierung, constexpr Funktionen vollständig eingebettet und wegoptimiert sind, und dann der if()

Beispiele (aus dem Godbolt-Compiler-Explorer)

#include <type_traits>
void baz() {
    if (std::is_integral<float>::value) f1();  // optimizes for gcc
    else f2();
}

Alle Compiler mit -O2 Optimierung aktiviert (für x86-64):

baz():
        jmp     f2()    # optimized tailcall

Codequalität im Debug-Modus, normalerweise nicht relevant

GCC mit deaktivierter Optimierung wertet den Ausdruck immer noch aus und eliminiert toten Code:

baz():
        push    rbp
        mov     rbp, rsp          # -fno-omit-frame-pointer is the default at -O0
        call    f2()              # still an unconditional call, no runtime branching
        nop
        pop     rbp
        ret

Um zu sehen, dass gcc bei deaktivierter Optimierung nicht inline ist

static constexpr bool always_false() { return sizeof(char)==2*sizeof(int); }
void baz() {
    if (always_false()) f1();
    else f2();
}
static constexpr bool always_false() { return sizeof(char)==2*sizeof(int); }
void baz() {
    if (always_false()) f1();
    else f2();
}
;; gcc9.1 with no optimization chooses not to inline the constexpr function
baz():
        push    rbp
        mov     rbp, rsp
        call    always_false()
        test    al, al              # the bool return value
        je      .L9
        call    f1()
        jmp     .L11
.L9:
        call    f2()
.L11:
        nop
        pop     rbp
        ret

MSVCs geistesgestörtes wörtliches Code-Gen mit deaktivierter Optimierung:

void foo() {
    if (false) f1();
    else f2();
}
;; MSVC 19.20 x86-64  no optimization
void foo(void) PROC                                        ; foo
        sub     rsp, 40                             ; 00000028H
        xor     eax, eax                     ; EAX=0
        test    eax, eax                     ; set flags from EAX (which were already set by xor)
        je      SHORT [email protected]               ; jump if ZF is set, i.e. if EAX==0
        call    void f1(void)                          ; f1
        jmp     SHORT [email protected]
[email protected]:
        call    void f2(void)                          ; f2
[email protected]:
        add     rsp, 40                             ; 00000028H
        ret     0

Benchmarking mit deaktivierter Optimierung ist nicht sinnvoll

Sie sollten immer Optimierung für echten Code aktivieren; die nur Zeit, in der die Leistung des Debug-Modus von Bedeutung ist, ist, wenn dies eine Vorbedingung für die Debugging-Fähigkeit ist. Es ist nicht ein nützlicher Proxy, um zu vermeiden, dass Ihr Benchmark wegoptimiert wird; unterschiedlicher Code profitiert mehr oder weniger vom Debug-Modus, je nachdem, wie er geschrieben ist.

Es sei denn, das ist eine wirklich große Sache für Ihr Projekt und Sie können einfach nicht genug Informationen über lokale Variablen oder etwas mit minimaler Optimierung wie g++ -Og finden , die Überschrift dieser Antwort ist die vollständige Antwort. Ignorieren Sie den Debug-Modus, denken Sie nur an die Qualität des asm in optimierten Builds. (Vorzugsweise mit aktiviertem LTO, wenn Ihr Projekt dies aktivieren kann, um dateiübergreifendes Inlining zu ermöglichen.)