Полгода назад я начал портировать нейросеть 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
ссылка на оригинал статьи https://habr.com/ru/articles/1029338/