Un LLM sur une Sony PSP
Une Sony PSP-2000 est un portable MIPS de 333 MHz datant de 2007 avec 64 Mo de RAM. La mienne, cette semaine, fait tourner un Transformer à 15 millions de paramètres et diffuse du texte anglais sur l’écran LCD à un ou deux tokens par seconde.

Le modèle est le stories15M de Karpathy (un checkpoint TinyStories), quantifié int8 à environ 17 Mo. Le runtime représente ~1100 lignes de C pur cross-compilé avec pspdev/pspdev dans Docker. Pas de Python, pas de libtorch, pas de runtime utile sur le device — la PSP est une boîte à processus unique qui charge un seul EBOOT.PBP depuis la carte mémoire et vous donne sceIo*, un framebuffer et une VFPU. Tout le reste, vous le construisez.
Ce post est le budget. Où va chaque octet, à quoi ressemblent les kernels et ce qui reste sur la table.
Le matériel
| CPU | MIPS Allegrex @ 333 MHz, in-order |
| FPU | fp32 scalaire + un coprocesseur VFPU (vectoriel) 4×4 |
| RAM | 64 Mo (PSP-2000/3000) ; 32 Mo sur la PSP-1000 originale |
| OS | XMB, pas de mémoire virtuelle, pas de mmap, pas de swap |
| Sortie | LCD 480×272, pas de stdout lisible par l’hôte |
La ligne « pas de mmap » est celle qui fait mal. Sur une machine Linux, vous feriez mmap du fichier de poids et laisseriez le cache de pages s’en occuper. Sur la PSP, vous avez sceIoLseek + sceIoRead et une unique arena allouée avec malloc. Vous lisez les 17 Mo en entier dans la RAM avant le forward pass n°1, ou vous streamez depuis la carte mémoire à peu près à la vitesse d’une clé USB 1.1 et vous regardez votre débit s’effondrer.
Les 32 Mo de la PSP-1000 ne suffisent pas pour laisser de la place heap pour les poids plus le cache KV plus les buffers de travail. Les 2000 et 3000 sont livrées avec 64 Mo. Il nous faut les 64.
Le modèle
stories15M est le plus petit des checkpoints TinyStories de Karpathy — 6 couches transformer, taille cachée 288, 6 têtes d’attention, vocabulaire 32000. Environ quinze millions de paramètres au total. En fp32, ~57 Mo. En int8 q80 — quantification symétrique par groupe, taille de groupe 64, une échelle fp32 par groupe — ~17 Mo.
Architecture : Décodeur style Llama, RoPE, SwiGLU FFN
Couches : 6
Caché : 288
Têtes : 6 (head_dim 48)
Vocabulaire : 32000
Contexte : 256 tokens
Quantification : int8 q80 (group=64, symétrique)
Taille disque : 17 Mo
La préparation du modèle est sa propre image Docker : python:3.11-slim + torch cpu-only + un commit épinglé de karpathy/llama2.c. Elle télécharge stories15M.pt, exécute export.py --version 2 pour produire le model.bin q80, construit le tokenizer.bin BPE, et — important — construit également la référence runq.c de Karpathy avec -ffp-contract=off -fno-fast-math et la fait tourner sur un prompt fixe pour produire tests/expected.txt. Ce fichier est la référence x86 exacte à l’octet près contre laquelle la PSP est comparée. Plus de détails dans un instant.
Le budget mémoire
24 Mo de heap, déclarés une fois au chargement du module :
PSP_HEAP_SIZE_KB(24576);
Répartis ainsi :
| Région | Taille | Notes |
|---|---|---|
| Poids (quantifiés int8) | ~17 Mo | arena unique allouée avec malloc, lue par chunks via sceIoLseek + sceIoRead |
| Cache KV | ~3,5 Mo | 6 couches × 256 ctx × 288 hidden × 2 (K+V) × fp32 |
Buffers de travail RunState | ~1 Mo | activations, scores d’attention, logits échantillonnés |
| Stack, libc, framework | ~2 Mo | overhead du PSPSDK |
| Marge | ~0,5 Mo |
Le truc qui mérite d’être signalé : la table d’embedding des tokens reste quantifiée dans l’arena. Le portage naïf la déquantifie une fois au chargement, ce qui coûte ~36 Mo et provoque immédiatement un OOM. À la place, à chaque forward on déquantifie une seule ligne — la ligne du token courant — dans un petit buffer fp32. Le coût est une déquantification supplémentaire par forward ; le gain est ~36 Mo qu’on n’a pas.
Les kernels
transformer.c contient les suspects habituels : rmsnorm, softmax, quantize/dequantize, matmul, RoPE, attention, SwiGLU, sampler. Chacun est la version du manuel avec -ffp-contract=off forcé pour que l’ordre des opérations multiply-add corresponde à runq.c sur x86. C’est important pour la surface de test (voir ci-dessous).
Le matmul est aujourd’hui du fp32 scalaire — trois boucles imbriquées, une multiply-add fp32 à la fois. Sur du vrai matériel, il atteint ~1–2 tok/s. C’est assez lent pour qu’une complétion de 64 tokens prenne environ une minute.
Le matmul est également factorisé comme un pointeur de fonction interchangeable. Le plan v1 est un kernel VFPU qui utilise les opérations vectorielles 4×4, ce qui devrait atteindre ~5–15 tok/s sur le même matériel. (La VFPU est l’unique composant du matériel PSP qui vieillit bien — un coprocesseur vectoriel avec 128 registres adressables comme huit matrices 4×4, capable d’exécuter une multiplication matricielle 4×4 en une seule instruction.) C’est un changement d’un seul fichier à insérer.
L’interface utilisateur
La PSP dispose d’un clavier à l’écran système que vous invoquez via sceUtilityOsk*. Il retourne du texte en UTF-16LE ; vous le convertissez en UTF-8 (BMP uniquement — l’OSK de la PSP n’atteint pas les paires de substitution) et vous le donnez au tokenizer BPE.
L’interface de chat est pspDebugScreen — la police de débogage intégrée de la PSP sur le framebuffer. Monospace, 8×8 pixels, 60 colonnes × 34 lignes sur l’affichage 480×272. Mise en page bicolore : le prompt en haut, les tokens générés qui s’affichent en dessous caractère par caractère. Quand le buffer atteint le bas de l’écran, le rendu revient en haut. Ce n’est pas beau, mais c’est lisible, et chaque caractère à l’écran est quelque chose que le modèle a réellement émis.
La démo
Prompt :
Once upon a time, there was a little girl named Layla
Généré (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.
Cette sortie est identique, octet pour octet, à ce que runq.c produit sur x86_64 avec le même modèle, le même prompt, la même température et des flags FP correspondants. Cette équivalence est la surface de test — diff -q state.txt tests/expected.txt retourne soit propre, soit chaque couche du moteur d’inférence est fausse. C’est bien plus fort que les tests basés sur l’OCR que l’ancienne build Pong dans ce même dépôt avait embarqués ; l’écran de la PSP est maintenant décoratif, et la vérité est un fichier texte sur la carte mémoire émulée.
Chargement sur du vrai matériel
Tout ce qui précède tourne sous PPSSPP dans la boucle de test. Pour le mettre sur du vrai matériel :
PSP/
└── GAME/
└── PspLlm/
├── EBOOT.PBP
├── model.bin
└── tokenizer.bin
Déposez les trois fichiers sur la carte mémoire, naviguez jusqu’au jeu dans le XMB, appuyez sur X. PSP-2000 ou PSP-3000 uniquement — les 32 Mo de la PSP-1000 ne laissent pas de place heap — et la PSP a besoin d’un firmware personnalisé pour faire tourner les EBOOT.PBP non signés (6.61 PRO-C2 ou 6.61 Infinity sur une PSP ; Adrenaline sur une PS Vita). Le démarrage à froid jusqu’à l’OSK prend environ trois secondes. La latence du premier token dépend presque entièrement de la longueur du prompt ; par token ensuite, c’est le matmul.
La suite
| Élément | Pourquoi | Impact attendu |
|---|---|---|
| Matmul VFPU | le fp32 scalaire laisse la seule bonne unité vectorielle du chip à l’arrêt | ~5–15 tok/s au lieu de 1–2 |
| Rétention KV multi-tour | chaque prompt remplit aujourd’hui le cache KV à partir de zéro | chat utilisable au lieu de continuations à un coup |
stories42M | tient en ~21 Mo quantifié ; toujours dans le heap de 24 Mo | sorties plus riches, même interface |
stories110M | ne tient pas ; nécessite le streaming des poids depuis la carte mémoire | probablement pas rentable face à la perte de débit |
La contrainte n’est pas la puissance de calcul — la mise à niveau VFPU rembourse un ordre de grandeur. La contrainte est la RAM. 64 Mo, c’est le budget, et une fois que vous avez payé pour l’OS, le heap, le cache KV et les buffers de travail, vous avez exactement assez de place pour un modèle de 17 Mo et pas un octet de plus. Sony a fabriqué ce matériel pour lire des MP3 et faire tourner Wipeout. Chaque autre LLM que j’ai utilisé cette semaine tournait sur un GPU qui coûtait plus cher que la PSP. Celui-ci tourne sur la PSP.