Почему C++17, если есть PyTorch?
Май 2026. PyTorch оброс адаптерами, лорами и квантизацией. Чтобы запустить LLM, достаточно трёх строк на Python. Но спроси любого Senior ML-инженера, как работает backprop в multi-head attention — и услышишь мычание. Библиотеки стали настолько удобными, что превратили инженеров в пользователей, а не создателей.
Я решил пойти другим путём. Написать трансформер на C++17 с нуля — без единой внешней зависимости (даже Eigen). Не потому, что это быстрее (хотя фреймворки вроде TensorRT выжимают больше). А чтобы разобраться до уровня, когда архитектура трансформера перестаёт быть чёрным ящиком.
В этой статье — полный разбор реализации. Чистый C++17, ручной backprop, обучение на CPU. Никакого CUDA (оставим для следующего раза). Только два файла: transformer.hpp и main.cpp. Поехали.
Архитектура: что мы будем писать
Берём классический encoder-only трансформер (как в BERT). Небольшой — 4 слоя, 4 головы внимания, размер эмбеддинга 128. Достаточно, чтобы обучить на задаче классификации тональности (положительный/отрицательный отзыв) или генерации следующего токена на маленьком корпусе.
- Токенизация: BPE без внешних библиотек, только стандартные контейнеры.
- Эмбеддинги + позиционные кодировки: синусоидальные, без обучения.
- Multi-Head Self-Attention: с маскировкой и масштабированием.
- Feed-Forward: два линейных слоя + ReLU.
- Layer Normalization: с learnable параметрами.
- Residual connections: стандартные skip-connection.
- Backprop: свой autograd-граф для каждого слоя.
- Оптимизатор: Adam с weight decay.
Весь код — около 1500 строк. Полный репозиторий лежит на GitHub (ссылка будет в конце).
Токенизация: BPE без боли
Самый частый вопрос: "Как делать токенизатор на C++ без regex?" Ответ — никак. Ну, почти. Мы используем простой character-level токенизатор с парой правил: разбиваем по пробелам и знакам препинания, затем каждое слово — в последовательность символов. Это не BPE, но для демонстрации сойдёт. Если хотите полноценный BPE — в статье про детерминированные трансформеры описан метод с merge правилами.
Типичная ошибка: пытаться хранить весь словарь в std::map с ключом std::string. При миллионах токенов поиск превращается в тормоз. Используйте std::unordered_map с кастомным хэшем или trie.
// Пример: токенизация строки в вектор int
std::vector<int> tokenize(const std::string& text,
const std::unordered_map<std::string, int>& vocab) {
std::vector<int> ids;
std::string word;
for (char ch : text) {
if (std::isalnum(ch) || ch == '_') {
word += ch;
} else {
if (!word.empty()) {
auto it = vocab.find(word);
ids.push_back(it != vocab.end() ? it->second : vocab.at("[UNK]"));
word.clear();
}
// добавляем знаки препинания как отдельные токены
if (!std::isspace(ch)) {
ids.push_back(vocab.at(std::string(1, ch)));
}
}
}
return ids;
}
Эмбеддинги + позиционные кодировки
Эмбеддинги — простая матрица vocab_size x d_model. Позиционные кодировки — синусоиды, как в оригинальной статье "Attention Is All You Need". Никаких learnable позиций — меньше параметров, проще бэкпроп.
// Вычисление позиционных энкодингов
std::vector<float> positional_encoding(int pos, int d_model) {
std::vector<float> pe(d_model);
for (int i = 0; i < d_model; i += 2) {
float angle = pos / std::pow(10000.0f, i / (float)d_model);
pe[i] = std::sin(angle);
if (i + 1 < d_model) pe[i + 1] = std::cos(angle);
}
return pe;
}
Складываем эмбеддинг токена и позиционный вектор. Всё просто.
Multi-Head Attention: сердце трансформера
Здесь зарыто больше всего граблей. Реализация на C++ без BLAS — вызов. Придётся вручную писать умножение матриц и хитро оптимизировать кэш.
Структура: Linear -> Split heads -> Scaled dot-product -> Concat -> Linear. Пара ключевых моментов:
- Маскировка: для decoder нужно закрывать будущие токены. В encoder — можно без маски.
- Scale factor: 1/sqrt(d_k) — иначе softmax уходит в насыщение.
- Einsum? Забудьте. Пишем тройной вложенный цикл, блокируем по M и N для кэша.
std::mdspan (C++23) или хотя бы плоский вектор с индексами. std::vector<std::vector<float>> — это кэш-ад.// Scaled dot-product attention (одна голова)
std::vector<float> scaled_dot_product_attention(
const std::vector<float>& Q, // [seq_len x d_k]
const std::vector<float>& K, // [seq_len x d_k]
const std::vector<float>& V, // [seq_len x d_k]
const std::vector<uint8_t>& mask, // [seq_len x seq_len], 0/1
int seq_len, int d_k) {
// Матрица внимания = Q * K^T / sqrt(d_k)
std::vector<float> attn(seq_len * seq_len, 0.0f);
for (int i = 0; i < seq_len; ++i) {
for (int j = 0; j < seq_len; ++j) {
float sum = 0.0f;
for (int k = 0; k < d_k; ++k) {
sum += Q[i * d_k + k] * K[j * d_k + k];
}
attn[i * seq_len + j] = sum / std::sqrt(float(d_k));
if (mask[i * seq_len + j] == 0) attn[i * seq_len + j] = -1e9;
}
}
// Softmax по строкам
for (int i = 0; i < seq_len; ++i) {
softmax_row(attn.data() + i * seq_len, seq_len);
}
// Выход = attn * V
std::vector<float> output(seq_len * d_k, 0.0f);
for (int i = 0; i < seq_len; ++i) {
for (int j = 0; j < d_k; ++j) {
float sum = 0.0f;
for (int k = 0; k < seq_len; ++k) {
sum += attn[i * seq_len + k] * V[k * d_k + j];
}
output[i * d_k + j] = sum;
}
}
return output;
}
Собираем блок трансформера
Каждый слой трансформера — это последовательность:
- LayerNorm (перед attention и FFN — pre-norm схема, как в современных моделях).
- Multi-Head Attention + residual.
- LayerNorm.
- Feed-Forward (два линейных слоя с ReLU) + residual.
Класс TransformerBlock хранит все веса: для Q, K, V, выходной линейный слой, два слоя FFN, параметры LayerNorm.
struct TransformerBlock {
// matmul веса хранятся как плоские векторы
std::vector<float> Wq, Wk, Wv, Wo; // [d_model x d_model]
std::vector<float> bias_q, bias_k, bias_v, bias_o;
std::vector<float> w1, b1, w2, b2; // FFN
std::vector<float> ln1_gamma, ln1_beta, ln2_gamma, ln2_beta; // LayerNorm
void forward(const std::vector<float>& x,
std::vector<float>& output,
int seq_len, int d_model) { ... }
};
Обратите внимание: все веса — float. Никаких double. На CPU разница в скорости — до 2x, а точность для обучения достаточно.
Ручной backprop: граф вычислений на коленке
Здесь начинается самое мясо. Вместо того чтобы тащить библиотеку autograd, мы для каждого слоя пишем обратный проход руками. Да, это много кода. Да, это легко ошибиться. Но именно так понимаешь, что chain rule — не магия.
Подход: каждый слой имеет структуру Cache, которая сохраняет входные тензоры forward-а. Затем backward принимает градиент на выходе и вычисляет градиенты на входе и по весам.
Адская ошибка #1: Softmax backward. Функция softmax даёт якобиан размера seq_len x seq_len для каждого токена. Если вычислить его напрямую, будет 4-мерный тензор. Правильный путь — вывести формулу ds = s * (dy - <s*dy>), где s — softmax, dy — градиент на выходе. Никаких матриц.
Для внимание backward градиенты проходят через матричное умножение. Формулы:
dQ = d_attn * KdK = d_attn^T * QdV = attn^T * d_attn- Плюс градиент масштаба и маски.
Весь автоград реализован вручную через флаги и вызовы. Никаких виртуальных функций — только шаблоны и if constexpr.
Оптимизатор Adam: чистый C++, без магии
Adam — это просто два экспоненциальных скользящих средних. Пишем класс, который хранит для каждого параметра m и v. Обновление на каждом шаге — стандартная формула с bias correction.
void Adam::update(std::vector<float>& param,
const std::vector<float>& grad,
std::vector<float>& m,
std::vector<float>& v,
size_t t) {
float lr = learning_rate_;
float beta1 = beta1_;
float beta2 = beta2_;
float eps = epsilon_;
float lr_t = lr * std::sqrt(1.0f - std::pow(beta2, t)) / (1.0f - std::pow(beta1, t));
for (size_t i = 0; i < param.size(); ++i) {
m[i] = beta1 * m[i] + (1.0f - beta1) * grad[i];
v[i] = beta2 * v[i] + (1.0f - beta2) * grad[i] * grad[i];
param[i] -= lr_t * m[i] / (std::sqrt(v[i]) + eps);
}
}
Weight decay добавил отдельным флагом — просто вычитаем weight_decay * param[i] из градиента.
Собираем всё вместе: обучение на CPU
Теперь объединяем в класс Transformer. Метод forward прогоняет вход через стопку блоков, backward — через них в обратном порядке. Абстрактный Loss — CrossEntropy с softmax.
Обучение на CPU: для маленькой модели (d_model=128, 4 слоя, seq_len=64) одна итерация на 100 примерах (батч 8) занимает ~200ms на современном Intel i7-14700. Да, это медленно по сравнению с GPU, но для отладки — идеально.
std::async для матричных умножений. Но это отдельная история, читайте про CUDA ядра или экстремальную оптимизацию.5 граблей, на которые я наступил
- Неинициализированные веса: Xavier нужно считать от входной/выходной размерности. Иначе градиенты взрываются на первых итерациях. Используйте
std::mt19937с нормальным распределением. - Маскировка в backward: градиент по маскированным позициям так и остаётся -1e9, но после softmax он становится нулём. В backward нужно не забыть обнулить градиенты для замаскированных ячеек.
- LayerNorm backward: замучился выводить: сначала посчитать
meanиvar, потомdx = (dy - mean(dy) - (x-mean)*(dy*(x-mean)).sum() / (var*N)) / sqrt(var+eps). Гуглить бесполезно, пришлось выводить самому. - Ошибка в редукции градиентов: когда одна и та же матрица весов используется для всех heads, нужно суммировать градиенты по головам, а не перезаписывать.
- Пул потоков: std::thread для каждого маленького умножения — убивает производительность из-за оверхеда. Пул с
std::jthread(C++20) или свой простой пул.
Что дальше?
Сейчас на GitHub лежит репозиторий cpp17-transformer с полным кодом, включая датасет IMDB (токенизированный) и скрипт для обучения. Результат: трансформер с 4 слоями достигает 84% accuracy на бинарной классификации тональности после 3 эпох. Не рекорд, но работает.
Планирую добавить поддержку KV-кэша для генерации, потом квантование int8. Если вам интересен C++ в DL — посмотрите ещё про фреймворки на стероидах и как использовать ИИ для ускорения разработки на C++.
Напишите в комментариях, какую часть разобрать подробнее — backprop multi-head, LayerNorm или оптимизатор? И не бойтесь писать код руками. После такой реализации вы уже никогда не будете смотреть на трансформер как на чёрный ящик.
FAQ: частые вопросы
Почему C++17, а не C++20/23?
C++17 везде, в том числе в embedded и старой toolchain. Концепты, корутины, mdspan — это круто, но не критично для базовой реализации. KISS.
Но ведь это медленно. Зачем такое делать?
Чтобы понять, как работают трансформеры. Это учебный проект, не продакшн. Для скорости используйте CUDA или готовые фреймворки.
Есть ли реализация на C++ с шаблонами для разных типов (float, half)?
Можно сделать шаблонный класс Transformer<T>. Но половинная точность на CPU эмулируется медленно. Лучше оставить float.
Как проверить корректность обратного прохода?
Сделайте численную проверку градиентов (finite differences). Для каждого параметра w вычислите (loss(w+eps) - loss(w-eps)) / (2*eps) и сравните с аналитическим градиентом. Это спасло меня десятки раз.
P.S. Если решите писать свой трансформер на C++ — не забудьте протестировать на синтетических данных (например, выучивание identity mapping). Если градиенты сошлись — ура, можно на реальные данные.