Полгода назад я начал портировать нейросеть 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
