LLM на Sony PSP
Sony PSP-2000 — это портативная консоль MIPS с частотой 333 МГц выпуска 2007 года с 64 МБ ОЗУ. На этой неделе моя PSP запускает Transformer с 15 миллионами параметров и выводит английский текст на LCD-экран со скоростью один-два токена в секунду.

Модель — stories15M Карпати (чекпоинт TinyStories), квантизованная int8 примерно до 17 МБ. Среда выполнения — ~1100 строк чистого C, кросс-скомпилированного с помощью pspdev/pspdev в Docker. Никакого Python, никакого libtorch, никакой вспомогательной среды выполнения на устройстве — PSP это коробка с одним процессом, которая загружает один EBOOT.PBP с карты памяти и предоставляет sceIo*, фреймбуфер и VFPU. Всё остальное строишь сам.
Этот пост — бюджет. Куда уходит каждый байт, как выглядят ядра и что остаётся на столе.
Железо
| CPU | MIPS Allegrex @ 333 МГц, in-order |
| FPU | скалярный fp32 + сопроцессор VFPU (векторный) 4×4 |
| ОЗУ | 64 МБ (PSP-2000/3000); 32 МБ у оригинальной PSP-1000 |
| ОС | XMB, нет виртуальной памяти, нет mmap, нет swap |
| Вывод | LCD 480×272, нет stdout, который может читать хост |
Строка «нет mmap» — та, что кусает. На Linux-машине вы бы сделали mmap файла весов и позволили кешу страниц заниматься остальным. На PSP у вас есть sceIoLseek + sceIoRead и единственная арена, выделенная через malloc. Вы читаете все 17 МБ в ОЗУ до первого прямого прохода, или стримите с карты памяти примерно со скоростью USB 1.1-флешки и наблюдаете, как пропускная способность рушится.
32 МБ PSP-1000 недостаточно, чтобы оставить места в куче для весов плюс KV-кеша плюс рабочих буферов. PSP-2000 и 3000 поставляются с 64 МБ. Нам нужны все 64.
Модель
stories15M — наименьший из чекпоинтов TinyStories Карпати — 6 слоёв трансформера, скрытый размер 288, 6 голов внимания, словарный запас 32000. Всего около пятнадцати миллионов параметров. В fp32 — ~57 МБ. В int8 q80 — симметричная квантизация по группам, размер группы 64, один масштаб fp32 на группу — ~17 МБ.
Архитектура: Декодер в стиле Llama, RoPE, SwiGLU FFN
Слои: 6
Скрытый: 288
Головы: 6 (head_dim 48)
Словарь: 32000
Контекст: 256 токенов
Квантизация: int8 q80 (group=64, симметричная)
Размер на диске: 17 МБ
Подготовка модели — это отдельный Docker-образ: python:3.11-slim + torch только для CPU + зафиксированный коммит karpathy/llama2.c. Он скачивает stories15M.pt, запускает export.py --version 2 для создания q80 model.bin, собирает BPE tokenizer.bin, и — важно — также собирает референсный runq.c Карпати с -ffp-contract=off -fno-fast-math и запускает его на фиксированном промпте для создания tests/expected.txt. Этот файл — побайтовый x86-эталон, с которым сравнивается PSP. Подробнее об этом чуть позже.
Бюджет памяти
24 МБ кучи, задекларированные один раз при загрузке модуля:
PSP_HEAP_SIZE_KB(24576);
Распределены так:
| Область | Размер | Примечания |
|---|---|---|
| Веса (квантизованные int8) | ~17 МБ | единственная арена через malloc, считанная чанками через sceIoLseek + sceIoRead |
| KV-кеш | ~3,5 МБ | 6 слоёв × 256 ctx × 288 hidden × 2 (K+V) × fp32 |
Рабочие буферы RunState | ~1 МБ | активации, веса внимания, сэмплированные логиты |
| Стек, libc, фреймворк | ~2 МБ | накладные расходы PSPSDK |
| Запас | ~0,5 МБ |
Трюк, заслуживающий упоминания: таблица эмбеддингов токенов остаётся квантизованной в арене. Наивный порт деквантизует её полностью при загрузке, что стоит ~36 МБ и немедленно вызывает OOM. Вместо этого при каждом прямом проходе деквантизуется только одна строка — строка для текущего токена — в небольшой fp32-буфер. Цена — одна дополнительная деквантизация за проход; выигрыш — ~36 МБ, которых у нас нет.
Ядра
transformer.c — обычные подозреваемые: rmsnorm, softmax, quantize/dequantize, matmul, RoPE, внимание, SwiGLU, сэмплер. Каждый — учебниковая версия с принудительным -ffp-contract=off, чтобы порядок операций умножение-сложение совпадал с runq.c на x86. Это важно для поверхности тестирования (см. ниже).
Сегодня matmul — скалярный fp32 — три вложенных цикла, по одной операции fp32 умножение-сложение за раз. На реальном железе это даёт ~1–2 tok/s. Достаточно медленно, чтобы завершение 64 токенов занимало около минуты.
Matmul также реализован как заменяемый указатель на функцию. По плану v1 — ядро VFPU, использующее векторные операции 4×4, которое должно давать ~5–15 tok/s на том же железе. (VFPU — единственная часть оборудования PSP, которая хорошо стареет — векторный сопроцессор со 128 регистрами, адресуемыми как восемь матриц 4×4, способный выполнить умножение матриц 4×4 за одну инструкцию.) Это изменение одного файла для подключения.
Интерфейс
PSP имеет системную экранную клавиатуру, вызываемую через sceUtilityOsk*. Она возвращает текст в UTF-16LE; вы конвертируете его в UTF-8 (только BMP — OSK PSP не работает с суррогатными парами) и передаёте BPE-токенизатору.
Чат-интерфейс — pspDebugScreen — встроенный отладочный шрифт PSP на фреймбуфере. Моноширинный, 8×8 пикселей, 60 столбцов × 34 строки на дисплее 480×272. Двухцветная компоновка: промпт сверху, сгенерированные токены потоком ниже — символ за символом. Когда буфер достигает нижней части экрана, рендеринг переходит наверх. Некрасиво, но читабельно, и каждый символ на экране — то, что модель действительно выдала.
Демонстрация
Промпт:
Once upon a time, there was a little girl named Layla
Сгенерировано (T=0, 64 токена):
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.
Этот вывод идентичен побайтово тому, что runq.c производит на x86_64 с той же моделью, тем же промптом, той же температурой и совпадающими FP-флагами. Эта эквивалентность — поверхность тестирования: diff -q state.txt tests/expected.txt либо возвращает чисто, либо каждый слой движка вывода ошибочен. Это намного сильнее, чем OCR-тесты, которые поставлял более ранний Pong-сборник в том же репозитории; экран на PSP теперь декоративный, а истина — текстовый файл на эмулируемой карте памяти.
Загрузка на реальное железо
Всё вышеизложенное работает под PPSSPP в тестовом цикле. Чтобы запустить на железе:
PSP/
└── GAME/
└── PspLlm/
├── EBOOT.PBP
├── model.bin
└── tokenizer.bin
Положите три файла на карту памяти, найдите игру в XMB, нажмите X. Только PSP-2000 или PSP-3000 — 32 МБ PSP-1000 не оставляют места в куче — и PSP нужна кастомная прошивка для запуска неподписанного EBOOT.PBP (6.61 PRO-C2 или 6.61 Infinity на PSP; Adrenaline на PS Vita). Холодный старт до OSK занимает около трёх секунд. Задержка первого токена почти полностью зависит от длины промпта; время на токен после этого — matmul.
Что дальше
| Элемент | Зачем | Ожидаемый эффект |
|---|---|---|
| VFPU matmul | скалярный fp32 оставляет единственный хороший векторный блок на чипе простаивать | ~5–15 tok/s вместо 1–2 |
| Мультиоборотное сохранение KV | каждый промпт сегодня заново заполняет KV-кеш с нуля | пригодный чат вместо однократных продолжений |
stories42M | умещается в ~21 МБ в квантизованном виде; всё ещё в куче 24 МБ | более богатые выводы, тот же интерфейс |
stories110M | не умещается; требует стриминга весов с карты памяти | вероятно, не стоит потери пропускной способности |
Ограничение — не вычисления: обновление VFPU даёт прирост на порядок. Ограничение — ОЗУ. 64 МБ — это бюджет, и как только вы заплатили за ОС, кучу, KV-кеш и рабочие буферы, у вас остаётся ровно столько места для модели в 17 МБ и ни байтом больше. Sony выпускала это железо для воспроизведения MP3 и запуска Wipeout. Каждый другой LLM, которым я пользовался на этой неделе, работал на GPU, который стоил дороже PSP. Этот работает на PSP.