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.

PSP-Framebuffer am Ende eines 64-Token-Bench-Laufs. Prompt 'Once upon a time, there was a little girl named Layla', Vervollständigung darunter, Fußzeile '64 tokens in 42.6s (1.5 tok/s)'.

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

CPUMIPS Allegrex @ 333 MHz, in-order
FPUskalares fp32 + ein 4×4 VFPU (Vektor) Coprozessor
RAM64 MB (PSP-2000/3000); 32 MB bei der ursprünglichen PSP-1000
OSXMB, kein virtueller Speicher, kein mmap, kein Swap
Ausgabe480×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:

RegionGrößeHinweise
Gewichte (int8 quantisiert)~17 MBeinzelne malloc’d Arena, eingelesen via sceIoLseek + sceIoRead Chunks
KV-Cache~3,5 MB6 Schichten × 256 ctx × 288 hidden × 2 (K+V) × fp32
RunState Arbeitspuffer~1 MBAktivierungen, Attention-Scores, gesamplete Logits
Stack, libc, Framework~2 MBOverhead 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

ElementWarumErwartete Auswirkung
VFPU Matmulskalares fp32 lässt die einzige gute Vektoreinheit auf dem Chip ungenutzt~5–15 tok/s statt 1–2
Multi-Turn KV-Erhaltungjeder Prompt füllt heute den KV-Cache von Nullnutzbarer Chat statt einmaliger Fortsetzungen
stories42Mpasst bei ~21 MB quantisiert; noch innerhalb des 24-MB-Heapsreichhaltigere Ausgaben, gleiche UI
stories110Mpasst nicht; benötigt Gewichts-Streaming vom Memory Stickwahrscheinlich 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.