Transformer-Architektur implementieren: Code-Erklärung Zeile für Zeile (Teil 3 von 3)

Transformer-Architektur implementieren: Code-Erklärung Zeile für Zeile (Teil 3 von 3)

Das Training eines Transformer-Modells im Detail - Ein umfassender Leitfaden

1. Einleitung - Was ist ein Transformer und warum ist das Training so komplex?

Stell dir vor, du möchtest einem Computer beibringen, Texte zu verstehen und zu generieren - genau wie ChatGPT. Das ist die Aufgabe des Transformer-Trainings! Die Transformer-Architektur ist wie ein hochkomplexes Gehirn aus mathematischen Operationen, das lernt, Muster in Sprache zu erkennen und vorherzusagen.

Dieser Artikel ist der dritte und letzte Teil unserer Serie über die Transformer-Architektur. Nach der theoretischen Grundlage im ersten Teil, wo wir die Konzepte wie Attention-Mechanismen und die mathematischen Hintergründe erklärt haben, und der praktischen Anleitung zum Training im zweiten Teil, wo du gelernt hast, wie man ein Modell trainiert, tauchen wir nun tief in den Code ein. Hier verstehen wir jede Zeile der Implementierung und sehen, wie die Theorie in funktionierenden Python-Code umgesetzt wird.

Der gesamte Quellcode zu dieser Serie ist verfügbar unter: https://github.com/ChristophKind/LargeLanguageModelsPublic

Was genau ist ein Transformer?

Ein Transformer ist im Kern eine spezielle Art von neuronalem Netzwerk. Stell dir ein neuronales Netzwerk wie ein riesiges Netz aus miteinander verbundenen Knotenpunkten vor - ähnlich wie Neuronen im menschlichen Gehirn. Jeder Knotenpunkt führt einfache mathematische Berechnungen durch, aber zusammen können sie unglaublich komplexe Aufgaben lösen.

Der Transformer wurde 2017 in dem bahnbrechenden Paper "Attention is All You Need" vorgestellt und hat seitdem die KI-Welt revolutioniert. Seine Superkraft liegt im sogenannten Attention-Mechanismus - die Fähigkeit, zu entscheiden, welche Teile eines Textes für welche anderen Teile wichtig sind.

GPT - Ein spezieller Transformer

In unserem Code implementieren wir GPT (Generative Pre-trained Transformer), eine spezielle Variante des Transformers. GPT ist autoregressiv, was bedeutet: Es generiert Text Wort für Wort (genauer: Token für Token), wobei jedes neue Wort auf allen vorherigen Wörtern basiert.

Schauen wir uns die grundlegende Struktur in model.py an:

# Aus model.py, Zeile 227-238
class GPT(nn.Module):
    """
    Das komplette GPT (Generative Pre-trained Transformer) Sprachmodell.
    
    Diese Klasse implementiert die vollständige GPT-Architektur mit:
    - Token- und Positions-Embeddings
    - Gestapelten Transformer-Blöcken
    - Finaler Projektionsschicht für Vorhersagen
    """
    def __init__(self, config):
        super().__init__()
        self.config = config

Was bedeutet das?

  • class GPT(nn.Module): Wir erstellen eine neue Klasse namens GPT, die von PyTorchs nn.Module erbt. Das ist wie eine Blaupause für unser Modell.
  • def __init__(self, config): Dies ist der Konstruktor - die Funktion, die aufgerufen wird, wenn wir ein neues GPT-Modell erstellen.
  • self.config = config: Wir speichern die Konfiguration (Einstellungen wie Modellgröße, Anzahl der Schichten etc.)

Die Herausforderungen beim Training

Das Training eines Transformers ist aus mehreren Gründen komplex:

  1. Massive Parameteranzahl: Ein GPT-2 Small Modell hat bereits 124 Millionen Parameter (das sind 124 Millionen Zahlen, die das Modell lernen muss!). Schauen wir uns an, wie das berechnet wird:
# Aus model.py, Zeilen 271-290
def calculate_parameter_count(self, non_embedding=True):
    """
    Berechnet die Anzahl der Parameter im Modell.
    """
    total_params = sum(param.numel() for param in self.parameters())
    if non_embedding:
        # Positions-Embeddings nicht mitzählen, da sie nicht zum 'Rechnen' beitragen
        total_params -= self.transformer.wpe.weight.numel()
    return total_params

# Bei der Initialisierung wird das ausgegeben:
print("Anzahl der Parameter: %.2fM" % (self.calculate_parameter_count()/1e6,))

Was passiert hier?

  • sum(param.numel() for param in self.parameters()): Für jeden Parameter im Modell zählen wir die Anzahl der Elemente (numel = number of elements) und summieren alles auf
  • total_params -= self.transformer.wpe.weight.numel(): Wir ziehen die Positions-Embeddings ab, weil diese "statisch" sind und nicht wirklich zum Rechenaufwand beitragen
  1. Rechenintensität: Jeder Trainingsschritt erfordert Milliarden von Berechnungen. Die Attention-Berechnung allein hat eine quadratische Komplexität zur Sequenzlänge!

  2. Speicherbedarf: Die Modelle und ihre Gradienten müssen im GPU-Speicher gehalten werden. Deshalb nutzen wir Tricks wie Gradient Accumulation:

# Aus train.py, Zeilen 42-43 und 77-78
gradient_accumulation_steps = 5 * 8  # = 40 Steps
batch_size = 12
# Das ergibt eine effektive Batch-Größe von 12 * 40 = 480 Sequenzen
tokens_per_iteration = gradient_accumulation_steps * batch_size * block_size
print(f"Tokens pro Iteration: {tokens_per_iteration:,}")  # = 491,520 Tokens!

Der Trainingsablauf im Überblick

Das Training folgt einem sich wiederholenden Zyklus:

  1. Daten laden: Wir nehmen einen Batch (eine Gruppe) von Textsequenzen
  2. Forward Pass: Das Modell verarbeitet die Texte und macht Vorhersagen
  3. Loss berechnen: Wir messen, wie falsch die Vorhersagen waren
  4. Backward Pass: Wir berechnen Gradienten (wie müssen wir die Parameter ändern?)
  5. Parameter-Update: Wir passen die Modellparameter an, um besser zu werden

Dieser Zyklus wird hunderttausende Male wiederholt, bis das Modell gut genug ist.

Die zwei zentralen Dateien

Unsere Implementierung besteht aus zwei Hauptdateien:

  1. model.py (522 Zeilen): Definiert die Architektur - WAS das Modell ist
  2. train.py (373 Zeilen): Definiert den Trainingsprozess - WIE wir das Modell trainieren

Im nächsten Abschnitt werden wir tief in die einzelnen Komponenten der Architektur eintauchen und verstehen, wie jede einzelne funktioniert.

2. Die Architektur verstehen - Komponenten im Training

2.1 Token und Position Embeddings - Wie Text zu Zahlen wird

Computer können nicht direkt mit Wörtern arbeiten - sie verstehen nur Zahlen. Der erste Schritt beim Training eines Transformers ist daher, Text in Zahlen umzuwandeln. Das geschieht in zwei Stufen: Tokenisierung (Text zu Token-IDs) und Embeddings (Token-IDs zu Vektoren).

Was sind Tokens?

Ein Token ist die kleinste Einheit, mit der unser Modell arbeitet. Das kann ein ganzes Wort sein ("Hund"), ein Teilwort ("un" aus "unglaublich") oder sogar nur ein einzelnes Zeichen. GPT-2 verwendet ein Vokabular von 50.257 verschiedenen Tokens.

Beispiel für Tokenisierung:

  • "Ich liebe Transformer!" ? [1273, 28486, 39185, 0]
  • Jedes Wort/Teilwort bekommt eine eindeutige ID-Nummer

Token-Embeddings: Von IDs zu Vektoren

Jetzt kommt der spannende Teil. Jede Token-ID wird in einen hochdimensionalen Vektor umgewandelt - eine Liste von Zahlen, die die "Bedeutung" des Tokens repräsentiert:

# Aus model.py, Zeilen 246-247
self.transformer = nn.ModuleDict(dict(
    wte = nn.Embedding(config.vocab_size, config.n_embd),  # Token-Embeddings

Was bedeutet dieser Code genau?

  • nn.Embedding erstellt eine Lookup-Tabelle (wie ein Wörterbuch)
  • config.vocab_size = 50.257 - so viele verschiedene Tokens kennt unser Modell
  • config.n_embd = 768 - jedes Token wird zu einem Vektor mit 768 Zahlen

Stell dir das wie eine riesige Excel-Tabelle vor:

  • Zeilen: 50.257 verschiedene Tokens
  • Spalten: 768 Dimensionen
  • Jede Zelle enthält eine Zahl, die gelernt wird

Wenn wir Token-ID 1273 ("Ich") nachschlagen, bekommen wir einen Vektor wie: [0.23, -1.45, 0.89, ..., 2.11] mit genau 768 Zahlen.

Diese Zahlen sind nicht zufällig! Sie werden während des Trainings so angepasst, dass ähnliche Wörter ähnliche Vektoren bekommen. Nach dem Training liegen "Hund" und "Katze" im Vektorraum näher beieinander als "Hund" und "Demokratie".

Positions-Embeddings: Wo steht das Wort?

Hier kommt ein kritischer Punkt: Der Transformer hat von sich aus KEIN Verständnis von Reihenfolge! Für ihn wäre "Der Hund jagt die Katze" dasselbe wie "Die Katze jagt den Hund", wenn wir nicht extra die Position kodieren würden.

# Aus model.py, Zeile 248
wpe = nn.Embedding(config.block_size, config.n_embd),  # Positions-Embeddings

Was passiert hier?

  • config.block_size = 1024 - die maximale Sequenzlänge
  • Jede Position (0, 1, 2, ..., 1023) bekommt auch einen 768-dimensionalen Vektor

Das ist wie eine zweite Tabelle:

  • Zeilen: 1024 Positionen (Position 0 bis Position 1023)
  • Spalten: 768 Dimensionen
  • Position 0 lernt "ich bin das erste Token"
  • Position 1 lernt "ich bin das zweite Token"
  • usw.

Die magische Addition

Jetzt kommt der geniale Trick - wir addieren einfach beide Embeddings:

# Aus model.py, Zeilen 329-331
def forward(self, input_ids, targets=None):
    # ... 
    token_embeddings = self.transformer.wte(input_ids)      # Shape: (Batch, Sequenz, 768)
    position_embeddings = self.transformer.wpe(position_ids) # Shape: (Sequenz, 768)
    hidden_states = self.transformer.drop(token_embeddings + position_embeddings)

Schritt für Schritt erklärt:

  1. input_ids enthält die Token-IDs, z.B. [1273, 28486, 39185, 0]
  2. self.transformer.wte(input_ids) schlägt für jede ID den Embedding-Vektor nach
  3. position_ids sind einfach [0, 1, 2, 3] für die vier Positionen
  4. self.transformer.wpe(position_ids) holt die Positions-Vektoren
  5. Wir addieren beide: Token-Vektor + Positions-Vektor = Finaler Vektor

Beispiel für Token "Ich" an Position 0:

  • Token-Embedding für "Ich": [0.23, -1.45, 0.89, ...]
  • Positions-Embedding für Position 0: [0.11, 0.33, -0.22, ...]
  • Resultat nach Addition: [0.34, -1.12, 0.67, ...]

Dieser kombinierte Vektor enthält jetzt beide Informationen: WAS das Token ist UND WO es steht!

Dropout für Regularisierung

Beachte das drop in der letzten Zeile:

hidden_states = self.transformer.drop(token_embeddings + position_embeddings)

Dropout ist eine Regularisierungstechnik. Während des Trainings werden zufällig einige Werte auf 0 gesetzt (wie wenn wir zufällig einige Neuronen "ausschalten"). Das zwingt das Modell, robuster zu werden und nicht zu sehr von einzelnen Features abhängig zu sein. Das verhindert Overfitting (Auswendiglernen statt Verstehen).

Die Embeddings sind die Grundlage für alles Weitere. Diese kombinierten Vektoren wandern nun durch alle Transformer-Blöcke und werden dabei immer weiter verfeinert und mit Kontext angereichert.

2.2 Multi-Head Self-Attention - Das Herzstück des Transformers

Jetzt kommen wir zum absoluten Kernstück des Transformers: dem Attention-Mechanismus. Attention ist die Fähigkeit des Modells zu entscheiden, welche Wörter für welche anderen Wörter wichtig sind. Das ist wie wenn du einen Text liest und automatisch verstehst, dass sich "er" auf "Peter" bezieht, der drei Sätze vorher erwähnt wurde.

Was ist Attention eigentlich?

Stell dir vor, du liest den Satz: "Die Katze, die gestern im Garten war, hat heute eine Maus gefangen."

Wenn du das Wort "hat" verstehen willst, ist "Katze" wichtiger als "Garten". Das Modell muss lernen, diese Verbindungen herzustellen. Genau das macht Attention: Es berechnet für jedes Wort, wie stark es auf alle anderen Wörter "achten" soll.

Die drei Hauptakteure: Query, Key und Value

Der Attention-Mechanismus arbeitet mit drei Konzepten, die aus der Datenbankwelt stammen:

# Aus model.py, Zeilen 67-75 (CausalSelfAttention.__init__)
def __init__(self, config):
    super().__init__()
    assert config.n_embd % config.n_head == 0  # Embedding-Dimension muss durch Anzahl Heads teilbar sein
    
    # Kombinierte Projektion für Query, Key und Value für alle Attention-Heads
    # Dies ist effizienter als separate Projektionen
    self.c_attn = nn.Linear(config.n_embd, 3 * config.n_embd, bias=config.bias)
    
    # Output-Projektion nach der Attention-Berechnung
    self.c_proj = nn.Linear(config.n_embd, config.n_embd, bias=config.bias)

Was passiert hier genau?

  1. Die kombinierte Projektion (self.c_attn):

    • Input: Ein Vektor mit 768 Dimensionen (n_embd)
    • Output: Ein Vektor mit 3 × 768 = 2304 Dimensionen
    • Warum 3×? Weil wir Query, Key UND Value auf einmal berechnen!
    • nn.Linear ist eine Matrix-Multiplikation: Output = Input × Gewichtsmatrix + Bias
  2. Query, Key, Value - Was sind das?

    • Query (Abfrage): "Wonach suche ich?" - Repräsentiert das aktuelle Wort
    • Key (Schlüssel): "Was biete ich an?" - Repräsentiert potentielle Bezugswörter
    • Value (Wert): "Was gebe ich weiter?" - Die tatsächliche Information

Analogie: Stell dir eine Bibliothek vor:

  • Query = Deine Suchanfrage ("Bücher über Transformers")
  • Key = Die Etiketten auf den Büchern
  • Value = Der Inhalt der Bücher
  • Du vergleichst deine Suchanfrage mit allen Etiketten und nehmen die Bücher mit den besten Übereinstimmungen

Die eigentliche Attention-Berechnung

Schauen wir uns den Forward-Pass genau an:

# Aus model.py, Zeilen 93-110 (CausalSelfAttention.forward)
def forward(self, hidden_states):
    batch_size, sequence_length, embedding_dim = hidden_states.size()
    # hidden_states hat Form: (Batch=32, Sequenz=1024, Embedding=768)
    
    # Berechne Query, Key und Value für alle Heads gleichzeitig
    queries, keys, values = self.c_attn(hidden_states).split(self.n_embd, dim=2)
    # Jeder hat Form: (32, 1024, 768)

Schritt 1: Die Projektion

  • self.c_attn(hidden_states) erzeugt einen großen Vektor (32, 1024, 2304)
  • .split(self.n_embd, dim=2) teilt ihn in drei gleiche Teile:
    • queries: Dimensionen 0-767
    • keys: Dimensionen 768-1535
    • values: Dimensionen 1536-2303

Multi-Head: Warum mehrere Köpfe?

Jetzt wird es interessant - wir teilen unsere Attention in mehrere "Köpfe" auf:

# Fortsetzung aus model.py
keys = keys.view(batch_size, sequence_length, self.n_head, embedding_dim // self.n_head).transpose(1, 2)
queries = queries.view(batch_size, sequence_length, self.n_head, embedding_dim // self.n_head).transpose(1, 2)
values = values.view(batch_size, sequence_length, self.n_head, embedding_dim // self.n_head).transpose(1, 2)
# Neue Form: (32, 12, 1024, 64) - 12 Heads mit je 64 Dimensionen

Was passiert hier?

  1. Wir haben 768 Dimensionen und 12 Heads
  2. Jeder Head bekommt 768 ÷ 12 = 64 Dimensionen
  3. .view() reshapet den Tensor ohne Daten zu kopieren
  4. .transpose(1, 2) vertauscht Sequenz- und Head-Dimension

Warum mehrere Heads? Jeder Head kann sich auf andere Aspekte konzentrieren:

  • Head 1: Grammatikalische Struktur (Subjekt-Verb-Beziehungen)
  • Head 2: Semantische Ähnlichkeit (Synonyme, Themenverwandtschaft)
  • Head 3: Positionale Nähe (benachbarte Wörter)
  • Head 4: Korreferenzen (Pronomen-Auflösung)
  • usw.

Die Attention-Formel

Jetzt kommt die eigentliche Magie - die Attention-Berechnung:

# Aus model.py, Zeilen 119-123
# Matrixmultiplikation: (B, n_heads, T, head_size) × (B, n_heads, head_size, T) ? (B, n_heads, T, T)
attention_weights = (queries @ keys.transpose(-2, -1)) * (1.0 / math.sqrt(keys.size(-1)))

Diese eine Zeile macht Folgendes:

  1. queries @ keys.transpose(-2, -1): Berechnet die Ähnlichkeit zwischen allen Query-Key-Paaren

    • @ ist Matrix-Multiplikation
    • Für jede Query-Position: "Wie ähnlich bin ich zu jeder Key-Position?"
    • Ergebnis: Eine (1024 × 1024) Matrix pro Head
  2. * (1.0 / math.sqrt(keys.size(-1))): Skalierung durch v64

    • Warum? Ohne Skalierung würden die Werte zu groß werden
    • Zu große Werte ? Softmax produziert extreme Wahrscheinlichkeiten (fast 0 oder 1)
    • Das macht Gradienten sehr klein und Training langsam

Kausale Maskierung - Die Zukunft verbergen

GPT ist autoregressiv - es darf nur in die Vergangenheit schauen, nicht in die Zukunft:

# Aus model.py, Zeilen 88-91 (im __init__)
# Kausale Maske: Stellt sicher, dass jede Position nur auf vorherige Positionen achten kann
self.register_buffer("bias", torch.tril(torch.ones(config.block_size, config.block_size))
                            .view(1, 1, config.block_size, config.block_size))

# Aus model.py, Zeilen 120-121 (im forward)
attention_weights = attention_weights.masked_fill(
    self.bias[:,:,:sequence_length,:sequence_length] == 0, float('-inf'))

Was macht die Maske?

  • torch.tril erzeugt eine untere Dreiecksmatrix:

    [[1, 0, 0, 0],
     [1, 1, 0, 0],
     [1, 1, 1, 0],
     [1, 1, 1, 1]]
  • Position 0 kann nur Position 0 sehen

  • Position 1 kann Positionen 0-1 sehen

  • Position 2 kann Positionen 0-2 sehen

  • usw.

  • masked_fill(..., float('-inf')): Setzt verbotene Positionen auf minus unendlich

  • Nach Softmax werden diese zu 0 (e^(-8) = 0)

Softmax - Von Scores zu Wahrscheinlichkeiten

# Aus model.py, Zeile 121
attention_weights = F.softmax(attention_weights, dim=-1)
attention_weights = self.attn_dropout(attention_weights)

Softmax wandelt die Attention-Scores in Wahrscheinlichkeiten um:

  • Alle Werte zwischen 0 und 1
  • Summe pro Zeile = 1
  • Höhere Scores ? Höhere Wahrscheinlichkeiten

Dropout wird wieder zur Regularisierung angewendet.

Der finale Schritt - Gewichtete Summe

# Aus model.py, Zeile 123
attention_output = attention_weights @ values
# Form: (32, 12, 1024, 64)

Jetzt verwenden wir die Attention-Gewichte, um eine gewichtete Summe der Values zu berechnen:

  • Wenn Token A zu 70% auf Token B und zu 30% auf Token C achtet
  • Dann ist das Ergebnis: 0.7 × Value_B + 0.3 × Value_C

Zusammenführung und Output-Projektion

# Aus model.py, Zeilen 124-128
attention_output = attention_output.transpose(1, 2).contiguous().view(
    batch_size, sequence_length, embedding_dim)
# Von (32, 12, 1024, 64) zurück zu (32, 1024, 768)

# Output-Projektion und Dropout für Regularisierung
attention_output = self.resid_dropout(self.c_proj(attention_output))

Die Heads werden wieder zusammengeführt:

  1. .transpose(1, 2): Head-Dimension zurück an Position 2
  2. .contiguous(): Stellt sicher, dass der Speicher zusammenhängend ist
  3. .view(): Reshapet zu ursprünglicher Form
  4. self.c_proj: Finale lineare Transformation
  5. self.resid_dropout: Dropout für Regularisierung

Flash Attention - Die Turbo-Version

Für noch schnelleres Training gibt es Flash Attention:

# Aus model.py, Zeilen 114-116
if self.flash:
    attention_output = torch.nn.functional.scaled_dot_product_attention(
        queries, keys, values, attn_mask=None, 
        dropout_p=self.dropout if self.training else 0, is_causal=True)

Flash Attention ist eine hochoptimierte Implementation, die:

  • Weniger Speicher braucht
  • Schneller rechnet (bis zu 3× schneller!)
  • Die gleichen Ergebnisse liefert

Das war der Attention-Mechanismus - das absolute Herzstück des Transformers. Durch diese Mechanismen kann das Modell komplexe Beziehungen zwischen Wörtern lernen und verstehen.

2.3 Feed-Forward Networks (MLP) - Die Verarbeitungseinheit

Nach der Attention-Schicht kommt in jedem Transformer-Block ein Feed-Forward Netzwerk, auch MLP (Multi-Layer Perceptron) genannt. Während Attention die Beziehungen zwischen Tokens herausfinden, ist die MLP-Schicht dafür da, diese Informationen zu verarbeiten und zu transformieren.

Was macht die MLP-Schicht?

Stell dir die MLP wie einen Datenverarbeitungsprozessor vor:

  • Attention sagt: "Diese Wörter sind wichtig füreinander"
  • MLP sagt: "Jetzt verarbeite ich diese Information und extrahiere komplexere Muster"

Die MLP arbeitet positionsweise - das bedeutet, sie verarbeitet jede Position in der Sequenz unabhängig von den anderen. Das ist ein wichtiger Unterschied zur Attention, die alle Positionen miteinander in Beziehung setzt.

Der Aufbau der MLP

Schauen wir uns den Code genau an:

# Aus model.py, Zeilen 130-147 (MLP Klasse)
class MLP(nn.Module):
    """
    Multi-Layer Perceptron (Feed-Forward Network) des Transformer-Blocks.
    
    Implementiert ein zweischichtiges neuronales Netzwerk mit GELU-Aktivierung.
    Die versteckte Schicht hat typischerweise die 4-fache Dimension der Eingabe.
    """
    
    def __init__(self, config):
        super().__init__()
        # Erste lineare Schicht: Expansion von n_embd auf 4*n_embd
        self.c_fc = nn.Linear(config.n_embd, 4 * config.n_embd, bias=config.bias)
        # GELU Aktivierungsfunktion
        self.gelu = nn.GELU()
        # Zweite lineare Schicht: Projektion zurück von 4*n_embd auf n_embd
        self.c_proj = nn.Linear(4 * config.n_embd, config.n_embd, bias=config.bias)
        # Dropout zur Regularisierung
        self.dropout = nn.Dropout(config.dropout)

Die MLP besteht aus zwei linearen Transformationen mit einer Aktivierungsfunktion dazwischen:

  1. Expansion (self.c_fc):

    • Input: 768 Dimensionen
    • Output: 3072 Dimensionen (4 × 768)
    • Das ist eine 4-fache Vergrößerung!
  2. Aktivierung (self.gelu):

    • GELU (Gaussian Error Linear Unit)
    • Eine sanfte Version von ReLU
  3. Projektion (self.c_proj):

    • Input: 3072 Dimensionen
    • Output: 768 Dimensionen
    • Zurück zur ursprünglichen Größe

Warum die 4-fache Expansion?

Die Expansion auf die 4-fache Größe ist ein bewährtes Design-Pattern in Transformers:

# Die Dimensionen im Detail:
# Eingabe:        (Batch=32, Sequenz=1024, Embedding=768)
# Nach c_fc:      (32, 1024, 3072)  # 4× größer!
# Nach GELU:      (32, 1024, 3072)  # gleiche Größe
# Nach c_proj:    (32, 1024, 768)   # wieder original

Warum macht man das?

  1. Mehr Kapazität: Mit 3072 Dimensionen kann das Netzwerk komplexere Funktionen lernen
  2. Informations-Flaschenhals: Die Expansion und Kontraktion zwingt das Modell, wichtige Features zu komprimieren
  3. Nicht-Linearität: Die GELU-Aktivierung in der Mitte ermöglicht nicht-lineare Transformationen

Analogie: Stell dir vor, du musst ein komplexes Problem lösen:

  1. Du breitest alle Informationen auf einem großen Tisch aus (Expansion)
  2. Du arbeitest mit den Informationen, kombinierst und transformierst sie (GELU)
  3. Du fasst die Ergebnisse kompakt zusammen (Projektion)

Der Forward Pass durch die MLP

# Aus model.py, Zeilen 149-163 (MLP.forward)
def forward(self, hidden_states):
    """
    Vorwärtsdurchlauf durch das Feed-Forward Netzwerk.
    
    Args:
        hidden_states: Eingabe-Tensor der Form (Batch, Sequenzlänge, n_embd)
    
    Returns:
        Transformierter Tensor gleicher Form wie die Eingabe
    """
    hidden_states = self.c_fc(hidden_states)    # Projektion auf 4*n_embd
    hidden_states = self.gelu(hidden_states)    # GELU Aktivierung
    hidden_states = self.c_proj(hidden_states)  # Projektion zurück auf n_embd
    hidden_states = self.dropout(hidden_states) # Dropout für Regularisierung
    return hidden_states

Schritt für Schritt:

  1. Lineare Transformation 1 (self.c_fc):

    hidden_states = self.c_fc(hidden_states)
    • Mathematisch: output = input @ weight.T + bias
    • Jedes der 768 Input-Features wird mit 3072 verschiedenen Gewichten multipliziert
    • Das ergibt 768 × 3072 = 2.359.296 Parameter allein in dieser Schicht!
  2. GELU Aktivierung (self.gelu):

    hidden_states = self.gelu(hidden_states)

    GELU ist definiert als: GELU(x) = x * F(x) wo F die kumulative Normalverteilung ist.

    Vereinfacht: GELU ist wie ReLU, aber mit einer sanften Kurve statt einer harten Kante:

    • Negative Werte werden nicht komplett auf 0 gesetzt
    • Es gibt einen sanften Übergang
    • Das führt zu besseren Gradienten beim Training
  3. Lineare Transformation 2 (self.c_proj):

    hidden_states = self.c_proj(hidden_states)
    • Reduziert von 3072 zurück auf 768 Dimensionen
    • Weitere 3072 × 768 = 2.359.296 Parameter
  4. Dropout (self.dropout):

    hidden_states = self.dropout(hidden_states)
    • Während des Trainings: Setzt zufällig Werte auf 0
    • Verhindert Overfitting
    • Macht das Modell robuster

Die Rolle der MLP im Gesamtkontext

Die MLP-Schicht hat mehrere wichtige Funktionen:

  1. Feature-Extraktion: Sie lernt, komplexe Features aus den Attention-Outputs zu extrahieren
  2. Nicht-lineare Transformation: Durch GELU kann sie nicht-lineare Muster lernen
  3. Positions-unabhängige Verarbeitung: Jede Position wird einzeln verarbeitet

Ein konkretes Beispiel:

  • Nach Attention weiß das Modell: "Token 5 ist stark mit Token 2 verbunden"
  • Die MLP verarbeitet das: "Wenn diese Verbindung existiert, könnte das ein Relativsatz sein"
  • Sie transformiert die rohe Attention-Information in höhere semantische Konzepte

Parameteranzahl in der MLP

Die MLP ist tatsächlich der größte Teil des Modells bezüglich Parametern:

# Pro MLP-Block:
# c_fc:   768 × 3072 = 2.359.296 Parameter
# c_proj: 3072 × 768 = 2.359.296 Parameter
# Gesamt pro Block: 4.718.592 Parameter

# Bei 12 Transformer-Blöcken:
# 12 × 4.718.592 = 56.623.104 Parameter nur für MLPs!

Das sind etwa 45% aller Parameter im GPT-2 Small Modell!

2.4 Layer Normalization und Residual Connections - Die Stabilisatoren

Diese beiden Techniken sind absolut essentiell für das Training tiefer Netzwerke. Ohne sie wäre es praktisch unmöglich, Transformer mit 12, 24 oder sogar 96 Schichten zu trainieren.

Das Problem mit tiefen Netzwerken

Stell dir vor, du multiplizierst eine Zahl immer wieder:

  • 1.1 × 1.1 × 1.1 × ... (12 mal) = 3.14 (exponentielles Wachstum)
  • 0.9 × 0.9 × 0.9 × ... (12 mal) = 0.28 (exponentieller Verfall)

Das gleiche passiert in tiefen Netzwerken:

  • Werte können explodieren (zu groß werden) ? Gradient Explosion
  • Werte können verschwinden (zu klein werden) ? Vanishing Gradients

Layer Normalization - Der Stabilisator

Layer Normalization normalisiert die Aktivierungen, sodass sie immer eine stabile Verteilung haben:

# Aus model.py, Zeilen 29-50 (LayerNorm Klasse)
class LayerNorm(nn.Module):
    """
    Layer Normalization mit optionalem Bias-Parameter.
    
    Layer Normalization normalisiert die Aktivierungen über die Feature-Dimension,
    was zu stabilerer und schnellerer Konvergenz beim Training führt.
    """
    
    def __init__(self, ndim, bias):
        super().__init__()
        # Lernbare Skalierung (gamma)
        self.weight = nn.Parameter(torch.ones(ndim))
        # Lernbarer Bias (beta) - optional
        self.bias = nn.Parameter(torch.zeros(ndim)) if bias else None
    
    def forward(self, input):
        return F.layer_norm(input, self.weight.shape, self.weight, self.bias, 1e-5)

Was macht Layer Norm genau?

  1. Berechne Mittelwert und Varianz für jede Position:

    mean = input.mean(dim=-1, keepdim=True)      # Mittelwert über Features
    var = input.var(dim=-1, keepdim=True)        # Varianz über Features
  2. Normalisiere:

    normalized = (input - mean) / sqrt(var + 1e-5)  # 1e-5 verhindert Division durch 0
  3. Skaliere und verschiebe (mit lernbaren Parametern):

    output = normalized * self.weight + self.bias

Beispiel mit konkreten Zahlen:

  • Input: [100, 200, 50] (sehr unterschiedliche Größenordnungen)
  • Nach Normalisierung: [-0.78, 1.09, -0.31] (standardisiert)
  • Nach Skalierung: Angepasst wie vom Modell gelernt

Residual Connections - Die Abkürzungen

Residual Connections (auch Skip Connections genannt) sind genial einfach: Sie addieren einfach den Input zum Output!

# Aus model.py, Zeilen 185-200 (Block.forward)
def forward(self, hidden_states):
    """
    Verarbeitet die Eingabe durch Attention und Feed-Forward Schichten.
    
    Verwendet Pre-Normalization (Layer Norm vor der Transformation)
    und Residual Connections für stabiles Training.
    """
    # Attention-Block mit Residual Connection
    hidden_states = hidden_states + self.attn(self.ln_1(hidden_states))
    
    # MLP-Block mit Residual Connection  
    hidden_states = hidden_states + self.mlp(self.ln_2(hidden_states))
    
    return hidden_states

Was passiert hier im Detail?

  1. Pre-Norm Attention:

    # Originalwert speichern
    residual = hidden_states
    # Normalisieren
    normalized = self.ln_1(hidden_states)
    # Durch Attention
    attention_out = self.attn(normalized)
    # Residual addieren
    hidden_states = residual + attention_out
  2. Pre-Norm MLP:

    # Wieder Original speichern
    residual = hidden_states
    # Normalisieren
    normalized = self.ln_2(hidden_states)
    # Durch MLP
    mlp_out = self.mlp(normalized)
    # Residual addieren
    hidden_states = residual + mlp_out

Warum sind Residual Connections so wichtig?

  1. Gradient Flow: Gradienten können direkt durch die Addition fließen

    • Ohne Residual: Gradient muss durch alle Schichten
    • Mit Residual: Gradient hat eine "Autobahn" durch die Addition
  2. Identitätsfunktion: Im Worst Case kann das Netzwerk einfach die Identität lernen

    • Wenn eine Schicht nichts Nützliches lernt, kann sie einfach 0 ausgeben
    • Der Residual sorgt dafür, dass trotzdem die Information durchkommt
  3. Ensemble-Effekt: Residual Connections erzeugen implizit ein Ensemble

    • Verschiedene "Pfade" durch das Netzwerk
    • Mehr Robustheit

Analogie: Stell dir ein mehrstöckiges Gebäude vor:

  • Ohne Residual: Du musst jede Etage durchlaufen (Treppe)
  • Mit Residual: Es gibt zusätzlich einen Aufzug direkt nach oben
  • Informationen können den schnellsten Weg nehmen

Das Zusammenspiel im Transformer-Block

# Aus model.py, Zeilen 165-184 (Block Klasse)
class Block(nn.Module):
    """
    Transformer-Block: Die Kernkomponente der GPT-Architektur.
    
    Jeder Block besteht aus:
    1. Multi-Head Self-Attention mit kausaler Maskierung
    2. Feed-Forward Netzwerk (MLP)
    3. Zwei Layer-Normalization Schichten
    4. Residual Connections um beide Hauptkomponenten
    """
    
    def __init__(self, config):
        super().__init__()
        self.ln_1 = LayerNorm(config.n_embd, bias=config.bias)  # Norm vor Attention
        self.attn = CausalSelfAttention(config)                 # Attention
        self.ln_2 = LayerNorm(config.n_embd, bias=config.bias)  # Norm vor MLP
        self.mlp = MLP(config)                                  # Feed-Forward

Die Reihenfolge ist wichtig:

  1. Layer Norm ? Stabilisiert Input
  2. Attention/MLP ? Transformation
  3. Residual Add ? Erhält Gradient Flow

Diese Struktur wird 12 mal (bei GPT-2 Small) wiederholt, wodurch das Modell immer komplexere Repräsentationen aufbaut!

3. Vorbereitung des Trainings

Bevor wir mit dem eigentlichen Training beginnen können, müssen wir vier wichtige Vorbereitungen treffen: Daten laden, Modell initialisieren, Hyperparameter setzen und den Optimizer konfigurieren.

3.1 Datenaufbereitung und Tokenisierung - Wie Texte zu Trainingsmaterial werden

Das Training beginnt mit Daten - in unserem Fall tokenisierter Text, der als binäre Dateien vorliegt. Schauen wir uns genau an, wie das funktioniert.

Die Datenstruktur

Unsere Trainingsdaten liegen in zwei Dateien:

  • train.bin: Trainingsdaten (meist 90% der Gesamtdaten)
  • val.bin: Validierungsdaten (meist 10% der Gesamtdaten)

Diese Dateien enthalten vorverarbeitete Token-IDs als 16-bit Integers:

# Aus train.py, Zeilen 103-108
data_directory = dataset  # dataset ist 'data'
train_data_path = os.path.join(data_directory, 'train.bin')
validation_data_path = os.path.join(data_directory, 'val.bin')
print(f"Trainingsdaten-Datei: {train_data_path}")
print(f"Validierungsdaten-Datei: {validation_data_path}")

Memory-Mapped Files - Der Trick für große Datensätze

Hier kommt ein cleverer Trick: Memory-Mapped Files. Statt die gesamten Daten in den RAM zu laden, mappen wir sie direkt von der Festplatte:

# Aus train.py, Zeilen 110-138 (load_data_batch Funktion)
def load_data_batch(split):
    """
    Lädt einen zufälligen Batch aus den Trainingsdaten.
    
    Verwendet memory-mapped Dateien für effizienten Zugriff auf große Datensätze.
    Erstellt np.memmap bei jedem Aufruf neu um Memory Leaks zu vermeiden.
    """
    # Neu erstellen von memmap vermeidet Memory Leak
    if split == 'train':
        data = np.memmap(os.path.join(data_directory, 'train.bin'), dtype=np.uint16, mode='r')
    else:
        data = np.memmap(os.path.join(data_directory, 'val.bin'), dtype=np.uint16, mode='r')

Was ist np.memmap?

  • Es ist wie eine virtuelle Ansicht auf die Datei
  • Die Datei bleibt auf der Festplatte
  • Nur die Teile, die wir gerade brauchen, werden in den RAM geladen
  • Perfekt für Datensätze, die größer als der RAM sind!

Wichtiger Trick: Wir erstellen memmap bei jedem Aufruf neu. Warum?

  • Python's Garbage Collector kann sonst Memory Leaks verursachen
  • Siehe Kommentar: "vermeidet Memory Leak"

Batch-Erstellung - Zufällige Sequenzen

Jetzt extrahieren wir zufällige Sequenzen aus den Daten:

# Fortsetzung load_data_batch
batch_indices = torch.randint(len(data) - block_size, (batch_size,))
input_sequences = torch.stack([torch.from_numpy((data[i:i+block_size]).astype(np.int64)) for i in batch_indices])
target_sequences = torch.stack([torch.from_numpy((data[i+1:i+1+block_size]).astype(np.int64)) for i in batch_indices])

Was passiert hier im Detail?

  1. Zufällige Startpositionen:

    batch_indices = torch.randint(len(data) - block_size, (batch_size,))
    • Wählt batch_size (z.B. 12) zufällige Startpositionen
    • len(data) - block_size stellt sicher, dass wir nicht über das Ende hinauslesen
    • Beispiel: Bei 1 Million Token und block_size=1024 können wir von Position 0 bis 998.976 starten
  2. Input-Sequenzen:

    data[i:i+block_size]  # z.B. Token 1000 bis 2023
    • Extrahiert eine Sequenz der Länge block_size (1024)
    • .astype(np.int64) konvertiert von uint16 zu int64 (PyTorch Standard)
  3. Target-Sequenzen:

    data[i+1:i+1+block_size]  # z.B. Token 1001 bis 2024
    • Die Targets sind um 1 Position verschoben!
    • Warum? Wir trainieren das Modell, das NÄCHSTE Token vorherzusagen
    • Input: "Der Hund ist" ? Target: "Hund ist groß"

GPU-Optimierung beim Datentransfer

if device_type == 'cuda':
    # Arrays im RAM pinnen für asynchronen GPU-Transfer
    input_sequences = input_sequences.pin_memory().to(device, non_blocking=True)
    target_sequences = target_sequences.pin_memory().to(device, non_blocking=True)
else:
    input_sequences = input_sequences.to(device)
    target_sequences = target_sequences.to(device)

Pinned Memory - Was ist das?

  • Normaler RAM kann vom OS verschoben werden (paging)
  • Pinned Memory ist "festgenagelt" im RAM
  • GPU kann direkt darauf zugreifen ohne Kopie
  • non_blocking=True macht den Transfer asynchron
  • Die CPU kann weiterarbeiten während die GPU die Daten empfängt!

Das Vokabular - Meta-Informationen

# Aus train.py, Zeilen 144-151
metadata_path = os.path.join(data_directory, 'meta.pkl')
metadata_vocab_size = None
if os.path.exists(metadata_path):
    with open(metadata_path, 'rb') as f:
        metadata = pickle.load(f)
    metadata_vocab_size = metadata['vocab_size']
    print(f"vocab_size gefunden = {metadata_vocab_size} (in {metadata_path})")

Die meta.pkl Datei enthält wichtige Informationen:

  • vocab_size: Wie viele verschiedene Tokens gibt es?
  • Eventuell auch: Tokenizer-Informationen, Sonderzeichen-Mappings etc.

3.2 Modell-Initialisierung - Von Grund auf oder vortrainiert?

Das Modell kann auf drei verschiedene Arten initialisiert werden. Jede hat ihre Vor- und Nachteile:

Modus 1: Training von Grund auf ("scratch")

# Aus train.py, Zeilen 156-164
if initialization_mode == 'scratch':
    # Initialisiere ein neues Modell von Grund auf
    print("Initialisiere ein neues Modell von Grund auf")
    
    # Bestimme die vocab_size
    if metadata_vocab_size is None:
        print("Verwende Standard vocab_size von GPT-2: 50304")
    model_arguments['vocab_size'] = metadata_vocab_size if metadata_vocab_size is not None else 50304
    
    gpt_configuration = GPTConfig(**model_arguments)
    model = GPT(gpt_configuration)

Warum 50304 statt 50257?

  • GPT-2 hat eigentlich 50.257 Token
  • 50.304 = 50.257 + 47 (aufgerundet auf Vielfaches von 64)
  • Warum aufrunden? GPU-Effizienz!
  • GPUs arbeiten am effizientesten mit Vielfachen von 32 oder 64

Die Gewichtsinitialisierung - Kritisch für den Erfolg

# Aus model.py, Zeilen 292-309
def _init_weights(self, module):
    """
    Initialisiert die Gewichte eines Moduls nach GPT-2 Spezifikation.
    """
    if isinstance(module, nn.Linear):
        # Normalverteilung mit Standardabweichung 0.02
        torch.nn.init.normal_(module.weight, mean=0.0, std=0.02)
        if module.bias is not None:
            torch.nn.init.zeros_(module.bias)
    elif isinstance(module, nn.Embedding):
        torch.nn.init.normal_(module.weight, mean=0.0, std=0.02)

Warum genau std=0.02?

  • Zu klein (0.001): Signale verschwinden in tiefen Netzwerken
  • Zu groß (0.1): Aktivierungen explodieren
  • 0.02 ist der Sweetspot für Transformer (empirisch ermittelt)

Spezialbehandlung für Residual-Projektionen:

# Aus model.py, Zeilen 264-266
for param_name, param in self.named_parameters():
    if param_name.endswith('c_proj.weight'):
        # Skalierung mit 1/v(2*n_layer)
        torch.nn.init.normal_(param, mean=0.0, std=0.02/math.sqrt(2 * config.n_layer))

Warum diese spezielle Skalierung?

  • Residual-Pfade addieren sich über alle Schichten
  • Bei 12 Schichten würden sich die Varianzen aufaddieren
  • Die Skalierung kompensiert das: std = 0.02/v(2×12) ˜ 0.0041
  • Hält die Gesamtvarianz konstant!

Modus 2: Training fortsetzen ("resume")

# Aus train.py, Zeilen 165-187
elif initialization_mode == 'resume':
    print(f"Setze Training fort von {output_directory}")
    
    # Lade Checkpoint
    checkpoint_path = os.path.join(output_directory, 'my_own_llm.pt')
    checkpoint = torch.load(checkpoint_path, map_location=device)
    
    # Validiere kritische Parameter
    checkpoint_model_arguments = checkpoint['model_args']
    for key in ['n_layer', 'n_head', 'n_embd', 'block_size', 'bias', 'vocab_size']:
        model_arguments[key] = checkpoint_model_arguments[key]

Wichtig: Bestimmte Parameter MÜSSEN übereinstimmen:

  • n_layer, n_head, n_embd: Architektur muss gleich sein
  • vocab_size: Gleiche Token-Anzahl
  • Andere Parameter (wie dropout) können geändert werden

Der mysteriöse Präfix-Bug:

# Korrigiere die Schlüssel des State Dictionary
unwanted_prefix = '_orig_mod.'
for k,v in list(state_dict.items()):
    if k.startswith(unwanted_prefix):
        state_dict[k[len(unwanted_prefix):]] = state_dict.pop(k)

Was ist das?

  • PyTorch's torch.compile() fügt manchmal '_orig_mod.' Präfix hinzu
  • Muss entfernt werden für Kompatibilität
  • Ein bekannter PyTorch-Quirk

Modus 3: Mit vortrainierten Gewichten ("gpt2")

# Aus train.py, Zeilen 188-195
elif initialization_mode.startswith('gpt2'):
    print(f"Initialisiere mit OpenAI GPT-2 Gewichten: {initialization_mode}")
    
    override_arguments = dict(dropout=dropout_rate)
    model = GPT.load_pretrained_weights(initialization_mode, override_arguments)

Die load_pretrained_weights Funktion lädt echte GPT-2 Gewichte von HuggingFace:

# Aus model.py, Zeilen 386-440
@classmethod
def load_pretrained_weights(cls, model_type, override_args=None):
    """
    Lädt vortrainierte GPT-2 Gewichte von HuggingFace.
    
    Modellgrößen:
    - gpt2: 124M Parameter
    - gpt2-medium: 350M Parameter  
    - gpt2-large: 774M Parameter
    - gpt2-xl: 1.5B Parameter
    """
    config_args = {
        'gpt2':         dict(n_layer=12, n_head=12, n_embd=768),  
        'gpt2-medium':  dict(n_layer=24, n_head=16, n_embd=1024),
        'gpt2-large':   dict(n_layer=36, n_head=20, n_embd=1280),
        'gpt2-xl':      dict(n_layer=48, n_head=25, n_embd=1600),
    }[model_type]

Der Gewichts-Transponierungstrick:

# OpenAI verwendet "Conv1D" Module, wir verwenden Standard Linear
transposed_weights = ['attn.c_attn.weight', 'attn.c_proj.weight', 
                      'mlp.c_fc.weight', 'mlp.c_proj.weight']

for key in huggingface_keys:
    if any(key.endswith(weight_name) for weight_name in transposed_weights):
        # Transponiere die Gewichte
        state_dict[key].copy_(huggingface_state_dict[key].t())

Warum transponieren?

  • OpenAI's originale Implementierung nutzt 1D-Convolutions
  • PyTorch's Linear erwartet transponierte Gewichte
  • .t() transponiert die Matrix

3.3 Hyperparameter-Konfiguration - Die Stellschrauben des Trainings

Hyperparameter sind die Einstellungen, die wir VOR dem Training festlegen müssen. Sie bestimmen, wie das Training abläuft:

# Aus train.py, Zeilen 30-68
# === Modell-Konfiguration ===
num_layers = 12           # Anzahl der Transformer-Blöcke
num_attention_heads = 12  # Anzahl der Attention-Heads
embedding_dimension = 768 # Dimension der Embeddings
dropout_rate = 0.0        # Dropout (0.0 für Pretraining, 0.1+ für Finetuning)
use_bias = False         # Bias in Linear/LayerNorm Schichten?

# === Trainings-Konfiguration ===
gradient_accumulation_steps = 5 * 8  # = 40
batch_size = 12
block_size = 1024

Batch-Größe und Gradient Accumulation

Die effektive Batch-Größe ist eines der wichtigsten Hyperparameter:

# Aus train.py, Zeilen 77-78
tokens_per_iteration = gradient_accumulation_steps * batch_size * block_size
print(f"Tokens pro Iteration: {tokens_per_iteration:,}")  # = 491,520!

Warum Gradient Accumulation?

  • Wir wollen große Batches (bessere Gradienten)
  • GPU-Speicher ist begrenzt
  • Lösung: Mehrere kleine Batches, Gradienten akkumulieren

Beispiel:

  • Ohne Accumulation: Batch-Size 12 (passt in GPU)
  • Mit Accumulation: Effektiv 12 × 40 = 480 Sequenzen
  • Das sind 480 × 1024 = 491.520 Token pro Update!

Learning Rate und Optimizer-Einstellungen

# === AdamW Optimizer Konfiguration ===
learning_rate = 6e-4      # Maximale Lernrate (0.0006)
weight_decay = 1e-1       # L2-Regularisierung (0.1)
adam_beta1 = 0.9         # Momentum-Koeffizient
adam_beta2 = 0.95        # RMSprop-Koeffizient
gradient_clipping_value = 1.0  # Gradient-Clipping Schwelle

Learning Rate 6e-4:

  • Zu hoch (1e-2): Training instabil, Loss explodiert
  • Zu niedrig (1e-5): Training zu langsam
  • 6e-4 ist optimal für GPT-2 Größe

Weight Decay 0.1:

  • Regularisierung gegen Overfitting
  • "Bestraft" große Gewichte
  • 0.1 ist überraschend hoch, funktioniert aber!

Adam Betas (0.9, 0.95):

  • Beta1=0.9: Momentum über letzte ~10 Steps
  • Beta2=0.95: Varianz-Anpassung über ~20 Steps
  • Niedriger als Standard (0.9, 0.999) ? weniger "Gedächtnis"

Learning Rate Schedule

# Lernraten-Decay Einstellungen
enable_learning_rate_decay = True
warmup_iterations = 2000              # Aufwärmphase
learning_rate_decay_iterations = 600000  # Decay-Dauer
minimum_learning_rate = 6e-5          # Min LR (1/10 der Max)

Der Schedule hat drei Phasen:

  1. Warmup (0-2000): Linear von 0 auf 6e-4
  2. Cosine Decay (2000-600000): Sanft von 6e-4 auf 6e-5
  3. Konstant (600000+): Bleibt bei 6e-5

System-Einstellungen

# System-Einstellungen
device = 'cuda'  # oder 'cpu', 'mps' für Apple Silicon
data_type = 'bfloat16' if torch.cuda.is_bf16_supported() else 'float16'
enable_model_compilation = True  # PyTorch 2.0 Compile

Datentypen:

  • float32: Volle Präzision, aber langsam und speicherhungrig
  • float16: Halbe Präzision, schnell aber numerisch instabil
  • bfloat16: Beste Option - größerer Exponent-Bereich als float16

3.4 Optimizer-Setup mit Weight Decay - Der Lernalgorithmus

Der Optimizer ist das Herzstück des Lernprozesses. Er bestimmt, WIE die Gewichte basierend auf den Gradienten aktualisiert werden.

AdamW - Der Standard für Transformer

# Aus model.py, Zeilen 442-481
def create_optimizer_with_weight_decay(self, weight_decay, learning_rate, betas, device_type):
    """
    Erstellt einen AdamW-Optimizer mit selektivem Weight Decay.
    
    Weight Decay wird nur auf 2D-Parameter (Gewichtsmatrizen) angewendet,
    nicht auf 1D-Parameter (Bias, LayerNorm).
    """
    # Sammle alle trainierbaren Parameter
    param_dict = {param_name: param for param_name, param in self.named_parameters()}
    param_dict = {param_name: param for param_name, param in param_dict.items() 
                  if param.requires_grad}

Die Weight Decay Strategie

Hier kommt ein wichtiger Trick - nicht alle Parameter bekommen Weight Decay:

# Erstelle Optimizer-Gruppen basierend auf Parameter-Dimensionen
decay_params = [param for name, param in param_dict.items() if param.dim() >= 2]
nodecay_params = [param for name, param in param_dict.items() if param.dim() < 2]

optim_groups = [
    {'params': decay_params, 'weight_decay': weight_decay},
    {'params': nodecay_params, 'weight_decay': 0.0}
]

Warum diese Unterscheidung?

  1. MIT Weight Decay (2D+ Parameter = Matrizen):

    • Alle Gewichtsmatrizen in Linear-Schichten
    • Embedding-Matrizen
    • Diese können zu groß werden ? Regularisierung nötig
  2. OHNE Weight Decay (1D Parameter):

    • Bias-Terme (Verschiebungen)
    • LayerNorm Parameter (gamma, beta)
    • Diese sind klein und wichtig für Flexibilität

Beispiel der Aufteilung:

num_decay_params = sum(param.numel() for param in decay_params)
num_nodecay_params = sum(param.numel() for param in nodecay_params)
print(f"Decay Parameter: {len(decay_params)} Tensoren, {num_decay_params:,} Parameter")
print(f"No-Decay Parameter: {len(nodecay_params)} Tensoren, {num_nodecay_params:,} Parameter")

# Typische Ausgabe für GPT-2 Small:
# Decay Parameter: 98 Tensoren, 123,654,144 Parameter
# No-Decay Parameter: 50 Tensoren, 62,976 Parameter

AdamW vs Adam

# Erstelle AdamW-Optimizer
fused_available = 'fused' in inspect.signature(torch.optim.AdamW).parameters
use_fused = fused_available and device_type == 'cuda'
extra_args = dict(fused=True) if use_fused else dict()
optimizer = torch.optim.AdamW(optim_groups, lr=learning_rate, betas=betas, **extra_args)

Was ist der Unterschied?

  • Adam: Weight Decay wird auf Gradienten angewendet
  • AdamW: Weight Decay direkt auf Gewichte (korrekter!)
  • AdamW entkoppelt Weight Decay von der Lernrate

Fused AdamW:

  • Kombiniert mehrere Operationen in einem CUDA-Kernel
  • Bis zu 2× schneller als Standard AdamW
  • Weniger Speicher-Overhead

Wie AdamW funktioniert

AdamW kombiniert drei Ideen:

  1. Momentum (Beta1=0.9):

    m = beta1 * m + (1-beta1) * gradient  # Exponentieller Durchschnitt der Gradienten
  2. RMSprop (Beta2=0.95):

    v = beta2 * v + (1-beta2) * gradient²  # Exponentieller Durchschnitt der quadrierten Gradienten
  3. Adaptive Learning Rate:

    parameter = parameter - lr * m / (sqrt(v) + epsilon) - weight_decay * parameter

Die Kombination macht AdamW so effektiv:

  • Momentum: Überwindet lokale Minima
  • RMSprop: Passt Lernrate pro Parameter an
  • Weight Decay: Verhindert Overfitting

4. Der Trainingsloop im Detail - Das Herz des Lernprozesses

Jetzt kommen wir zum eigentlichen Training - dem sich wiederholenden Zyklus, in dem das Modell lernt. Der Trainingsloop ist das Herzstück des gesamten Prozesses.

4.1 Die Hauptschleife - Struktur und Ablauf

Der Trainingsloop beginnt mit der Initialisierung und läuft dann in einer endlosen Schleife:

# Aus train.py, Zeilen 274-281
input_batch, target_batch = load_data_batch('train')  # Erster Batch
start_time = time.time()
local_iteration_number = 0
raw_model = model  # Kein DDP-Container, direkt das Modell

# Variablen für ETA-Berechnung
iteration_times = []  # Speichere Iterationszeiten für gleitenden Durchschnitt

while True:  # Endlos-Schleife!

Die Hauptschleife läuft endlos, bis wir eine Abbruchbedingung erreichen:

  • max_iterations erreicht (z.B. 600.000 Iterationen)
  • Manueller Abbruch mit Ctrl+C
  • Early Stopping (wenn implementiert)

Learning Rate Anpassung

Als erstes in jeder Iteration wird die Lernrate angepasst:

# Aus train.py, Zeilen 284-286
# Bestimme und setze die Lernrate für diese Iteration
current_learning_rate = get_scheduled_learning_rate(iteration_number) if enable_learning_rate_decay else learning_rate
for param_group in optimizer.param_groups:
    param_group['lr'] = current_learning_rate

Die Funktion get_scheduled_learning_rate implementiert den dreiphasigen Schedule:

# Aus train.py, Zeilen 244-269
def get_scheduled_learning_rate(current_iteration):
    # Phase 1: Linearer Warmup von 0 bis learning_rate
    if current_iteration < warmup_iterations:
        return learning_rate * (current_iteration + 1) / (warmup_iterations + 1)
    
    # Phase 2: Nach Decay-Phase konstant bei Minimal-Lernrate
    if current_iteration > learning_rate_decay_iterations:
        return minimum_learning_rate
    
    # Phase 3: Cosine Decay zwischen Warmup und finaler Phase
    decay_ratio = (current_iteration - warmup_iterations) / 
                 (learning_rate_decay_iterations - warmup_iterations)
    coefficient = 0.5 * (1.0 + math.cos(math.pi * decay_ratio))
    return minimum_learning_rate + coefficient * (learning_rate - minimum_learning_rate)

Warum dieser komplexe Schedule?

  1. Warmup Phase (0-2000 Iterationen):

    • Start bei LR=0, linear auf 6e-4
    • Verhindert zu große Updates am Anfang
    • Modell "gewöhnt sich" an die Daten
  2. Cosine Decay (2000-600.000):

    • Sanfte Reduktion von 6e-4 auf 6e-5
    • Cosine-Kurve ist sanfter als linear
    • Ermöglicht Feinabstimmung
  3. Konstante Phase (600.000+):

    • Bleibt bei minimaler LR
    • Weitere Verfeinerung ohne Instabilität

4.2 Forward Pass - Die Vorhersage

Der Forward Pass ist der Durchlauf der Daten durch das Modell:

# Aus train.py, Zeilen 313-316
for gradient_accumulation_step in range(gradient_accumulation_steps):
    with autocast_context:
        logits, loss = model(input_batch, target_batch)
        loss = loss / gradient_accumulation_steps

Was passiert im model() Aufruf? Schauen wir in model.py:

# Aus model.py, Zeilen 311-346 (GPT.forward)
def forward(self, input_ids, targets=None):
    device = input_ids.device
    batch_size, sequence_length = input_ids.size()
    
    # Positions-IDs erstellen: [0, 1, 2, ..., sequence_length-1]
    position_ids = torch.arange(0, sequence_length, dtype=torch.long, device=device)
    
    # Schritt 1: Embeddings
    token_embeddings = self.transformer.wte(input_ids)  # (B, T, 768)
    position_embeddings = self.transformer.wpe(position_ids)  # (T, 768)
    hidden_states = self.transformer.drop(token_embeddings + position_embeddings)
    
    # Schritt 2: Durch alle Transformer-Blöcke
    for block in self.transformer.blocks:
        hidden_states = block(hidden_states)
    
    # Schritt 3: Finale Layer Norm
    hidden_states = self.transformer.ln_f(hidden_states)

Der Datenfluss im Detail:

  1. Input: Token-IDs, z.B. [42, 318, 1623, ...] für "The cat sat..."
  2. Embeddings: Jedes Token wird zu 768-dimensionalem Vektor
  3. 12 Transformer-Blöcke: Jeder verfeinert die Repräsentation
  4. Output: Versteckte Zustände für jede Position

Die Logits-Berechnung

# Fortsetzung model.py forward
if targets is not None:
    # Training: Berechne Logits für alle Positionen
    logits = self.lm_head(hidden_states)  # (B, T, vocab_size)
    # Logits Shape: (32, 1024, 50257)
else:
    # Inferenz: Nur letzte Position
    logits = self.lm_head(hidden_states[:, [-1], :])

Was sind Logits?

  • Unnormalisierte Vorhersage-Scores
  • Ein Score für jedes mögliche nächste Token
  • Shape: (Batch, Sequenz, Vokabular)
  • Beispiel: logits[0, 5, 1423] = Score für Token 1423 an Position 6

4.3 Loss-Berechnung - Wie falsch lagen wir?

Der Loss misst, wie falsch unsere Vorhersagen waren:

# Aus model.py, Zeilen 336-339
if targets is not None:
    logits = self.lm_head(hidden_states)
    loss = F.cross_entropy(logits.view(-1, logits.size(-1)), 
                          targets.view(-1), ignore_index=-1)

Cross-Entropy Loss im Detail:

  1. Reshape für Loss-Berechnung:

    logits.view(-1, logits.size(-1))  # Von (B, T, V) zu (B*T, V)
    targets.view(-1)                   # Von (B, T) zu (B*T,)
  2. Was berechnet Cross-Entropy?

    # Vereinfacht:
    probabilities = softmax(logits)  # Konvertiere zu Wahrscheinlichkeiten
    loss = -log(probabilities[correct_token])  # Negative Log-Wahrscheinlichkeit
  3. Beispiel:

    • Modell sagt: "cat" (70%), "dog" (20%), "bird" (10%)

    • Richtiges Token: "cat"

    • Loss = -log(0.7) ˜ 0.36 (niedriger Loss, gute Vorhersage!)

    • Modell sagt: "cat" (5%), "dog" (90%), "bird" (5%)

    • Richtiges Token: "cat"

    • Loss = -log(0.05) ˜ 3.0 (hoher Loss, schlechte Vorhersage!)

Loss-Skalierung für Gradient Accumulation

loss = loss / gradient_accumulation_steps  # Wichtig!

Warum teilen?

  • Wir akkumulieren Gradienten über 40 Mini-Batches
  • Ohne Division würden sich die Gradienten aufsummieren
  • Mit Division bekommen wir den Durchschnitt
  • Entspricht einem großen Batch

4.4 Backward Pass - Gradientenberechnung

Der Backward Pass berechnet die Gradienten - wie müssen wir die Gewichte ändern?

# Aus train.py, Zeile 320
gradient_scaler.scale(loss).backward()

Was passiert bei .backward()?

  1. Kettenregel wird angewendet:

    • Start: Gradient des Loss = 1
    • Rückwärts durch jede Operation
    • Berechnet ?Loss/?Parameter für JEDEN Parameter
  2. Automatic Differentiation:

    • PyTorch speichert alle Operationen
    • Baut einen Berechnungsgraph
    • Traversiert rückwärts durch den Graph
  3. Gradient Accumulation:

    # Gradienten werden ADDIERT zu existierenden!
    parameter.grad += new_gradient

Beispiel für einen Parameter:

  • Gewicht W hat aktuellen Wert 0.5
  • Loss = 2.3
  • Gradient ?Loss/?W = -0.1
  • Bedeutung: "Wenn W größer wäre, wäre der Loss kleiner"

Mixed Precision und Gradient Scaling

# Der gradient_scaler ist wichtig für float16 Training
gradient_scaler = torch.cuda.amp.GradScaler(enabled=(data_type == 'float16'))
gradient_scaler.scale(loss).backward()

Warum Gradient Scaling?

  • float16 hat kleinen Wertebereich (±65.504)
  • Kleine Gradienten können zu 0 werden (Underflow)
  • Lösung: Skaliere Loss hoch ? Gradienten werden größer
  • Später: Skaliere Gradienten wieder runter

4.5 Gradient Accumulation - Große Batches simulieren

Gradient Accumulation ist ein cleverer Trick für große effektive Batch-Größen:

# Aus train.py, Zeilen 313-320
for gradient_accumulation_step in range(gradient_accumulation_steps):  # 40 mal!
    with autocast_context:
        logits, loss = model(input_batch, target_batch)
        loss = loss / gradient_accumulation_steps  # Division!
    
    # Lade NÄCHSTEN Batch während GPU noch rechnet
    input_batch, target_batch = load_data_batch('train')
    
    # Backward Pass (Gradienten werden ADDIERT)
    gradient_scaler.scale(loss).backward()

Der Ablauf im Detail:

Iteration 1:

  • Batch 1: 12 Sequenzen
  • Forward ? Loss ? Backward
  • Gradienten werden berechnet und gespeichert

Iteration 2-40:

  • Batch 2-40: Je 12 Sequenzen
  • Forward ? Loss ? Backward
  • Gradienten werden zu existierenden ADDIERT

Nach 40 Iterationen:

  • Effektiv: 480 Sequenzen verarbeitet
  • Gradienten repräsentieren den Durchschnitt

Warum nicht einfach große Batches?

  • GPU-Speicher-Limit!
  • 12 Sequenzen: ~4GB VRAM
  • 480 Sequenzen: ~160GB VRAM (unmöglich!)
  • Mit Accumulation: Immer nur 4GB gleichzeitig

4.6 Gradient Clipping - Stabilität sichern

Gradient Clipping verhindert explodierende Gradienten:

# Aus train.py, Zeilen 322-324
if gradient_clipping_value != 0.0:
    gradient_scaler.unscale_(optimizer)  # Skalierung rückgängig machen
    torch.nn.utils.clip_grad_norm_(model.parameters(), gradient_clipping_value)

Was macht clip_grad_norm_?

  1. Berechne die Norm aller Gradienten:

    total_norm = sqrt(sum(p.grad.norm()**2 for p in parameters))
  2. Wenn zu groß, skaliere runter:

    if total_norm > max_norm:
        for p in parameters:
            p.grad *= max_norm / total_norm

Beispiel:

  • Gradient-Norm = 10.0
  • Max-Norm = 1.0
  • Alle Gradienten werden durch 10 geteilt
  • Richtung bleibt gleich, nur Größe ändert sich

4.7 Optimizer-Update - Die Gewichte anpassen

Endlich! Nach 40 Mini-Batches führen wir das Update durch:

# Aus train.py, Zeilen 325-329
gradient_scaler.step(optimizer)  # Führe Update durch
gradient_scaler.update()         # Update Skalierungsfaktor
optimizer.zero_grad(set_to_none=True)  # Lösche Gradienten

Was passiert bei optimizer.step()?

Der AdamW-Optimizer macht für jeden Parameter:

# Vereinfachte AdamW Update-Regel
m = beta1 * m + (1-beta1) * grad          # Momentum
v = beta2 * v + (1-beta2) * grad**2       # Zweites Moment
m_hat = m / (1 - beta1**t)                # Bias-Korrektur
v_hat = v / (1 - beta2**t)                # Bias-Korrektur

# Parameter-Update
param = param - lr * m_hat / (sqrt(v_hat) + eps) - weight_decay * lr * param

Konkretes Beispiel:

  • Parameter: 0.5
  • Gradient: -0.1 (Loss würde sinken wenn Parameter größer)
  • Learning Rate: 0.0006
  • Update: 0.5 - 0.0006 × (-0.1) = 0.50006
  • Parameter wird minimal größer!

Zero Grad - Aufräumen für nächste Iteration

optimizer.zero_grad(set_to_none=True)

Warum set_to_none=True?

  • Standard: grad = torch.zeros_like(param) (Speicher allokieren)
  • Mit set_to_none: grad = None (Speicher freigeben)
  • Spart Speicher und ist schneller!

4.8 Monitoring und Logging

Nach jedem Update wird der Fortschritt geloggt:

# Aus train.py, Zeilen 332-363
# Zeit-Messung
end_time = time.time()
iteration_duration = end_time - start_time
start_time = end_time

# ETA-Berechnung
iteration_times.append(iteration_duration)
if len(iteration_times) > 100:  # Nur letzte 100 behalten
    iteration_times.pop(0)

if iteration_number % logging_interval == 0:  # Alle 10 Iterationen
    # Fortschritt berechnen
    progress_percentage = (iteration_number / max_iterations) * 100
    
    # ETA berechnen
    if len(iteration_times) > 0:
        average_iteration_time = sum(iteration_times) / len(iteration_times)
        remaining_iterations = max_iterations - iteration_number
        estimated_time_seconds = remaining_iterations * average_iteration_time
        
        # Formatiere als HH:MM:SS
        estimated_hours = int(estimated_time_seconds // 3600)
        estimated_minutes = int((estimated_time_seconds % 3600) // 60)
        estimated_seconds = int(estimated_time_seconds % 60)
        estimated_time_string = f"{estimated_hours:02d}:{estimated_minutes:02d}:{estimated_seconds:02d}"

Die Log-Nachricht:

Iteration 1000: Loss 4.2341, Zeit 523.45ms | Fortschritt 0.2% | ETA 87:23:15

Bedeutung:

  • Iteration 1000 von 600.000
  • Loss ist 4.2341 (wird hoffentlich kleiner!)
  • Eine Iteration dauerte 523ms
  • 0.2% fertig
  • Noch ~87 Stunden Training!

5. Optimierungstechniken - Schneller und besser trainieren

Moderne Transformer-Trainings nutzen viele Optimierungstechniken für Geschwindigkeit und Qualität. Schauen wir uns die wichtigsten im Detail an.

5.1 Mixed Precision Training - Halbe Präzision, doppelte Geschwindigkeit

Mixed Precision Training ist eine der wichtigsten Optimierungen für modernes Deep Learning:

# Aus train.py, Zeilen 67-68, 99-100
data_type = 'bfloat16' if torch.cuda.is_bf16_supported() else 'float16'
pytorch_dtype = {'float32': torch.float32, 'bfloat16': torch.bfloat16, 
                'float16': torch.float16}[data_type]
autocast_context = torch.amp.autocast(device_type=device_type, dtype=pytorch_dtype)

Die drei Datentypen im Vergleich

float32 (Standard):

  • 32 Bit pro Zahl (1 Sign, 8 Exponent, 23 Mantisse)
  • Wertebereich: ±1.2e-38 bis ±3.4e38
  • Sehr präzise, aber langsam und speicherhungrig

float16 (Half Precision):

  • 16 Bit pro Zahl (1 Sign, 5 Exponent, 10 Mantisse)
  • Wertebereich: ±6.1e-5 bis ±65,504
  • Problem: Kleiner Wertebereich ? Overflow/Underflow

bfloat16 (Brain Float):

  • 16 Bit pro Zahl (1 Sign, 8 Exponent, 7 Mantisse)
  • Wertebereich: ±1.2e-38 bis ±3.4e38 (wie float32!)
  • Weniger präzise Mantisse, aber größerer Wertebereich
# Vergleich der Formate:
# float32:  SEEEEEEE EMMMMMMM MMMMMMMM MMMMMMMM
# float16:  SEEEE EMMM MMMM MM
# bfloat16: SEEEEEEE EMMMMMMM

Automatic Mixed Precision (AMP)

# Aus train.py, Zeilen 314-316
with autocast_context:  # Automatische Präzisions-Wahl
    logits, loss = model(input_batch, target_batch)
    loss = loss / gradient_accumulation_steps

Was macht autocast?

  • Matrix-Multiplikationen: float16/bfloat16 (schnell!)
  • Loss-Berechnung: float32 (präzise!)
  • Normalisierungen: float32 (numerisch stabil)
  • Automatische Konversion zwischen Typen

Gradient Scaling für float16

# Aus train.py, Zeile 203
gradient_scaler = torch.cuda.amp.GradScaler(enabled=(data_type == 'float16'))

# Bei Backward Pass
gradient_scaler.scale(loss).backward()

# Bei Optimizer Update
gradient_scaler.unscale_(optimizer)
gradient_scaler.step(optimizer)
gradient_scaler.update()

Der Gradient Scaling Workflow:

  1. Scale Up (vor Backward):

    scaled_loss = loss * scale_factor  # z.B. loss * 65536
    • Verhindert Gradient Underflow
  2. Backward Pass:

    • Gradienten werden mit skaliertem Loss berechnet
    • Sind auch skaliert!
  3. Unscale (vor Optimizer):

    gradient = gradient / scale_factor
    • Gradienten wieder in normalen Bereich
  4. Dynamic Scaling:

    • Wenn keine NaNs/Infs: scale_factor *= 2 (alle 2000 Steps)
    • Wenn NaNs/Infs: scale_factor *= 0.5, Skip Update

Geschwindigkeitsgewinn:

  • Tensor Cores auf NVIDIA GPUs: 2-3× schneller
  • Halber Speicherbedarf: Größere Batches möglich
  • Typisch: 50-70% schnelleres Training!

5.2 Flash Attention - Die Turbo-Attention

Flash Attention ist eine revolutionäre Optimierung des Attention-Mechanismus:

# Aus model.py, Zeilen 85-91
self.flash = hasattr(torch.nn.functional, 'scaled_dot_product_attention')
if not self.flash:
    print("WARNUNG: Verwende langsame Attention. Flash Attention benötigt PyTorch >= 2.0")

# Zeilen 114-116
if self.flash:
    attention_output = torch.nn.functional.scaled_dot_product_attention(
        queries, keys, values, attn_mask=None, 
        dropout_p=self.dropout if self.training else 0, is_causal=True)

Was macht Flash Attention anders?

Standard Attention (vereinfacht):

# Schritt 1: Berechne Attention-Matrix (RIESIG!)
scores = queries @ keys.T  # (1024, 1024) Matrix!

# Schritt 2: Softmax
attention = softmax(scores)

# Schritt 3: Gewichtete Summe
output = attention @ values

Problem: Die (1024 × 1024) Attention-Matrix muss komplett im Speicher gehalten werden!

Flash Attention:

  • Berechnet Attention blockweise
  • Hält nie die komplette Attention-Matrix im Speicher
  • Nutzt schnellen SRAM statt langsamen HBM
  • Fusioniert Operationen in einem Kernel

Die Speicher-Hierarchie verstehen

CPU ? RAM ? GPU HBM ? GPU SRAM ? GPU Register
     ?        ?          ?           ?
   ~100GB   ~40GB      ~20MB      ~256KB
   100GB/s  1.5TB/s    19TB/s     Fast!

Flash Attention nutzt den schnellen SRAM optimal:

  • Lädt kleine Blöcke in SRAM
  • Berechnet Attention für diesen Block
  • Schreibt Ergebnis zurück
  • Nächster Block

Performance-Gewinn:

  • Speicher: O(n) statt O(n²)
  • Geschwindigkeit: 2-4× schneller
  • Längere Sequenzen möglich (bis 16k+)

5.3 Model Compilation - JIT für Geschwindigkeit

PyTorch 2.0 führte torch.compile ein:

# Aus train.py, Zeilen 212-215
if enable_model_compilation:
    print("Kompiliere das Modell... (dauert ~1 Minute)")
    unoptimized_model = model
    model = torch.compile(model)

Was macht torch.compile?

Ohne Compilation (Eager Mode):

  • Jede Operation einzeln ausgeführt
  • Python Overhead bei jeder Op
  • Viele kleine CUDA Kernel Launches

Mit Compilation:

  1. Graph Capture: Analysiert den Berechnungsgraph
  2. Optimization:
    • Fusioniert Operationen
    • Eliminiert redundante Berechnungen
    • Optimiert Memory Access Patterns
  3. Code Generation: Generiert optimierten CUDA/CPU Code

Beispiel einer Fusion:

# Vorher: 3 separate Kernel
x = self.linear(x)      # Kernel 1
x = self.gelu(x)        # Kernel 2  
x = self.dropout(x)     # Kernel 3

# Nach Compilation: 1 fusionierter Kernel!
x = fused_linear_gelu_dropout(x)

Performance-Gewinn:

  • 20-50% schneller
  • Weniger Kernel Launches
  • Bessere Memory Locality
  • Automatische Operator Fusion

5.4 Gradient Checkpointing - Speicher vs Zeit Trade-off

Gradient Checkpointing (nicht in unserem Code, aber wichtig zu verstehen):

# Pseudo-Code für Gradient Checkpointing
class CheckpointedBlock(nn.Module):
    def forward(self, x):
        if self.training:
            # Speichere nur Input, berechne Output
            return checkpoint(self._forward, x)
        else:
            return self._forward(x)
    
    def _forward(self, x):
        x = self.attention(x)
        x = self.mlp(x)
        return x

Das Problem:

  • Backward Pass braucht alle Zwischenergebnisse
  • Bei 96 Layers: Gigabytes an Aktivierungen!

Die Lösung:

  • Speichere nur Checkpoints (z.B. jeden 4. Layer)
  • Bei Backward: Recompute von letztem Checkpoint
  • Trade-off: 33% langsamer, 75% weniger Speicher

5.5 Optimierte Datenlade-Pipeline

Unser Code nutzt mehrere Optimierungen beim Datenladen:

# Aus train.py, Zeilen 318-319
# Lade nächsten Batch WÄHREND GPU rechnet!
input_batch, target_batch = load_data_batch('train')

Pipeline-Parallelismus:

GPU:  |--Forward--|--Backward--|--Update--|--Forward--|
CPU:              |--Load-----|          |--Load-----|

Die CPU lädt den nächsten Batch während die GPU noch rechnet!

Pinned Memory für schnellen Transfer

# Aus train.py, Zeilen 134-136
if device_type == 'cuda':
    input_sequences = input_sequences.pin_memory().to(device, non_blocking=True)
    target_sequences = target_sequences.pin_memory().to(device, non_blocking=True)

Pinned vs Pageable Memory:

Pageable (Standard):

  • CPU ? Staging Buffer ? GPU
  • Zwei Kopien nötig
  • Kann vom OS verschoben werden

Pinned:

  • CPU ? GPU direkt (DMA Transfer)
  • Eine Kopie
  • Fest im RAM
  • non_blocking=True: Asynchroner Transfer!

5.6 Fused Optimizer

# Aus model.py, Zeilen 475-478
fused_available = 'fused' in inspect.signature(torch.optim.AdamW).parameters
use_fused = fused_available and device_type == 'cuda'
optimizer = torch.optim.AdamW(optim_groups, lr=learning_rate, betas=betas, 
                              fused=use_fused)

Standard AdamW:

  • Jeder Parameter: Separater Kernel Launch
  • Bei 124M Parametern: Tausende Kernel!

Fused AdamW:

  • Ein großer Kernel für alle Parameter
  • Weniger Overhead
  • Bessere Memory Coalescing
  • 20-30% schnellerer Optimizer Step

6. Evaluierung und Checkpointing - Fortschritt überwachen und sichern

6.1 Evaluierung während des Trainings

Regelmäßige Evaluierung ist essentiell um den Trainingsfortschritt zu überwachen:

# Aus train.py, Zeilen 289-307
if iteration_number % evaluation_interval == 0:  # Alle 1000 Iterationen
    losses = evaluate_loss_on_splits()
    log_message = f"Schritt {iteration_number}: Train-Loss {losses['train']:.4f}, Val-Loss {losses['val']:.4f}"
    print(log_message)

Die Evaluierungsfunktion im Detail

# Aus train.py, Zeilen 220-242
@torch.no_grad()  # WICHTIG: Keine Gradienten während Evaluierung!
def evaluate_loss_on_splits():
    """
    Evaluiert den Modell-Loss auf Training- und Validierungsdaten.
    """
    results = {}
    model.eval()  # Eval-Modus: Dropout aus, etc.
    
    for split in ['train', 'val']:
        losses = torch.zeros(evaluation_iterations)  # 200 Iterationen
        
        for evaluation_step in range(evaluation_iterations):
            input_batch, target_batch = load_data_batch(split)
            
            with autocast_context:
                logits, loss = model(input_batch, target_batch)
            
            losses[evaluation_step] = loss.item()  # .item() holt Skalar von GPU
        
        results[split] = losses.mean()
    
    model.train()  # Zurück in Train-Modus
    return results

Wichtige Details:

  1. @torch.no_grad():

    • Deaktiviert Gradient-Berechnung
    • Spart Speicher (keine Zwischenergebnisse)
    • Schneller (kein Berechnungsgraph)
  2. model.eval() vs model.train():

    # Train-Modus:
    dropout_rate = 0.1  # Dropout aktiv
    
    # Eval-Modus:
    dropout_rate = 0.0  # Dropout deaktiviert
  3. Mehrere Batches evaluieren:

    • 200 Batches für stabilere Schätzung
    • Mittelwert über alle Batches
    • Reduziert Varianz der Schätzung

Overfitting erkennen

Train-Loss: 2.34  Val-Loss: 2.41  # Gut! Ähnliche Werte
Train-Loss: 1.23  Val-Loss: 3.45  # Overfitting! Val viel höher

Warnsignale für Overfitting:

  • Val-Loss steigt während Train-Loss sinkt
  • Große Lücke zwischen Train und Val
  • Val-Loss oszilliert stark

6.2 Checkpoint-System

Checkpoints speichern den kompletten Trainingszustand:

# Aus train.py, Zeilen 295-307
if losses['val'] < best_validation_loss or always_save_checkpoint:
    best_validation_loss = losses['val']
    
    if iteration_number > 0:
        checkpoint = {
            'model': raw_model.state_dict(),
            'optimizer': optimizer.state_dict(),
            'model_args': model_arguments,
            'iter_num': iteration_number,
            'best_val_loss': best_validation_loss,
            'config': config,
        }
        
        print(f"Speichere Checkpoint in {output_directory}")
        torch.save(checkpoint, os.path.join(output_directory, 'my_own_llm.pt'))

Was wird gespeichert?

  1. model.state_dict():

    • Alle Modell-Parameter
    • Gewichte und Bias aller Layer
    • Embeddings
    • ~500MB für GPT-2 Small
  2. optimizer.state_dict():

    • Momentum-Buffers (m)
    • Zweite Momente (v)
    • Step Count
    • ~1GB (doppelt so groß wie Modell!)
  3. Metadaten:

    • Iteration Nummer
    • Beste Validation Loss
    • Modell-Konfiguration
    • Training-Konfiguration

Checkpoint laden und fortsetzen

# Aus train.py, Zeilen 165-187 (Resume Mode)
elif initialization_mode == 'resume':
    checkpoint_path = os.path.join(output_directory, 'my_own_llm.pt')
    checkpoint = torch.load(checkpoint_path, map_location=device)
    
    # Modell wiederherstellen
    model.load_state_dict(checkpoint['model'])
    
    # Optimizer wiederherstellen
    optimizer.load_state_dict(checkpoint['optimizer'])
    
    # Training-Status wiederherstellen
    iteration_number = checkpoint['iter_num']
    best_validation_loss = checkpoint['best_val_loss']

Warum Optimizer State speichern?

  • Momentum-Informationen gehen sonst verloren
  • Training würde "von vorne" anfangen
  • Besonders wichtig nach Warmup-Phase

6.3 Logging-System

Detailliertes Logging für Debugging und Monitoring:

# Aus train.py, Zeilen 81-87
# Log-Datei einrichten
import datetime
log_filename = os.path.join(output_directory, 
    f'training_log_{datetime.datetime.now().strftime("%Y%m%d_%H%M%S")}.txt')
log_file = open(log_filename, 'w', buffering=1)  # Zeilen-Pufferung!

buffering=1 - Zeilen-Pufferung:

  • Jede Zeile wird sofort geschrieben
  • Auch bei Crash ist Log aktuell
  • Wichtig für lange Trainings!

Log-Format

# Aus train.py, Zeilen 360-362
log_message = (f"Iteration {iteration_number}: Loss {total_loss_value:.4f}, "
              f"Zeit {iteration_duration*1000:.2f}ms | "
              f"Fortschritt {progress_percentage:.1f}% | ETA {estimated_time_string}")

Beispiel-Log:

Training gestartet um 2024-01-15 10:23:45
Konfiguration: {'batch_size': 12, 'learning_rate': 0.0006, ...}

Iteration 0: Train-Loss 11.2341, Val-Loss 11.2156
Iteration 10: Loss 10.8932, Zeit 523.45ms | Fortschritt 0.0% | ETA 87:23:15
Iteration 20: Loss 10.4521, Zeit 521.23ms | Fortschritt 0.0% | ETA 87:18:42
...
Iteration 1000: Train-Loss 4.5632, Val-Loss 4.6234
Speichere Checkpoint in out/

6.4 Early Stopping (Optional)

Obwohl nicht im Code implementiert, ist Early Stopping wichtig:

# Pseudo-Code für Early Stopping
patience = 10
patience_counter = 0
best_val_loss = float('inf')

for iteration in range(max_iterations):
    # ... Training ...
    
    if iteration % eval_interval == 0:
        val_loss = evaluate()
        
        if val_loss < best_val_loss:
            best_val_loss = val_loss
            patience_counter = 0
            save_checkpoint()
        else:
            patience_counter += 1
            
        if patience_counter >= patience:
            print("Early Stopping!")
            break

Wann Early Stopping sinnvoll ist:

  • Finetuning auf kleinen Datensätzen
  • Wenn Overfitting wahrscheinlich
  • Bei begrenzter Rechenzeit

Wann NICHT:

  • Pretraining auf großen Datensätzen
  • Wenn mehr Daten als Parameter
  • Bei Curriculum Learning

7. Praktische Tipps und Troubleshooting

7.1 Häufige Probleme und Lösungen

Problem: Loss explodiert (NaN/Inf)

Symptome:

Iteration 100: Loss nan

Ursachen und Lösungen:

  1. Zu hohe Learning Rate:

    # Reduziere Learning Rate
    learning_rate = 6e-5  # statt 6e-4
  2. Gradient Explosion:

    # Aktiviere/Reduziere Gradient Clipping
    gradient_clipping_value = 0.5  # statt 1.0
  3. Numerische Instabilität:

    # Wechsel zu float32
    data_type = 'float32'  # statt float16

Problem: Training zu langsam

Lösungen:

  1. Aktiviere alle Optimierungen:

    enable_model_compilation = True
    data_type = 'bfloat16'
    # Flash Attention automatisch wenn PyTorch >= 2.0
  2. Gradient Accumulation anpassen:

    # Weniger Accumulation Steps, größere Batches
    gradient_accumulation_steps = 20  # statt 40
    batch_size = 24  # wenn Speicher reicht
  3. Mixed Precision Debugging:

    # Check ob AMP richtig funktioniert
    print(f"Autocast enabled: {autocast_context}")
    print(f"Scaler enabled: {gradient_scaler.is_enabled()}")

Problem: Out of Memory (OOM)

Sofortmaßnahmen:

# 1. Reduziere Batch-Größe
batch_size = 4  # statt 12

# 2. Erhöhe Gradient Accumulation
gradient_accumulation_steps = 120  # statt 40

# 3. Reduziere Sequenzlänge
block_size = 512  # statt 1024

# 4. Aktiviere Memory-Optimierungen
torch.cuda.empty_cache()  # Cache leeren

7.2 Performance-Monitoring

GPU-Auslastung prüfen

# Terminal Command
nvidia-smi -l 1  # Update jede Sekunde

# Optimal:
# GPU-Util: >90%
# Memory: Fast voll aber nicht 100%

Profiling für Bottlenecks

# Profiler einbauen
from torch.profiler import profile, ProfilerActivity

with profile(activities=[ProfilerActivity.CUDA]) as prof:
    for i in range(10):
        logits, loss = model(input_batch, target_batch)
        loss.backward()

print(prof.key_averages().table(sort_by="cuda_time_total"))

7.3 Hyperparameter-Tuning Tipps

Learning Rate Finding

# Learning Rate Range Test
lr_min, lr_max = 1e-6, 1e-1
iterations = 1000

for i in range(iterations):
    # Exponentiell erhöhen
    lr = lr_min * (lr_max/lr_min) ** (i/iterations)
    
    # Train one batch
    loss = train_step(lr)
    
    # Plot loss vs lr
    losses.append(loss)
    lrs.append(lr)

# Beste LR: Kurz bevor Loss explodiert

Batch Size vs Learning Rate Scaling

# Linear Scaling Rule
# Wenn Batch-Größe verdoppelt ? LR verdoppeln
base_batch = 32
base_lr = 3e-4

new_batch = 128
new_lr = base_lr * (new_batch / base_batch)  # 1.2e-3

# Aber: Warmup wichtiger bei großen Batches!
warmup_iterations = 4000  # statt 2000

7.4 Best Practices

1. Immer mit kleinem Test beginnen

# Debug-Konfiguration
if DEBUG:
    max_iterations = 100
    evaluation_interval = 10
    logging_interval = 1
    batch_size = 2
    gradient_accumulation_steps = 2

2. Gradient Checking

# Gradienten-Statistiken loggen
for name, param in model.named_parameters():
    if param.grad is not None:
        grad_norm = param.grad.norm().item()
        print(f"{name}: {grad_norm:.6f}")

3. Deterministisches Training

# Für Reproduzierbarkeit
torch.manual_seed(42)
torch.cuda.manual_seed(42)
torch.backends.cudnn.deterministic = True
torch.backends.cudnn.benchmark = False

7.5 Skalierung auf Multi-GPU

Obwohl unser Code Single-GPU ist, hier die Konzepte für Multi-GPU:

Data Parallel (DP):

model = nn.DataParallel(model)
# Batch wird auf GPUs aufgeteilt

Distributed Data Parallel (DDP):

model = nn.parallel.DistributedDataParallel(model)
# Effizienter, jede GPU hat eigene Kopie

Model Parallel:

# Layer auf verschiedene GPUs
model.layer1.to('cuda:0')
model.layer2.to('cuda:1')

Zusammenfassung

Das Training eines Transformer-Modells ist ein komplexes Zusammenspiel vieler Komponenten:

  1. Architektur: Embeddings ? Attention ? MLP ? Layer Norm ? Residuals
  2. Datenfluss: Memory-Mapped Files ? Batching ? GPU Transfer
  3. Training Loop: Forward ? Loss ? Backward ? Gradient Accumulation ? Update
  4. Optimierungen: Mixed Precision ? Flash Attention ? Compilation
  5. Monitoring: Evaluierung ? Checkpointing ? Logging

Der Schlüssel zum erfolgreichen Training liegt im Verständnis dieser Komponenten und ihrer Interaktionen. Mit diesem Wissen kannst du:

  • Probleme diagnostizieren und lösen
  • Performance optimieren
  • Eigene Modelle trainieren
  • State-of-the-Art Ergebnisse erzielen

Das Training großer Sprachmodelle mag komplex erscheinen, aber mit dem richtigen Verständnis der Grundlagen wird es zu einem mächtigen Werkzeug in deinem KI-Arsenal!