Как я обучил GPT с нуля на русском языке — и что из этого получилось

Всё началось с наивной мысли: зачем платить за API или запускать 7B-модель, если нужна небольшая модель для простых разговоров на одном языке? Большие модели умеют всё и сразу, но это избыточно. Казалось, что 0.7B, заточенная под один язык и стиль общения, справится не хуже.

Спойлер: это было наивно. Но путь оказался ценнее результата.

В этой статье — как я прошёл путь от стандартного nanoGPT до кастомной архитектуры с RoPE, SwiGLU и GQA, собрал русскоязычный корпус с нуля и реализовал распределённое обучение на бесплатных Colab-воркерах через Google Drive.

Почему не взять готовую модель?

Честный ответ: хотел понять, как это работает изнутри. Взять Qwen или Llama и сделать файнтюнинг — просто и эффективно. Но когда строишь модель с нуля, каждая деталь перестаёт быть магией.

Была и конкретная задача: нужен персонаж с определённым стилем речи на русском. Казалось логичным — меньше модель, меньше ресурсов, проще управлять поведением. Позже стало ясно: 0.7B — это слишком мало для качественной генерации связного текста.

Датасет: какой язык похож на нужный мне

Перед сбором данных встал неочевидный вопрос: какой стиль русского языка нужен? Официальные новости и научные тексты — нет. Нужен живой, разговорный, эмоциональный язык.

Корпус собрал из трёх частей:

  • Taiga — готовый русскоязычный корпус: новости, журналы, художественная литература, субтитры. Хорошая база, но стиль неоднородный.
  • Собственный скрейп — около 566 тысяч документов из игровых медиа, блог-платформ и литературных сообществ. Здесь живой язык: эмоции, сленг, неформальные обсуждения.
  • FineWeb2 (rus_Cyrl) — веб-корпус, но сырой. Пришлось фильтровать стриминговым пайплайном.

Фильтрация FineWeb2

Веб-данные — это мусор по умолчанию: SEO-тексты, прайс-листы, резюме, битые символы. Написал стриминговый фильтр, чтобы не грузить всё в память.

Добавил список стоп-слов («резюме», «прайс-лист», «seo», «вакансия» и т.д.) — это быстро отсекает большую часть мусора до тяжёлых проверок.

Итого: около 12 млрд токенов после фильтрации.

Токенизатор

Создал кастомный BPE-токенизатор на 51 200 токенов, обученный на русскоязычном корпусе. Стандартный токенизатор GPT-2 плохо справляется с русским — слова разбиваются на мелкие части, и контекстное окено расходуется неэффективно.

Эволюция архитектуры: пять итераций

Итерация 1 — GPT-2 Small, базовый старт

Первая модель — чистый nanoGPT без изменений: 124 млн параметров, fp32, без компиляции.

Работает, loss падает, но медленно. Генерация слабая — данных слишком мало. Добавил torch.compile и переход на fp16. Это дало заметный прирост скорости без изменений в архитектуре.

Итерация 2 — GPT-2 Small, динамические гиперпараметры

Эксперимент: изменение гиперпараметров в процессе обучения без перезапуска. Постепенно увеличивал batch size и gradient accumulation, подбирал weight decay.

Прогресс есть: большой gradient accumulation даёт стабильнее обучение. Но производительность падает.

Итерация 3 — GPT-2 Medium, переход на 345 млн параметров

Та же архитектура, больше параметров. Добавил dropout во второй половине обучения.

Итерация 4 — GPT-2 Large, распределённое обучение

GPT-2 Large (774 млн параметров) влезала в одну Colab-сессию, но только с gradient checkpointing. Активации не сохраняются при forward и пересчитываются при backward. Экономия памяти, но скорость падает вдвое. Обучение стало невыносимо медленным.

Из-за этого появилась распределённая схема — об этом далее.

При 5 воркерах ускорение — около x2 относительно одного. Казалось бы, мало, но на практике это разница между «обучение идёт» и «обучение стоит». После 15 млрд токенов качество генерации стало ощутимо лучше.

Итерация 5 — кастомная архитектура

Финальная модель — переработанная архитектура с современными компонентами. По сути, это то, что отличает LLaMA от GPT-2.

Использованы:

  • RoPE — относительные позиционные эмбеддинги через поворот векторов Q и K. Лучше экстраполирует на длинные контексты.
  • SwiGLU — вместо одной матрицы в FFN — две с gate-механизмом. hidden_dim = int(4 * d_model * 2/3), чтобы сохранить FLOPs.
  • GQA — Grouped Query Attention. Одна группа KV на несколько Q-голов. Меньше памяти, быстрее инференс, особенно с длинным контекстом.
  • Flash Attention — ускорение вычислений на GPU.

Loss похож на итерацию 3, но модель видела меньше токенов. Архитектура эффективнее использует параметры, а инференс — заметно быстрее.

Детали архитектуры

RoPE заменяет абсолютные позиционные эмбеддинги — расстояние между токенами кодируется в attention.

SwiGLU в FFN даёт лучшее качество при тех же вычислительных затратах.

GQA снижает объём KV-кеша и ускоряет инференс.

Selective Gradient Checkpointing

Полный gradient checkpointing режет память вдвое, но замедляет обучение на ~30%. Решение — чекпоинтить только часть слоёв.

На практике чекпоинтинг чётных слоёв дал лучший баланс между памятью и скоростью.

Распределённое обучение на Colab через Google Drive

Самая нестандартная часть. GPT-2 Large с полным gradient checkpointing обучалась слишком медленно на одной сессии. Нужно было распараллелить.

Стандартный DDP (NCCL/Gloo) невозможен в Colab — нет постоянных IP и общей сети. Решение: использовать Google Drive как шину передачи градиентов.

Идея: Google Drive как шина градиентов

Google Диск в Colab монтируется как файловая система. Каждый воркер пишет и читает файлы. Градиенты передаются через файлы.

Каждый воркер знает свой worker_id и общее число count_workers. После backward pass сохраняет градиенты под своим ID и номером итерации, затем ждёт файлы от других.

Усреднение градиентов делает каждый воркер самостоятельно.

Инфраструктура: несколько аккаунтов Google

Каждый воркер — отдельная Colab-сессия под своим Google-аккаунтом. Общая папка на Google Drive, открытая для всех, используется как точка синхронизации. Каждый воркер монтирует свой диск, но имеет доступ к общей папке — туда и пишутся градиенты.

Два процесса на каждом воркере

В одной сессии работали два процесса. Обучение запускалось через subprocess.Popen — фоново, не блокируя ячейку.

Вторая ячейка запускала даунлоадер — следил за появлением файлов градиентов других воркеров и скачивал их локально.

Флаги как сигнал готовности

Google Drive не синхронизирует незавершённые файлы, но при скачивании файл может появиться до полной загрузки.

Решение: после скачивания даунлоадер создавал локальный .flag-файл.

Процесс обучения ждал флаг, а не сам файл. Это гарантирует, что градиенты полностью загружены.

Градиенты в float16

Файлы градиентов для Large-модели весят сотни мегабайт. Сохранение в float32 — слишком медленно. Решение: сохранять в float16, конвертировать при загрузке.

Ограничения подхода

  • Скорость синхронизации: ожидание Google Drive — самый медленный этап. Несколько секунд на итерацию накапливаются.
  • КПД распараллеливания: 5 воркеров дали ускорение x2, а не x5. Большая часть времени — ожидание.
  • Нестабильность: Colab-сессии падают без предупреждения. Обязательно нужно уметь возобновлять обучение с чекпоинта.
  • Масштаб: больше 5–6 воркеров — координация через файлы становится узким местом.

Для прода такой подход не годится. Для бесплатного обучения на Colab — работает.

Результаты и выводы

Что я понял

0.7B — это мало. Для нормальных связных диалогов нужно минимум 3–7B, и даже тогда требуется качественный файнтюнинг. Идея, что «маленькая специализированная модель заменит большую», на практике работает хуже ожиданий.

Данные важнее архитектуры. Модель v4 с простой GPT-2 Large-архитектурой и 15 млрд токенов показала лучший loss, чем v5 с современной архитектурой, но 5 млрд токенов. Качество и объём данных важнее архитектурных улучшений на малых масштабах.

Распределённое обучение — не только про скорость. Два воркера с синхронизацией через Drive позволили обучить модель, которая не помещалась в одну сессию. Это важнее ускорения.

Chinchilla-оптимум достигли только v2 и v4. Около 20 токенов на параметр. Это объясняет их отрыв от других итераций лучше, чем любые архитектурные детали. Остальные модели просто недообучены из-за нехватки данных.

Читать оригинал