Een LLM op een Sony PSP

Een Sony PSP-2000 is een 333 MHz MIPS-handheld uit 2007 met 64 MB RAM. De mijne draait deze week een Transformer met 15 miljoen parameters en streamt Engelse tekst naar het LCD-scherm met één à twee tokens per seconde.

PSP-framebuffer aan het einde van een bench-run van 64 tokens. Prompt 'Once upon a time, there was a little girl named Layla', aanvulling eronder, voettekst '64 tokens in 42.6s (1.5 tok/s)'.

Het model is Karpathy’s stories15M (een TinyStories-checkpoint), int8-gekwantiseerd naar circa 17 MB. De runtime is ~1100 regels puur C, cross-gecompileerd met pspdev/pspdev in Docker. Er is geen Python, geen libtorch, geen handige runtime op het apparaat — de PSP is een single-process box die één EBOOT.PBP van de memory stick laadt en je sceIo*, een framebuffer en een VFPU geeft. De rest bouw je zelf.

Dit bericht is de begroting. Waar elke byte naartoe gaat, hoe de kernels eruitzien en wat er op tafel ligt.

De hardware

CPUMIPS Allegrex @ 333 MHz, in-order
FPUscalaire fp32 + een 4×4 VFPU (vector) coprocessor
RAM64 MB (PSP-2000/3000); 32 MB op de originele PSP-1000
OSXMB, geen virtueel geheugen, geen mmap, geen swap
Uitvoer480×272 LCD, geen stdout die de host kan lezen

De “geen mmap"-regel is de pijnlijke. Op een Linux-box zou je mmap op het gewichtenbestand doen en de page cache de rest laten regelen. Op de PSP heb je sceIoLseek + sceIoRead en één met malloc toegewezen arena. Je leest alle 17 MB in RAM vóór forward pass #1, of je streamt van de memory stick met ruwweg de snelheid van een USB 1.1-stick en ziet je doorvoer instorten.

De 32 MB van de PSP-1000 zijn niet genoeg om heapruimte over te laten voor de gewichten plus KV-cache plus werkbuffers. De 2000 en 3000 worden geleverd met 64 MB. We hebben de 64 nodig.

Het model

stories15M is de kleinste van Karpathy’s TinyStories-checkpoints — 6 transformer-lagen, verborgen grootte 288, 6 attentiehoofden, vocabulaire 32000. Ongeveer vijftien miljoen parameters totaal. In fp32 ~57 MB. In int8 q80 — symmetrische kwantisatie per groep, groepsgrootte 64, één fp32-schaal per groep — ~17 MB.

Architectuur:    Llama-stijl decoder, RoPE, SwiGLU FFN
Lagen:           6
Verborgen:       288
Hoofden:         6 (head_dim 48)
Vocabulaire:     32000
Context:         256 tokens
Kwantisatie:     int8 q80 (group=64, symmetrisch)
Schijfgrootte:   17 MB

De modelvoorbereiding is zijn eigen Docker-image: python:3.11-slim + cpu-only torch + een vastgepinde commit van karpathy/llama2.c. Het downloadt stories15M.pt, voert export.py --version 2 uit om de q80 model.bin te produceren, bouwt de BPE tokenizer.bin, en — belangrijk — bouwt ook Karpathy’s runq.c-referentie met -ffp-contract=off -fno-fast-math en draait die op een vaste prompt om tests/expected.txt te produceren. Dat bestand is de byte-exacte x86-referentie waaraan de PSP wordt gediffed. Meer hierover zo meteen.

Het geheugenbudget

24 MB heap, eenmalig gedeclareerd bij het laden van de module:

PSP_HEAP_SIZE_KB(24576);

Besteed als:

RegioGrootteNotities
Gewichten (int8 gekwantiseerd)~17 MBenkelvoudige malloc’d arena, ingelezen via sceIoLseek + sceIoRead chunks
KV-cache~3,5 MB6 lagen × 256 ctx × 288 verborgen × 2 (K+V) × fp32
RunState werkbuffers~1 MBactivaties, aandachtsscores, gesamplde logits
Stack, libc, framework~2 MBoverhead van de PSPSDK
Reserve~0,5 MB

De truc die het waard is te vermelden: de token-embeddingtabel blijft gekwantiseerd in de arena. De naïeve port dequantiseert hem eenmalig bij het laden, wat ~36 MB kost en direct OOM veroorzaakt. In plaats daarvan dequantiseren we bij elke forward slechts één rij — de rij voor het huidige token — naar een kleine fp32-buffer. De kosten zijn één extra dequantisatie per forward; de winst is ~36 MB die we niet hebben.

De kernels

transformer.c is de gebruikelijke verdachten: rmsnorm, softmax, quantize/dequantize, matmul, RoPE, aandacht, SwiGLU, sampler. Elke één is de leerboekversie met -ffp-contract=off geforceerd zodat de volgorde van multiply-add-bewerkingen overeenkomt met runq.c op x86. Dat telt voor het testoppervlak (zie hieronder).

De matmul is vandaag scalaire fp32 — drie geneste lussen, één fp32 multiply-add tegelijk. Op echte hardware haalt het ~1–2 tok/s. Dat is traag genoeg dat een aanvulling van 64 tokens ongeveer een minuut duurt.

De matmul is ook gefactoriseerd als een verwisselbare functiewijzer. Het v1-plan is een VFPU-kernel die de 4×4 vectoroperaties gebruikt, wat ~5–15 tok/s op dezelfde hardware zou moeten halen. (De VFPU is het enige onderdeel van PSP-hardware dat goed ouder wordt — een vectorcoprocessor met 128 registers die adresseerbaar zijn als acht 4×4-matrices, in staat een 4×4-matrixvermenigvuldiging in één instructie uit te voeren.) Dat is een wijziging van één bestand om in te droppen.

De UI

De PSP heeft een systeem-onschermtoetsenbord dat je aanroept via sceUtilityOsk*. Het retourneert tekst als UTF-16LE; je converteert het naar UTF-8 (alleen BMP — de OSK van de PSP reikt niet tot surrogaatparen) en voedt het aan de BPE-tokenizer.

De chat-UI is pspDebugScreen — het ingebouwde debuglettertype van de PSP op de framebuffer. Monospace, 8×8 pixels, 60 kolommen × 34 rijen op het 480×272-scherm. Tweekleurige lay-out: de prompt bovenaan, gegenereerde tokens die er teken voor teken onder stromen. Wanneer de buffer het schermonderste bereikt, loopt de weergave terug. Het is niet mooi, maar het is leesbaar, en elk teken op het scherm is iets dat het model daadwerkelijk heeft uitgezonden.

De demo

Prompt:

Once upon a time, there was a little girl named Layla

Gegenereerd (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.

Die uitvoer is byte voor byte identiek aan wat runq.c op x86_64 produceert met hetzelfde model, dezelfde prompt, dezelfde temperatuur en overeenkomende FP-vlaggen. Die equivalentie is het testoppervlak — diff -q state.txt tests/expected.txt geeft ofwel schoon terug, of elke laag van de inferentie-engine is fout. Het is veel sterker dan de OCR-gebaseerde tests die de eerdere Pong-build in dezelfde repo verscheepte; het scherm op de PSP is nu decoratief, en de waarheid is een tekstbestand op de geëmuleerde memory stick.

Sideloaden naar echte hardware

Alles hierboven draait onder PPSSPP in de testlus. Om het op echt hardware te zetten:

PSP/
└── GAME/
    └── PspLlm/
        ├── EBOOT.PBP
        ├── model.bin
        └── tokenizer.bin

Zet de drie bestanden op de memory stick, blader naar het spel in de XMB, druk op X. Alleen PSP-2000 of PSP-3000 — de 32 MB van de PSP-1000 laat geen heapruimte over — en de PSP heeft aangepaste firmware nodig om niet-ondertekende EBOOT.PBP te draaien (6.61 PRO-C2 of 6.61 Infinity op een PSP; Adrenaline op een PS Vita). Koude start tot OSK duurt ongeveer drie seconden. Eerste-tokenlatentie hangt bijna volledig af van de promptlengte; per-token daarna is de matmul.

Wat hierna komt

ItemWaaromVerwachte impact
VFPU matmulscalaire fp32 laat de enige goede vectoreenheid op de chip inactief~5–15 tok/s in plaats van 1–2
Multi-turn KV-retentieelke prompt vult vandaag de KV-cache opnieuw van nulbruikbare chat in plaats van eenmalige vervolgingen
stories42Mpast op ~21 MB gekwantiseerd; nog steeds binnen de 24 MB heaprijkere uitvoer, dezelfde UI
stories110Mpast niet; vereist gewichtsstreaming van memory stickwaarschijnlijk de doorvoerklap niet waard

De beperking is niet de rekenkracht — de VFPU-upgrade geeft een orde van grootte terug. De beperking is RAM. 64 MB is het budget, en als je eenmaal betaald hebt voor het OS, de heap, de KV-cache en de werkbuffers, heb je precies genoeg ruimte voor een model van 17 MB en niet één byte meer. Sony heeft deze hardware gemaakt om MP3’s af te spelen en Wipeout te draaien. Elke andere LLM die ik deze week heb gebruikt, draaide op een GPU die meer kostte dan de PSP. Deze draait op de PSP.