На занятии 3 мы ввели граничные вычисления, формат ONNX и паттерн worker-объект в QThread. Теперь появится настоящая нейросеть. Есть два приложения, которые ОМП опубликовала как официальные примеры для ОС Аврора:
Оба примера написаны на C++/QML, оба используют ONNX Runtime 1.18.1 через пакетный менеджер Conan, оба полностью работают без сети. При этом они представляют два разных класса задач (генерация текста и распознавание речи) и две разные архитектуры многопоточности.
Это не пересказ README, а частичный разбор исходного кода приложения OnnxRunner. Все фрагменты ниже взяты из реальных репозиториев. При подготовке использовалась LLM Anthropic Claude Opus 4.8.
Оба приложения опираются на одну и ту же базу: C++ API ONNX Runtime и единую схему упаковки библиотеки в RPM.
ONNX Runtime (ORT) — кроссплатформенная библиотека инференса моделей в формате ONNX.
| Класс | Назначение | Сколько экземпляров |
|---|---|---|
Ort::Env |
окружение: логирование, глобальные пулы потоков | один на приложение (или на исполнителя) |
Ort::SessionOptions |
настройки сессии: потоки, уровень оптимизации графа | на каждую сессию |
Ort::Session |
загруженная модель + метод Run() |
один на модель |
Ort::Value |
тензор (вход или выход) | на каждый ввод/вывод |
Ort::MemoryInfo |
описание размещения данных (здесь на CPU) | переиспользуется |
Ort::RunOptions |
параметры конкретного запуска | переиспользуется |
Ключевые факты, которые объясняют почти весь код обоих примеров:
Session — дорогая операция (чтение файла модели, построение и оптимизация графа). Поэтому сессию создают один раз и хранят, а не пересоздают на каждый запрос. В обоих примерах сессия живёт всё время работы приложения.Ort::Value::CreateTensor не копирует данные — он лишь оборачивает уже существующий буфер. Получается, исходный std::vector обязан жить до конца Run().GetInputNameAllocated/ GetOutputNameAllocated), а не забивают константами.Session::Run() потокобезопасен для одной сессии (можно вызывать из разных потоков), но конкретные настройки потоков задаются в SessionOptions.Минимальный цикл:
#include <onnxruntime_cxx_api.h>
Ort::Env env(ORT_LOGGING_LEVEL_WARNING, "App"); // окружение — один раз
Ort::SessionOptions options; // настройки сессии
options.SetIntraOpNumThreads(2); // потоки внутри оператора
options.SetGraphOptimizationLevel(ORT_ENABLE_ALL); // слияние операторов и т. п.
Ort::Session session(env, "model.onnx", options); //загрузка модели — один раз
Ort::AllocatorWithDefaultOptions allocator; // имена — у модели
const std::string inName = session.GetInputNameAllocated(0, allocator).get();
const std::string outName = session.GetOutputNameAllocated(0, allocator).get();
Ort::MemoryInfo memInfo = Ort::MemoryInfo::CreateCpu(OrtArenaAllocator, OrtMemTypeDefault);
Ort::Value input = Ort::Value::CreateTensor<float>( // тензор поверх своего буфера
memInfo, data.data(), data.size(), shape.data(), shape.size());
const char *inNames[] = {inName.c_str()};
const char *outNames[] = {outName.c_str()};
auto outputs = session.Run(Ort::RunOptions{nullptr}, // запуск инференса
inNames, &input, 1, outNames, 1);
const float *result = outputs.front().GetTensorData<float>(); // 6) чтение результата
ONNX Runtime не входит в состав ОС Аврора, поэтому библиотека получается пакетным менеджером и упаковывается внутрь RPM приложения. И OnnxRunner, и SpeechToText используют одну и ту же схему из трёх инструментов.
OnnxRunner зависит только от ONNX Runtime:
from conan import ConanFile
class Application(ConanFile):
settings = "os", "compiler", "arch", "build_type"
generators = "PkgConfigDeps"
requires = (
"onnxruntime/1.18.1@aurora",
)
SpeechToText дополнительно тянет библиотеку быстрого преобразования Фурье — она
нужна для аудио-препроцессинга:
requires = (
"kissfft/131.1.0@aurora",
"onnxruntime/1.18.1@aurora",
)
Генератор PkgConfigDeps создаёт .pc-файлы, чтобы CMake находил библиотеки обычным механизмом pkg-config. Суффикс @aurora означает сборку пакета для целевых архитектур ОС Аврора из репозитория ОМП.
include(FindPkgConfig)
pkg_search_module(AURORA auroraapp REQUIRED)
pkg_check_modules(onnxruntime REQUIRED IMPORTED_TARGET onnxruntime)
target_link_libraries(${PROJECT_NAME} PRIVATE
Qt5::Quick
${AURORA_LDFLAGS}
PkgConfig::onnxruntime
)
set(CMAKE_SKIP_RPATH FALSE)
set(CMAKE_BUILD_WITH_INSTALL_RPATH TRUE)
set(CMAKE_INSTALL_RPATH "${CMAKE_INSTALL_PREFIX}/share/${PROJECT_NAME}/lib")
SpeechToText добавляет ещё Qt5::Multimedia (для микрофона) и PkgConfig::KISSFFT — но принцип тот же. Обратите внимание: оба проекта собираются CMake, а не qmake — интеграция Conan в spec-файл рассчитана именно на CMake. Для установки зависимостей требуется Python.
В секции %build перед запуском CMake устанавливаются Conan-зависимости и в PKG_CONFIG_PATH добавляется каталог с их .pc-файлами; в %install разделяемые библиотеки копируются внутрь пакета:
BuildRequires: conan
%build
CONAN_LIB_DIR="%{_builddir}/conan-libs/"
%{set_build_flags}
rm -f "$CONAN_LIB_DIR/conanrun.sh"
conan-install-if-modified --source-folder="%{_sourcedir}/.." \
--output-folder="$CONAN_LIB_DIR" -vwarning
PKG_CONFIG_PATH="$CONAN_LIB_DIR":$PKG_CONFIG_PATH
export PKG_CONFIG_PATH
%cmake -GNinja -DCMAKE_SYSTEM_PROCESSOR=%{_arch}
%ninja_build
%install
%ninja_install
EXECUTABLE="%{buildroot}/%{_bindir}/%{name}"
CONAN_LIB_DIR="%{_builddir}/conan-libs/"
SHARED_LIBRARIES="%{buildroot}/%{_datadir}/%{name}/lib"
mkdir -p "$SHARED_LIBRARIES"
conan-deploy-libraries "$EXECUTABLE" "$CONAN_LIB_DIR" "$SHARED_LIBRARIES"
Что здесь важно понять:
conan-install-if-modified ставит зависимости только при изменении conanfile.py — повторные сборки используют кэш и идут быстро;conan-deploy-libraries анализирует зависимости исполняемого файла и копирует нужные .so в share/<имя>/lib;%define __provides_exclude_from ^%{_datadir}/%{name}/lib/.*$
%define __requires_exclude ^(libonnxruntime.*|libonnx.*|libprotobuf.*|libabsl.*|...)$
Модель — TinyStories-33M (около 33 млн параметров), компактная GPT-2-совместимая языковая модель, обученная писать простые короткие истории. Пользователь вводит фразу — приложение дописывает текст токен за токеном, показывая их по мере генерации.
Состав каталога models/:
tinystories.onnx — сама модель (формат float32);vocab.json (около 50 тыс. токенов), merges.txt, tokenizer.json, special_tokens_map.json — файлы токенизатора GPT-2.Разрешения в .desktop — пустые.
| Класс | Роль | Где живёт |
|---|---|---|
LLMRunner |
загрузка модели, токенизация, генерация, сэмплирование | главный поток, но Run() — в пуле QtConcurrent |
ChatModel |
QAbstractListModel истории сообщений |
главный поток |
ChatMessage |
одно сообщение (текст, флаг "от пользователя", время) | главный поток |
qmlRegisterType<LLMRunner>("LLMRunner", 1, 0, "LLMRunner");
qmlRegisterType<ChatModel>("LLMRunner", 1, 0, "ChatModel");
LLMRunner *llmRunner = new LLMRunner(view.data());
ChatModel *chatModel = new ChatModel(view.data());
llmRunner->setModelPath("/usr/share/ru.auroraos.OnnxRunner/models/tinystories.onnx");
QObject::connect(llmRunner, &LLMRunner::tokenGenerated,
chatModel, &ChatModel::appendToBotMessage);
ChatModel — QAbstractListModel с ролями text, isUser, timestamp. Для потоковой генерации в нём есть два специальных слота: startBotMessage() создаёт пустое сообщение бота, а appendToBotMessage(token) дописывает к нему символы и посылает dataChanged.
LLMRunner хранит окружение, опции и сессию как unique_ptr-поля и настраивает
их в конструкторе:
m_env = std::make_unique<Ort::Env>(ORT_LOGGING_LEVEL_WARNING, "LLMRunner");
m_sessionOptions = std::make_unique<Ort::SessionOptions>();
m_sessionOptions->SetIntraOpNumThreads(2);
m_sessionOptions->SetInterOpNumThreads(2);
m_sessionOptions->SetGraphOptimizationLevel(GraphOptimizationLevel::ORT_ENABLE_ALL);
В loadModel() создаётся сессия и приложение спрашивает у модели, какие у неё входы и выходы, затем определяет, какие из стандартных»входов модель поддерживает:
m_session = std::make_unique<Ort::Session>(*m_env, modelPath.c_str(), *m_sessionOptions);
Ort::AllocatorWithDefaultOptions allocator;
for (size_t i = 0; i < m_session->GetInputCount(); ++i) {
auto name = m_session->GetInputNameAllocated(i, allocator);
m_inputNames.push_back(std::string(name.get()));
}
// Какие входы реально есть у этой модели?
for (const auto &name : m_inputNames) {
if (name == "attention_mask") m_requiresAttentionMask = true;
if (name == "position_ids") m_requiresPositionIds = true;
}
Это так называемый model-agnostic подход. Можно подложить другую GPT-подобную модель (через пикер *.onnx в UI), и приложение подстроится под её входы.
Большая языковая модель за один вызов предсказывает распределение вероятностей для следующего токена. Чтобы получить целый ответ, модель вызывают в цикле, каждый раз добавляя выбранный токен к последовательности — это называется авторегрессией:
std::vector<int64_t> generatedTokens = tokenize(prompt);
for (int i = 0; i < m_maxNewTokens; ++i) {
{ QMutexLocker locker(&m_mutex); if (!m_isGenerating) break; }
std::vector<float> logits = runInference(generatedTokens);
if (logits.empty()) break;
int64_t nextToken = sampleFromLogits(logits, generatedTokens);
generatedTokens.push_back(nextToken);
QString tokenText = decode(nextToken);
emit tokenGenerated(tokenText);
if (nextToken == eosToken) break;
}
emit responseGenerated(response);
Если всегда брать токен с максимальным логитом (жадно), текст часто получается повторяющимся. OnnxRunner реализует полноценный набор приёмов управления генерацией; все они применяются по очереди в sampleFromLogits:
applyTemperature(logits, m_temperature);
if (m_topK > 0) applyTopK(logits, m_topK);
if (m_topP > 0 && m_topP<1) applyTopP(logits, m_topP);
std::vector<float> probs = softmax(logits);
if (m_temperature < 1e-3) return argmax(probs);
std::discrete_distribution<int> dist(probs.begin(), probs.end());
return dist(rng);
Каждый параметр — это Q_PROPERTY, доступный из QML, со значениями по умолчанию:
| Параметр | Что делает | По умолчанию |
|---|---|---|
maxNewTokens |
сколько токенов сгенерировать максимум | 100 |
temperature |
>1 — разнообразнее, <1 — консервативнее, 0 — жадно | 0.9 |
topK |
оставить только k самых вероятных токенов | 50 |
topP |
оставить минимальное ядро токенов с суммарной вероятностью ≥ p | 0.95 |
repetitionPenalty |
делит логиты недавно встреченных токенов | 1.1 |
t. Меньше t — распределение острее (увереннее); при t стремится 0 код переключается на детерминированный argmax.std::nth_element находит k-й по величине логит и зануляет (ставит -1e9) всё, что ниже.p и отсекает хвост.Финальный выбор — по вероятностям после softmax.
В отличие от занятия 3, где мы вручную поднимали QThread + worker, OnnxRunner
использует более лёгкий механизм QtConcurrent::run:
setIsGenerating(true);
QFuture<void> future = QtConcurrent::run([this, prompt]() {
// весь цикл генерации, включая emit tokenGenerated(...) ...
setIsGenerating(false);
});
Почему здесь это допустимо:
Ort::Session потокобезопасна для Run();tokenGenerated, responseGenerated, errorOccurred) из фонового потока доставляются в QML очередным соединением — то есть в главном потоке, как и положено для обновления интерфейса;m_isGenerating защищён QMutex и проверяется в цикле — механизм мягкой отмены: stopGeneration() просто снимает флаг.