Развертывание нейросетевых моделей в 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 - тонкая настройка сессии

Что это: Конфигурационный объект, который определяет как будет выполняться модель. Это самый важный объект для оптимизации производительности.

Основные категории настроек:

Категория

Методы

Влияние на производительность

Параллелизм

SetIntraOpNumThreads(), SetInterOpNumThreads(), SetExecutionMode()

До 300% на многоядерных CPU

Оптимизации

SetGraphOptimizationLevel(), EnableCpuMemArena()

20-50% ускорение

Провайдеры

AppendExecutionProvider_CUDA(), AppendExecutionProvider_OpenVINO()

5-100x на GPU/NPU

Память

DisableMemPattern(), EnableCpuMemArena()

Стабильность 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 - загруженная модель

Что это: Объект, представляющий загруженную и оптимизированную модель. При создании сессии:

  1. Загружается ONNX-файл

  2. Проверяется корректность графа

  3. Применяются оптимизации (fusion операций, удаление ненужных узлов)

  4. Выбирается лучший провайдер для каждой операции

  5. Выделяется память под промежуточные тензоры

Конструкторы:

// Из файла
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: получить указатель на устройство
// (требует кастомного аллокатора)
  1. Данные в Ort::Value должны жить дольше вызова Run()

  2. 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→Host

6. 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 и видим граф вычислений. Нас интересуют:

  1. Входной узел (Input): Обычно имеет имя (например, "input") и форму (например, [batch_size, 6]).

  2. Выходной узел (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-поддержкой. Изменения в коде минимальны:

  1. Добавление провайдера выполнения: В SessionOptions нужно добавить соответствующий провайдер (например, CUDAExecutionProvider). Делается это через options.AppendExecutionProvider_CUDA(...). Для GPU обычно не задают количество потоков вручную, как для CPU.

  2. Память: Тензоры автоматически будут создаваться в GPU-памяти, если использовать соответствующий MemoryInfo. ORT также поддерживает копирование данных с CPU на GPU "под капотом", если передать CPU тензор в сессии с GPU провайдером.

  3. Асинхронность: GPU-провайдеры часто поддерживают асинхронное выполнение, что позволяет совмещать вычисления на GPU с подготовкой данных на CPU.

Вывод

ONNX Runtime предоставляет элегантный и мощный C++ API для инференса нейросетевых моделей. Он позволяет:

  • Избавиться от зависимостей от тяжелых ML-фреймворков в продакшн-коде.

  • Достичь высокой производительности за счет оптимизированных ядер и гибкой настройки сессии.

  • Унифицировать процесс развертывания моделей из разных фреймворков через единый ONNX формат.

  • Легко переключаться между CPU и GPU выполнениями с минимальными изменениями кода.

Представленный каркас классов можно легко адаптировать под любую табличную модель, изменив логику предобработки в методе update, параметры нормализации и тип тензора Ort::MemoryInfo. Это делает подход идеальным для встраивания ML-моделей в высоконагруженные C++ приложения, где важны контроль над памятью, потоками и детерминированное поведение.