Перейти к содержимому
ПереводыDatawhale и китайские курсы

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, SwiGLU4.5.3 Современный блок
5.1.3 GQA и repeat_kv5.2.1 MQA и GQA
5.2 Виды токенизаторов, обучение BPE4.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-k4.1.3 Сэмплирование

Это параллельный маршрут к модулю 5.6 (MiniMind): архитектура почти та же, MiniMind дальше уходит в RL, а Happy-LLM подробнее объясняет каждый блок. Удобно читать главу перед уроком 5.6.1 или сразу после него.

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 (схема наша; в книге — рисунок 5.1 с архитектурой LLaMA2)

Все размеры хранятся в одном классе конфигурации. Книга наследует его от PretrainedConfig из библиотеки transformers: так модель потом легко сохранить и загрузить в формате Hugging Face. Основные поля:

ПолеЗначение по умолчаниюСмысл
dim768размер вектора токена (в обучаемой модели — 1024)
n_layers12число блоков (в обучаемой — 18)
n_heads16голов запросов
n_kv_heads8голов ключей и значений (GQA)
vocab_size6144размер словаря
hidden_dimвычисляетсяразмер скрытого слоя MLP
multiple_of64к чему округлять hidden_dim
norm_eps1e-5ϵ\epsilon в RMSNorm
max_seq_len512максимальная длина контекста
dropout0.0вероятность dropout
flash_attnTrueиспользовать быстрое внимание PyTorch, если доступно

Формула уже знакома по главе 3: вектор делится на корень из среднего квадрата координат и умножается на обучаемый масштаб γ\gamma. Книга проверяет модуль на тензоре формы (1, 50, 768): нормализация не меняет форму. Наш вариант:

import torch
import 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])

Групповое внимание (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) матрицы WkW_k и WvW_v при 8 kv-головах имеют размер 1024 × 512 вместо 1024 × 1024 — вдвое меньше параметров, и вдвое меньше KV-кэш при генерации.

Поворотные эмбеддинги (RoPE). Вместо того чтобы прибавлять позицию к вектору токена, RoPE поворачивает векторы запросов и ключей: пары координат (x2i,x2i+1)(x_{2i}, x_{2i+1}) поворачиваются на угол m θim\,\theta_i, где mm — номер позиции, а θi=10000−2i/d\theta_i = 10000^{-2i/d} (в коде книги основание задаётся параметром theta). Тогда скалярное произведение запроса на позиции mm и ключа на позиции nn зависит только от разности m−nm - n — это та же цель, что и в выводе синусоидального кодирования в главе 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

Сборка внимания. Модуль внимания в книге: три линейных слоя WqW_q, WkW_k, WvW_v (последние два — на меньшее число голов), поворот RoPE для запросов и ключей, repeat_kv, затем либо быстрое torch.nn.functional.scaled_dot_product_attention с is_causal=True (если доступно), либо ручной расчёт с маской из −∞-\infty, как в главе 2. В конце — выходная проекция WoW_o и dropout. Проверка: вход (1, 50, 768) даёт выход той же формы.

Вместо «Linear → ReLU → Linear» LLaMA использует SwiGLU — блок с тремя матрицами:

MLP(x)=W2(SiLU(W1x)⊙W3x),SiLU(z)=z⋅σ(z).\mathrm{MLP}(x) = W_2\bigl(\mathrm{SiLU}(W_1 x) \odot W_3 x\bigr), \qquad \mathrm{SiLU}(z) = z \cdot \sigma(z).

Что здесь написано: две параллельные проекции вверх; одна проходит через SiLU и работает как «вентиль», который поэлементно (⊙\odot) умножается на другую; затем проекция вниз. Чтобы число параметров было как у обычного MLP с расширением в 4 раза, скрытый размер берут как 23⋅4d\tfrac{2}{3} \cdot 4d и округляют вверх до кратного 64: для d=768d = 768 получается 2048, для d=1024d = 1024 — 2752.

import torch.nn as nn
import 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)))

Блок собирается из уже готовых частей по схеме Pre-Norm: h = x + attention(norm1(x)), затем out = h + mlp(norm2(h)). Форма на выходе равна форме на входе.

Модель — это эмбеддинги, dropout, стопка из n_layers блоков, финальный RMSNorm и выходной линейный слой до размера словаря. Детали авторской реализации:

  • Общие веса: матрица эмбеддингов и выходной слой — один и тот же тензор (weight tying). Это экономит 6144×1024≈6,36144 \times 1024 \approx 6{,}3 млн параметров.
  • Таблицы RoPE считаются один раз при создании модели и хранятся как буферы.
  • Инициализация: нормальное распределение со стандартным отклонением 0,02, а для выходных проекций каждого блока (WoW_o и W2W_2) — уменьшенное в 2⋅nlayers\sqrt{2 \cdot n_\text{layers}} раз, чтобы сумма остаточных вкладов не росла с глубиной (приём из 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 2752
print(count_params(768, 12, 16, 8, 6144)) # 82594560 — тестовая конфигурация
print(count_params(1024, 18, 16, 8, 6144)) # 215127040 — модель, которую обучают

Видно, что почти все параметры — в блоках (MLP весит больше внимания), а эмбеддинги при маленьком словаре дают всего несколько процентов.

ПодходКак режетПлюсыМинусы
По словам (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.

Книга обучает байтовый BPE библиотекой tokenizers от Hugging Face. Шаги:

  1. Зависимости: tokenizers, datasets, transformers.
  2. Данные: тот же корпус, что для предобучения (Seq-Monkey). Авторы предупреждают, что на всём корпусе может не хватить оперативной памяти, советуют взять часть, а готовый токенизатор выкладывают в репозиторий (code/tokenizer_k).
  3. Конфигурация: файлы tokenizer_config.json и special_tokens_map.json. Шаблон чата (chat_template) взят как у Qwen2.5.
  4. Обучение: модель BPE, предварительное деление на байты (ByteLevel), служебные токены <unk>, <s>, </s>, <|im_start|>, <|im_end|>; размер словаря 6144.
  5. Проверка: размер словаря, служебные токены, применение шаблона чата, кодирование и обратное декодирование (текст должен восстановиться без потерь).

Как выглядит диалог после применения шаблона в стиле Qwen (служебные токены отделяют роли):

<|im_start|>system
You are a helpful assistant.<|im_end|>
<|im_start|>user
Привет!<|im_end|>
<|im_start|>assistant
Здравствуйте! Чем помочь?<|im_end|>

Почему словарь такой маленький (6144 против 150 тыс. у Qwen)? Модель маленькая, а матрица эмбеддингов растёт со словарём. Плата — китайский и особенно другие языки режутся на большее число токенов. Это обсуждается в уроке 4.2.3.

  • Seq-Monkey (универсальный текстовый датасет компании Mobvoi) — около 10 млрд токенов: веб-страницы, энциклопедии, блоги, вопросы и ответы, код, книги, газеты, патенты, учебники, экзаменационные задачи. Скрипты download_dataset.sh и deal_dataset.py скачивают его и режут длинные тексты на куски, удобные для контекста 512.
  • BelleGroup train_3.5M_CN — 3,5 млн китайских диалогов (человек — модель, человек — человек, ролевые). Скрипт переводит их в формат «список сообщений с ролями» для SFT.

Тот же код, что в 5.2.4, только на реальном корпусе; функция eval_tokenizer() проверяет результат. Можно сразу взять готовый токенизатор из репозитория.

Для предобучения (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
Цель YT1 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]

(В авторском коде маска строится по номерам токенов, а не по строкам, но логика та же.)

Сначала книга показывает, как модель генерирует: метод 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. Разбор аргументов: число эпох (1), батч на карту (64), шаг обучения (2e-4), накопление градиентов (8 шагов), номера видеокарт, частота логирования и сохранения; по желанию — логирование в SwanLab.
  2. Расписание шага: линейный разогрев (по умолчанию выключен), затем косинусное убывание до 1/10 начального шага.
  3. Модель с конфигурацией 1024 / 18 слоёв, токенизатор, PretrainDataset, загрузчик данных.
  4. Оптимизатор Adam, смешанная точность (bf16/fp16 через autocast и GradScaler).
  5. Цикл: прямой проход → потери с маской → деление на число шагов накопления → обратный проход; каждые 8 шагов — обрезка нормы градиента и шаг оптимизатора. Периодически — лог и сохранение весов.
  6. Несколько карт подключаются через torch.nn.DataParallel (аргумент --gpus 0,1,...) — несмотря на префикс ddp_ в имени файла.

Эффективный батч: 64×8=51264 \times 8 = 512 последовательностей на карту за шаг оптимизатора, то есть 512×512≈262512 \times 512 \approx 262 тыс. токенов; на 8 картах — около 2,1 млн токенов на шаг.

Скрипт ddp_sft_full.py почти совпадает с предобучением: отличаются только датасет (SFTDataset с маской на ответах) и стартовые веса — загружаются веса после предобучения. Это полное дообучение всех параметров (не LoRA).

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, там же есть онлайн-демо.

  1. Запустите count_params выше и посчитайте параметры для своей конфигурации: например, dim=512, 8 слоёв, 8 голов, 4 kv-головы, словарь 6144. Ответ: 26 747 392.
  2. Проверьте свойство RoPE: возьмите случайные q и k, поверните их на позиции (5, 3) и (12, 10) и сравните скалярные произведения — они совпадут, потому что разность позиций одинакова.
  3. Обучите BPE-токенизатор tokenizers на нескольких мегабайтах русского текста со словарём 6144 и посчитайте, сколько токенов в среднем уходит на слово. Сравните с токенизатором Qwen.
  4. Склонируйте репозиторий книги, установите зависимости из docs/chapter5/code/requirements.txt и запустите предобучение на маленьком куске данных и маленькой конфигурации — потеря должна падать с ≈8,7 (это ln⁡6144\ln 6144).
  5. Скачайте готовую 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