Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Kapitel 14 - Hardware-Sicht: Generische Programmierung unter der Lupe von CPU und RAM

Hallo Thorsten! Nachdem wir uns mit den ausdrucksstarken Möglichkeiten von Trait Bounds und den Architekturmustern generischer Programmierung beschäftigt haben, reißen wir jetzt die Motorhaube auf.

In vielen objektorientierten Sprachen wie Java oder C# werden Generics über Type Erasure (Typ-Löschung) implementiert. Das bedeutet, dass der Compiler zur Laufzeit alle Typinformationen verwirft und stattdessen mit Zeigern auf ein universelles Basisobjekt (z. B. Object) arbeitet. Dies spart zwar Platz in der Binärdatei, kostet aber enorm viel Performance, da die CPU ständig Zeiger dereferenzieren muss (Indirektion) und Werte auf dem Heap allokiert werden müssen (Boxing).

Rust geht den entgegengesetzten Weg: Null-Kosten-Abstraktion (Zero-Cost Abstractions). Generics werden in Rust so übersetzt, dass sie zur Laufzeit exakt die Performance von handgeschriebenem, spezialisiertem Code haben.


1. Die Monomorphisierung im Detail

Der Prozess, der dies ermöglicht, heißt Monomorphisierung (von griechisch mono = einzeln und morph = Form; “in eine einzelne Form bringen”).

Schauen wir uns an, was der Compiler aus generischem Code macht:

// Quellcode, den Sie schreiben:
struct Wrapper<T> {
    wert: T,
}

fn main() {
    let a = Wrapper { wert: 42i32 };
    let b = Wrapper { wert: 3.14f64 };
}

Wenn der Compiler diesen Code übersetzt, führt er im Hintergrund folgende Schritte aus:

  1. Er analysiert die main-Funktion und stellt fest, dass Wrapper mit i32 und f64 aufgerufen wird.
  2. Er dupliziert die Strukturdefinition und generiert konkreten Maschinencode für beide Typen.
  3. Er ersetzt die generischen Aufrufe durch die spezifischen Typen.

Der generierte Zwischencode sieht gedanklich so aus:

// Vom Compiler generierter Code:
struct Wrapper_i32 {
    wert: i32,
}

struct Wrapper_f64 {
    wert: f64,
}

fn main() {
    let a = Wrapper_i32 { wert: 42i32 };
    let b = Wrapper_f64 { wert: 3.14f64 };
}

Die Hardware-Auswirkung von Monomorphisierung:

  • Kein Laufzeit-Overhead: Die CPU führt exakt dieselben Befehle aus, als hättest du zwei separate Strukturen geschrieben. Es gibt keine Indirektionen, keine Vtables (Dynamic Dispatch) und kein Laufzeit-Type-Casting.
  • Effizientes Alignment: Der Compiler berechnet das Speicherlayout für jede Variante optimal. Ein Wrapper_i32 belegt 4 Bytes im RAM (mit einem Alignment von 4), während ein Wrapper_f64 8 Bytes belegt (Alignment 8).

2. Die Schattenseite: Code-Aufblähung (Binary Bloat)

Die Monomorphisierung klingt nach einem Traum für Performance-Enthusiasten. Sie hat jedoch einen physikalischen Haken: Speicherplatz.

Wenn du eine komplexe generische Funktion (z. B. einen Sortieralgorithmus mit Hunderten Zeilen Code) mit 15 verschiedenen Datentypen aufrufst, kopiert der Compiler diese Funktion 15-mal in deine ausführbare Binärdatei.

Das hat zwei gravierende Nachteile für die Hardware:

  1. Größere Binärdatei: Die ausführbare Datei wächst stark an (Binary Bloat).
  2. I-Cache-Verschmutzung (Instruction Cache Pollution): CPUs besitzen einen sehr schnellen, aber winzigen internen Speicher für Instruktionen (den L1-Instruction-Cache). Wenn das Programm ständig zwischen verschiedenen monomorphisierten Kopien derselben Funktion hin- und herspringt, passt der ausführbare Code nicht mehr in den Cache. Die CPU muss den Code aus dem langsameren Hauptspeicher (RAM) nachladen. Die Folge? Cache-Misses und ein spürbarer Performance-Einbruch.

3. Optimierungsmuster: Die innere nicht-generische Hilfsfunktion

Um die Code-Aufblähung bei großen generischen Funktionen zu verhindern, wendet die Rust-Standardbibliothek (und professionelle Bibliotheken) oft ein fortgeschrittenes Entwurfsmuster an: Thin Generic Wrappers.

Dabei wird die komplexe Logik aus der generischen Funktion in eine innere, nicht-generische Funktion ausgelagert, die mit rohen Typen (z. B. Zeigern und Byte-Größen) arbeitet. Die generische Funktion dient nur noch als typsichere Hülle.

Schauen wir uns dieses Muster an:

#![allow(unused)]
fn main() {
// Die generische API, die der Benutzer sieht:
pub fn daten_verarbeiten<T>(daten: &mut [T]) {
    // Wir konvertieren den typisierten Slice in einen rohen Byte-Slice
    let ptr = daten.as_mut_ptr() as *mut u8;
    let laenge = daten.len();
    let element_groesse = std::mem::size_of::<T>();

    // Wir rufen die eigentliche, nicht-generische Implementierung auf
    unsafe {
        hilfs_verarbeitung(ptr, laenge, element_groesse);
    }
}

// Diese Funktion enthält die gesamte komplexe Logik.
// Da sie KEINE generischen Parameter besitzt, existiert sie im fertigen
// Programm genau EINMAL!
unsafe fn hilfs_verarbeitung(daten: *mut u8, laenge: usize, element_groesse: usize) {
    for i in 0..laenge {
        let element_ptr = daten.add(i * element_groesse);
        // Komplexe byteweise Verarbeitung hier...
    }
}
}

Der Gewinn für die Hardware:

  • Die große Funktion hilfs_verarbeitung belegt nur einmal Platz im I-Cache der CPU.
  • Die generische Funktion daten_verarbeiten<T> wird zwar weiterhin monomorphisiert, ist aber so winzig (sie reicht nur Parameter durch), dass die Kopien kaum ins Gewicht fallen.
  • Wir behalten die volle Typsicherheit beim Aufruf, optimieren aber das Cache-Verhalten der CPU!