Раздел 30 · Web3 на Rust

Основы HFT: из чего складывается задержка

senior~60 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Основы HFT: из чего складывается задержка

Весь предыдущий блок мы строили то, что торгует в цепи, где один блок это секунды. Теперь уходим туда, где счёт идёт на наносекунды, и первое, что придётся сделать, это перестать говорить «быстро». В HFT нет быстрого кода, есть бюджет времени, разложенный по участкам, и у каждого участка своя цена сокращения. Пока бюджета нет, любая оптимизация это гадание.

Что такое HFT и чего в нём нет

Высокочастотная торговля это не «торговля часто». Частота тут следствие, а не цель. Определяющих признаков три, и все три про инженерию:

Решение принимается автоматически и без человека в контуре. Человек задаёт правила и границы риска, дальше цикл замкнут внутри программы. Человек не успевает физически: пока он моргнул, прошло сто тысяч событий.

Позиция держится коротко. От долей секунды до минут. Прибыль на сделку крошечная, иногда доли тика, а зарабатывает она на количестве сделок. Отсюда требование к издержкам: комиссия и проскальзывание съедают такую стратегию мгновенно.

Скорость это часть стратегии, а не деталь реализации. Если стратегия работает одинаково при задержке в микросекунду и в миллисекунду, это не HFT, а просто автоматическая торговля, и слава богу: жить там сильно легче.

Теперь чего в HFT нет. Это не предсказание цены на завтра: горизонт слишком короткий, и в нём работают не новости, а поток заявок. Это не «печатный станок»: доля прибыльных дней высокая, но маржа на сделку такая, что любой сбой в риске съедает месяц. И это не что-то принципиально новое: маркетмейкер в биржевой яме занимался ровно тем же, только его преимуществом было физическое место у столба, а не место в стойке.

Откуда берётся прибыль

Три источника, и они устроены по-разному.

Плата за мгновенность. Маркетмейкер стоит по обе стороны и даёт другим возможность купить или продать прямо сейчас. За это он получает спред. Мы считали это в уроке 44: спред это не жадность, а плата за право не ждать. Риск маркетмейкера в том, что кто-то знает больше него, и об этом ниже.

Связанные инструменты. Фьючерс на индекс и корзина акций этого индекса связаны арифметикой. Если одно уехало, а второе ещё нет, между ними есть разница, и она живёт ровно столько, сколько нужно самому быстрому, чтобы её забрать. Это тот же арбитраж, что между двумя пулами, только вместо блокчейна две площадки и провод между ними.

Опережение по данным. Одна и та же цена появляется на разных площадках не одновременно. Тот, кто увидел её на первой площадке и успел до второй, торгует по цене, которой уже нет. Это самый спорный источник: технически это работа с публичными данными, экономически это налог на тех, кто медленнее.

Кто платит. В первом случае платит тот, кому нужна мгновенность, и он получает за свои деньги реальную услугу. Во втором и третьем платит тот, кто медленнее, и никакой услуги не получает. Разговор о том, где проходит граница между полезным и паразитическим, идёт в индустрии двадцать лет и не закончен. Наше дело как инженеров понимать, что именно мы строим, и не путать одно с другим.

Бюджет tick-to-trade

Путь от того, как биржа положила байт в провод, до того, как наша заявка вернулась к ней обратно, называется tick-to-trade. Это единственное число, которое имеет смысл оптимизировать, и оно складывается из участков.

Опишем бюджет типом, а не комментарием. Тогда его можно считать, а не обсуждать:

/// Наносекунды. Отдельный тип, чтобы не путать их с микросекундами.
pub type Nanos = u64;

/// Участок пути сигнала: имя, задержка и то, чем её можно сократить.
pub struct Stage {
    pub name: &'static str,
    pub nanos: Nanos,
    pub lever: Lever,
}

/// Способ сократить участок. Порядок вариантов это порядок стоимости:
/// сначала то, что делается кодом, потом то, что делается деньгами.
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum Lever {
    /// Переписать код: убрать аллокации, кэш-промахи, ветвления.
    Code,
    /// Настроить машину и ядро: изоляция ядер, huge pages, отключение прерываний.
    Kernel,
    /// Обойти ядро: сетевой стек в пользовательском пространстве.
    Bypass,
    /// Перенести логику в железо: FPGA, сетевая карта с отбором пакетов.
    Hardware,
    /// Сократить расстояние: колокация, кросс-коннект, прямой канал.
    Distance,
    /// Не сокращается: чужая сторона, ждём и учитываем.
    None,
}

Типовой бюджет софтового движка, который уже стоит в стойке биржи, но ещё ничем не оптимизирован:

УчастокНаносекундЧем лечится
провод до нашей стойки250расстояние
сетевая карта и ядро на приём4000обход ядра
разбор пакета фида300код
обновление стакана400код
решение стратегии900код
проверка риска200код
сборка заявки и ядро на отправку3500обход ядра
провод обратно250расстояние
итого9800

Смотри на распределение, а не на сумму. Весь наш код это 1800 наносекунд из 9800, то есть меньше пятой части. Ядро операционной системы съедает 7500, три четверти бюджета.

impl LatencyBudget {
    /// Сколько времени скрыто за одним способом ускорения.
    /// Отвечает на вопрос «что даст обход ядра» до того, как мы его внедрили.
    pub fn by_lever(&self, lever: Lever) -> Nanos {
        self.stages
            .iter()
            .filter(|stage| stage.lever == lever)
            .map(|stage| stage.nanos)
            .sum()
    }

    /// Применить ускорение: участки с этим рычагом становятся быстрее
    /// в `factor` раз. Так проверяется гипотеза до того, как за неё заплатили.
    pub fn optimize(&self, lever: Lever, factor: f64) -> Self {
        let stages = self
            .stages
            .iter()
            .map(|stage| Stage {
                name: stage.name,
                nanos: if stage.lever == lever {
                    (stage.nanos as f64 / factor).round() as Nanos
                } else {
                    stage.nanos
                },
                lever: stage.lever,
            })
            .collect();
        Self { stages }
    }
}

Это закон Амдала в его исходном виде: ускорение системы ограничено долей той части, которую ты ускоряешь. Переписать разбор пакета так, чтобы он летал в десять раз быстрее, звучит как достижение, а даёт 270 наносекунд из 9800, меньше трёх процентов. Обход ядра при том же множителе даёт почти семь микросекунд.

Покрути бюджет руками. Включи ускорения по одному и в разных сочетаниях:

Обрати внимание на важное: выигрыш от правки кода зависит от того, что ты сделал до неё. В исходном бюджете код почти не виден, а после обхода ядра он становится главным участком, и та же самая правка вдруг начинает окупаться. Порядок работ важнее самих работ.

Почему смотрят на хвост, а не на среднее

Возьми движок, который обрабатывает событие за 400 наносекунд. Раз в пятьсот событий планировщик отбирает у него ядро, или срабатывает сборка мусора в соседнем процессе, или страница памяти уезжает в своп, и обработка занимает треть миллисекунды. Это две десятых процента случаев.

Среднее почти не изменится. А деньги потеряются именно в эти две десятых процента, потому что заявка, опоздавшая на треть миллисекунды, не опоздала «немножко»: она опоздала совсем, сделка уже ушла к другому.

Поэтому измеряют квантили. И измеряют их так, чтобы само измерение не стало источником задержки: гистограмма с логарифмическими корзинами делает запись за несколько операций, без аллокаций и без сортировки.

/// Корзин хватает на диапазон примерно от наносекунды до девяти секунд.
const BUCKETS: usize = 64;

pub struct Histogram {
    buckets: Vec<u64>,
    count: u64,
    sum: u128,
    max: Nanos,
    min: Nanos,
}

impl Histogram {
    /// Номер корзины: степень двойки, в которую попадает значение.
    /// Одна инструкция процессора, никаких сравнений в цикле.
    fn bucket_of(value: Nanos) -> usize {
        if value == 0 {
            0
        } else {
            (Nanos::BITS - value.leading_zeros()) as usize
        }
    }

    /// Записать замер. Это и есть весь горячий путь измерения.
    pub fn record(&mut self, value: Nanos) {
        let index = Self::bucket_of(value).min(BUCKETS - 1);
        self.buckets[index] += 1;
        self.count += 1;
        self.sum += value as u128;
        self.max = self.max.max(value);
        self.min = self.min.min(value);
    }

    /// Квантиль по корзинам: нижняя граница корзины, в которой лежит
    /// нужный по счёту замер. Ответ огрубляется, но никогда не врёт
    /// в сторону «у нас быстрее, чем на самом деле».
    pub fn quantile(&self, q: f64) -> Nanos {
        if self.count == 0 {
            return 0;
        }
        let target = (self.count as f64 * q).ceil().max(1.0) as u64;
        let mut seen = 0;
        for (index, &hits) in self.buckets.iter().enumerate() {
            seen += hits;
            if seen >= target {
                return Self::bucket_low(index);
            }
        }
        Self::bucket_low(BUCKETS - 1)
    }
}

Точность падает с ростом значения, и это ровно то, что нужно. Разница между 300 и 310 наносекундами важна. Разница между 30 и 31 миллисекундой не важна: обе означают «мы опоздали».

Вот что даёт этот код на сто тысячах замеров с описанным выше поведением:

МетрикаЗначение
среднее1448 нс
медиана512 нс
p99512 нс
p99.9262144 нс
максимум300000 нс

Среднее и p99 говорят одно и то же и оба врут. p99.9 говорит правду.

премиум-урок

Продолжение в премиуме: 1 200 ₽ в месяц или 10 000 ₽ в год. Вместе с ним серверные проверки задач и школьные токены AI-коуча.

Оплата пока не подключена: премиум и курсы открываются промокодом.