Ein LLM auf einer Sony PSP
Eine Sony PSP-2000 ist ein 333-MHz-MIPS-Handheld aus dem Jahr 2007 mit 64 MB RAM. Meins läuft diese Woche mit einem Transformer mit 15 Millionen Parametern und streamt englischen Text mit ein bis zwei Tokens pro Sekunde auf das LCD.

Das Modell ist Karpathys stories15M (ein TinyStories-Checkpoint), int8-quantisiert auf etwa 17 MB. Die Laufzeitumgebung sind ~1100 Zeilen reines C, cross-kompiliert mit pspdev/pspdev in Docker. Es gibt kein Python, kein libtorch, keine hilfreiche Laufzeitumgebung auf dem Gerät — die PSP ist eine Single-Process-Box, die eine einzige EBOOT.PBP vom Memory Stick lädt und dir sceIo*, einen Framebuffer und eine VFPU gibt. Den Rest baust du selbst.
Dieser Beitrag ist das Budget. Wohin jedes Byte geht, wie die Kernels aussehen und was auf dem Tisch bleibt.
Die Hardware
| CPU | MIPS Allegrex @ 333 MHz, in-order |
| FPU | skalares fp32 + ein 4×4 VFPU (Vektor) Coprozessor |
| RAM | 64 MB (PSP-2000/3000); 32 MB bei der ursprünglichen PSP-1000 |
| OS | XMB, kein virtueller Speicher, kein mmap, kein Swap |
| Ausgabe | 480×272 LCD, kein stdout, den der Host lesen kann |
Die Zeile „kein mmap" ist die, die wehtut. Auf einer Linux-Box würdest du mmap auf die Gewichtsdatei anwenden und den Page-Cache den Rest erledigen lassen. Auf der PSP hast du sceIoLseek + sceIoRead und eine einzige malloc’d Arena. Du liest alle 17 MB in den RAM, bevor Forward Pass #1 beginnt, oder du streamst vom Memory Stick mit ungefähr der Geschwindigkeit eines USB-1.1-Sticks und siehst deinen Durchsatz einbrechen.
Die 32 MB der PSP-1000 reichen nicht aus, um Heap-Platz für die Gewichte plus KV-Cache plus Arbeitspuffer zu lassen. Die 2000 und 3000 werden mit 64 MB geliefert. Wir brauchen die 64.
Das Modell
stories15M ist der kleinste von Karpathys TinyStories-Checkpoints — 6 Transformer-Schichten, versteckte Größe 288, 6 Attention-Köpfe, Vokabular 32000. Insgesamt etwa fünfzehn Millionen Parameter. In fp32 ~57 MB. In int8 q80 — symmetrische Quantisierung pro Gruppe, Gruppengröße 64, ein fp32-Skalierungsfaktor pro Gruppe — ~17 MB.
Architektur: Llama-Stil Decoder, RoPE, SwiGLU FFN
Schichten: 6
Versteckt: 288
Köpfe: 6 (head_dim 48)
Vokabular: 32000
Kontext: 256 Tokens
Quantisierung: int8 q80 (group=64, symmetrisch)
Dateigröße: 17 MB
Die Modellvorbereitung ist ihr eigenes Docker-Image: python:3.11-slim + cpu-only torch + ein gepinnter Commit von karpathy/llama2.c. Es lädt stories15M.pt herunter, führt export.py --version 2 aus, um die q80 model.bin zu erstellen, baut die BPE tokenizer.bin, und — wichtig — baut auch Karpathys runq.c-Referenz mit -ffp-contract=off -fno-fast-math und führt sie auf einem festen Prompt aus, um tests/expected.txt zu produzieren. Diese Datei ist die byte-genaue x86-Referenz, gegen die die PSP verglichen wird. Mehr dazu gleich.
Das Speicherbudget
24 MB Heap, einmalig beim Laden des Moduls deklariert:
PSP_HEAP_SIZE_KB(24576);
Aufgeteilt wie folgt:
| Region | Größe | Hinweise |
|---|---|---|
| Gewichte (int8 quantisiert) | ~17 MB | einzelne malloc’d Arena, eingelesen via sceIoLseek + sceIoRead Chunks |
| KV-Cache | ~3,5 MB | 6 Schichten × 256 ctx × 288 hidden × 2 (K+V) × fp32 |
RunState Arbeitspuffer | ~1 MB | Aktivierungen, Attention-Scores, gesamplete Logits |
| Stack, libc, Framework | ~2 MB | Overhead des PSPSDK |
| Reserve | ~0,5 MB |
Der Trick, der erwähnenswert ist: die Token-Embedding-Tabelle bleibt quantisiert in der Arena. Die naive Portierung dequantisiert sie einmalig beim Laden, was ~36 MB kostet und sofort OOM verursacht. Stattdessen dequantisieren wir bei jedem Forward nur eine einzige Zeile — die Zeile für das aktuelle Token — in einen kleinen fp32-Puffer. Die Kosten sind eine extra Dequantisierung pro Forward; der Gewinn sind ~36 MB, die wir nicht haben.
Die Kernels
transformer.c ist die übliche Liste: rmsnorm, softmax, quantize/dequantize, matmul, RoPE, Attention, SwiGLU, Sampler. Jeder ist die Lehrbuchversion mit erzwungenem -ffp-contract=off, damit die Reihenfolge der Multiply-Add-Operationen mit runq.c auf x86 übereinstimmt. Das ist wichtig für die Testoberfläche (siehe unten).
Der Matmul ist heute skalares fp32 — drei verschachtelte Schleifen, eine fp32-Multiply-Add auf einmal. Auf echter Hardware erreicht er ~1–2 tok/s. Das ist langsam genug, dass eine 64-Token-Vervollständigung etwa eine Minute dauert.
Der Matmul ist auch als austauschbarer Funktionszeiger faktorisiert. Der v1-Plan ist ein VFPU-Kernel, der die 4×4-Vektoroperationen verwendet, was ~5–15 tok/s auf derselben Hardware erreichen sollte. (Die VFPU ist das einzige Stück PSP-Hardware, das gut altert — ein Vektorcoprozessor mit 128 Registern, die als acht 4×4-Matrizen adressierbar sind und eine 4×4-Matrixmultiplikation in einer einzigen Instruktion ausführen können.) Das ist eine Änderung einer einzigen Datei zum Einsetzen.
Die Benutzeroberfläche
Die PSP hat eine System-Bildschirmtastatur, die du über sceUtilityOsk* aufrufst. Sie gibt Text als UTF-16LE zurück; du konvertierst ihn zu UTF-8 (nur BMP — die OSK der PSP erreicht keine Surrogatpaare) und gibst ihn an den BPE-Tokenizer weiter.
Die Chat-UI ist pspDebugScreen — die integrierte Debug-Schriftart der PSP auf dem Framebuffer. Monospace, 8×8 Pixel, 60 Spalten × 34 Zeilen auf dem 480×272-Display. Zweifarbiges Layout: der Prompt oben, generierte Tokens, die darunter Zeichen für Zeichen streamen. Wenn der Puffer das untere Bildschirmende erreicht, bricht die Darstellung um. Es ist nicht schön, aber lesbar, und jedes Zeichen auf dem Bildschirm ist etwas, das das Modell tatsächlich ausgegeben hat.
Die Demo
Prompt:
Once upon a time, there was a little girl named Layla
Generiert (T=0, 64 Tokens):
She was three years old and loved to explore. One day, she decided to go on an adventure. She put on her shoes and grabbed her bag. Layla walked outside and saw a big, tall tree.
Diese Ausgabe ist byte-für-byte identisch mit dem, was runq.c auf x86_64 mit demselben Modell, demselben Prompt, derselben Temperatur und übereinstimmenden FP-Flags produziert. Diese Äquivalenz ist die Testoberfläche — diff -q state.txt tests/expected.txt gibt entweder sauber zurück, oder jede Schicht der Inferenz-Engine ist falsch. Das ist viel stärker als die OCR-basierten Tests, die der frühere Pong-Build in demselben Repo geliefert hat; der Bildschirm auf der PSP ist jetzt dekorativ, und die Wahrheit ist eine Textdatei auf dem emulierten Memory Stick.
Sideloading auf echte Hardware
Alles oben läuft unter PPSSPP in der Testschleife. Um es auf echte Hardware zu bringen:
PSP/
└── GAME/
└── PspLlm/
├── EBOOT.PBP
├── model.bin
└── tokenizer.bin
Lege die drei Dateien auf den Memory Stick, navigiere zum Spiel im XMB, drücke X. Nur PSP-2000 oder PSP-3000 — die 32 MB der PSP-1000 lassen keinen Heap-Platz — und die PSP benötigt Custom Firmware, um unsignierte EBOOT.PBP auszuführen (6.61 PRO-C2 oder 6.61 Infinity auf einer PSP; Adrenaline auf einer PS Vita). Kaltstart bis OSK dauert etwa drei Sekunden. Die First-Token-Latenz hängt fast vollständig von der Promptlänge ab; pro Token danach ist es der Matmul.
Was als Nächstes kommt
| Element | Warum | Erwartete Auswirkung |
|---|---|---|
| VFPU Matmul | skalares fp32 lässt die einzige gute Vektoreinheit auf dem Chip ungenutzt | ~5–15 tok/s statt 1–2 |
| Multi-Turn KV-Erhaltung | jeder Prompt füllt heute den KV-Cache von Null | nutzbarer Chat statt einmaliger Fortsetzungen |
stories42M | passt bei ~21 MB quantisiert; noch innerhalb des 24-MB-Heaps | reichhaltigere Ausgaben, gleiche UI |
stories110M | passt nicht; benötigt Gewichts-Streaming vom Memory Stick | wahrscheinlich den Durchsatzverlust nicht wert |
Die Einschränkung ist nicht die Rechenleistung — das VFPU-Upgrade zahlt eine Größenordnung zurück. Die Einschränkung ist RAM. 64 MB ist das Budget, und wenn du einmal für das OS, den Heap, den KV-Cache und die Arbeitspuffer bezahlt hast, hast du genau genug Platz für ein 17-MB-Modell und nicht ein Byte mehr. Sony hat diese Hardware gebaut, um MP3s abzuspielen und Wipeout auszuführen. Jedes andere LLM, das ich diese Woche verwendet habe, lief auf einer GPU, die mehr kostete als die PSP. Dieses läuft auf der PSP.