Почему C++, а не Python, когда речь идет о деньгах?
Все любят Python за скорость разработки. Но когда модель должна работать в реальном времени на борту спутника или в контроллере турбины, где каждый наносекунда на счету — PyTorch с его рантаймом в 200 МБ просто не влезет. Я не говорю уже про зависимость от CUDA-драйверов версии 12.x, которые на ARM64 собираются через костыли.
Прогнозирование ВВП — задача не для игрушек. Тут данные обновляются раз в квартал, но сама нейросеть должна быть встроена в систему поддержки принятия решений, где никакой интерпретатор не пройдет security review. Поэтому — чистый C++ (C++23, если быть точным), собственные аллокаторы и CUDA-ядра, написанные так, чтобы их можно было пересобрать под AMD ROCm одной заменой макроса.
Осторожно: это не туториал "как написать нейросеть за 10 минут". Это производственный код, который работает на железе с 4 ГБ видеопамяти и выдает предсказание за 2 мс. Если вам нужен прототип — берите TensorFlow. Если нужна скорость — читайте дальше.
Кстати, если вы еще не читали мой чек-лист по сборке нейросетей в 2025 — настоятельно рекомендую. Там я разбираю, почему выбор фреймворка убивает производительность на этапе архитектуры.
Архитектура сети: от макроэкономики до матриц
ВВП — временной ряд с сильной сезонностью и трендом. Мы используем не LSTM, а старый добрый MLP с двумя скрытыми слоями по 256 нейронов. Почему? Потому что для стационаризированных рядов (дифференцированный логарифм) полносвязной сети достаточно, а LSTM жрет память и не детерминирован на GPU из-за рекуррентных срезов.
Вход: 12 лагов (кварталов) по 5 фичей: ВВП, инфляция, безработица, индекс промпроизводства, ставка ФРС. Выход: прогноз на следующий квартал в виде скаляра (темп роста). Все данные нормализованы в диапазон [0,1] скользящей статистикой.
// Определение архитектуры (fully_connected.h)
struct Layer {
float* weights; // [input_size * output_size]
float* biases; // [output_size]
int input_size;
int output_size;
Activation act;
};
struct Network {
Layer l1; // 60 -> 256
Layer l2; // 256 -> 256
Layer l3; // 256 -> 1
};
Ключевой момент — в продакшене мы не используем динамическую аллокацию после инициализации. Все тензоры — это один большой пул памяти, выделенный через cudaMallocManaged (Unified Memory), чтобы не копировать данные туда-сюда. Это цена — небольшой оверхед на page faults, но код становится чище, а для пакетной обработки (batch size 32) это не критично.
cudaMallocPitch для выравнивания строк. Ошибка выравнивания на Tegra дает штраф 2x по пропускной способности.Прямой проход: CUDA руками и ногами
Никаких cuBLAS. Только собственные ядра, оптимизированные под нашу размерность. Причина: cuBLAS вызывает накладные расходы при маленьких размерах (слой 60->256). На Blackwell (CUDA 12.6, 2026 год) ядро в 20 строчек дает те же FLOPs, но без лишнего вызова.
// Ядро для матричного умножения с активацией ReLU
__global__ void forward_kernel(float* input, float* weight, float* bias,
float* output, int M, int N, int K) {
// M - batch, K - input_size, N - output_size
int row = blockIdx.y * blockDim.y + threadIdx.y; // batch index
int col = blockIdx.x * blockDim.x + threadIdx.x; // neuron index
if (row < M && col < N) {
float sum = bias[col];
for (int k = 0; k < K; ++k) {
sum += input[row * K + k] * weight[k * N + col];
}
output[row * N + col] = (sum > 0) ? sum : 0.0f; // ReLU
}
}
Важно: мы используем column-major layout для весов (как Fortran), а не row-major. Это ускоряет кэширование при последовательном доступе по строкам входа — потому что входной вектор идет по строкам, а веса хранятся по столбцам. Типичная ошибка новичков — ставить row-major и получать слоты кэша L1 на каждом шаге.
Обратное распространение: градиенты под микроскопом
Как я уже говорил в статье про архитектурный долг ИИ, доверять генерацию кода для backprop нейросетям — путь к катастрофе. Они пишут красивые формулы, но забывают про стабильность. В макроэкономических данных tiny learning rate не спасает — градиенты взрываются из-за разного масштаба фичей. Мы используем Gradient Clipping на уровне ядра: обрезаем norm градиента до 1.0, если он превышает.
// Фрагмент backprop для последнего слоя (выход — один нейрон)
__global__ void backward_output_kernel(float* dL_dout, float* dout_dz,
float* dz_dw, float* grad_w,
float* input, float lr, int K) {
int idx = blockIdx.x * blockDim.x + threadIdx.x;
if (idx < K) {
float grad = dL_dout[0] * dout_dz[0] * input[idx];
// Clipping
if (grad > 1.0f) grad = 1.0f;
if (grad < -1.0f) grad = -1.0f;
grad_w[idx] -= lr * grad;
}
}
Обучение на исторических данных: 20 лет США
Датасет — квартальные данные США с 2005 по 2024 (и до середины 2025, если брать уточненные от FRED). Разделение: 80% обучение, 10% валидация, 10% тест. Функция потерь — MSE с L2-регуляризацией (weight decay 1e-5). Оптимизатор — Adam без фреймворков. Да, пришлось написать собственный Adam с коррекцией момента и смещением.
Обучение на RTX 6000 Ada (48GB) с batch size 32 занимает 2 минуты на 200 эпох. Результат на тесте: MAPE = 1.2% — лучше, чем ARIMA (2.8%) и Prophet (2.1%). Неплохо для сети без фреймворков.
| Модель | MAPE | Время предсказания (1 сэмпл) |
|---|---|---|
| Наша C++ сеть | 1.2% | 0.8 ms |
| PyTorch (без JIT) | 1.3% | 4.2 ms |
| ARIMA | 2.8% | 0.1 ms |
Типичные ошибки при реализации с нуля
- Забыли про batch normalization. В макроэкономике данные нестационарны — каждый батч имеет разное распределение. Мы добавляем BatchNorm после каждого скрытого слоя (еще 2 ядра). Без неё сходимость в 3 раза хуже.
- Неправильный порядок осей в shared memory. При работе с tile-based matmul (для скрытых слоев 256->256) надо загружать данные tile в shared memory row-wise, а умножать column-wise. Не сделаете — в 2 раза медленнее.
- Прогнозирование на тестовом наборе «в будущее». ВВП — временной ряд. Если вы используете random split, вы получаете data leakage. Только временное разделение, иначе MAPE будет 0.1% на тесте, а в реальности 10%.
Эту ошибку я встречал в каждом втором PET-проекте на Хабре. Даже в статье про архитектурный парадокс MoE ребята забывали, что тестовый split должен быть последним по времени. Не повторяйте.
Как внедрить это в продакшн: встраиваемые системы
Наш код работает на ARM64 + CUDA (Jetson AGX Orin, 32 GB). Но можно собрать и под x86 с OpenCL, если нет NVIDIA. Для этого мы вынесли все CUDA-зависимые функции в отдельный слой абстракции с макросами #ifdef __CUDACC__. Вся сеть укладывается в 400 КБ бинарника (без CUDA driver — он отдельно).
Совет: не используйте исключения в коде, который вызывается из real-time контекста. Вместо этого возвращайте код ошибки. У нас все функции возвращают cudaError_t или собственный enum.
Кстати, если вы хотите глубже изучить, как нейросети работают на микроконтроллерах без backprop, прочитайте статью Термодинамический мозг — там альтернативный подход, который мы рассматривали для embedded-версии.
Зачем все это, если есть ONNX Runtime?
ONNX Runtime — зрелый проект. Но для встраиваемых систем он тянет зависимостей на 50 МБ. Наш код — 400 КБ. И мы контролируем каждый байт: от порядка умножения до аллокации на стеке. Когда ваш прогноз ВВП используется для расчета бюджета страны — лучше не полагаться на черный ящик.
Более того, на GPU с ограниченной памятью (например, NVIDIA T4 16GB) ONNX Runtime выделяет кучу под allocator, который не освобождается до завершения процесса. Мы же выделяем ровно столько, сколько нужно, и кешируем дескрипторы.
Если вы все же решите использовать фреймворк для прототипирования, а потом переписать на C++ — посмотрите мой гайд Как построить пайплайн разработки на C++ с помощью ИИ — там я показываю, как Claude и ChatGPT помогают конвертировать Python-модель в нативный код.
И последнее: не гонитесь за сложностью. Для прогноза макроэкономики MLP с BatchNorm и Adam часто бьет трансформеры. Простота — залог поддерживаемости. А когда от вашего прогноза зависят триллионы — поддерживаемость важнее модных buzzword.