или почему _mm256_* без понимания кэша и uops — дорогой способ соврать себе
TL;DR
Я пишу Quince — торговый движок с открытым исходным кодом на Rust. Внутри свой язык стратегий QFL, компилятор в байткод, регистровая VM, риск-контур, replay исторических данных и нативные индикаторы, которые можно писать самому. Это не очередной питон-скрипт, который раз в минуту дергает REST и шедеврально называется «высокочастотным». Движок принимает поток сделок и изменений DOM, обновляет признаки, исполняет стратегию, проверяет риск и формирует ордер в одном ограниченном по времени event path.
В какой-то момент обычный scalar loop перестал быть «достаточно быстрым»: WMA, LSMA, CCI, Bollinger Bands и Z-Score повторно проходили rolling window, а кольцевой буфер превращал красивую формулу в hot path, чувствительный к кэшу.
Я вынес вычисления в шесть AVX2/FMA-ядер: sum, weighted_sum, sum_and_sum_xy, sum_abs_diff, min_max, sum_sq_diff. Но реальная работа оказалась не в том, чтобы заменить + на _mm256_add_pd. Мне пришлось:
-
отдать SIMD два непрерывных slice без копирования кольцевого окна;
-
сохранить логическую нумерацию элементов после wrap-around;
-
разделить hot/cold state VM, чтобы SIMD не ускорял код, который все равно проигрывает на промахах L1;
-
определить scalar tail и численные контракты;
-
не перепутать AVX, AVX2 и FMA;
-
построить Criterion gate, который не объявляет снижение частоты из-за нагрева «регрессией»;
-
проверять не среднюю, а доверительный интервал и весь pipeline целиком.
Это не справочник intrinsics и не соревнование по количеству unsafe. Я буду разбирать не только что делает инструкция, но и почему она ускоряет или замедляет конкретный pipeline: от строк кэша и uops до численных контрактов и статистики Criterion. Материал рассчитан на инженеров, которые уже умеют читать Rust и готовы открыть ассемблерный листинг. Без этого получается не оптимизация, а сумасшедший карго-культ с _mm256_* и прикольным графиком.
Оглавление
0. Что такое Quince и откуда берется latency budget
Quince — торговая система, в которой стратегия пишется не внутри исходников движка, а на QFL — небольшом типизированном языке с синтаксисом, отдаленно похожим на Lua. Исходник проходит синтаксический анализ, проверку типов, компиляцию и 11 проходов оптимизатора, после чего исполняется собственной регистровой VM.
Упрощенная стратегия выглядит так:
@exchange binance@network testnet@using signed_volumeon trade(t) { feature pressure = quince.get("signed_volume") if pressure < -0.6 { quince.order(1, 0.001, 0) }}
QFL нужен не ради очередного синтаксиса, языков и без меня наплодили достаточно. Он создает границу, на которой до запуска проверяются типы, ограничивается instruction budget, а индикаторы привязываются к числовым слотам. Благодаря этому синтаксический анализатор, строки и динамический поиск не тащатся через каждый тик.
Архитектурно путь события выглядит так:
exchange WebSocket │ ├─ strict protocol parser ├─ sequence / gap / staleness validation ▼normalized Trade | Depth | Fill │ ├─ native indicator bank ├─ pre-resolved feature slots ▼QFL register VM │ ├─ risk controls ├─ shadow/live deployment gate ├─ order journal ▼exchange adapter
Рядом работают replay по архивным данным, сверка незавершенных заявок, учет комиссий и slippage, bounded control plane и дашборд на Axum. Но эти подсистемы не имеют права блокировать market-data thread. «Иногда блокирует» — для меня катастрофа, ведь отсюда берутся скачки latency.
Вот тут и появляется latency бюджет. Его нельзя описать одной цифрой «наносекунд на VM instruction». Для одного market event существует сумма:
T_event = T_parse + T_integrity + T_indicators + T_feature_write + T_vm + T_risk + T_transport + T_telemetry
А интересует нас не только среднее:
SLO = { throughput, p50, p95, p99, max bounded work }
Если SIMD уменьшил T_indicators на 60%, но добавил runtime dispatch, раздул машинный код и поднял p99 всего pipeline, то ускорения нет. Есть красивая локальная цифра и архитектурный самообман. Называть это победой — значит врать себе уже не на словах, а с графиком и кодом в руках.
Это принципиальная рамка всей статьи: оптимизируется контракт обработки события, а не intrinsic без контекста.
1. Иллюзия быстрого Rust
Rust легко создает опасное ощущение, что программа быстрая уже потому, что в ней нет GC, а релиз собирается с --release. Нет, она не быстрая. Она просто не платит один конкретный класс runtime-издержек. Остальные классы — промахи кэша, ошибки предсказания ветвлений, голодание frontend, обращения к аллокатору и coherence traffic — процессор считает отдельно. Если единственное доказательство скорости — язык и флаг сборки, перед нами вовсе не инженерия…
Компилятор действительно умеет в автовекторизацию. Но иногда. Если alias analysis доказал независимость указателей. Если редукция допустима при выбранной семантике вычислений с плавающей запятой. Если цикл достаточно прост. Если LLVM не споткнулся о bounds checks, цепочку итераторов, wrap-around кольцевого буфера и условную инициализацию окна. Короче, если вы дали ему код, который вообще можно честно векторизовать, а не надеетесь на магию букв LLVM.
В торговом движке hot path выглядит хуже лабораторного sum(&[f64]):
WebSocket frame -> strict parse -> normalized market event -> rolling indicators -> feature slots -> bytecode VM -> risk checks -> order intent -> bounded transport
Можно ускорить одну редукцию в три раза и проиграть все обратно на аллоках, поиск в HashMap, доступ к cold state или перебрасывание строки кэша между счетчиками телеметрии.
Поэтому первый принцип такой:
SIMD — не архитектура. SIMD — локальный способ исполнить уже нормальную архитектуру.
До intrinsics я зафиксировал четыре контракта:
-
На тик нет аллоки.
-
Rolling window не копируется и не линеаризуется.
-
Слот индикатора разрешается до входа в hot path.
-
Скалярная реализация остается эталоном корректности и фолбеком.
Только после этого появляется смысл открывать core::arch::x86_64.
2. Сначала алгоритм, потом векторный регистр
Самая выгодная SIMD-инструкция — та, которую не пришлось выполнять.
SMA и VWMA не должны каждый tick суммировать окно независимо от ширины регистра. Для SMA достаточно running sum:
if let Some(evicted) = buffer.push(value) { sum -= evicted;}sum += value;
Сложность обновления — O(1). Векторизовать O(N) пересчет здесь означало бы красиво оптимизировать косяк архитектуры. По сути более дорогое исполнение работы, которой вообще не должно быть.
Но есть формулы, где новый элемент меняет вклад всех предыдущих:
-
WMA: вес каждого наблюдения зависит от позиции;
-
LSMA: нужен
ΣyиΣxy; -
CCI: mean absolute deviation относительно только что вычисленного среднего;
-
Bollinger/Z-Score: квадрат отклонения относительно текущего центра;
-
Stochastic: минимум и максимум на окне.
Половину из этого тоже можно превратить в O(1) через дополнительный стейт или monotonic deque. И это нужно делать, когда контракт позволяет. Но реальная эксплуатация противнее идеальной:
-
накопительные формулы дрейфуют из-за потери значащих разрядов;
-
окно может иметь динамический period;
-
одно хранилище обслуживает разные индикаторы;
-
восстановление state после hot reload должно быть детерминированным;
-
усложнение state machine повышает цену аудита.
В Quince я использую оба подхода: running aggregates там, где инварианты просты, monotonic deque для VM rolling windows и SIMD-reduction там, где полный проход остается наиболее проверяемым контрактом.
Оптимизация — не путь к O(1). Это выбор минимальной стоимости при заданных требованиях к точности, состоянию и восстановлению.
3. Кольцевой буфер: две физические области, одно логическое окно
Классический ring buffer после wrap-around физически выглядит так:
physical: [ newest ... ][ ... oldest ] ^ headlogical: oldest ................. newest
Тупой путь перед SIMD:
let linear: Vec<f64> = ring.iter().collect();simd_kernel(&linear);
Поздравляю!!! Мы ускорили арифметику и добавили аллоку, копирование N * 8 байт и лишний проход по памяти. Именно так рождаются оптимизации, которые прекрасно выглядят на бумаге и делают прод медленнее. Бумагу же можно показать руководству; процессор, к сожалению, премию не даст.
Нормальный API возвращает две непрерывные области в логическом порядке:
pub fn as_chunks(&self) -> (&[f64], &[f64]) { if self.len == 0 { (&[], &[]) } else if self.len < self.cap { (&self.data[..self.len], &[]) } else { (&self.data[self.head..self.cap], &self.data[..self.head]) }}
Теперь kernel принимает (a, b) и обрабатывает сначала a, потом b. Никакой линеаризации, никаких темп-буферов. Логический индекс продолжает расти через границу slices.
Это особенно важно для WMA/LSMA. Если на втором slice сбросить вес или x обратно в ноль, численно корректный SIMD-код начнет вычислять другую формулу:
правильно: a0*1 + a1*2 + ... + b0*(len(a)+1)ошибка: a0*1 + a1*2 + ... + b0*1
То есть layout данных здесь является частью математического контракта.
Почему два slice обычно лучше, чем хитрейший gather?
-
_mm256_i64gather_pdимеет существенно худшую throughput/latency-модель, чем последовательный load; -
два линейных потока хорошо понимает аппаратный префетчер;
-
_mm256_loadu_pdна современных x86 нормально работает с unaligned address, пока load не пересекает неудачную границу страницы или строки кэша; -
стоимость второго scalar tail ограничена тремя
f64для AVX width 4.
Именно API хранилища, а не крутой intrinsic, открывает основную оптимизацию.
4. SSE, AVX, AVX2 и FMA — это не синонимы
Перед кодом нужна короткая декомпозиция ISA, потому что половина «AVX2-гайдов» в интернете называет AVX2 все, что начинается с _mm256_. Уверенность автора там обычно обратно пропорциональна количеству открытых им гайдов Intel.
Для f64:
|
ISA |
Ширина |
|
Что важно |
|---|---|---|---|
|
SSE2 |
128 bit |
2 |
baseline для x86-64, XMM-регистры |
|
AVX |
256 bit |
4 |
YMM и трехоперандные инструкции с плавающей запятой |
|
AVX2 |
256 bit |
4 |
в основном расширяет целочисленный SIMD и gather |
|
FMA3 |
256 bit |
4 |
|
_mm256_add_pd — AVX. _mm256_fmadd_pd — FMA. Факт наличия AVX2 на конкретном CPU практически часто совпадает с FMA, но компиляционный контракт обязан проверять обе фичи, если используется FMA.
SSE2 при этом не «устаревший шлак». Для x86-64 это удобный baseline и инструмент для branchless scalar guards. Например, _mm_cmpunord_sd(x, x) строит mask для NaN без перехода. Но он не ловит infinity: unordered comparison срабатывает только для NaN. Если комментарий обещает NaN/Inf sanitizer, а код использует один cmpunord, комментарий врет. Полная is_finite проверка должна анализировать exponent биты (0x7ff0_0000_0000_0000) либо оставлять scalar is_finite(), предварительно убедившись по ассемблерному листингу, что компилятор не построил дорогое непредсказуемое ветвление. SIMD не освобождает вас от чтения документации Intel или AMD.
Надежные варианты:
#[target_feature(enable = "avx2,fma")]unsafe fn weighted_sum_avx2_fma(...) -> f64 { ... }
и runtime dispatch:
if is_x86_feature_detected!("avx2") && is_x86_feature_detected!("fma") { unsafe { weighted_sum_avx2_fma(...) }} else if is_x86_feature_detected!("sse2") { unsafe { weighted_sum_sse2(...) }} else { weighted_sum_scalar(...)}
Либо compile-time specialization:
#[cfg(all(target_feature = "avx2", target_feature = "fma"))]return unsafe { weighted_sum_avx2_fma(...) };
Compile-time путь дает нулевую цену dispatch, но бинарник нельзя бездумно переносить на более старый CPU: получите SIGILL, а не «чуть-чуть медленнее».
Runtime detection позволяет один портабл бинарь, но ветвление нельзя делать на каждый элемент. Выбирайте function pointer один раз через OnceLock, enum backend при старте либо multiversioning.
И еще один эффект, который любят забывать: на некоторых поколениях Intel тяжелый AVX2 workload снижает частоту ядра. Локальное ускорение инструкции может ухудшить соседний скалярный hot path. Измерять надо не только kernel, но и полный pipeline события. Иначе вы торжественно ускорили четыре умножения и замедлили фулл приложение — отличный итог микрооптимизации вслепую.
Что реально исполняет CPU: инструкции — это еще не uops
x86-инструкция из ассемблерного листинга не обязана соответствовать одной операции внутри процессора. Frontend забирает байты инструкций из L1I, декодирует их в uops, складывает в reorder buffer, а scheduler отправляет uops на execution ports. После этого они выполняются out-of-order, но retire происходит в программном порядке. Не понимаете этот абзац — рано считать количество intrinsics в проекте и обещать ускорение.
Поэтому вопрос «сколько у меня AVX-инструкций?» почти бесполезен. Нужны как минимум четыре характеристики:
-
latency — через сколько тактов результат операции доступен зависимой инструкции;
-
reciprocal throughput — как часто ядро способно принимать новые независимые операции этого типа;
-
port pressure — какие execution ports конкурируют за эти uops;
-
load/store pressure — успевает ли подсистема памяти кормить арифметику.
Например, цепочка:
acc = _mm256_add_pd(acc, values);acc = _mm256_add_pd(acc, next);acc = _mm256_add_pd(acc, third);
имеет loop-carried dependency: вторая операция ждет результат первой. Даже если исполнительные блоки умеют принимать больше одного vector add за такт, одна цепочка не дает использовать throughput. Два или четыре независимых аккумулятора создают instruction-level parallelism:
acc0 = _mm256_add_pd(acc0, v0);acc1 = _mm256_add_pd(acc1, v1);acc2 = _mm256_add_pd(acc2, v2);acc3 = _mm256_add_pd(acc3, v3);
Теперь scheduler может перекрывать latency. Но бесплатного сыра опять нет: растет register pressure, тело цикла занимает больше uops, а после цикла нужно редуцировать четыре YMM-регистра.
Arithmetic intensity: почему WMA почти всегда упирается в память
Удобная модель — arithmetic intensity:
AI = число arithmetic operations / bytes transferred
Один YMM-load приносит 32 байта, то есть четыре f64. FMA выполняет четыре умножения и четыре сложения — условно 8 FLOP. Если веса уже в регистрах:
AI ≈ 8 FLOP / 32 bytes = 0.25 FLOP/byte
Это низкая интенсивность. Когда окно не в L1, kernel быстро упирается в память: дополнительные ALU/FMA-блоки простаивают, потому что данные еще не доехали. Когда окно в L1, ограничение смещается к load ports и цепочкам зависимостей. Когда period совсем мал, доминируют подготовка, ветвление и horizontal reduction.
Именно поэтому «AVX обрабатывает четыре double одновременно, значит будет ускорение х4» — технический нонсенс и чушь. Ширина регистра задает верхнюю границу для конкретного фрагмента арифметики, но не отменяет закон Амдала и roofline.
Если доля SIMD-ускоряемой части равна P, а локальное ускорение равно S, то:
Speedup_total = 1 / ((1 - P) + P / S)
При P = 0.35 и фантастическом S = 4 весь pipeline ускорится максимум примерно в 1.36×. Остальные 65% времени никуда не делись. Цифры, обещающие «х4 на весь движок» из одного _mm256_fmadd_pd, можно сразу отправлять на помойку.
5. Первое ядро: sum без промежуточного массива
Базовая редукция:
unsafe fn sum_avx2(a: &[f64], b: &[f64]) -> f64 { let mut acc = _mm256_setzero_pd(); let mut tail = 0.0; for slice in [a, b] { let vectorized = slice.len() & !3; let (full, rem) = slice.split_at(vectorized); for chunk in full.chunks_exact(4) { let v = _mm256_loadu_pd(chunk.as_ptr()); acc = _mm256_add_pd(acc, v); } for &value in rem { tail += value; } } horizontal_sum(acc) + tail}
Horizontal reduction:
unsafe fn horizontal_sum(v: __m256d) -> f64 { let hi = _mm256_extractf128_pd(v, 1); let lo = _mm256_castpd256_pd128(v); let pair = _mm_add_pd(lo, hi); let swapped = _mm_shuffle_pd(pair, pair, 0b01); _mm_cvtsd_f64(_mm_add_sd(pair, swapped))}
Почему не складывать lanes на каждой итерации? Потому что horizontal operation ломает параллелизм и создает dependency chain. Пока accumulator остается YMM, четыре независимые lane редуцируются вертикально. Горизонтальное сведение выполняется один раз.
Для больших окон полезно юзать несколько аккумуляторов:
acc0 = add(acc0, load(ptr));acc1 = add(acc1, load(ptr.add(4)));
Это скрывает latency vaddpd через instruction-level parallelism. Но unroll нужно подтверждать под конкретную микроархитектуру: можно упереться в load ports, register pressure и front-end bandwidth. «Больше accumulator» не является панацеей.
И кстати да, порядок сложения изменился. Скалярка:
(((x0 + x1) + x2) + x3) + ...
SIMD:
(x0 + x4 + ...) + (x1 + x5 + ...) + ...
Сложение по IEEE-754 не ассоциативно. Поэтому контракт теста — не bitwise equality, если bitwise determinism не заявлен. Нужен явно выбранный tolerance, тесты на NaN/Inf и понимание, допустим ли FMA с одним округлением вместо двух. Сравнивать такие результаты через ==, а потом ругать SIMD — глупость.
Почему f64 не является стратегией численной устойчивости
Увеличить разрядность с f32 до f64 полезно, но cancellation от этого не исчезает. Если складываются значения сильно разного масштаба, младшие биты малого слагаемого теряются. В rolling variance выражение:
Σx² - (Σx)² / N
может вычитать два почти равных больших числа. Результат теоретически неотрицателен, а практически способен стать маленьким отрицательным значением из-за округления.
Есть несколько уровней защиты:
-
Pairwise reduction. SIMD естественным образом создает несколько частичных сумм и затем сводит их деревом. Ошибка часто меньше линейной редукции.
-
Kahan/Neumaier compensation. Хранит потерянный младший остаток, но добавляет dependency chains и плохо масштабируется в простую SIMD-схему.
-
Welford variance. Численно устойчивее наивного
Σx² - Σx²/N, но rolling eviction требует аккуратного обратного обновления или полного пересчета. -
Периодический rebasing. Быстрый incremental state используется обычно, а раз в
Kticks пересчитывается из окна эталонным способом.
Выбор зависит не от вкуса. Для сигнала разница 1e-12 может быть безразлична, пока порог находится далеко. Но если signal > 0.0 переключает сторону заявки, один последний бит становится решением в control flow с финансовым эффектом, зачастую негативным;)
В контракте production-системы полезно разделять:
numeric equivalence — |simd - scalar| <= atol + rtol * |scalar|decision equivalence — стратегия принимает одинаковое решениеreplay equivalence — совпадает последовательность intents/fills/PnL
Первый тест необходим, но только третий доказывает, что численная оптимизация не изменила наблюдаемое поведение системы.
6. WMA: генерируем веса прямо в YMM
Weighted Moving Average имеет формулу:
WMA = Σ(yᵢ · (i + 1)) / Σ(i + 1)
Хранить отдельный массив [1.0, 2.0, ...] — лишний поток загрузок и дополнительное давление на кэш. Веса дешевле синтезировать в регистрах:
let mut weights = _mm256_set_pd( (start + 3) as f64, (start + 2) as f64, (start + 1) as f64, start as f64,);let step = _mm256_set1_pd(4.0);for chunk in full.chunks_exact(4) { let values = _mm256_loadu_pd(chunk.as_ptr()); acc = _mm256_fmadd_pd(values, weights, acc); weights = _mm256_add_pd(weights, step);}
При переходе от a к b scalar weight увеличивается на full.len() и затем на длину tail. Это мелкая деталь, из-за которой тест на монотонной последовательности должен быть обязательным.
Здесь FMA дает:
-
одну fused instruction вместо отдельных multiply и add;
-
меньше decoded uops;
-
одно округление;
-
потенциально более точный результат, но не bitwise-идентичный scalar реализации.
Знаменатель N(N+1)/2 вычисляется один раз в конструкторе, а не на tick. Это банально, но скорость production-системы состоит из сотни таких банальностей. Тот, кто ищет одну «ультра-секретную NDA инструкцию», обычно просто не хочет разгребать сотню мелких потерь.
Для period 10 SIMD может проиграть scalar из-за setup и horizontal reduction. Для 200 — начинает окупаться. Поэтому backend можно выбирать не только по CPU фиче, но и по длине:
if len < SIMD_BREAK_EVEN { scalar(...)} else { avx2(...)}
Break-even нельзя брать из головы. Он зависит от CPU, версии компилятора, окружающего кода и того, прогреты ли данные.
7. LSMA: две редукции за один проход
Linear Regression Moving Average требует:
ΣyΣxyslope = (NΣxy - ΣxΣy) / (NΣx² - (Σx)²)
Σx и Σx² зависят только от периода и предвычисляются. Ошибка — отдельно пройти окно для Σy, затем второй раз для Σxy. Арифметически понятно, для подсистемы памяти бессмысленно.
Один load обслуживает две редукции:
let mut acc_y = _mm256_setzero_pd();let mut acc_xy = _mm256_setzero_pd();for chunk in full.chunks_exact(4) { let y = _mm256_loadu_pd(chunk.as_ptr()); acc_y = _mm256_add_pd(acc_y, y); acc_xy = _mm256_fmadd_pd(y, x, acc_xy); x = _mm256_add_pd(x, four);}
Это уже не «в четыре раза быстрее». Kernel одновременно ограничен:
-
двумя dependency chains;
-
одним 32-byte load;
-
FMA/add execution ports;
-
финальными horizontal reductions.
Но главное преимущество — один проход по памяти. На данных, которые вышли из L1, сокращение traffic важнее количества FLOPs.
Если у вас SoA layout и несколько независимых инструментов, есть другой уровень векторизации: считать один индикатор сразу для четырех symbols. Это устраняет horizontal reduction, потому что lane соответствует инструменту. Но меняет всю архитектуру scheduler и усложняет ситуации, где symbols получают ticks асинхронно.
SIMD по временной оси проще встроить. SIMD по инструментам потенциально быстрее. Цена — синхронизация временной оси и mask lanes. Выбирать тут надо по модели рыночных данных, а не по красоте ассемблера.
8. MAD, variance и min/max: шесть ядер вместо зоопарка циклов
После sum и weighted_sum остальные формулы скользящих окон сводятся к небольшому набору примитивов.
Mean absolute deviation (MAD)
let diff = _mm256_sub_pd(values, center);let abs = _mm256_andnot_pd(sign_mask, diff);acc = _mm256_add_pd(acc, abs);
Вариант через max(diff, -diff) тоже работает для конечных чисел, но поведение min/max при NaN требует отдельного контракта. Маскирование знакового бита обычно выражает abs точнее по намерению.
Sum of squared deviations
let diff = _mm256_sub_pd(values, center);acc = _mm256_fmadd_pd(diff, diff, acc);
Используется в Bollinger Bands и Z-Score. Здесь важно решить, где вычисляется центр. Если сначала SIMD sum, потом SIMD sum_sq_diff, окно читается дважды. Для умеренного периода оно все еще в L1; для большого — возможно выгоднее Welford/парная редукция, но тогда численная семантика будет другой.
Min/max
mins = _mm256_min_pd(mins, values);maxs = _mm256_max_pd(maxs, values);
Для sliding extrema лучше monotonic deque с amortized O(1). SIMD скан уместен, если окно небольшое, стейт должен быть минимальным или одно хранилище разделяется несколькими формулами.
Почему primitives, а не SIMD внутри каждого indicator
Потому что код на intrinsics — unsafe boundary. Я хочу аудировать шесть kernels, а не 52 индикатора с копипастой из unsafe. Размазывать такое по каталогу индикаторов — копать самому себе яму техдолга + промышленное производство будущих UB, то есть финансовых убытков:
Indicator -> safe slice contract -> six reviewed SIMD primitives -> scalar reference
Это сокращает поверхность UB, упрощает property tests и позволяет заменить AVX2 backend на AVX-512/SVE без переписывания финансовой математики.
9. Tail, alignment и численная семантика
Это место, где чаще всего ломается логический индекс.
Для двух slices возможны два tail. Вес/индекс должен продолжаться:
for slice in [a, b] { // vector body index += full.len(); for &value in rem { scalar_acc += value * index as f64; index += 1; }}
Набор обязательных тестов:
-
пустое окно;
-
1, 2, 3 элемента;
-
длина ровно 4;
-
5 и 7 элементов;
-
wrapped buffer, где
a.len()иb.len()не кратны четырем; -
head == 0; -
разные позиции wrap при одинаковом логическом содержимом;
-
отрицательные значения;
-
-0.0; -
очень большие и очень малые magnitude;
-
NaN/Inf согласно публичному контракту.
Aligned или unaligned load
Vec<f64> гарантирует alignment для f64, а не 32 байта. Можно написать custom allocator и использовать _mm256_load_pd, но выигрыш на современных x86 часто исчезает, если адрес не пересекает проблемную границу. Сначала измерение, потом allocator. Обратный порядок называется не оптимизацией, а развлечением с ассемблером.
fast-math
Глобальный fast-math для торгового движка — решение уровня архитектуры, а не compiler flag «сделать быстро». Он может разрешить reassociation, игнорировать signed zero и предполагать отсутствие NaN/Inf. Если risk boundary ожидает fail-closed на non-finite input, такое изменение способно превратить проверку безопасности в мертвый код. Зато benchmark будет зеленым ровно до первого нештатного числа.
FMA и reproducibility
FMA делает одно округление. Scalar mul + add — два. Оба ответа могут быть корректны в tolerance, но replay между разными CPU/backend перестанет быть bitwise-identical.
Варианты:
-
Требовать только tolerance-level determinism.
-
Отключить FMA в deterministic replay.
-
Версионировать numerical backend в capture/report metadata.
Я предпочитаю явно фиксировать численный контракт. «У нас везде f64, значит результаты одинаковые» это не детерминизм, это предположение с потолка.
10. Compile-time dispatch против runtime detection
Есть три нормальные стратегии.
1. Один переносимый scalar binary
Минимум риска, максимум переносимости. Для control plane, CLI и cold path этого достаточно.
2. Отдельные release-сборки
quince-x86_64-v2quince-x86_64-v3-avx2-fma
При развертывании сборка выбирается по возможностям CPU. Для управляемой инфраструктуры это лучший вариант: нулевая цена dispatch и явный SIGILL-контракт до запуска.
3. Multiversioning внутри одного binary
type Kernel = fn(&[f64], &[f64]) -> f64;static SUM: OnceLock<Kernel> = OnceLock::new();fn sum(a: &[f64], b: &[f64]) -> f64 { let kernel = SUM.get_or_init(select_sum_backend); kernel(a, b)}
Feature detection выполняется один раз. Но indirect call может мешать inlining. Можно выбрать backend на уровне IndicatorBank, сохранить enum или function pointers и не делать глобальный lookup на tick.
Compile-time #[cfg(target_feature = "avx2")] безусловно удобен, но проверяйте реальную сборку:
RUSTFLAGS='-C target-cpu=native' cargo rustc -p quince-indicators --release -- --emit=asm
В Quince Cargo feature simd включает возможность специализированного backend, а target_feature решает, попадет ли AVX2-ветка в конкретную сборку. Это два разных рубильника. Обычный release для неизвестного x86-64 CPU обязан оставаться переносимым и может использовать scalar fallback. Сборка с -C target-cpu=native активирует возможности машины сборки, поэтому такой бинарь нельзя затем молча унести на другой сервер.
Если инфраструктура неоднородна, native в CI — ленивое и опасное решение: получившийся бинарь описывает случайный runner, а не контракт развертывания. Либо собирайте под явно выбранный target-cpu/target-feature baseline, либо делайте runtime multiversioning.
Ищите vaddpd, vfmadd*pd, ширину loads и отсутствие неожиданной scalarization. Название функции sum_avx2 ничего не доказывает. Переименовать скалярный цикл компилятору несложно.
На Linux полезны:
perf stat -e cycles,instructions,branches,branch-misses,cache-misses \ target/release/your-benchmarkperf record -g target/release/your-benchmarkperf report
Смотрите IPC, cache misses и cycles/event. ns/iter без аппаратных счетчиков не скажет, почему результат изменился.
11. SIMD не спасет VM, которая не помещается в L1
После индикаторов событие попадает в QFL VM. Там другой класс оптимизаций.
Инструкция упакована в u64:
[ opcode:8 | rd:8 | rs1:8 | rs2:8 | imm:32 ]
Register file — 256 восьмибайтовых слотов, i64/f64 через #[repr(C)] union. В hot state остаются registers, pc, call stack и code pointer. Большие массивы balances, indicator slots, depth book, rolling arenas и profiler вынесены в Box<ColdVm>.
Зачем? Потому что SIMD-kernel длится десятки наносекунд, а случайный L1 miss легко съедает сопоставимый бюджет. Если Vm таскает за собой 30+ KB cold arrays, каждый dispatch конкурирует с данными индикаторов за L1D. После этого спорить о цене одной vaddpd можно только ради спортивного унижения здравого смысла.
Dispatch — таблица function pointers на 256 опкодов. Handler:
-
извлекает поля instruction битовыми сдвигами;
-
читает registers через
get_unchecked; -
выполняет операцию;
-
tail-dispatches следующий handler.
Почему регистровая VM, а не стековая VM? В стек машине арифметическая инструкция обычно делает implicit push/pop, увеличивает traffic к operand stack и требует больше байткод-инструкций для тех же data dependencies. Регистровый байткод кодирует rd, rs1, rs2 прямо в packed instruction. Байткод шире, зато:
-
меньше dispatches на выражение;
-
dataflow виден compiler passes;
-
constant propagation и CSE проще;
-
значения остаются в плотном register file;
-
handler не обслуживает отдельный top-of-stack protocol.
Indirect dispatch все равно конфликтует с branch prediction. CPU должен предсказать target следующего function pointer. Если opcode stream хаотичен, branch target buffer ошибается, pipeline очищается, и несколько сэкономленных ALU uops становятся неважны. Поэтому оптимизатор влияет на VM дважды: он уменьшает число инструкций и делает opcode sequence более предсказуемой.
С другой стороны, агрессивный #[inline(always)] способен раздуть handlers и instruction footprint. L1I измеряется десятками килобайт, а не мегабайтами. Когда hot dispatch code перестает помещаться, frontend начинает кормить backend рывками. Поэтому «заинлайнить вообще все» для меня не стратегия. Hot handlers должны быть компактными, cold error/reporting paths — вынесенными и помеченными как маловероятные.
Packed u64 также дает predictable fetch:
8 bytes/instruction8 instructions/cache line при line size 64 байт
Но реальная плотность исполнения зависит от jumps и entry layout. Полезно проверять не только размер binary, а L1-icache-load-misses, branch-misses, idq_uops_not_delivered или доступные аналоги конкретного PMU. Названия событий различаются между микроархитектурами — копировать событие perf из чужой статьи без проверки модели CPU достаточно бессмысленно.
unsafe здесь тоже не «ультра-ускорение». Компилятор и type checker до VM обязаны доказать:
-
register index в диапазоне;
-
instruction encoding соответствует опкоду;
-
code pointer валиден;
-
jump target проверен;
-
instruction budget не отключаем.
Иначе один некорректный байткод превращает HFT-оптимизацию в возможность удаленного повреждения памяти.
До VM работает оптимизатор из 11 проходов: constant folding, CFG simplify, SCCP, CSE, local shadowing, LICM, loop unroll, fused lowering, GVN, DCE, persist coalescing. Удалить инструкцию всегда выгоднее, чем ускорить ее dispatch.
Это тот же принцип, что со SMA:
сначала не выполняем работу, затем ускоряем оставшуюся.
12. Non-blocking hot path: каналы, телеметрия и memory ordering
Можно идеально векторизовать индикаторы и поставить в конце что-то типа Mutex<HashMap<...>> для метрик. Поздравляю еще раз!!! Вы ускорили арифметику, чтобы быстрее доехать до лока.
В Quince engine loop — единственный владелец изменяемого торгового стейта. Внешние producers получают bounded crossbeam sender и делают try_send. Они не могут:
-
заблокировать engine;
-
напрямую изменить VM/risk/order state;
-
бесконечно раздувать очередь.
Engine читает ограниченный batch команд за итерацию. Boundedness нужна с обеих сторон: bounded channel без bounded drain позволяет потоку команд управления вытеснить market data.
Hot telemetry — fixed-size atomics:
market_events.fetch_add(1, Ordering::Relaxed);latency_buckets[bucket].fetch_add(1, Ordering::Relaxed);
Relaxed корректен для независимых счетчиков, где не публикуется связанное состояние. Для readiness/revision применяется Release/Acquire, потому что значение служит границей публикации.
Латентность складывается в 64 log2 buckets. Никакого HDR allocation, lock или сортировки на тиках:
let bucket = 63 - nanos.leading_zeros() as usize;histogram[bucket].fetch_add(1, Ordering::Relaxed);
Снапшот и расчет процентилей происходят вне hot path.
Здесь есть нюанс cache coherence: несколько atomic counters рядом могут попасть в одну cache line. Если пишет один engine thread, это нормально. Если несколько producers долбят разные counters, нужен padding, per-thread shards или aggregation, иначе получите false sharing.
Atomic RMW все равно владеет cache line. И нет, наличие атомика не делает архитектуру «неблокирующей» автоматически — это еще один мой любимый карго-культ.
13. Как Criterion соврал мне на 120%
Сначала важная оговорка: Criterion не «плохой и ужасный». Он честно анализирует samples, которые ему дали. Если среда дрейфует, статистика аккуратно измерит дрейф среды. Ошибка начинается тогда, когда инженер называет это фичей кода. Термодатчик показал нагрев, а в отчете внезапно появилась «регрессия алгоритма». Удобно, но лживо.
В Quince benchmarks разделены на уровни:
indicator/update измеряет конкретную rolling formulaindicator_only/strategy измеряет IndicatorBank и выбранный набор индикаторовvm_tick/strategy измеряет 10 000 вызовов QFL handlerfull_pipeline/strategy indicator update + slot writes + VM eventruntime_feed runtime boundary с нормализованными Trade
Такое разбиение нужно для локализации. Если kernel ускорился, а full_pipeline нет, проблема находится между ними: dispatch, запись slots, давление на кэш или доля ускоряемого кода. Один E2E benchmark покажет факт, но не причину. Один microbenchmark покажет причину, но не эффект для всей системы. Нужны оба сразу.
Еще один неприятный момент — stateful benchmarks. Индикатор нельзя каждый sample случайно оставлять в warmup phase или создавать внутри измеряемого участка, если исследуется steady-state update. Но если production часто пересоздает runtime при hot deployment, setup обязан иметь отдельный benchmark. Иначе его просто спрятали из отчета, а не оптимизировали.
В терминах latency нужно различать:
-
distribution времени отдельного event;
-
throughput batch из 10 000 events;
-
tail latency при одновременной работе exchange/control/telemetry;
-
худший кейс bounded work при burst очереди.
Criterion хорошо отвечает на второй вопрос. Для p99 production event path нужна встроенная телеметрия или отдельный нагрузочный стенд. Делить batch time на 10 000 и называть результат p99 — очередная глупость, которую я почему-то вижу.
Во время подготовки релиза regression gate сообщил:
indicator_only/dom_scalper: +123%indicator_only/heavy_test: +116%vm_tick/macd_cross: upper CI +29%
Изменений в indicator kernels между baseline и коммитом не было. Это важный момент: нормальный инженер не обновляет baseline со словами «GitHub Actions тупит». Он повторяет замер и ищет ключевой фактор.
Первый прогон шел так:
QFL suite -> indicators suite -> engine suite
Несколько минут непрерывной нагрузки на CPU. К моменту engine microbench частота и thermal state runner уже отличались от момента сохранения baseline. Самые короткие indicator_only cases, где полезная работа измеряется десятками микросекунд на 10k ticks, получили до +120%.
Повтор engine suite в чистом процессе дал:
dom_scalper: около -54%heavy_test: около -53%test_data_passing: около -21%ema_cross: около -4%
Тот же бинарь. Противоположный вывод. Вот вам и цена «объективной цифры» без контроля эксперимента: можно доказать и ускорение, и деградацию, не меняя ни строчки.
Что было исправлено:
-
Latency-sensitive engine suite запускается первым.
-
QFL и indicators идут после него.
-
Каждый suite получает отдельный timestamp marker.
-
Gate анализирует только estimates, созданные после marker.
-
Решение принимается после каждого suite, поэтому stale result не загрязняет следующий.
Для серьезных измерений этого все еще мало. Идеальный стенд:
-
фиксированный governor (
performance); -
фиксированная частота;
-
изоляция ядра;
-
поток benchmark закреплен за ядром;
-
IRQ/RPS вынесены с этого ядра;
-
зафиксированы compiler/toolchain flags;
-
модель CPU, microcode и версия ядра ОС записаны рядом с результатом.
Почему прогрев не решает thermal drift
Warm-up Criterion нужен, чтобы:
-
завершить lazy initialization;
-
прогреть кэши кода и данных;
-
стабилизировать branch predictor;
-
вывести JIT из уравнения, если он есть.
Но Rust AOT бинарь не становится «стабильным» после одной секунды магически. Длинный suite меняет температуру, package power, turbo residency и поведение соседней нагрузки на общей машине. Это процесс с временной шкалой в минуты.
Отсюда практическое правило: порядок бенчей является частью методики. Случайный порядок уменьшает систематическое смещение, но усложняет сравнение с версионированным baseline. Фиксированный порядок воспроизводим, но чувствителен к накоплению тепла. Раздельные jobs дают более чистый старт, но разные VM могут получить разного соседа. Выбирать приходится явно и документировать.
14. Статистический regression gate без гадания на upper bound
В Criterion change estimate есть confidence interval:
{ "mean": { "confidence_interval": { "lower_bound": 0.05, "point_estimate": 0.09, "upper_bound": 0.20 } }}
Порог — 10%.
point_estimate — оценка изменения среднего, а не абсолютная истина. Confidence interval описывает диапазон оценок, совместимых с наблюдаемыми samples при выбранной процедуре bootstrap. Он не означает буквально «вероятность 95%, что истинное значение лежит здесь» в частотной интерпретации. Но для regression gate его удобно использовать как границу доказательности.
Старый gate проверял:
upper_bound > 10% => fail
То есть пример выше падал, хотя:
-
point estimate ниже бюджета;
-
lower bound всего 5%;
-
интервал пересекает threshold;
-
статистически утверждать «регрессия больше 10%» нельзя.
Если политика звучит «fail, когда статистически подтвержденная регрессия превышает 10%», проверять нужно:
lower_bound > 10% => fail
Тогда весь 95% CI находится за пределом бюджета.
Другие допустимые варианты:
-
point estimate > threshold и
p < α; -
lower bound > 0 и point estimate > threshold;
-
отдельные бюджеты по классам benchmarks.
Порог тоже не обязан быть одинаковым. Для 50-ns kernel разброс runner может быть относительно огромным, а +10% почти ничего не меняет в E2E budget. Для 2-ms risk/reconciliation path +5% может быть уже существенно. Нормальная политика привязывает budget к классу:
kernel: relative threshold + minimum absolute deltaVM tick: cycles/event and relative thresholdfull pipeline: p95/p99 latency budgetthroughput: events/sec floor
Комбинация relative и absolute guard не дает падать из-за перехода с 20 на 23 ns, но остановит переход с 200 на 230 us.
Главное — политика должна быть формализована и покрыта тестом. Я добавил фикстуру:
CI [5%, 20%] -> passCI [11%, 20%] -> fail
Сам gate тоже является production code. Если его не тестировать, одна неверная граница доверительного интервала превращает CI в генератор случайных крестиков…
15. Чек-лист перед production
Перед тем как написать в README «у нас супер оптимизация SIMD», я бы требовал пройти этот список. Если не прошли, то значит у вас пока не SIMD-оптимизация, а демка intrinsics.
Алго
-
Нет ли
O(1)update вместо полного прохода? -
Можно ли объединить несколько reductions в один проход по памяти?
-
Предвычислены ли константы, зависящие только от period?
-
Не скрывает ли красивый kernel allocation или копирование вокруг него?
Layout
-
Данные непрерывны или представлены максимум несколькими slices?
-
Сохранен логический порядок wrapped ring buffer?
-
SoA/AoS выбран по реальному шаблону доступа?
-
Нет gather там, где возможны последовательные loads?
ISA
-
AVX, AVX2 и FMA объявлены корректно?
-
Старый CPU получает fallback, а не
SIGILL? -
Runtime detection не выполняется на каждый элемент?
-
Проверен сгенерированный ассемблерный листинг?
-
Измерен AVX frequency effect на весь pipeline?
Корректность
-
Scalar backend является эталоном?
-
Проверены все длины tail и позиции wrap?
-
Определено поведение NaN/Inf?
-
Определен tolerance или bitwise contract?
-
FMA/reassociation отражены в replay determinism?
-
Предусловия
unsafeдокументированы и проверяются до входа в boundary?
Hot path
-
Нет аллоки, форматирования логов и поиска в
HashMapна тик? -
Hot/cold state разделены?
-
Каналы bounded и non-blocking?
-
Drain тоже bounded?
-
Атомики используют минимально достаточный ordering?
-
Нет false sharing на multi-writer counters?
Измерения
-
Есть kernel, VM и full-pipeline benchmarks?
-
Throughput считается по реальному числу событий?
-
Setup не попал в измерение случайно?
-
CPU frequency/thermal state контролируются?
-
Сравнение выполняется на одинаковой машине и toolchain?
-
Gate проверяет правильную границу CI?
-
Baseline версионирован и не переписывается автоматически?
Вывод
SIMD-оптимизация начинается не с _mm256_loadu_pd. Она начинается в момент, когда вы можете ответить по своему проекту:
-
какие байты придут в L1;
-
в каком логическом порядке;
-
сколько раз они будут прочитаны;
-
где находится цепочка зависимостей;
-
что произойдет с tail;
-
какую семантику вычислений с плавающей запятой вы обещаете;
-
как докажете ускорение вне прогретого микробенчмарка.
В Quince шесть AVX2/FMA kernels стали полезны только после zero-copy RingVec::as_chunks, предвычисленных слотов, разделения hot/cold state VM, bounded non-blocking транспорта и многоуровневых benchmarks. Затем пришлось чинить уже саму инфраструктуру измерений: gate с неправильной статистикой опаснее отсутствия gate — он уверенно врет и еще требует подчинения.
Самый опасный противник здесь — желание увидеть зеленую цифру и немедленно объявить победу. Процессору плевать на название PR, количество intrinsics и уверенность автора. Он исполняет uops, ждет строки кэша и сбрасывает pipeline после ошибки предсказания ветвления.
Дальше открываются perf, ассемблерный листинг и confidence intervals. Если после этого ускорение осталось — оно настоящее. Если исчезло, значит мы не «потеряли оптимизацию», а вовремя не пустили красивую ложь в прод.
И только после этого можно писать в changelog про SIMD.
Команды для воспроизведения
# Scalar/reference buildcargo bench -p quince-indicators --no-default-features --bench bench -- --noplot# Native CPU build; бинарь нельзя переносить на неизвестный CPURUSTFLAGS='-C target-cpu=native' \ cargo bench -p quince-indicators --bench bench -- --noplot# VM и полный engine pipelinecargo bench -p quince-qfl --bench bench -- --noplotcargo bench -p quince-engine --bench bench -- --noplot# Аппаратные счетчики на отдельном benchmark binaryperf stat -r 10 \ -e cycles,instructions,branches,branch-misses,cache-references,cache-misses \ target/release/deps/bench-<hash>
Проект
Проект: Quince
Сайт: Not A Fraud
Поддержать автора
Если ценишь подробные разборы и есть желание подарить кофе:
BTC: bc1qnvn2fwkptfw5n7q033gkd38atlntp08s5d2vuh
TRC20: TVNPzo91J7cPv2tZnvHfKyH8HMGzwXQg44
ссылка на оригинал статьи https://habr.com/ru/articles/1064622/