Du bist nicht angemeldet.

Stilllegung des Forums
Das Forum wurde am 05.06.2023 nach über 20 Jahren stillgelegt (weitere Informationen und ein kleiner Rückblick).
Registrierungen, Anmeldungen und Postings sind nicht mehr möglich. Öffentliche Inhalte sind weiterhin zugänglich.
Das Team von spieleprogrammierer.de bedankt sich bei der Community für die vielen schönen Jahre.
Wenn du eine deutschsprachige Spieleentwickler-Community suchst, schau doch mal im Discord und auf ZFX vorbei!

Werbeanzeige

LukasBanana

Alter Hase

  • »LukasBanana« ist der Autor dieses Themas

Beiträge: 1 097

Beruf: Shader Tools Programmer

  • Private Nachricht senden

1

11.09.2016, 22:41

Schlechte switch-case Optimierung in MSVC14

Hallo allerseits,

ich dachte gerade über die Optimierung von einigen meiner switch-case Anweisungen nach, die vom C++ Compiler übernommen werden sollten.
Es scheint aber, als würde der MSVC14 (VisualC++ 2015) hier nicht so gut optimieren, wie ich dachte.

Folgendes Problem: ich habe jede Menge "Map" Funktionen, die mir meine eigenen Enumerationen auf Direct3D bzw. OpenGL 'um-mappen':

C-/C++-Quelltext

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
D3D12_COMPARISON_FUNC Map(const CompareOp compareOp)
{
    switch (compareOp)
    {
        case CompareOp::Never:          return D3D12_COMPARISON_FUNC_NEVER;
        case CompareOp::Less:           return D3D12_COMPARISON_FUNC_LESS;
        case CompareOp::Equal:          return D3D12_COMPARISON_FUNC_EQUAL;
        case CompareOp::LessEqual:      return D3D12_COMPARISON_FUNC_LESS_EQUAL;
        case CompareOp::Greater:        return D3D12_COMPARISON_FUNC_GREATER;
        case CompareOp::NotEqual:       return D3D12_COMPARISON_FUNC_NOT_EQUAL;
        case CompareOp::GreaterEqual:   return D3D12_COMPARISON_FUNC_GREATER_EQUAL;
        case CompareOp::Ever:           return D3D12_COMPARISON_FUNC_ALWAYS;
    }
    MapFailed("CompareOp", "D3D12_COMPARISON_FUNC");
}

Eigentlich mache ich sowas häufig mit internen arrays, also so:

C-/C++-Quelltext

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
static const D3D12_COMPARISON_FUNC compareOpMap[] =
{
    D3D12_COMPARISON_FUNC_NEVER,
    D3D12_COMPARISON_FUNC_LESS,
    D3D12_COMPARISON_FUNC_EQUAL,
    D3D12_COMPARISON_FUNC_LESS_EQUAL,
    D3D12_COMPARISON_FUNC_GREATER,
    D3D12_COMPARISON_FUNC_NOT_EQUAL,
    D3D12_COMPARISON_FUNC_GREATER_EQUAL,
    D3D12_COMPARISON_FUNC_ALWAYS,
};

D3D12_COMPARISON_FUNC Map(const CompareOp compareOp)
{
    if (compareOp < CompareOp::Never || compareOp > CompareOp::Ever)
        MapFailed("CompareOp", "D3D12_COMPARISON_FUNC");
    return compareOpMap[static_cast<int>(compareOp) - static_cast<int>(CompareOp::Never)];
}

Allerdings finde ich die switch-case Anweisung etwas eleganter, wohingegen das Array mehr nach ANSI-C Code aussieht.
Nun dachte ich eigentlich der C++ Compiler würde in beiden Varianten so ziemlich den gleichen Code erzeugt, aber hier im Ausschnitt der Assembly Ausgabe sieht man etwas anderes (im Release Mode wohlgemerkt!):

Quellcode

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
; 270  :         case CompareOp::Less:           return D3D12_COMPARISON_FUNC_LESS;

    mov eax, 2
    jmp SHORT $LN1@Map
$LN6@Map:

; 271  :         case CompareOp::Equal:          return D3D12_COMPARISON_FUNC_EQUAL;

    mov eax, 3
    jmp SHORT $LN1@Map
$LN7@Map:

; 272  :         case CompareOp::LessEqual:      return D3D12_COMPARISON_FUNC_LESS_EQUAL;

    mov eax, 4
    jmp SHORT $LN1@Map
$LN8@Map:

; 273  :         case CompareOp::Greater:        return D3D12_COMPARISON_FUNC_GREATER;

    mov eax, 5
    jmp SHORT $LN1@Map
$LN9@Map:

Anstatt, dass der Compiler im Assembly ein Array anlegt (so wie es auch für die Switch-Sprung-Labels gemacht wird) hat er für jede Case Anweisung eine eigene "mov" und "jmp" Instruktion.
Sprünge sind ja nicht gerade Performant, oder ist das bei unbedingten Sprüngen relativ egal?
Mir erscheint ein festangelegtes Array jedenfalls immer noch effizienter zu sein.
In manchen Fällen (wie auch im obigen Beispiel) könnte man sogar einfach den Eingabe Wert direkt ausgeben, da meine Enumeration hier 1-zu-1 auf die D3D Enumeration gemappt werden kann.
Warum macht der Compiler das nicht?

Was meint ihr dazu?

Nimelrian

Alter Hase

Beiträge: 1 216

Beruf: Softwareentwickler (aktuell Web/Node); Freiberuflicher Google Proxy

  • Private Nachricht senden

2

11.09.2016, 22:45

Ist das ein echtes Performanceproblem, welches du nachweislich (Profiling) gemessen hast und was deine Anwendung beeinträchtigt, oder geht es hier um Premature Optimization?
Ich bin kein UserSideGoogleProxy. Und nein, dieses Forum ist kein UserSideGoogleProxyAbstractFactorySingleton.

LukasBanana

Alter Hase

  • »LukasBanana« ist der Autor dieses Themas

Beiträge: 1 097

Beruf: Shader Tools Programmer

  • Private Nachricht senden

3

11.09.2016, 22:48

Das ist kein ernstes Performance Problem, aber im Moment interessiert mich nur, warum der Compiler so etwas nicht selbst optimieren kann.
Denn genau das sollte der Compiler tun, damit ich mir eben nicht selbst den Kopf über derartige Optimierungen zerbrechen muss.

dot

Supermoderator

Beiträge: 9 757

Wohnort: Graz

  • Private Nachricht senden

4

11.09.2016, 23:31

Das ist sicher der Release Build?

Sprünge sind ja nicht gerade Performant, oder ist das bei unbedingten Sprüngen relativ egal?
Mir erscheint ein festangelegtes Array jedenfalls immer noch effizienter zu sein.

Da wäre ich mir mal nicht so sicher; bedenke, dass die Lösung per Jumptable das Ergebnis aus dem Speicher lesen muss, während die hier vom Compiler implementierte Lösung das Ergebnis direkt in der Instruction ablegt...

LukasBanana

Alter Hase

  • »LukasBanana« ist der Autor dieses Themas

Beiträge: 1 097

Beruf: Shader Tools Programmer

  • Private Nachricht senden

5

11.09.2016, 23:54

Auch wieder wahr, und die CPU Pipeline lädt meines Wissens bei direkten Sprüngen die nächsten Instruktionen direkt ab der Sprungadresse weiter.
Dann könnte das sogar wirklich die optimalere Variante sein.

Und ja, das ist der Release Build.
Aber an dieser Stelle, sieht der Assembly im Debug Build fast genau so aus.

TGGC

1x Rätselkönig

Beiträge: 1 799

Beruf: Software Entwickler

  • Private Nachricht senden

6

12.09.2016, 08:29

Da wäre ich mir mal nicht so sicher; bedenke, dass die Lösung per Jumptable das Ergebnis aus dem Speicher lesen muss, während die hier vom Compiler implementierte Lösung das Ergebnis direkt in der Instruction ablegt...
Instruktionen stehen ja auch im Speicher und müssen dort geholt werden. Aber wenigstens ist es so näher zusammen. Allgemeine Aussagen lassen sich aber ehh nicht treffen.

BlueCobold

Community-Fossil

Beiträge: 10 738

Beruf: Teamleiter Mobile Applikationen & Senior Software Engineer

  • Private Nachricht senden

7

12.09.2016, 09:18

So ist es. Eine der obersten Regeln bei Optimierung lautet: Deine Vermutung ist immer falsch. Ohne Profiler nicht in's Blaue optimieren.
Teamleiter von Rickety Racquet (ehemals das "Foren-Projekt") und von Marble Theory

Willkommen auf SPPRO, auch dir wird man zu Unity oder zur Unreal-Engine raten, ganz bestimmt.[/Sarkasmus]

dot

Supermoderator

Beiträge: 9 757

Wohnort: Graz

  • Private Nachricht senden

8

12.09.2016, 12:44

Hab mich mal etwas damit rumgespielt: Eine Lookup-Table hab ich MSVC auf die Schnelle nicht entlocken können. Ab 6 Case-Labels generiert er eine Jump-Table, was etwas schade ist, weil eine direkte Lookup-Table da definitiv effizienter sein sollte. Clang mit Microsoft-Codegen tut das gleiche wie MSVC; Clang mit LLVM-Backend dagegen erzeugt eine direkte Lookup Table so lange zwischen den Werten der Case-Labels keine zu großen Lücken sind, im Allgemeinen Fall tut es ansonsten exakt das gleiche wie MSVC. LLVM schafft es offenbar sogar, zu erkennen, dass ein switch nur ein lineares Mapping implementiert und ersetzt das dann direkt durch die Formel. Den Spezialfall eines identity Switch (also wo nur gleiche Werte auf gleiche abgebildet werden) optimiert LLVM dann direkt weiter zu einem einfachen conditional Return...

C-/C++-Quelltext

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
int main()
{
    volatile int blub = 18;

    switch (blub)
    {
    case 1:
        return 1*1;
    case 2:
        return 2*2;
    case 3:
        return 3*3;
    case 4:
        return 4*4;
    case 5:
        return 5*5;
    case 6:
        return 6*6;
    }

    return 0;
}

Dieser Beitrag wurde bereits 1 mal editiert, zuletzt von »dot« (12.09.2016, 12:54)


Beiträge: 1 223

Wohnort: Deutschland Bayern

Beruf: Schüler

  • Private Nachricht senden

9

12.09.2016, 13:06

Das mit dem Profiler ist ja richtig - aber es beantwortet die Frage nicht.

Eine Wertetabelle ist in dem Fall effizienter, weil es keinen Control Flow benötigt und dadurch keine Pipeline Stalls verursacht. Das heißt: Der Compiler führt diese Optimierungen einfach nicht durch.
Der Einwand von Dot ist nicht zutreffend, weil der Compiler auch hier eine Tabelle anlegt.
Der Code von LukasBanana war leider nicht vollständig, um das eindeutig zu zeigen. Hier mal der vollständige Code (x64, Release, Standardeinstellungen):

Der Compiler prüft erst, ob der Wert für den switch außerhalb des Wertebereichs der Tabelle liegt (Zeile 13). Dann in Zeile 20 gibt es einen indirekten Sprung an die Tabellenposition. Das ist ein Standardverfahren für aufeinanderfolgende Werte eines switch. Das ist in diesem Fall nur leider unnötig kompliziert. Jeder Zweig der switch-Anweisung tut nichts weiter als einen bestimmten Wert zu laden. Es wäre sehr viel sinnvoller diesen Wert direkt aus der Tabelle zu laden.

Clang führt nicht bloß diese Optimierung durch, sondern bemerken noch etwas anderes: "D3D12_COMPARISON" ist als aufsteigende Ganzzahlen ausgehend von 1 definiert. Dadurch kann er nach der Prüfung ob der Wert in einen switch-Branch fällt, einfach 1 zu dem Eingabewert hinzuaddieren! (Test bei Godbolt: Code) Interessant ist auch, dass deine Variante mit der Tabelle auf den selben Maschinencode optimiert wird. (EDIT, Kleiner Irrtum: Das Gegenteil ist leider der Fall) Falls die Werte von "D3D12_COMPARISON_FUNC" nicht direkt aufeinanderfolgen, wird außerdem korrekt eine Wertetabelle anstatt einer SprUngtabelle erzeugt, wie von dir vorgeschlagen.

Ok, was heißt das? Die Variante die mit dem MSVC den besten Maschinencode erzeugen würde (Den Zahlenwert + 1), basiert auf der externen Definition von "D3D12_COMPARISON_FUNC". Das ist suboptimal. Es gibt zwar keinen Grund, warum sich diese Definition ändern sollte, allerdings würde ich diese Optimierung trotzdem skeptisch betrachten. Bei der Version mit der Tabelle gibt es ein ähnliches Problem: Die Einträge der Tabelle müssen zur Enumeration passen die an einer völlig anderen Stelle definiert ist. Es wird also nicht eine Enumeration zu einer anderen gemappt, sondern ein impliziter Zahlenwert zu einer Enumeration. Deshalb ist die Tabelle in meinen Augen weniger elegant. Das scheint mir nichts mit einer ANSI-C-Ablehnung zu tun zu haben. In ANSI C gab es nämlich schon immer switch.

Ein lesenswerter Artikel der sich mit dem Problem auseinandersetzt: http://www.g-truc.net/doc/C++%20Translations.pdf
Sein Fazit ist, dass er eine Wertetabelle verwendet.

Dieser Beitrag wurde bereits 3 mal editiert, zuletzt von »Spiele Programmierer« (12.09.2016, 15:34)


dot

Supermoderator

Beiträge: 9 757

Wohnort: Graz

  • Private Nachricht senden

10

12.09.2016, 15:14

Der Einwand von Dot ist nicht zutreffend, weil der Compiler auch hier eine Tabelle anlegt.

Da der entsprechende Teil des Assemblycode nicht gepostet wurde, konnte ich das zu dem Zeitpunkt nicht wissen... ;) MSVC erzeugt nicht unbedingt immer eine Jump-Table. Aus meinen Beobachtungen würde ich schließen, dass er eine Jump-Table generiert, sobald das Switch mehr als 5 Cases hat, ansonsten eine Kaskade aus Compares und Jumps...

Eine Wertetabelle ist in dem Fall effizienter, weil es keinen Control Flow benötigt und dadurch keine Pipeline Stalls verursacht.

Das kann man so einfach leider nicht sagen, da die relative Performance dieser unterschiedlichen Implementierungen sehr stark davon abhängt, was für Zahlen am Ende in der laufenden Anwendung konkret durch den Code gehen. Ich hätte beispielsweise erwartet, dass die Lösung mit Immediate-Values effizienter ist, so lange es keine Jump-Table gibt und die Branch-Prediction funktioniert. Und tatsächlich: In einem schnellen Test hier war diese Variante konsistent schneller als eine Lookup-Table wenn immer der selbe Wert durchgeht aber bis zu 10× langsamer wenn komplett zufällige Werte durchgehen. Ich würde auch sagen, dass die Lookup-Table vermutlich die overall bessere Lösung sein sollte. Aber je nach Situation muss das nicht unbedingt stimmen. Die nächste Frage ist dann also, was für Code MSVC mit Profile-Guided-Optimizations generieren würde... :P

Dieser Beitrag wurde bereits 3 mal editiert, zuletzt von »dot« (12.09.2016, 15:22)


Werbeanzeige