Запуск LLM на NES и в compile-time C++: экстремальная оптимизация моделей | AiManual
AiManual Logo Ai / Manual.
18 Янв 2026 Гайд

Экстремальная оптимизация языковых моделей: как запустить LLM на NES и в compile-time C++

Технический эксперимент: как запустить языковую модель на Nintendo Entertainment System и в compile-time C++ с помощью template метапрограммирования. 6502 asm,

Зачем это вообще нужно?

Все говорят про запуск 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-байтовый префикс
; Дальше – бинарный поиск по таблице в ПЗУ...

Умножение вероятностей? Эмулируем сдвигами и сложениями. Генерация случайного числа? Используем линейный конгруэнтный генератор, зашитый в код.

💡
Реальный проект "Talk to the Power Pad" (можно найти на GitHub) реализует подобную идею. Модель обучена на репликах из старых игр, упакована в 128 КБ и способна генерировать тексты в духе "It's dangerous to go alone! Take this."

Другой полюс: 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++, который содержит эти веса как константы в шаблонах. Процесс выглядит так:

  1. Обучаешь крошечную модель (например, на PyTorch).
  2. Пишишь скрипт, который проходит по всем весам и генерирует .hpp файл с вложенными шаблонами.
  3. Подключаешь этот файл в свой проект.
  4. Компилируешь. Модель "живет" в типизированном мире компилятора.

Инференс (прямой проход) становится серией 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. Не как абстракции из библиотеки, а как последовательность машинных инструкций.
💡
Если ваш проект не требует экстрима, используйте проверенные инструменты. Для локального запуска полноценных моделей есть LM Studio и llama.cpp, а для браузера – MLC.

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.