Развертывание нейросетевых моделей в production-среде — критически важный этап ML-пайплайна. Когда речь заходит о встраивании в C++ приложения (будь то высоконагруженные сервисы, desktop-софт или встраиваемые системы), выбор инструментария сужается. Прямое использование фреймворков вроде PyTorch или TensorFlow часто избыточно и приводит к зависимостям, сложностям сборки и излишнему потреблению памяти.
ONNX Runtime (ORT) — это высокопроизводительный движок для выполнения моделей в формате Open Neural Network Exchange (ONNX). Он предлагает оптимизированные реализации для CPU и GPU, поддержку различных аппаратных ускорителей и, что ключевое, простой C++ API. В этой статье мы разберем, как выполнить инференс модели для табличных данных, используя ONNX Runtime в C++ проекте.
Ссылка для скачивания: Библиотеку можно получить через официальный GitHub (сборка из исходников). Но для простоты часто достаточно забрать предсобранные бинарники из релизов.
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++.
Что это: Корневой объект, представляющий среду выполнения 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 потокобезопасен
Что это: Конфигурационный объект, который определяет как будет выполняться модель. Это самый важный объект для оптимизации производительности.
Основные категории настроек:
|
Категория |
Методы |
Влияние на производительность |
|---|---|---|
|
Параллелизм |
|
До 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/
Что это: Объект, представляющий загруженную и оптимизированную модель. При создании сессии:
Загружается 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);
Что это: Умный контейнер для данных, передаваемых в модель и получаемых из нее. Основные особенности:
Автоматическое управление памятью (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
Что это: Дескриптор, описывающий местонахождение и способ выделения памяти. Это критически важная абстракция для гетерогенных вычислений.
Создание:
// 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
Что это: Настройки для конкретного вызова 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");
Что это: Низкоуровневый интерфейс для управления памятью. Обычно используется 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");
Допустим, у нас уже есть готовая модель, например, для прогнозирования на основе 6 признаков, сохраненная как model.onnx. Первый шаг — понять ее сигнатуру: имена входных и выходных узлов, а также форму входных данных.
Для визуализации и изучения архитектуры ONNX моделей существует отличное бесплатное приложение Netron (https://netron.app/). У него также есть свой репозитория (https://github.com/lutzroeder/netron). Просто открываем в нем файл *.onnx и видим граф вычислений. Нас интересуют:
Входной узел (Input): Обычно имеет имя (например, "input") и форму (например, [batch_size, 6]).
Выходной узел (Output): Также имеет имя (например, "output").
Именно по этим именам ORT будет искать тензоры в графе модели. Запишем их в константы в нашем коде.
Рассмотрим реализацию двух классов, которые инкапсулируют работу с ONNX Runtime.
#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_ };
#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 предоставляет богатый набор опций. Вот часть из них:
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 выполнения }
Переход с 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++ приложения, где важны контроль над памятью, потоками и детерминированное поведение.
Источник


