Развертывание нейросетевых моделей в production-среде — критически важный этап ML-пайплайна. Когда речь заходит о встраивании в C++ приложения (будь то высоконагруженные сервисы, desktop-софт или встраиваемые системы), выбор инструментария сужается. Прямое использование фреймворков вроде PyTorch или TensorFlow часто избыточно и приводит к зависимостям, сложностям сборки и излишнему потреблению памяти.
ONNX Runtime (ORT) — это высокопроизводительный движок для выполнения моделей в формате Open Neural Network Exchange (ONNX). Он предлагает оптимизированные реализации для CPU и GPU, поддержку различных аппаратных ускорителей и, что ключевое, простой C++ API. В этой статье мы разберем, как выполнить инференс модели для табличных данных, используя ONNX Runtime в C++ проекте.
Ссылка для скачивания: Библиотеку можно получить через официальный GitHub (сборка из исходников). Но для простоты часто достаточно забрать предсобранные бинарники из релизов.
Преимущества ONNX Runtime перед альтернативами
Сравнение с TensorRT
NVIDIA TensorRT — мощный фреймворк для инференса, но с ключевыми ограничениями:
Жесткая привязка к железу NVIDIA: Не работает на CPU, AMD или других GPU
Сложность портирования: Требует компиляции модели под конкретную GPU
Переконвертация: Модель из ONNX → TensorRT может требовать дополнительной настройки
ONNX Runtime в этом плане универсален:
Кросс-платформенность: Один формат модели работает на CPU, NVIDIA GPU, AMD GPU (через ROCm), Intel (OpenVINO), Arm NPU и других акселераторах
Гибкость деплоя: Можете разрабатывать на CPU, а в продакшне переключиться на GPU изменением одной опции
Единый пайплайн: Одна модель → один формат → множество устройств
ORT оптимизирован именно для инференса:
Минимальный footprint: Библиотека на порядок легче полного фреймворка
Статическая компиляция графа: максимальная производительность за счет предварительных оптимизаций
Чистый inference-ориентированный API: Все то, что нужно для предсказаний
Ключевые сущности ONNX Runtime C++ API
Перед работой с ONNX Runtime необходимо понять его архитектуру и основные абстракции. Вот детальный разбор ключевых сущностей, которые составляют основу любого инференс-приложения на C++.
1. Ort::Env - глобальное окружение
Что это: Корневой объект, представляющий среду выполнения ONNX Runtime. Это синглтон на уровне процесса, который инициализирует внутренние системы ORT: менеджер памяти, систему логирования, реестр провайдеров.
Создается один раз при старте приложения. Не создавайте несколько Env объектов - это пустая трата ресурсов и может привести к неопределенному поведению.
Конструктор:
// Уровни логирования от наиболее подробного к наименее:
// ORT_LOGGING_LEVEL_VERBOSE - для отладки, очень много логов
// ORT_LOGGING_LEVEL_INFO - информационные сообщения
// ORT_LOGGING_LEVEL_WARNING - предупреждения (рекомендуемый уровень по умолчанию)
// ORT_LOGGING_LEVEL_ERROR - только ошибки
// ORT_LOGGING_LEVEL_FATAL - только критические ошибки
Ort::Env env(ORT_LOGGING_LEVEL_WARNING, "MyApplication");
// Второй параметр - логин-префикс для фильтрации в логахВажные нюансы:
Envдолжен жить дольше всех сессий, созданных в его контекстеВ многопоточных приложениях доступ к
Envпотокобезопасен
2. Ort::SessionOptions - тонкая настройка сессии
Что это: Конфигурационный объект, который определяет как будет выполняться модель. Это самый важный объект для оптимизации производительности.
Основные категории настроек:
Категория | Методы | Влияние на производительность |
|---|---|---|
Параллелизм |
| До 300% на многоядерных CPU |
Оптимизации |
| 20-50% ускорение |
Провайдеры |
| 5-100x на GPU/NPU |
Память |
| Стабильность vs скорость |
Пример продвинутой конфигурации:
Ort::SessionOptions options;
// Оптимизация для inference (убирает dropout, объединяет операции)
options.SetGraphOptimizationLevel(GraphOptimizationLevel::ORT_ENABLE_EXTENDED);
// Параллелизм: 4 потока для операций, 2 для независимых ветвей графа
options.SetIntraOpNumThreads(4);
options.SetInterOpNumThreads(2);
options.SetExecutionMode(ExecutionMode::ORT_PARALLEL);
// Для embedded систем с ограниченной памятью:
options.DisableMemPattern(); // Отключаем динамическое выделение памяти
options.DisableCpuMemArena(); // Фиксированный размер памяти
// Включаем профилирование (замедляет на 5-10%, но дает детальную статистику)
options.EnableProfiling("model_execution_profile");
// Документация по всем опциям:
// https://onnxruntime.ai/docs/api/c/group___global.html
// https://onnxruntime.ai/docs/execution-providers/3. Ort::Session - загруженная модель
Что это: Объект, представляющий загруженную и оптимизированную модель. При создании сессии:
Загружается ONNX-файл
Проверяется корректность графа
Применяются оптимизации (fusion операций, удаление ненужных узлов)
Выбирается лучший провайдер для каждой операции
Выделяется память под промежуточные тензоры
Конструкторы:
// Из файла
Ort::Session session(env, "model.onnx", options);
// Из буфера в памяти (удобно для зашитых в бинарник моделей)
std::vector<uint8_t> model_data = loadModelData();
Ort::Session session(env, model_data.data(), model_data.size(), options);
// С пользовательскими путями (для mobile/embedded)
Ort::Session session(env, model_path, options);Ключевые методы:
// Получить информацию о входах/выходах модели
size_t num_inputs = session.GetInputCount();
size_t num_outputs = session.GetOutputCount();
// Получить метаданные входного тензора i
Ort::TypeInfo type_info = session.GetInputTypeInfo(i);
Ort::TensorTypeAndShapeInfo tensor_info = type_info.GetTensorTypeAndShapeInfo();
ONNXTensorElementDataType type = tensor_info.GetElementType(); // например ONNX_TENSOR_ELEMENT_DATA_TYPE_FLOAT
std::vector<int64_t> shape = tensor_info.GetShape(); // например {1, 6}
// Имя входа/выхода по индексу
char* input_name = session.GetInputName(i, allocator);4. Ort::Value - тензор с данными
Что это: Умный контейнер для данных, передаваемых в модель и получаемых из нее. Основные особенности:
Автоматическое управление памятью (RAII)
Поддержка CPU и GPU памяти через единый интерфейс
Информация о форме (shape) и типе данных (dtype)
Создание тензоров:
// 1. Тензор из существующего буфера (без копирования)
std::vector<float> input_data(6, 0.0f);
Ort::Value tensor = Ort::Value::CreateTensor<float>(
memory_info, // Ort::MemoryInfo
input_data.data(), // указатель на данные
input_data.size(), // общее количество элементов
shape.data(), // форма {1, 6}
shape.size() // ранг (2)
);
// 2. Тензор с собственной памятью (ORT управляет памятью)
Ort::Value tensor = Ort::Value::CreateTensor<float>(
memory_info,
shape.data(),
shape.size()
);
// затем копируем данные:
float* tensor_data = tensor.GetTensorMutableData<float>();
std::copy(input_data.begin(), input_data.end(), tensor_data);
// 3. Тензор на GPU (требуется GPU провайдер)
Ort::MemoryInfo gpu_mem_info("Cuda", OrtArenaAllocator, 0, OrtMemTypeDefault);
Ort::Value gpu_tensor = Ort::Value::CreateTensor<float>(gpu_mem_info, shape.data(), shape.size());Методы доступа:
// Проверка, что это тензор
bool is_tensor = tensor.IsTensor();
// Получить информацию
Ort::TensorTypeAndShapeInfo info = tensor.GetTensorTypeAndShapeInfo();
std::vector<int64_t> shape = info.GetShape();
size_t element_count = info.GetElementCount();
// Доступ к данным
const float* data = tensor.GetTensorData<float>(); // только чтение
float* mutable_data = tensor.GetTensorMutableData<float>(); // для записи
// Для GPU: получить указатель на устройство
// (требует кастомного аллокатора)Данные в
Ort::Valueдолжны жить дольше вызоваRun()GPU память освобождается автоматически при разрушении
Ort::Value
Важные нюансы:
Данные в
Ort::Valueдолжны жить дольше вызоваRun()GPU память освобождается автоматически при разрушении
Ort::Value
5. Ort::MemoryInfo - где живут данные
Что это: Дескриптор, описывающий местонахождение и способ выделения памяти. Это критически важная абстракция для гетерогенных вычислений.
Создание:
// CPU память (самый частый случай)
Ort::MemoryInfo cpu_mem_info = Ort::MemoryInfo::CreateCpu(
OrtArenaAllocator, // Использовать arena (быстрее)
OrtMemTypeCPU // Обычная CPU память
);
// CPU память без arena (для embedded/real-time)
Ort::MemoryInfo cpu_mem_info_no_arena = Ort::MemoryInfo::CreateCpu(
OrtDeviceAllocator, // Прямое выделение
OrtMemTypeCPU
);
// GPU память (если есть CUDA/ROCM провайдер)
Ort::MemoryInfo cuda_mem_info = Ort::MemoryInfo::CreateCpu(
"Cuda", // Имя провайдера
OrtArenaAllocator,
0, // Device ID
OrtMemTypeDefault // Память устройства по умолчанию
);Особенности для GPU:
// При использовании CUDA провайдера:
// 1. Входные данные могут быть в CPU памяти - ORT сам скопирует
// 2. Для zero-copy лучше создавать тензоры сразу в GPU памяти
// 3. MemoryInfo для входов и выходов может различаться
// Пример: модель работает на GPU, но выход хотим получить на CPU
std::vector<const char*> output_names = {"output"};
std::vector<Ort::Value> outputs = session.Run(
run_options,
input_names.data(), input_tensors.data(), input_tensors.size(),
output_names.data(), output_names.size()
);
// outputs[0] будет в CPU памяти, даже если вычисления на GPU
// ORT автоматически выполнил cudaMemcpy Device→Host6. Ort::RunOptions - параметры выполнения
Что это: Настройки для конкретного вызова Session::Run. В отличие от SessionOptions, которые настраивают сессию глобально, RunOptions позволяют контролировать отдельный запуск.
Основные сценарии использования:
Ort::RunOptions run_options;
// 1. Логирование конкретного запуска
run_options.SetRunLogVerbosityLevel(ORT_LOGGING_LEVEL_VERBOSE);
run_options.SetRunTag("inference_batch_42"); // Метка для поиска в логах
// 2. Обработка прерываний (для interactive приложений)
run_options.SetTerminate(); // Асинхронная остановка выполнения
// 3. Профилирование только этого запуска
run_options.AddConfigEntry("profiling.enable", "1");
// 4. Выбор конкретного провайдера для этого запуска
// (если сессия поддерживает несколько)
run_options.AddConfigEntry("execution_provider_preference", "CUDA:0;CPU:1");7. Ort::Allocator - управление памятью
Что это: Низкоуровневый интерфейс для управления памятью. Обычно используется ORT внутренне, но доступен для продвинутых сценариев.
Когда нужен:
Кастомные аллокаторы для embedded систем
Shared memory между процессами
Memory-mapped файлы для больших моделей
Пример пользовательского аллокатора:
class CustomAllocator : public OrtAllocator {
public:
void* Alloc(size_t size) override {
return my_memory_pool.allocate(size);
}
void Free(void* p) override {
my_memory_pool.free(p);
}
const OrtMemoryInfo* GetInfo() const override {
return Ort::MemoryInfo::CreateCpu("Custom", OrtCustomAllocator, 0, OrtMemTypeCPU);
}
};
// Использование в сессии
CustomAllocator custom_allocator;
options.AddConfigEntry("session.use_custom_allocator", "1");Подготовка модели и ее изучение в Netron
Допустим, у нас уже есть готовая модель, например, для прогнозирования на основе 6 признаков, сохраненная как model.onnx. Первый шаг — понять ее сигнатуру: имена входных и выходных узлов, а также форму входных данных.
Для визуализации и изучения архитектуры ONNX моделей существует отличное бесплатное приложение Netron (https://netron.app/). У него также есть свой репозитория (https://github.com/lutzroeder/netron). Просто открываем в нем файл *.onnx и видим граф вычислений. Нас интересуют:
Входной узел (Input): Обычно имеет имя (например,
"input") и форму (например,[batch_size, 6]).Выходной узел (Output): Также имеет имя (например,
"output").
Именно по этим именам ORT будет искать тензоры в графе модели. Запишем их в константы в нашем коде.
Практика: Классы для инференса на C++
Рассмотрим реализацию двух классов, которые инкапсулируют работу с ONNX Runtime.
Базовый класс BaseNeuralModel
#pragma once
#include <cmath>
#include <string>
#include <vector>
#include <onnxruntime/onnxruntime_cxx_api.h> // Подключаем C++ API ORT
class BaseNeuralModel {
public:
BaseNeuralModel(
const Ort::Env & env,
Ort::SessionOptions network_session_options,
std::string network_path,
std::vector<float> offset_data,
std::vector<float> scale_data,
std::vector<std::int64_t> input_shape) noexcept
: network_session_options_(network_session_options),
network_path_(network_path),
// Создание сессии.
// Сессия загружает модель из файла, проводит оптимизации графа
// и готовит его к выполнению на выбранном провайдере (CPU/GPU).
session_(env, network_path_.c_str(), network_session_options_),
offset_data_(offset_data),
scale_data_(scale_data),
input_shape_(input_shape),
input_data_(input_shape_.back(), 0.0F),
// Создание входного тензора.
// Ort::Value - обертка ORT для тензора.
// CreateTensor не копирует данные, а использует переданный указатель (input_data_.data()).
// Важно: данные должны жить дольше, чем этот тензор.
input_tensor_(Ort::Value::CreateTensor<float>(
mem_info_,
input_data_.data(),
input_data_.size(),
input_shape_.data(),
input_shape_.size())) {}
BaseNeuralModel() = delete;
virtual ~BaseNeuralModel() = default;
[[nodiscard]] virtual float update(const std::vector<InputParam> & input) noexcept = 0;
private:
[[nodiscard]] virtual float calcNeural() noexcept = 0;
protected:
Ort::SessionOptions network_session_options_;
std::string network_path_;
Ort::Session session_; // Основной объект для выполнения модели
// Имена входного и выходного узлов.
// Должны точно совпадать с именами, увиденными в Netron.
static constexpr auto input_name_onnx_ = "input";
static constexpr auto output_name_onnx_ = "output";
std::vector<float> offset_data_;
std::vector<float> scale_data_;
std::vector<std::int64_t> input_shape_;
std::vector<float> input_data_; // Буфер для входных данных
// Информация о памяти.
// Указывает ORT, где размещать/искать тензоры (CPU память в данном случае).
Ort::MemoryInfo mem_info_ = Ort::MemoryInfo::CreateCpu(OrtArenaAllocator, OrtMemTypeCPU);
Ort::RunOptions run_opts_; // Опции для выполнения (можно оставить по умолчанию)
Ort::Value input_tensor_; // Тензор, связанный с буфером input_data_
};Реализация конкретной модели NeuralModel
#pragma once
#include "base_neural_model.hpp"
class NeuralModel final : public BaseNeuralModel {
public:
using BaseNeuralModel::BaseNeuralModel;
~NeuralModel() override = default;
[[nodiscard]] float update(const std::vector<InputParam> & input) noexcept override {
// Предобработка данных.
// Чаще всего данные нужно нормализовать. Здесь применяется Scale-Shift.
// Вычисления происходят в буфере input_data_, на который ссылается input_tensor_.
for (std::size_t i = 0; i != 3; ++i) {
input_data_[i] = (input[i].target_acc - offset_data_[i]) * scale_data_[i];
}
const auto & input3 = input[3];
input_data_[3] = (input3.target_acc - offset_data_[3]) * scale_data_[3];
input_data_[4] = (input3.curr_vel - offset_data_[4]) * scale_data_[4];
input_data_[5] = (input3.curr_acc - offset_data_[5]) * scale_data_[5];
// Запуск метода инференса.
return calcNeural();
}
private:
[[nodiscard]] float calcNeural() noexcept override {
// Вызов Session::Run - точка запуска модели.
// Метод принимает имена узлов и соответствующие им тензоры.
// Возвращает вектор Ort::Value с результатами.
auto output = session_.Run(run_opts_,
&input_name_onnx_, &input_tensor_, 1, // 1 входной тензор
&output_name_onnx_, 1); // 1 выходной тензор
// ПОЛУЧЕНИЕ РЕЗУЛЬТАТА.
// Извлекаем сырые данные из выходного тензора.
return output.front().GetTensorMutableData<float>()[0];
}
};Настройка сессии: Ort::SessionOptions
Тонкая настройка сессии — залог производительности и предсказуемого потребления памяти. ORT предоставляет богатый набор опций. Вот часть из них:
void configureSession(Ort::SessionOptions & options) {
// Уровни оптимизации графа (по нарастанию агрессивности):
// ORT_DISABLE_ALL, ORT_ENABLE_BASIC, ORT_ENABLE_EXTENDED, ORT_ENABLE_ALL
options.SetGraphOptimizationLevel(GraphOptimizationLevel::ORT_ENABLE_EXTENDED);
// Параллелизм на CPU:
options.SetIntraOpNumThreads(4); // Потоки внутри операций (MatMul, Conv)
options.SetInterOpNumThreads(2); // Потоки между независимыми операциями
options.SetExecutionMode(ExecutionMode::ORT_PARALLEL); // ORT_SEQUENTIAL для детерминизма
// Отключение паттернов памяти (важно для real-time):
// Без этого ORT может аллоцировать память "умно", но с непредсказуемыми задержками
options.DisableMemPattern();
// Использование arena-аллокатора (по умолчанию включено):
// Ускоряет выделение памяти за счет пула предварительных аллокаций
options.EnableCpuMemArena(); // Или DisableCpuMemArena() для полного контроля
// Логирование
// Уровни логирования:
// ORT_LOGGING_LEVEL_VERBOSE, INFO, WARNING, ERROR, FATAL
options.SetLogSeverityLevel(ORT_LOGGING_LEVEL_WARNING);
// Идентификатор сессии для логов:
options.SetLogId("MyModelSession");
// Профилирование производительности:
options.EnableProfiling("profile.json"); // Сохранит timeline выполнения
}Инференс на GPU: что изменится?
Переход с CPU на GPU в ORT требует пересборки библиотеки с поддержкой CUDA (или ROCm), либо использования готовых бинарников с GPU-поддержкой. Изменения в коде минимальны:
Добавление провайдера выполнения: В
SessionOptionsнужно добавить соответствующий провайдер (например,CUDAExecutionProvider). Делается это черезoptions.AppendExecutionProvider_CUDA(...). Для GPU обычно не задают количество потоков вручную, как для CPU.Память: Тензоры автоматически будут создаваться в GPU-памяти, если использовать соответствующий
MemoryInfo. ORT также поддерживает копирование данных с CPU на GPU "под капотом", если передать CPU тензор в сессии с GPU провайдером.Асинхронность: GPU-провайдеры часто поддерживают асинхронное выполнение, что позволяет совмещать вычисления на GPU с подготовкой данных на CPU.
Вывод
ONNX Runtime предоставляет элегантный и мощный C++ API для инференса нейросетевых моделей. Он позволяет:
Избавиться от зависимостей от тяжелых ML-фреймворков в продакшн-коде.
Достичь высокой производительности за счет оптимизированных ядер и гибкой настройки сессии.
Унифицировать процесс развертывания моделей из разных фреймворков через единый ONNX формат.
Легко переключаться между CPU и GPU выполнениями с минимальными изменениями кода.
Представленный каркас классов можно легко адаптировать под любую табличную модель, изменив логику предобработки в методе update, параметры нормализации и тип тензора Ort::MemoryInfo. Это делает подход идеальным для встраивания ML-моделей в высоконагруженные C++ приложения, где важны контроль над памятью, потоками и детерминированное поведение.
