Зачем это вообще нужно?
Все говорят про запуск LLM на GPU, про квантование 4-bit, про дефицит видеокарт. Скучно. Что, если пойти другим путем? Не просто сжать модель, а вогнать ее в такие условия, где каждый байт на счету. Где нет floating point. Где память измеряется килобайтами.
Запуск LLM на NES – это не практическая задача. Это стресс-тест для понимания основ. Если ты сможешь заставить модель работать на процессоре 1985 года, то оптимизация под современное железо покажется детской игрой. Плюс это чертовски весело.
Цель: не создать полезный продукт, а исследовать абсолютные пределы сжатия и вычислений. Это как собрать двигатель в банке из-под колы – бесполезно, но показывает мастерство.
NES: 2 КБ ОЗУ и мечты об искусственном интеллекте
Nintendo Entertainment System. Центральный процессор: Ricoh 2A03 (клон MOS 6502). Тактовая частота: ~1.79 МГц. ОЗУ: 2 КБ. ПЗУ картриджа: до 512 КБ. Нет операций с плавающей точкой. Нет даже умножения и деления в ассемблере – только сдвиги и сложения.
| Ресурс | NES | Типичная LLM (TinyLlama) | Соотношение |
|---|---|---|---|
| ОЗУ | 2 КБ | >1 ГБ | >500,000x |
| ПЗУ (для модели) | ~256 КБ макс | ~1.1 ГБ (FP16) | >4,000x |
| FLOPS | ~0.001 MFLOPS (эмуляция) | >10 TFLOPS (GPU) | >10,000,000,000x |
Первая мысль: "Это невозможно". Вторая: "А что, если..."
1Шаг 1: Отказываемся от всего лишнего
Никаких трансформеров. Даже крошечный трансформер с 1 слоем и 64 размерностью эмбеддинга не влезет. Нужно проще. Значительно проще.
Выбор пал на n-gram модель с обратным распространением (backoff). По сути, усовершенствованный марковский процесс. Вместо нейросети – большая таблица вероятностей. Вместо внимания – поиск по префиксу.
# НЕ ДЕЛАЙТЕ ТАК. Это псевдокод для иллюстрации идеи.
# Полноценная модель даже такого типа не влезет.
model = {
(".", "Привет"): {"мир": 0.7, "друг": 0.3},
("Привет", "мир"): {"!": 1.0},
("мир", "!"): {"": 1.0}
}
def predict(prefix):
# Ищем самую длинную подходящую n-грамму
for n in range(MAX_N, 0, -1):
if prefix[-n:] in model:
return sample(model[prefix[-n:]])
return random_token()Основная ошибка на этом этапе – пытаться сохранить архитектуру. Забудьте про слои, активации, эмбеддинги. Нужно мыслить таблицами и вероятностями.
2Шаг 2: Сжимаем до битов
256 КБ на картридж. В эти байты нужно упаковать: словарь (токены), структуру данных для быстрого поиска n-грамм и сами вероятности.
Решение:
- Словарь: 256 токенов максимум (1 байт на токен). Берем не слова, а байты (Byte Pair Encoding в экстремальном режиме). Или даже 64 токена – только буквы, цифры, пунктуация.
- Вероятности: Не float. Даже не 8-битный integer. Используем алгоритмическое вероятностное кодирование. Храним не число 0.7, а правило: "если предыдущий токен X, то с вероятностью 7 из 10 следующий – Y". Вероятности – это маленькие целые числа с общим знаменателем.
- Структура данных: Простой отсортированный массив префиксов. Поиск – бинарный или даже линейный (на 1.79 МГц с этим можно смириться).
В итоге получается не "нейросеть", а детерминированный вероятностный автомат, зашитый в ПЗУ.
3Шаг 3: Пишем на 6502 asm
Здесь начинается настоящая магия. Нет стекового кадра? Нет проблем. Нет вызовов функций с аргументами? Пишем прямо в регистрах.
Ключевая оптимизация – работа с памятью. 2 КБ ОЗУ делятся на: буфер для текущего контекста (последние N токенов), буфер для вычислений, служебные переменные.
; Пример фрагмента: загрузка префикса для поиска
; Предположим, что контекст хранится по адресу $0200
; Ищем 3-грамму (3 токена)
LDX #$02 ; Индекс = 2 (третий токен с конца)
LDA $0200,X ; Загружаем токен в аккумулятор
STA search_buf ; Сохраняем в буфер поиска
DEX
BPL load_loop ; Повторяем для X=1, X=0
; Теперь в search_buf лежит 3-байтовый префикс
; Дальше – бинарный поиск по таблице в ПЗУ...Умножение вероятностей? Эмулируем сдвигами и сложениями. Генерация случайного числа? Используем линейный конгруэнтный генератор, зашитый в код.
Другой полюс: compile-time LLM на C++ шаблонах
Если NES – это экстремальное ограничение железа, то compile-time вычисления в C++ – это экстремальное ограничение этапа компиляции. Модель должна "заработать" не во время выполнения программы, а в момент ее компиляции.
Зачем? Представьте: ваша модель – это константное выражение. Никаких загрузок весов, никаких рантайм библиотек. Один исполняемый файл, который уже содержит "запеченные" знания. Идеально для встраиваемых систем или мест, где динамическое выделение памяти под запретом.
1Шаг 1: Представляем модель как тип
Веса модели – это не массив float, а пачка вложенных шаблонов. Каждый слой – отдельный тип. Линейный слой – это тип, который принимает на вход другой тип (входные данные) и имеет статический метод forward.
// Упрощенная концепция
template<typename T, float W0, float W1, float B>
struct LinearLayer {
using InputType = T;
static constexpr float weights[2] = {W0, W1};
static constexpr float bias = B;
template<float X0, float X1>
struct Forward {
static constexpr float value0 = X0 * W0 + X1 * W1 + B;
// ... активация ...
};
};
// "Модель" собирается как матрешка
typedef LinearLayer<Input, 0.5f, -0.2f, 0.1f> MyTinyModel;
// Использование во время компиляции
constexpr float output = MyTinyModel::Forward<1.0f, 0.0f>::value0;Компилятор (GCC, Clang) вычисляет все это на этапе компиляции. В бинарнике останется только готовый результат output.
Главный подводный камень: компилятор не предназначен для таких вычислений. Сложная модель может увеличить время компиляции до часов. Размер бинарника тоже взлетит, потому что каждый инстанс шаблона – это отдельная сущность.
2Шаг 2: Генерация кода как обучение
Ты не "загружаешь" веса. Ты генерируешь исходный код C++, который содержит эти веса как константы в шаблонах. Процесс выглядит так:
- Обучаешь крошечную модель (например, на PyTorch).
- Пишишь скрипт, который проходит по всем весам и генерирует .hpp файл с вложенными шаблонами.
- Подключаешь этот файл в свой проект.
- Компилируешь. Модель "живет" в типизированном мире компилятора.
Инференс (прямой проход) становится серией constexpr вычислений. Никаких аллокаций, никаких указателей.
3Шаг 3: Работа с последовательностями
Самое сложное – последовательности переменной длины. Токенизация, контекст. В мире шаблонов нет динамических массивов.
Решение: используем варианты (std::variant) с фиксированным набором длин или рекурсивные шаблоны, которые раскрываются в цепочку типов. Каждый токен – это тип. Предложение – это кортеж (tuple) типов.
// Концепция: последовательность как кортеж
using Prompt = std::tuple<Token<'H'>, Token<'e'>, Token<'l'>, Token<'l'>, Token<'o'>>;
// Модель принимает этот кортеж и рекурсивно его обрабатывает
template<typename... Tokens>
struct ModelForward;
// Специализация для рекурсивного разбора
template<typename First, typename... Rest>
struct ModelForward<First, Rest...> {
static constexpr auto process() {
// Обрабатываем First, затем рекурсивно Rest...
return combine(First::embedding, ModelForward<Rest...>::process());
}
};
// Базовый случай
template<>
struct ModelForward<> { /* ... */ };Это мозговыносяще. Но это работает. Компилятор развернет всю рекурсию, и в итоговом коде будет прямая последовательность операций.
Что это дает на практике?
Абсолютно ничего. (Шутка).
На самом деле, такие эксперименты меняют подход к оптимизации:
- Понимание накладных расходов: Когда борешься за каждый байт на NES, начинаешь видеть, сколько памяти съедает даже простой фреймворк вроде llama.cpp на служебные структуры.
- Альтернативные архитектуры: Может, не всегда нужен трансформер? Для конкретной задачи (автодополнение команд, простой чат) n-gram модель с хорошим сжатием даст результат быстрее и на weaker hardware.
- Безопасность и детерминизм: Compile-time модель не может быть изменена во время выполнения. Никаких инъекций в веса, никаких проблем с лицензиями на распространение модели отдельным файлом.
- Образовательная ценность: Попробовав написать LLM с нуля на ассемблере, начинаешь по-настоящему понимать, что такое внимание, эмбеддинг и softmax. Не как абстракции из библиотеки, а как последовательность машинных инструкций.
FAQ: Частые вопросы и ошибки
1. Почему бы просто не использовать уже квантованную 2-bit модель?
Потому что даже 2-bit квантованная версия TinyLlama занимает десятки мегабайт. Она требует поддержки операций с целыми числами, загрузки весов в память, выполнения матричных умножений. NES с этим не справится физически. Речь идет о смене парадигмы, а не о сжатии в рамках одной.
2. Compile-time модель – это же только для фиксированного промпта?
Не обязательно. Можно написать код так, чтобы модель компилировалась как библиотека, а инференс принимал строку (массив char) во время выполнения. Но сами вычисления (матричные умножения, активации) будут развернуты компилятором и превратятся в предсказуемый набор инструкций. Это сложно, но возможно с помощью техник метапрограммирования и constexpr всего на свете.
3. Где взять код для экспериментов?
На GitHub ищете по ключевым словам: "NES language model", "6502 AI", "compile-time neural network C++". Есть несколько research и хобби-проектов. Не ожидайте production-ready кода, но там есть рабочие прототипы, с которых можно начать.
4. Самая частая ошибка новичков?
Пытаться портировать существующую микро-архитектуру (например, NanoGPT). Она все равно слишком тяжела. Нужно проектировать архитектуру с нуля, исходя из жестких ограничений целевой платформы. Сначала лимиты памяти и вычислений, потом – идея модели.
Итог: Запуск LLM на NES и в compile-time – это не про utility. Это про mastery. Это вызов самому себе: "Насколько далеко я могу зайти?" После такого опыта взгляд на обычную оптимизацию моделей меняется навсегда. Ты начинаешь видеть жир там, где другие видят necessity.