Happy-LLM, глава 5: строим свою LLaMA2
Конспект пятой главы книги Datawhale «Строим большую модель с нуля»: LLaMA2-подобная модель на PyTorch (RMSNorm, RoPE, GQA, SwiGLU), обучение BPE-токенизатора, предобучение модели на 215 млн параметров, SFT и генерация.
Пятая глава книги «Строим большую модель с нуля» (Happy-LLM) — первая практическая. Авторы по кирпичику собирают на PyTorch модель в стиле LLaMA2, обучают для неё BPE-токенизатор на китайском корпусе, предобучают модель на ≈215 млн параметров, дообучают её на диалогах и запускают генерацию. Готовые веса выложены на ModelScope.
Конспект написан своими словами. Код ниже — наши короткие иллюстрации к каждому шагу; полный авторский код с
китайскими комментариями лежит в папке
docs/chapter5/code репозитория книги
(k_model.py — модель, train_tokenizer.py, dataset.py, ddp_pretrain.py, ddp_sft_full.py, model_sample.py).
Авторы благодарят проекты llama2.c и llm.c Андрея Карпатого и MiniMind — код главы во многом на них опирается.
Коротко
Заголовок раздела «Коротко»- Модель — Decoder-only Transformer как в LLaMA2: RMSNorm вместо LayerNorm, поворотные позиционные эмбеддинги (RoPE), групповое внимание (GQA), полносвязный блок SwiGLU, общие веса у эмбеддингов и выходного слоя.
- Конфигурация: тестовая — 768 / 12 слоёв (≈83 млн параметров); для обучения — размер 1024, 18 слоёв, 16 голов запросов и 8 голов ключей-значений, словарь 6144, контекст 512 (≈215 млн параметров).
- Токенизатор — байтовый BPE (библиотека
tokenizersот Hugging Face) со словарём 6144 и служебными токенами для чата в формате Qwen2.5. - Данные: предобучение — китайский корпус Seq-Monkey (компания Mobvoi, ≈10 млрд токенов), SFT — 3,5 млн диалогов BelleGroup.
- Обучение: Adam, косинусное расписание шага, накопление градиентов, обучение на нескольких картах. У авторов на 8×RTX 4090 предобучение заняло 46 часов, SFT — 24 часа.
Как читать вместе с курсом
Заголовок раздела «Как читать вместе с курсом»| Раздел главы | Урок курса |
|---|---|
| 5.1 RMSNorm, RoPE, SwiGLU | 4.5.3 Современный блок |
5.1.3 GQA и repeat_kv | 5.2.1 MQA и GQA |
| 5.2 Виды токенизаторов, обучение BPE | 4.2.2 BPE, 4.2.3 Токенизаторы на практике |
| 5.3.3–5.3.4 Датасет, цикл обучения | 4.6.3 Цикл обучения, 5.6.2 Проект предобучения |
| 5.3.5 SFT и маска потерь | 6.1.2 SFT |
| 5.3.6 Генерация: температура, top-k | 4.1.3 Сэмплирование |
Это параллельный маршрут к модулю 5.6 (MiniMind): архитектура почти та же, MiniMind дальше уходит в RL, а Happy-LLM подробнее объясняет каждый блок. Удобно читать главу перед уроком 5.6.1 или сразу после него.
5.1 Модель LLaMA2 своими руками
Заголовок раздела «5.1 Модель LLaMA2 своими руками»flowchart TB T["Номера токенов"] --> E["Эмбеддинги<br/>(общие веса с выходом)"] E --> L["N блоков декодера"] subgraph B["Блок декодера (Pre-Norm)"] N1["RMSNorm"] --> A["Внимание GQA + RoPE<br/>с каузальной маской"] A --> R1["+ остаток"] R1 --> N2["RMSNorm"] --> M["SwiGLU MLP"] --> R2["+ остаток"] end L --> F["RMSNorm"] --> O["Linear до размера словаря"] --> P["Логиты следующего токена"]
5.1.1 Гиперпараметры
Заголовок раздела «5.1.1 Гиперпараметры»Все размеры хранятся в одном классе конфигурации. Книга наследует его от PretrainedConfig из библиотеки
transformers: так модель потом легко сохранить и загрузить в формате Hugging Face. Основные поля:
| Поле | Значение по умолчанию | Смысл |
|---|---|---|
dim | 768 | размер вектора токена (в обучаемой модели — 1024) |
n_layers | 12 | число блоков (в обучаемой — 18) |
n_heads | 16 | голов запросов |
n_kv_heads | 8 | голов ключей и значений (GQA) |
vocab_size | 6144 | размер словаря |
hidden_dim | вычисляется | размер скрытого слоя MLP |
multiple_of | 64 | к чему округлять hidden_dim |
norm_eps | 1e-5 | в RMSNorm |
max_seq_len | 512 | максимальная длина контекста |
dropout | 0.0 | вероятность dropout |
flash_attn | True | использовать быстрое внимание PyTorch, если доступно |
5.1.2 RMSNorm
Заголовок раздела «5.1.2 RMSNorm»Формула уже знакома по главе 3: вектор делится на корень из среднего квадрата координат и умножается на обучаемый
масштаб . Книга проверяет модуль на тензоре формы (1, 50, 768): нормализация не меняет форму. Наш вариант:
import torchimport torch.nn as nn
class RMSNorm(nn.Module): def __init__(self, dim, eps=1e-5): super().__init__() self.eps = eps self.weight = nn.Parameter(torch.ones(dim)) # γ — обучаемый масштаб, сначала единицы
def forward(self, x): # Считаем в float32 для точности, потом возвращаем исходный тип (bf16/fp16) rms = torch.rsqrt(x.float().pow(2).mean(-1, keepdim=True) + self.eps) # 1 / sqrt(mean(x²) + eps) return (x.float() * rms).type_as(x) * self.weight
print(RMSNorm(768)(torch.randn(1, 50, 768)).shape) # torch.Size([1, 50, 768])5.1.3 Внимание: GQA и RoPE
Заголовок раздела «5.1.3 Внимание: GQA и RoPE»Групповое внимание (GQA). Книга отмечает, что в самой LLaMA2 GQA есть только у модели на 70 млрд, но использует
его и в маленькой модели — оно экономит память. Идея: голов запросов 16, а голов ключей и значений — 8; каждая пара
«ключ-значение» обслуживает 2 головы запросов. Для вычислений ключи и значения «размножают» до 16 голов — это функция
repeat_kv. В книге она сделана через expand и reshape (без копирования данных до последнего шага); вот
эквивалентная запись короче:
import torch
def repeat_kv(x, n_rep): """x: (батч, длина, n_kv_heads, head_dim) -> (батч, длина, n_kv_heads * n_rep, head_dim).""" if n_rep == 1: return x return torch.repeat_interleave(x, n_rep, dim=2) # каждая kv-голова повторяется n_rep раз подряд
k = torch.randn(1, 5, 8, 64) # 8 голов ключейprint(repeat_kv(k, 2).shape) # torch.Size([1, 5, 16, 64]) — как у 16 голов запросовСколько это экономит: при размере 1024 и 16 головах (по 64) матрицы и при 8 kv-головах имеют размер 1024 × 512 вместо 1024 × 1024 — вдвое меньше параметров, и вдвое меньше KV-кэш при генерации.
Поворотные эмбеддинги (RoPE). Вместо того чтобы прибавлять позицию к вектору токена, RoPE поворачивает векторы
запросов и ключей: пары координат поворачиваются на угол , где — номер позиции,
а (в коде книги основание задаётся параметром theta). Тогда скалярное произведение
запроса на позиции и ключа на позиции зависит только от разности — это та же цель, что и в выводе
синусоидального кодирования в главе 2.
Книга реализует RoPE в три шага: заранее посчитать косинусы и синусы углов для всех позиций
(precompute_freqs_cis), привести их форму к форме запросов для трансляции (reshape_for_broadcast) и применить
поворот (apply_rotary_emb), расписав комплексное умножение через вещественную и мнимую части. Наш компактный вариант:
import torch
def rope_tables(head_dim, max_len, theta=10000.0): """Косинусы и синусы углов m·θ_i для всех позиций m и всех пар координат i.""" inv_freq = 1.0 / theta ** (torch.arange(0, head_dim, 2).float() / head_dim) # θ_i, длина head_dim/2 angles = torch.outer(torch.arange(max_len).float(), inv_freq) # (max_len, head_dim/2) return angles.cos(), angles.sin()
def apply_rope(x, cos, sin): """x: (батч, длина, головы, head_dim). Поворачиваем каждую пару (x_чёт, x_нечёт) на свой угол.""" T = x.shape[1] cos, sin = cos[:T, None, :], sin[:T, None, :] # форма (длина, 1, head_dim/2) — трансляция по головам x_even, x_odd = x[..., 0::2], x[..., 1::2] out = torch.empty_like(x) out[..., 0::2] = x_even * cos - x_odd * sin # формула поворота на плоскости out[..., 1::2] = x_even * sin + x_odd * cos return out
cos, sin = rope_tables(head_dim=64, max_len=512)q = torch.randn(1, 50, 16, 64)print(apply_rope(q, cos, sin).shape) # torch.Size([1, 50, 16, 64])# Поворот не меняет длину векторов:print(torch.allclose(q.norm(dim=-1), apply_rope(q, cos, sin).norm(dim=-1), atol=1e-5)) # TrueСборка внимания. Модуль внимания в книге: три линейных слоя , , (последние два — на меньшее число
голов), поворот RoPE для запросов и ключей, repeat_kv, затем либо быстрое
torch.nn.functional.scaled_dot_product_attention с is_causal=True (если доступно), либо ручной расчёт с маской из
, как в главе 2. В конце — выходная проекция и dropout. Проверка: вход (1, 50, 768) даёт выход той
же формы.
5.1.4 Блок MLP (SwiGLU)
Заголовок раздела «5.1.4 Блок MLP (SwiGLU)»Вместо «Linear → ReLU → Linear» LLaMA использует SwiGLU — блок с тремя матрицами:
Что здесь написано: две параллельные проекции вверх; одна проходит через SiLU и работает как «вентиль», который поэлементно () умножается на другую; затем проекция вниз. Чтобы число параметров было как у обычного MLP с расширением в 4 раза, скрытый размер берут как и округляют вверх до кратного 64: для получается 2048, для — 2752.
import torch.nn as nnimport torch.nn.functional as F
def ffn_hidden(dim, multiple_of=64): h = int(2 * (4 * dim) / 3) # 2/3 от 4·dim return multiple_of * ((h + multiple_of - 1) // multiple_of) # округление вверх до кратного 64
class SwiGLU(nn.Module): def __init__(self, dim, dropout=0.0): super().__init__() hidden = ffn_hidden(dim) self.w1 = nn.Linear(dim, hidden, bias=False) # ветка-вентиль self.w3 = nn.Linear(dim, hidden, bias=False) # ветка-значение self.w2 = nn.Linear(hidden, dim, bias=False) # обратно в размер модели self.drop = nn.Dropout(dropout)
def forward(self, x): return self.drop(self.w2(F.silu(self.w1(x)) * self.w3(x)))5.1.5 Блок декодера
Заголовок раздела «5.1.5 Блок декодера»Блок собирается из уже готовых частей по схеме Pre-Norm:
h = x + attention(norm1(x)), затем out = h + mlp(norm2(h)). Форма на выходе равна форме на входе.
5.1.6 Модель целиком
Заголовок раздела «5.1.6 Модель целиком»Модель — это эмбеддинги, dropout, стопка из n_layers блоков, финальный RMSNorm и выходной линейный слой до размера
словаря. Детали авторской реализации:
- Общие веса: матрица эмбеддингов и выходной слой — один и тот же тензор (weight tying). Это экономит млн параметров.
- Таблицы RoPE считаются один раз при создании модели и хранятся как буферы.
- Инициализация: нормальное распределение со стандартным отклонением 0,02, а для выходных проекций каждого блока ( и ) — уменьшенное в раз, чтобы сумма остаточных вкладов не росла с глубиной (приём из GPT-2).
- Прямой проход при наличии целевых токенов считает перекрёстную энтропию по всем позициям; без них — логиты
только для последней позиции. Модель возвращает результат в формате
CausalLMOutputWithPastизtransformers.
Книга проверяет модель на случайных номерах токенов формы (1, 50): выход (1, 1, 6144), параметров 82 594 560.
Откуда это число — посчитаем сами (код запускается без PyTorch):
def ffn_hidden(dim, multiple_of=64): """Размер скрытого слоя SwiGLU по правилу LLaMA: 2/3 от 4·dim, округлить вверх до кратного multiple_of.""" h = int(2 * (4 * dim) / 3) return multiple_of * ((h + multiple_of - 1) // multiple_of)
def count_params(dim, n_layers, n_heads, n_kv_heads, vocab_size, tied=True): head_dim = dim // n_heads attn = dim * dim * 2 + dim * (n_kv_heads * head_dim) * 2 # wq и wo полные, wk и wv — только для kv-голов mlp = 3 * dim * ffn_hidden(dim) # w1, w2, w3 norms = 2 * dim # два RMSNorm в каждом слое layer = attn + mlp + norms emb = vocab_size * dim # таблица эмбеддингов out = 0 if tied else vocab_size * dim # выходной слой делит веса с эмбеддингами return n_layers * layer + emb + out + dim # + финальный RMSNorm
print(ffn_hidden(768), ffn_hidden(1024)) # 2048 2752print(count_params(768, 12, 16, 8, 6144)) # 82594560 — тестовая конфигурацияprint(count_params(1024, 18, 16, 8, 6144)) # 215127040 — модель, которую обучаютВидно, что почти все параметры — в блоках (MLP весит больше внимания), а эмбеддинги при маленьком словаре дают всего несколько процентов.
5.2 Токенизатор
Заголовок раздела «5.2 Токенизатор»5.2.1–5.2.3 Три подхода
Заголовок раздела «5.2.1–5.2.3 Три подхода»| Подход | Как режет | Плюсы | Минусы |
|---|---|---|---|
| По словам (word-based) | по пробелам и знакам препинания | просто, понятно человеку | неизвестные слова (OOV), огромный словарь, нет пробелов в китайском и японском |
| По символам (character-based) | каждый символ — токен | нет неизвестных слов, любой язык | очень длинные последовательности, символ мало значит сам по себе |
| Подслова (subword) | части слов | баланс длины и смысла, редкие слова собираются из частей | нужно обучать |
Три алгоритма подслов, которые описывает книга:
- BPE (Byte Pair Encoding): начинаем с символов и раз за разом склеиваем самую частую соседнюю пару, пока словарь не достигнет нужного размера. Пример: «lower» → «low» + «er», «newest» → «new» + «est».
- WordPiece (BERT): тоже склеивает, но выбирает пару, которая сильнее всего повышает правдоподобие языковой
модели. Продолжения слов помечаются
##: «unhappiness» → «un», «##happiness». - Unigram: начинает с большого словаря и выкидывает лишнее; у каждого подслова своя вероятность, и текст режется на самую вероятную последовательность.
Подробно, с кодом BPE с нуля, — урок 4.2.2.
5.2.4 Обучаем BPE-токенизатор
Заголовок раздела «5.2.4 Обучаем BPE-токенизатор»Книга обучает байтовый BPE библиотекой tokenizers от Hugging Face. Шаги:
- Зависимости:
tokenizers,datasets,transformers. - Данные: тот же корпус, что для предобучения (Seq-Monkey). Авторы предупреждают, что на всём корпусе может не
хватить оперативной памяти, советуют взять часть, а готовый токенизатор выкладывают в репозиторий
(
code/tokenizer_k). - Конфигурация: файлы
tokenizer_config.jsonиspecial_tokens_map.json. Шаблон чата (chat_template) взят как у Qwen2.5. - Обучение: модель BPE, предварительное деление на байты (ByteLevel), служебные токены
<unk>,<s>,</s>,<|im_start|>,<|im_end|>; размер словаря 6144. - Проверка: размер словаря, служебные токены, применение шаблона чата, кодирование и обратное декодирование (текст должен восстановиться без потерь).
Как выглядит диалог после применения шаблона в стиле Qwen (служебные токены отделяют роли):
<|im_start|>systemYou are a helpful assistant.<|im_end|><|im_start|>userПривет!<|im_end|><|im_start|>assistantЗдравствуйте! Чем помочь?<|im_end|>Почему словарь такой маленький (6144 против 150 тыс. у Qwen)? Модель маленькая, а матрица эмбеддингов растёт со словарём. Плата — китайский и особенно другие языки режутся на большее число токенов. Это обсуждается в уроке 4.2.3.
5.3 Предобучение маленькой LLM
Заголовок раздела «5.3 Предобучение маленькой LLM»5.3.1 Данные
Заголовок раздела «5.3.1 Данные»- Seq-Monkey (универсальный текстовый датасет компании Mobvoi) — около 10 млрд токенов: веб-страницы, энциклопедии, блоги, вопросы
и ответы, код, книги, газеты, патенты, учебники, экзаменационные задачи. Скрипты
download_dataset.shиdeal_dataset.pyскачивают его и режут длинные тексты на куски, удобные для контекста 512. - BelleGroup train_3.5M_CN — 3,5 млн китайских диалогов (человек — модель, человек — человек, ролевые). Скрипт переводит их в формат «список сообщений с ролями» для SFT.
5.3.2 Токенизатор
Заголовок раздела «5.3.2 Токенизатор»Тот же код, что в 5.2.4, только на реальном корпусе; функция eval_tokenizer() проверяет результат. Можно сразу взять
готовый токенизатор из репозитория.
5.3.3 Датасеты
Заголовок раздела «5.3.3 Датасеты»Для предобучения (PretrainDataset). Текст кодируется в номера токенов, к нему добавляется <s> в начале, он
обрезается или дополняется до длины 512. Затем строка сдвигается на один токен: вход X — все токены, кроме последнего,
цель Y — все, кроме первого. Маска потерь закрывает позиции-заполнители (padding). Пример при длине 9:
| Позиции | |
|---|---|
| Последовательность | <s> T1 T2 T3 T4 T5 T6 T7 </s> |
| Вход X | <s> T1 T2 T3 T4 T5 T6 T7 |
| Цель Y | T1 T2 T3 T4 T5 T6 T7 </s> |
Для SFT (SFTDataset). Диалог проходит через шаблон чата, X и Y получаются тем же сдвигом. Разница — в маске
потерь: единицы стоят только на токенах ответов ассистента — от <|im_start|>assistant до <|im_end|>
включительно. Это тот самый «способ 3» из главы 4: все ответы диалога учатся за один проход. Наша иллюстрация:
def loss_mask(tokens, start=("<|im_start|>", "assistant"), end="<|im_end|>"): """1 — токен ответа ассистента (по нему считаем потери), 0 — всё остальное.""" mask, inside, i = [0] * len(tokens), False, 0 while i < len(tokens): if tuple(tokens[i:i + 2]) == start: # начало реплики ассистента inside, i = True, i + 2 continue if inside: mask[i] = 1 # учимся предсказывать и сам <|im_end|> if tokens[i] == end: inside = False i += 1 return mask
dialog = ["<|im_start|>", "user", "Привет", "<|im_end|>", "<|im_start|>", "assistant", "Здравствуйте", "!", "<|im_end|>"]print(loss_mask(dialog)) # [0, 0, 0, 0, 0, 0, 1, 1, 1](В авторском коде маска строится по номерам токенов, а не по строкам, но логика та же.)
5.3.4 Предобучение
Заголовок раздела «5.3.4 Предобучение»Сначала книга показывает, как модель генерирует: метод generate берёт логиты последней позиции, делит их на
температуру, при необходимости оставляет только top-k самых вероятных токенов, сэмплирует следующий токен,
приписывает его к последовательности и повторяет, пока не встретит конец текста или не исчерпает лимит. При
температуре 0 выбирается самый вероятный токен (жадный поиск).
import torch
@torch.no_grad()def generate(model, idx, max_new_tokens, temperature=1.0, top_k=None, eos_id=None, max_len=512): """idx: (1, T) — номера токенов затравки. Возвращает затравку + сгенерированное продолжение.""" for _ in range(max_new_tokens): logits = model(idx[:, -max_len:]).logits[:, -1, :] # логиты последней позиции; контекст не длиннее max_len if temperature == 0: next_id = logits.argmax(dim=-1, keepdim=True) # жадный выбор else: logits = logits / temperature # <1 — увереннее, >1 — разнообразнее if top_k is not None: kth = torch.topk(logits, top_k).values[:, [-1]] logits[logits < kth] = float("-inf") # оставляем только k лучших next_id = torch.multinomial(logits.softmax(-1), num_samples=1) idx = torch.cat([idx, next_id], dim=1) if eos_id is not None and next_id.item() == eos_id: break return idxСкрипт обучения (ddp_pretrain.py) по шагам:
- Разбор аргументов: число эпох (1), батч на карту (64), шаг обучения (2e-4), накопление градиентов (8 шагов), номера видеокарт, частота логирования и сохранения; по желанию — логирование в SwanLab.
- Расписание шага: линейный разогрев (по умолчанию выключен), затем косинусное убывание до 1/10 начального шага.
- Модель с конфигурацией 1024 / 18 слоёв, токенизатор,
PretrainDataset, загрузчик данных. - Оптимизатор Adam, смешанная точность (bf16/fp16 через
autocastиGradScaler). - Цикл: прямой проход → потери с маской → деление на число шагов накопления → обратный проход; каждые 8 шагов — обрезка нормы градиента и шаг оптимизатора. Периодически — лог и сохранение весов.
- Несколько карт подключаются через
torch.nn.DataParallel(аргумент--gpus 0,1,...) — несмотря на префиксddp_в имени файла.
Эффективный батч: последовательностей на карту за шаг оптимизатора, то есть тыс. токенов; на 8 картах — около 2,1 млн токенов на шаг.
5.3.5 SFT
Заголовок раздела «5.3.5 SFT»Скрипт ddp_sft_full.py почти совпадает с предобучением: отличаются только датасет (SFTDataset с маской на ответах)
и стартовые веса — загружаются веса после предобучения. Это полное дообучение всех параметров (не LoRA).
5.3.6 Генерация текста
Заголовок раздела «5.3.6 Генерация текста»model_sample.py содержит класс TextGenerator: он загружает веса и токенизатор и умеет два режима — продолжение текста
базовой моделью и ответ на вопрос в формате чата (через шаблон). Книга показывает примеры: базовая модель гладко
продолжает китайские фразы, SFT-модель отвечает на вопросы — для 215 млн параметров связно, но, конечно, без глубоких
знаний.
Сколько это стоит. По данным авторов: предобучение — 46 часов на 8×RTX 4090, SFT на 3,5 млн диалогов — 24 часа. При батче 4 модель помещается в 7 ГБ видеопамяти, но на одной карте предобучение займёт порядка 533 часов. Готовые модели — Happy-LLM-Chapter5-Base-215M и SFT-215M — лежат на ModelScope, там же есть онлайн-демо.
Что сделать руками
Заголовок раздела «Что сделать руками»- Запустите
count_paramsвыше и посчитайте параметры для своей конфигурации: например,dim=512, 8 слоёв, 8 голов, 4 kv-головы, словарь 6144. Ответ: 26 747 392. - Проверьте свойство RoPE: возьмите случайные
qиk, поверните их на позиции (5, 3) и (12, 10) и сравните скалярные произведения — они совпадут, потому что разность позиций одинакова. - Обучите BPE-токенизатор
tokenizersна нескольких мегабайтах русского текста со словарём 6144 и посчитайте, сколько токенов в среднем уходит на слово. Сравните с токенизатором Qwen. - Склонируйте репозиторий книги, установите зависимости из
docs/chapter5/code/requirements.txtи запустите предобучение на маленьком куске данных и маленькой конфигурации — потеря должна падать с ≈8,7 (это ). - Скачайте готовую SFT-модель с ModelScope и сравните ответы при температуре 0, 0,7 и 1,2.
Словарик
Заголовок раздела «Словарик»| English | Русский |
|---|---|
| hyperparameter | гиперпараметр |
| RMSNorm | нормализация по среднеквадратичному |
| rotary position embedding, RoPE | поворотные позиционные эмбеддинги |
| grouped-query attention, GQA | групповое внимание по запросам |
| key-value heads | головы ключей и значений |
| gated linear unit | вентильный блок SwiGLU |
| weight tying | общие веса эмбеддингов и выхода |
| tokenizer | токенизатор |
| out-of-vocabulary, OOV | неизвестное слово |
| subword | подслово |
| special tokens | служебные токены |
| chat template | шаблон чата |
| loss mask | маска потерь |
| gradient accumulation | накопление градиентов |
| cosine schedule | косинусное расписание шага |
| temperature / top-k sampling | температура / сэмплирование top-k |