Ein GPT von Grund auf trainiert: 350 Millionen Parameter auf einer RTX 4090

Eigenes ModellTransformer-Training · 2025
PythonPyTorchnanoGPTtiktokenHugging Face DatasetsNumPyCUDA

Projekt-Übersicht

In diesem Projekt habe ich ein Sprachmodell nach dem Vorbild von GPT-2 vollständig selbst trainiert – ohne vortrainierte Gewichte, mit zufällig initialisierten Parametern als Ausgangspunkt. Das Modell hat die Größe von GPT-2 Medium: 24 Transformer-Schichten, 16 Attention-Heads, eine Embedding-Dimension von 1.024 und damit rund 350 Millionen Parameter. Trainiert habe ich es auf dem Datensatz WikiText-103 über 50.000 Iterationen auf einer einzelnen NVIDIA RTX 4090 mit 24 GB Grafikspeicher.

Die Codebasis ist nanoGPT von Andrej Karpathy. nanoGPT ist eine kompakte, gut lesbare Implementierung von GPT-2-Training in PyTorch und für genau diesen Zweck gedacht. Ich habe den Code nicht neu erfunden, sondern übernommen und für meine Zwecke umgebaut: Der Code ist durchgehend auf Deutsch kommentiert, die Variablen tragen sprechende Namen, das Multi-GPU-Training habe ich zugunsten eines schlanken Single-GPU-Pfads entfernt, und die Datenvorbereitung ist um eine Datensatzauswahl und einen Statistikbericht erweitert. Drei Shell-Skripte führen durch den Ablauf: Daten vorbereiten, trainieren, Text generieren.

Aus dem Projekt ist die dreiteilige Blogserie zur Transformer-Architektur entstanden: Teil 1 erklärt die Theorie, Teil 2 den praktischen Ablauf des Trainings, Teil 3 den Code im Detail.

Ausgangslage

Wer mit Agentensystemen arbeitet, arbeitet mit Sprachmodellen als Bausteinen. Man ruft sie über APIs auf, wählt Modellgrößen, Quantisierungen und Kontextlängen und interpretiert ihr Verhalten. Vieles davon bleibt abstrakt, solange man nie selbst gesehen hat, wie ein solches Modell entsteht: warum der Loss am Anfang bei etwa 10 liegt, wofür Warmup und Gradient Clipping da sind, warum ein Modell mit 350 Millionen Parametern auf einer Consumer-GPU nur mit Gradient Accumulation und bfloat16 trainierbar ist.

Ziel war deshalb nicht, ein konkurrenzfähiges Modell zu bauen. Ziel war, den vollständigen Weg einmal selbst zu gehen – vom Rohtext über Tokenisierung und Training bis zum Sampling – und jede Zeile des Codes so weit zu verstehen, dass ich sie erklären kann. Die deutschen Kommentare im Code sind das Ergebnis dieses Durcharbeitens.

Daten

Als Trainingsdatensatz dient WikiText-103, eine Sammlung von Wikipedia-Artikeln, die über die Hugging-Face-Bibliothek datasets geladen wird. Verwendet wird die Konfiguration wikitext-103-v1, also die bereits vorverarbeitete Fassung, in der seltene Wörter durch ein <unk>-Token ersetzt und Bindestriche als @-@ markiert sind. Diese Artefakte tauchen später im generierten Text wieder auf – das Modell lernt, was es sieht.

Laut dem automatisch erzeugten Datensatzbericht umfasst der Trainingssplit 1.801.350 Einträge mit geschätzt rund 100 Millionen Wörtern, der Validierungssplit 3.760 Einträge mit rund 209.000 Wörtern. Ich verwende die offizielle Aufteilung des Datensatzes. Ein Eintrag entspricht dabei einer Textzeile des Datensatzes, nicht einem ganzen Artikel; in einer Stichprobe von 1.000 Einträgen lag der Durchschnitt bei 83 Wörtern und 97 Tokens.

Die Vorbereitung übernimmt prepare.py. Das Skript bietet fünf Datensätze zur Auswahl, von WikiText-2 für schnelle Tests bis WikiText-103 für das eigentliche Training. Es tokenisiert alle Texte parallel mit dem GPT-2-BPE-Tokenizer aus tiktoken (Vokabular von 50.257 Tokens), hängt an jeden Eintrag das End-of-Text-Token an und schreibt alle Token-IDs hintereinander in zwei Binärdateien, train.bin und val.bin. Weil die größte Token-ID unter 65.536 liegt, reicht uint16 als Datentyp, was den Speicherbedarf halbiert.

Zusätzlich erzeugt das Skript den Bericht data/info.md mit Statistiken zu Textlängen, Token-pro-Wort-Verhältnis (1,16) und zufälligen Beispieltexten. Rechnet man die Wortzahl mit diesem Verhältnis hoch, ergeben sich rund 116 Millionen Trainings-Tokens.

Beim Training liest train.py die Binärdateien als Memory-Mapped Arrays und zieht für jeden Batch zufällige Ausschnitte von 1.024 Tokens. Das Ziel ist jeweils dieselbe Sequenz um eine Position verschoben – das Modell lernt, an jeder Stelle das nächste Token vorherzusagen.

data = np.memmap(os.path.join(data_directory, 'train.bin'),
                 dtype=np.uint16, mode='r')
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])

Modell und Training

flowchart LR
    A["WikiText-103 über Hugging Face"] --> B["prepare.py: GPT-2-BPE-Tokenisierung"]
    B --> C["train.bin und val.bin als uint16"]
    C --> D["train.py: GPT-2 Medium, 50.000 Iterationen"]
    D --> E["Checkpoint my_own_llm.pt"]
    E --> F["sample.py: Textgenerierung"]

Architekturwahl und Begründung

Die Architektur ist ein reiner Decoder-Transformer im Stil von GPT-2. Tokens und Positionen werden jeweils in einen 1.024-dimensionalen Vektor eingebettet und addiert. Darauf folgen 24 identische Blöcke aus kausaler Multi-Head-Self-Attention und einem Feed-Forward-Netz mit GELU-Aktivierung, jeweils mit Layer-Normalisierung davor und einer Residualverbindung darum (Pre-Norm). Am Ende projiziert ein linearer Layer zurück auf das Vokabular. Die Gewichte dieses Layers teilt sich das Modell mit der Token-Embedding-Matrix (Weight Tying), was Parameter spart.

Für die Attention nutzt der Code scaled_dot_product_attention aus PyTorch 2, das auf geeigneter Hardware Flash Attention verwendet. Bias-Terme in Linear- und Normalisierungsschichten sind abgeschaltet, Dropout steht auf null, weil beim Pretraining auf einem großen Datensatz kaum Überanpassung an einzelne Beispiele zu erwarten ist. Das Vokabular wird von 50.257 auf 50.304 aufgerundet, ein Vielfaches von 64, das die Matrixoperationen auf der GPU effizienter macht.

Die Größe GPT-2 Medium habe ich gewählt, weil sie auf einer RTX 4090 mit 24 GB gerade noch sinnvoll trainierbar ist. Kleinere Modelle hätten schneller trainiert, aber weniger davon gezeigt, was beim Training großer Modelle Probleme macht: Speicherbudget, Batch-Größe, numerische Stabilität.

Hyperparameter

Die Konfiguration steht in start_train.sh und orientiert sich an den Werten, die nanoGPT für GPT-2 verwendet, angepasst an eine einzelne GPU:

--batch_size=8 --gradient_accumulation_steps=16 \
--block_size=1024 \
--n_layer=24 --n_head=16 --n_embd=1024 \
--max_iters=50000 \
--learning_rate=6e-4 --warmup_iters=2000 \
--lr_decay_iters=50000 --min_lr=6e-5 \
--dtype=bfloat16 --compile=True \
--dropout=0.0 --weight_decay=0.1 --grad_clip=1.0

Acht Sequenzen passen gleichzeitig in den Grafikspeicher. Sechzehn Akkumulationsschritte ergeben eine effektive Batch-Größe von 128 Sequenzen, also 131.072 Tokens pro Optimierungsschritt. Über 50.000 Iterationen verarbeitet das Modell damit rechnerisch rund 6,5 Milliarden Tokens.

Als Optimizer dient AdamW mit den Beta-Werten 0,9 und 0,95. Weight Decay von 0,1 wirkt nur auf die zweidimensionalen Gewichtsmatrizen, nicht auf Biases und Normalisierungsparameter. Auf CUDA verwendet der Code die fusionierte AdamW-Variante. Die Gradienten werden auf eine Norm von 1,0 begrenzt.

Trainingsablauf

Die Lernrate folgt einem dreistufigen Plan: In den ersten 2.000 Iterationen steigt sie linear auf 6e-4, danach fällt sie entlang einer Kosinuskurve bis Iteration 50.000 auf 6e-5.

if current_iteration < warmup_iterations:
    return learning_rate * (current_iteration + 1) / (warmup_iterations + 1)
if current_iteration > learning_rate_decay_iterations:
    return minimum_learning_rate
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)

Der Warmup verhindert, dass die zufällig initialisierten Gewichte in den ersten Schritten durch zu große Updates aus dem Gleichgewicht geraten. Der Kosinus-Abfall lässt das Modell gegen Ende mit kleinen Schritten feinjustieren.

Jede Iteration besteht aus 16 Vorwärts- und Rückwärtsdurchläufen, deren Gradienten summiert werden, bevor der Optimizer einen Schritt macht. Während die GPU rechnet, lädt der Code bereits den nächsten Batch in gepinnten Arbeitsspeicher, um die Übertragung asynchron zu machen. Die Berechnung läuft in bfloat16 mit Autocast, Matrixmultiplikationen dürfen TF32 nutzen, und torch.compile übersetzt das Modell vor dem Start in optimierte Kernel.

Alle 100 Iterationen schätzt das Skript den Loss auf Trainings- und Validierungsdaten über jeweils 200 Batches und speichert einen Checkpoint mit Modell- und Optimizer-Zustand, Konfiguration und aktuellem Iterationsstand. Damit lässt sich ein unterbrochenes Training über den Modus resume fortsetzen. Zusätzlich zur Konsolenausgabe schreibt meine Version alle Loss-Werte in eine Logdatei und berechnet aus dem gleitenden Mittel der letzten 100 Iterationszeiten eine Restlaufzeit.

Einsatz

Das Modell ist ein Lern- und Demonstrationsprojekt, kein Produktivsystem. Genutzt wird es über sample.py: Das Skript lädt den Checkpoint, kodiert einen Start-Prompt mit dem GPT-2-Tokenizer und erzeugt Token für Token eine Fortsetzung. Das mitgelieferte Skript generate_text.sh startet mit einem englischen Satzanfang und erzeugt 300 neue Tokens bei einer Temperatur von 0,8 und Top-k-Sampling mit k = 40. Die Temperatur steuert, wie stark die Wahrscheinlichkeitsverteilung geglättet wird; Top-k schneidet alle bis auf die 40 wahrscheinlichsten Kandidaten ab und verhindert so, dass das Modell in seltene, meist unsinnige Tokens abrutscht.

Da das Modell ausschließlich auf englischsprachigen Wikipedia-Texten trainiert ist, setzt es Texte im Stil von Lexikonartikeln fort. Es ist ein Basismodell ohne Instruction-Tuning: Es folgt keinen Anweisungen, sondern schreibt fort, was es bekommt.

Der wichtigere Einsatz ist ein anderer: Der kommentierte Code und die dazugehörige Blogserie dienen als Lernmaterial. Wer die Theorie aus Teil 1 kennt, kann in Teil 2 das Training selbst nachvollziehen und in Teil 3 Zeile für Zeile sehen, wie Attention, Embeddings und Trainingsschleife im Code aussehen.

Ergebnis

Das Training ist mit der beschriebenen Konfiguration über die vollen 50.000 Iterationen durchgelaufen und hat einen Checkpoint erzeugt, mit dem sich Text generieren lässt. Damit ist der vollständige Weg einmal durchschritten: Rohtext laden, tokenisieren, ein Modell mit 350 Millionen Parametern von Grund auf trainieren, Text erzeugen.

Konkrete Loss-Werte und Beispielausgaben nenne ich hier nicht, weil ich in einer Fallstudie nur Zahlen angebe, die ich auch belegen kann. Unabhängig davon gilt: Ein Basismodell dieser Größe, trainiert auf rund 100 Millionen Wörtern Wikipedia-Text, ist mit heutigen Sprachmodellen nicht vergleichbar und soll es auch nicht sein.

Bleibend ist das Verständnis, das ich in der Arbeit mit Agentensystemen täglich nutze: wie Speicherbedarf, Batch-Größe und Modellgröße zusammenhängen, warum Quantisierung und Präzision eine Rolle spielen, und was ein Modell eigentlich gelernt hat, wenn es „nur" das nächste Token vorhersagt.

Was ich dabei gelernt habe

Die Datenmenge setzt die Grenze, nicht die Rechenzeit. WikiText-103 hat hochgerechnet rund 116 Millionen Tokens. Bei 6,5 Milliarden verarbeiteten Tokens sieht das Modell jeden Trainingsabschnitt rechnerisch etwa 56-mal. Nach der gängigen Faustregel aus der Chinchilla-Arbeit bräuchte ein Modell mit 350 Millionen Parametern rund 7 Milliarden unterschiedliche Tokens. Modellgröße und Datenmenge plane ich deshalb gemeinsam: Wer die verfügbare Rechenzeit voll ausschöpfen will, braucht entweder mehr unterschiedlichen Text oder ein kleineres Modell.

Speicher ist das eigentliche Designkriterium. Die Hyperparameter ergeben sich nicht aus Theorie, sondern aus dem Grafikspeicher: Wie viele Sequenzen mit 1.024 Tokens passen gleichzeitig hinein? Gradient Accumulation, bfloat16 und das Abschalten von Bias-Termen sind keine Optimierungen für später, sondern Voraussetzung dafür, dass das Training auf einer GPU überhaupt läuft.

Evaluierungsintervalle kosten Zeit. Jede Loss-Schätzung über Hunderte Batches und jeder Checkpoint von mehreren Gigabyte bremst das eigentliche Training. Das Intervall sollte zur Länge des Laufs passen: so selten wie möglich, aber oft genug, dass ein Neustart nach einem Abbruch nicht viel Arbeit kostet.

Logs und Artefakte gehören zum Ergebnis. Trainingsprotokolle, Konfiguration und Beispielausgaben lege ich gemeinsam ab, sobald ein Lauf fertig ist. Erst damit lässt sich ein Training später nachvollziehen, vergleichen und belegen.

Code, den man kommentiert, versteht man. Das Übersetzen der Kommentare und das Umbenennen von Variablen wie n_embd in embedding_dimension hat mich gezwungen, jede Stelle wirklich nachzuvollziehen. Dieser Schritt war für das Verständnis wertvoller als das eigentliche Training.

Technologien

  • Python und PyTorch 2: Modell, Training, torch.compile, Flash Attention über scaled_dot_product_attention
  • nanoGPT (Andrej Karpathy): Codebasis für Modell und Trainingsschleife
  • tiktoken: GPT-2-BPE-Tokenizer
  • Hugging Face Datasets: Laden von WikiText-103
  • NumPy: Memory-Mapped Binärdateien für die Trainingsdaten
  • CUDA, bfloat16, TF32: Training auf einer NVIDIA RTX 4090 mit 24 GB
  • Bash: Skripte für Datenvorbereitung, Training und Textgenerierung