Ich bin heute auf ein schönes Beispiel gestoßen, das gut verdeutlicht, wie schwierig Optimierungen für Prozessoren seien können und dass manch scheinbare Verbesserung keine besseren Ergebnisse liefern muss. Ich stieß auf die Methode f32.clamp in der Standardbibliothek von Rust, die wie folgt umgesetzt ist:

pub fn clamp(self, min: f32, max: f32) -> f32 {
    assert!(min <= max);
    let mut x = self;
    if x < min {
        x = min;
    }
    if x > max {
        x = max;
    }
    x
}

Spontan war mein Gedanke, als ich den Code sah, dass man doch besser ein else if x > max verwenden sollte, da sich beide Fälle logisch ausschließen. Aber da diesen Code sicher schon sehr viele Leute begutachtet haben, ist es unwahrscheinlich, dass diese Möglichkeit der Verbesserung nicht bereits aufgefallen wäre, und daher habe ich nochmal darüber nachgedacht und die Sache genauer analysiert.

Assembler

Ein Prozessor arbeitet nicht mit Anweisungen wie match und struct, weshalb der Compiler – und genau das ist seine Aufgabe – die Rust-Anweisungen in Maschinenbefehle (Binärcode) umwandeln muss. Für diesen Binärcode gibt zum Glück auch eine lesbare Form: Assembler. Die Anweisungen sind meist Funktionen mit einem oder mehreren Parametern (auch Operanden genannt), die Konstanten, Registernamen oder Speicheradressen seien können. Assemblercode ist wesentlich primitiver als Programmcode in Hochsprachen, aber daher auch so schnell und vielfältig, dass damit unterschiedliche Programmiersprachen verwirklicht werden können und gleichzeitig Prozessoren ihre bekannte Leistungsfähigkeit erreichen.

Compiler müssen oft die Anweisungen der Programmiersprache in mehrere Prozessorbefehle übersetzen – es gibt auch den umgekehrten Fall, dass mehrere Anweisungen zu einem Prozessorbefehl werden, aber dies ist schwierig, wie sich auch später zeigen wird. Eine if-Anweisung zum Beispiel wird vom Prozessor durch einen Vergleich und einen Sprung für den Fall, dass der Vergleich nicht zugetroffen hat, umgesetzt. Aus if a < b { … } werden dann Maschinenbefehle dieser Art:

    cmp a, b            ; Vergleich von a mit b
    b.ge .NOT_MATCHED   ; Sprung (branch) zu NOT_MATCHED, falls a >= b (greater equal)
    …                   ; Code für den Fall a < b
.NOT_MATCHED:           ; Sprungstelle nach dem if
    …                   ; Code nach dem if { }

Es gibt noch viele Befehle, die auch von Prozessortyp zu Prozessortyp verschieden sind und zum Teil recht umfangreiche Berechnungen ausführen. Um sich den Assemblercode anzusehen, muss man den Compiler wie folgt aufrufen: rustc --emit asm --crate-type rlib -O prog.rs. Damit entsteht statt des Programms die Datei prog.s, in der der Assemblercode für das Programm steht. (durch -O wird der Code optimiert und kompakter, durch --crate-type rlib braucht man keine main-Funktion) Allerdings geht dies auch leichter und schöner mit dem Projekt Godbolt; hier ist das Projekt für die folgenden Beispiele.

Zurück zu Ausgangsfunktion clamp. Der Rust-Compiler (Version 1.50) erstellt daraus folgendes Assemblerprogramm1:

  1. Im Folgenden werde ich die Assemblersprache für die Prozessorarchitektur Arm verwenden, da ich den Assemblercode von RISC-Architekturen allgemein dem von x86/amd64 vorziehe. 

pub fn clamp(inp: f32, lower: f32, upper: f32) -> f32 {
    let mut x = inp;
    if x < lower {
        x = lower;
    }
    if x > upper {
        x = upper;
    }
    x
}
fcmp    s0, s1              ; vergleiche inp und lower
fcsel   s0, s1, s0, mi      ; x = if kleiner { lower } else { inp }
fcmp    s0, s2              ; vergleiche x und upper
fcsel   s0, s2, s0, gt      ; x = if größer { upper } else { x }
ret

Interessanterweise enthält der Code nicht die erwarteten Sprünge, denn es wird die komplexe Anweisung fcsel verwendet, die gemäß dem vierten Operanden den zweiten oder dritten in den ersten überträgt. Die ersten beiden Befehle entsprechen somit der mathematischen Operation max(inp, lower) und die folgenden beiden Befehle der Operation min(x, upper). Mit diesen lässt sich das Ergebnis tatsächlich ohne Sprünge ermitteln.

Sprünge sind schlecht für den Prozessor

Prozessoren sind sehr komplexe Bauteile. In der einfachsten Form kann man sich ein Rechenwerk, Register (für die Daten) und Anschlüsse an externe Komponenten (wie RAM und Festplatte) vorstellen. Dieser Aufbau wurde über die Jahrzehnte sehr stark erweitert und vom Rechenwerk gibt es mehrere Ausführungen und noch einige Spezialeinheiten für zum Beispiel Vektoren oder Kryptografie. Auch von den Registern gibt es intern mehr als von außen zugänglich sind. Solche erweiterten Prozessorarchitekturen nennt man superskalar.

Da durch mehrere Rechenwerke zeitgleich mehrere Operationen ausgeführt werden können, kann es dazu kommen, dass eine später begonnene Operation vor einer früheren fertig ist, weil sie weniger Rechenzyklen benötigte. Ein moderner Prozessor bearbeitet die Befehle intern also nicht immer in der Reihenfolge ab, wie es der Programmcode vorgibt, sondern kann die Befehle auch in gewissen Grenzen umordnen; dies bezeichnet man als out-of-order execution.

Wenn dann noch der Ablauf des Programmcodes variabel ist und durch bedingt Sprünge zwei Möglichkeiten für den nächsten Befehl bestehen, muss der Prozessor die Arbeit immer wieder unterbrechen, um auf das Ergebnis des Vergleichs zu warten. Es gibt auch die Möglichkeiten, dass beide Pfade bearbeitet werden und am Ende der falsche verworfen wird oder es wird eine Prognose getätigt, welcher Pfad wahrscheinlich genommen wird, und sollte sich später herausstellen, dass die Prognose falsch war, wir alles rückabgewickelt.

Diese drei Fälle verdeutlichen schon, zu welchen Schwierigkeiten Sprünge führen, weil sie immer wieder den linearen Arbeitsfluss stören. (Die Komplexität der Sprungvorhersage hat auch zu den gravierenden Sicherheitsproblemen Spectre und Meltdown vor allem bei Intel-Prozessoren beigetragen.) Ein Compiler wird daher alles tun, um zu verhindern, dass er Sprunganweisungen erzeugen muss.

Interessanter Weise kommt es bei dem Beispielprogramm mit einer else-Anweisung zu genau solch einem Sprung. Das Rust-Programm wird zu folgendem Assembler-Programm mit deutlich mehr Anweisungen übersetzt:

pub fn with_else(inp: f32, lower: f32, upper: f32) -> f32 {
    let mut x = inp;
    if x < lower {
        x = lower;
    } else if x > upper {
        x = upper;
    }
    x
}
    fcmp    s0, s1
    b.mi    .LBB1_3
    fcmp    s0, s2
    mov     v1.16b, v0.16b
    b.le    .LBB1_3
    mov     v1.16b, v2.16b
.LBB1_3:
    mov     v0.16b, v1.16b
    ret

Der naive Ansatz, die beiden logisch getrennten Fälle durch eine else-Anweisung zu trennen, führt zu einem wesentlich schlechteren Ergebnis – so kann man in die Falle tappen. Aber es kommt schlimmer bzw. besser.

Funktionale Variante

Als ich den Code von with_else dann betrachtet habe, dachte ich »so würde ich das doch nicht schreiben« und bin damit zu folgender Lösung gekommen:

pub fn functional(inp: f32, lower: f32, upper: f32) -> f32 {
    if inp < lower {
        lower
    } else if inp > upper {
        upper
    } else {
        inp
    }
}

Diese Form der Codeorganisation entspricht einer Verkettung der Operationen resp. Funktion von min(max(inp, lower), upper). Man nennt diese Form der Programmierung daher auch funktionale Programmierung, weil man keine Reihenfolge von Befehlen konstruiert (imperative Schreibweise), sondern Funktionsaufrufe ineinander verschachtelt (Operationen verkettet). Mit Rust lässt sich auch statt if-else die match-Anweisung verwenden, womit der Code noch mehr der mathematischen Schreibweise für Funktionsdefinitionen ähnelt:

pub fn with_match(inp: f32, lower: f32, upper: f32) -> f32 {
    match inp {
        x if x < lower => lower,
        x if x > upper => upper,
        x => x
    }
}

Für diese beiden Varianten erzeugt rustc 1.50 den gleichen Code wie für clamp:

    fcmp    s0, s2
    fcsel   s2, s2, s0, gt
    fcmp    s0, s1
    fcsel   s0, s1, s2, mi
    ret

Fazit

Optimierungen sind nicht einfach und nicht immer ist im Code einer Hochsprache zu erahnen, welche Prozessorbefehle der Compiler generieren wird. Auch ist den Prozessorbefehlen nicht immer anzusehen, wie performant sie sind und manchmal hängt dies auch von den vorangegangen und folgenden Befehlen ab.

Das Ergebnis kann auch von Compilerversion zu Compilerversion variieren bzw. kann eine Verbesserung für eine CPU-Architektur vorteilhaft, für eine andere nachteilig sein; wie man an den funktionalen Funktionen sieht, wenn man bei Godbolt die Intel-Architektur wählt. Wiederum ist der Assemblercode der Funktionen für die Intel-Architektur mit u32 (statt f32) gleich. Solche Optimierungen sind nicht einfach und es kommt sehr auf den Einzelfall an!

Daher sollte man sich nicht mit solchen Fragen herumschlagen und den Code in erster Linie in einer stimmigen und lesbaren Form schreiben. Sollte sich durch eine Analyse (z. B. mittels Profiling) ergeben, dass eine Codestelle optimiert werden muss, kann man sich das entsprechende Ergebnis in Assembler ansehen und nach Verbesserungsmöglichkeiten suchen.

Bemerkenswert ist aber, dass Rust durch die funktionale Schreibweise den Programmierer zu guten Ergebnissen und lesbarem Code verleitet.