Полгода назад я начал портировать нейросеть EdgeFace-XS из ONNX в чистый C. Думал — граф небольшой, 1.77M параметров, что может пойти не так? Первый наивный порт выдал 24мс. ONNX Runtime — 3.9мс. В 6 раз медленнее. А потом началась оптимизация.
Результат
FaceX |
ONNX Runtime 1.23 |
|
|---|---|---|
Медиана |
3.0 мс |
3.9 мс |
Минимум |
2.87 мс |
3.18 мс |
Размер библиотеки |
148 КБ |
28 МБ |
Зависимости |
нет |
Python + onnxruntime |
Точность LFW |
99.73% |
99.73% |
Чистый C с SIMD интринсиками обгоняет ONNX Runtime на 23%. Один и тот же CPU (i5-11500), одна модель, одни входные данные.
Путь оптимизации: 24мс → 3мс
Этап 0: Профилирование
Замерил каждую операцию отдельно. Главный сюрприз:
Матричное умножение — всего 6% от общего времени инференса.
Настоящие убийцы производительности:
Операция |
Доля |
Проблема |
|---|---|---|
Depthwise conv |
~30% |
Транспозы HWC↔CHW на каждом блоке |
LayerNorm × 17 |
~16% |
Скалярный mean/variance |
GELU × 17 |
~10% |
Наивный |
Транспозы памяти |
~8% |
Лишние копирования |
MatMul |
~6% |
Уже быстро |
Этап 1: SIMD ядра (24мс → 8мс)
Написал AVX2 версии для каждой операции:
LayerNorm — fused mean+variance в одном проходе. Вместо двух циклов по памяти — один с
mm256fmadd_psдля накопления суммы и суммы квадратовGELU — выкинул
tanh(). Реализовал exact erf через полиномиальную аппроксимацию Абрамовица-Стегуна (формула 7.1.26) с кастомнымmm256exp_psна 8 элементов за тактDepthwise conv — перевёл весь движок на нативный HWC layout. Ни одного транспоза во всём forward pass
Этап 2: MatMul (8мс → 5мс)
FP32 packed column-panel: веса перепакованы в формат
[ceil(N/8), K, 8]— каждый столбец-панель помещается в L1 кэш-
INT8 GEMM микроядро с per-channel квантизацией:
AVX2:
vpmaddubswс ±63 clamping для предотвращения s16 насыщенияAVX-512 VNNI:
vpdpbusd— нативные INT8 dot products без насыщения
Thread pool — lock-free с work-stealing через атомарный счётчик и
WaitOnAddress/futex
Этап 3: Последние миллисекунды (5мс → 3мс)
Убрал все транспозы — данные в HWC от входа до выхода
Статический workspace вместо
mallocна каждом вызовеPre-computed position embedding — это константа, не зависит от входа
Pre-packed веса — транспозиция и паковка при загрузке, не при инференсе
Хронология
Фаза |
Время |
Длительность работы |
|---|---|---|
Наивный порт |
24 мс |
2 недели |
SIMD ядра |
8 мс |
3 недели |
MatMul + INT8 |
5 мс |
1 месяц |
Финальная полировка |
3 мс |
4 месяца |
Последние 2мс заняли 4 месяца. Первые 16мс — 2 недели. Вот что такое оптимизация.
7 багов точности
Самая болезненная часть. Cosine similarity с ONNX reference начиналась на 0.067 (мусор). Должно быть 1.0. Нашёл 7 багов через послойные дампы — каждый из 286 тензоров сравнивался с NumPy эталоном.
Баг 1: Индекс gamma
Stage 0, block 2: использовал W(38) — это bias Правильно: W(39) — это gamma
Баг 2: XCA residual connections
Было: attention_residual = original_input mlp_residual = original_input Надо: attention_residual = DW_output + pos_embed mlp_residual = original_input
Баг 3: XCA Depthwise Conv — каскадный, не независимый
Было: conv0(x_split0), conv1(x_split1) — независимо Надо: r0 = conv0(x_split0), conv1(r0 + x_split1) — каскадно
Баг 4: Position Embedding — это константа
Было: pos = Conv1x1(INPUT, W) — пересчитывается каждый раз Надо: pos = Conv1x1(CONSTANT, W) — вычисляется один раз при загрузке
Баг 5: XCA Attention — размерность
Было: attn = softmax(Q @ K^T / τ) — полная [C × C] матрица Надо: attn_h = softmax(Q_h @ K_h^T / τ) — per-head [dim × dim]
Баг 6: Workspace overlap
Stage 3, head_dim=48: attn_buf начинался по адресу, перекрывающему конец V_nhd. Сдвиг буфера решил проблему.
Баг 7: GELU drift
tanh-аппроксимация GELU: ошибка ε на каждом блоке. 17 блоков × ε = заметное расхождение. Фикс: заменил на exact erf (A&S 7.1.26).
Каждый фикс: +0.1 cosine similarity → Семь фиксов: 1.000
API
// Инициализация (~100мс, один раз) FaceX* fx = facex_init("edgeface_xs_fp32.bin", NULL); // Эмбеддинг (3мс на вызов) float face[112 * 112 * 3]; // RGB, HWC layout, [-1, 1] float embedding[512]; facex_embed(fx, face, embedding); // Сравнение двух лиц float sim = facex_similarity(emb_a, emb_b); // sim > 0.3 → один и тот же человек facex_free(fx);
4 функции · 148 КБ · Ноль зависимостей · Apache 2.0
Вывод
Один человек может написать inference движок быстрее продукта Microsoft — если оптимизирует под одну конкретную модель. ONNX Runtime рассчитан на тысячи моделей. FaceX — на одну. Специализация бьёт универсальность.
Исходники: github.com/facex-engine/facex
Комментарии (11)

JustMoose
29.04.2026 07:50Омг... Ничего не понятно, но очень интересно!
Только один вопрос: а почему "чистый C"?Что такого понадобилось из С, чего нет в С++?

bauratynov Автор
29.04.2026 07:50Чистый C не потому что в C++ чего-то нет, а потому что в C нет лишнего)

JustMoose
29.04.2026 07:50Во-первых: очень крутая статья, я бы "ниасилил".
Нуууу, я всегда думал, что в С++ тоже нет ничего лишнего. В том смысле, что если не писать в main.cpp что-то вроде std::map или std::unordered_map, то и в итоговый код оно не попадёт. Зато можно написать class MySuperAlgorithm {}; и это не будет стоить ничего. То есть, последний пример статьи можно переписать как-то так:class OOFaceX { public: FaceX() { fx_ = facex_init("edgeface_xs_fp32.bin", NULL); } ~FaceX() { facex_free(fx_); } void embed() { facex_embed(fx_, face, embedding); } float compare(? emb_a, ? emb_b) { return facex_similarity(emb_a, emb_b); } private: FaceX* fx_ = nullptr; float face_[112 * 112 * 3]; float embedding_[512]; }; OOFaceX t; t.embed(); float sim = t.compare(emb_a, emb_b);После компиляции должен получиться тот же самый бинарь, что и из чистого Си, зато уже не нужно следить за ресурсами. (А при необходимости после этого можно будет попробовать взять что-то из STL, а-ля vector, для случаев, когда нужно просто хранить что-то не слишком важное).
ИМХО, да.

Filipp42
29.04.2026 07:50Добрый день!
Скажите пожалуйста, а каков общий алгоритм оптимизации любой программы?
Может быть, вы знаете книги, в которых его можно посмотреть?
JustMoose
29.04.2026 07:50Извините, что влез.
Вот книжка: Крис Касперски, Техника оптимизации программ.
Общий алгоритм оптимизации, ИМХО, такой:запустить программу под профайлером, посмотреть, какие участки выполняются дольше всего;
оптимизировать найденные участки, сначала алгоритмически, затем низкоуровневым программированием;
повторять до получения полного удовлетворения.

ErmIg
29.04.2026 07:50Здравствуйте. А это какая-то квантизированная модель? (Иначе к чему тут AVX-512VNNI).
Если это не какая-то внутренняя разработка, то можете на нее ссылку кинуть? Хотелось бы ее потестировать на своем движке.
Naps
Хотел запустить в браузере потыкать, но видимо чего то не хватает...
Failed to load /weights.bin
skovpen
Этот файл в разделе релиза скачивается
bauratynov Автор
браузере еще недоработал(