October 30

Code is cheap. Show me the reasoning: разбор решений Low Latency Data Transfer Challenge

В этом материале собрали интересные технические подходы из решений участников Low Latency Data Transfer Challenge: от работы с памятью и сетевым стеком до восстановления потерь и организации экспериментов.

Допустим, оптимизация A дает прирост производительности на 10%, а оптимизация B – на 20%. Кажется, что вместе они должны дать еще больший выигрыш. Но что, если их совместное использование дает всего 7%?

Оптимизации не всегда независимы. Они могут влиять друг на друга, и результат одной меняется при использовании другой.

Поэтому главный вывод из Low Latency Data Transfer Challenge – все нужно измерять и перепроверять.

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

1. Постановка задачи

В Low Latency Data Transfer Challenge нужно было реализовать транспорт для передачи данных от одного Producer к нескольким Consumers.

Цель – минимизировать задержку доставки, особенно на хвостовых перцентилях p99, p99.9 и p99.99, и сохранить производительность при увеличении количества получателей и интенсивности потока.

Участники работали с фиксированными Producer и Consumer и предоставленным measurement harness. При этом сам механизм транспорта можно было реализовать по-разному: оптимизировать программный путь, работать с сетевым стеком и драйверами или пробовать альтернативные способы передачи.

Начнем с того, что происходит непосредственно при обработке сообщения.

2. Hotpath: избавляемся от лишней работы

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

Отключение инструментации в compile-time

Инструментация – это не только логирование, но и счетчики пакетов и потерь, измерение времени выполнения, отладочные сообщения и условные ветвления по конфигурации.

Например, проверки вида if (config.feature_enabled) – на hotpath даже такая дополнительная работа стоит дорого.

Простейший подход – передавать feature flags на этапе сборки через CMake или переменные окружения и использовать #ifdef либо if constexpr, чтобы исключить ненужные операции из финального бинарника.

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

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

Например, компонент для моделирования потерь:

template <typename Next>
class LossInjectionPipe : public Next {
  public:
    void process(OutboundDatagram datagram) {
    if (should_drop()) {
      return;
    }
    Next::process(datagram);
    }
 };

При необходимости такой компонент включают в пайплайн, а в сборке без моделирования потерь отсутствует полностью.

Подход лучше масштабируется с ростом количества фич: сохраняет структуру и локальность кода, уменьшает сложность вложенных #ifdef и позволяет отдельно тестировать микрокомпоненты.

В результате можно проводить эксперименты с включенной инструментацией и расширенными метриками, а финальные end-to-end замеры делать без нее.

Про то, как организовать “дешевую” инструментацию, мы уже рассказывали на конференции, посмотреть можно в отдельном посте.

Исключить лишние операции – часть задачи. Даже когда в hotpath оптимизирован, процессор тратит время на ожидание данных из памяти.

Prefetching: подготавливаем данные заранее

Prefetching нужен, чтобы скрыть часть задержки при чтении данных из RAM, заранее запросив их загрузку в процессорный кэш. Главная идея – подготовить данные до того, как программа непосредственно к ним обратится. В решениях использовали prefetch как на чтение, так и на запись: в ShmRing и в сетевом коде, при работе с буферами пакетов. Важный нюанс: без prefetch может быть лучше, чем с неправильно настроенным prefetch. Его эффективность зависит от того, какие данные запрашиваются заранее и насколько своевременно это происходит.

Многие участники добавляли prefetch в собственный код, но мало кто модифицировал harness.

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

Управление памятью и Zero-copy

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

Базовый подход выглядит достаточно просто:

  • Заранее выделить необходимую память
  • Использовать кольцевые буферы и предварительно выделенные пулы объектов
  • Учитывать page faults и заранее подготавливать страницы памяти
  • Избегать лишних копирований данных

Для работы со страницами можно использовать предварительное обращение к памяти и механизмы вроде mlock. Но как проверить, что в критическом участке действительно не осталось аллокаций? Например, сделать их запрещенными.

Для кода на C можно использовать возможность линкера перехватывать вызовы malloc через -Wl,--wrap=malloc:

extern "C" {
[[gnu::weak]] void* __real_malloc(std::size_t size);
void* __wrap_malloc(std::size_t size) {
  // Here be your malloc interceptor code
  return __real_malloc(size);
}
}

Для C++ можно дополнительно переопределить варианты operator new: обычный, массивный, aligned и nothrow. А чтобы разрешать выделение памяти за пределами hotpath, предусмотреть переключаемые режимы работы.

Сам по себе запрет аллокаций не гарантирует zero-copy: копирования могут происходить и между заранее выделенными буферами. Поэтому эти оптимизации важно проверять отдельно. Теперь попробуем сократить и сам объем данных, с которыми мы работаем.

Формат пакета: передаем меньше данных

В решениях использовали varint/zigzag, дельты, фиксированные точки, битовые флаги и словари инструментов. Исходные форматы размером 192/192/576 байт удавалось сжать до 160, 101 и даже 50/58/148 байт. При этом важны два условия: пакет должен оставаться самодостаточным, а кодек – сохранять точность исходных данных.

Оптимизации hotpath позволяют сократить работу с данными. Следующий источник накладных расходов – между приложением и сетевым оборудованием.

3. Сетевой стек и NIC

Даже если приложение обрабатывает сообщение очень быстро, его еще нужно передать через сетевой адаптер. Здесь появляются расходы на работу с очередями, дескрипторами, буферами, памятью устройства и драйверами.

Kernel bypass позволяет работать с частью этих ограничений. Мы уже подробно разбирали его в отдельном материале, поэтому сейчас сосредоточимся на конкретных технических приемах.

Шардирование по RSS-очередям

RSS распределяет входящий трафик между очередями сетевого адаптера. Для выбора очереди можно использовать хэширование Теплица по полям пакета – например, 2-tuple или 4-tuple.

В рассматриваемой конфигурации использовали 1, 2, 4 или 8 очередей, а трафик, отличный от TCP/UDP, попадал в нулевую очередь. Отсюда возникает гипотеза: разные 4-tuple могут показывать разную задержку. В решениях встречались два способа использовать это наблюдение.

Первый – при старте выбрать несколько source port, отправить через каждый тестовый трафик в течение нескольких секунд, получить обратную связь по задержкам и выбрать лучший.

Второй – выбрать две разные комбинации 4-tuple и отправлять каждый пакет дважды.

Во втором случае количество отправляемых пакетов удваивается, поэтому возможный выигрыш в задержке нужно сопоставлять с дополнительной сетевой нагрузкой.

Подробнее о механизме – в документации DPDK.

Тюнинг DPDK

“Now implement transport using DPDK library.”

С таким промптом можно получить базовое решение на DPDK, которое написано в духе примеров из библиотеки. Но само использование DPDK еще не означает, что все накладные расходы на hotpath устранены. Некоторые участники пошли дальше стандартных настроек:

1. Single-producer / single-consumer для mempool

Использовали флаги:

RTE_MEMPOOL_F_SP_PUT | RTE_MEMPOOL_F_SC_GET

Они переключают соответствующие операции mempool на оптимизированные пути для single-producer и single-consumer, сокращая расходы на синхронизацию.

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

2. Быстрое освобождение mbuf

Использовали:

RTE_ETH_TX_OFFLOAD_MBUF_FAST_FREE

Флаг позволяет оптимизировать возвращение mbuf в пул.

Ограничения: все mbuf в TX-очереди используют один mempool, а счетчик ссылок должен быть равен 1. Поэтому нельзя использовать один и тот же mbuf для отправки нескольким получателям.

3. Настройка tx_free_thresh

Это пример, почему нельзя забывать о стоимости деаллокаций.

В ENA использованные mbuf накапливаются и возвращаются в пул большими пачками при достижении определенного порога. Такая работа попадает на момент обработки следующей отправки и создает спайк задержки. Некоторые участники изменяли tx_free_thresh, чтобы скорректировать это поведение.

4. Явный вызов rte_eth_tx_done_cleanup

Логическое продолжение предыдущего подхода.

Чтобы не ждать освобождения буферов в момент новой отправки, можно на idle-итерациях вызывать rte_eth_tx_done_cleanup(...) небольшими батчами. Тогда к моменту появления новых данных часть завершенных TX-буферов уже будет возвращена в пул.

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

Не все ограничения можно устранить на уровне приложения. Иногда для оптимизации транспорта приходится дорабатывать драйвер сетевого адаптера.

Например, в драйвере bnxt_en для Broadcom NetXtreme E отсутствовала поддержка XDP_SETUP_XSK_POOL, необходимая для работы AF_XDP в режиме zero-copy. Чтобы использовать этот режим, потребовалось добавить поддержку непосредственно в драйвер.

Доработки также позволили обойти Linux NAPI и объединить отправку TX-дескрипторов в батчи. Вместо отдельных операций для каждого дескриптора можно использовать один doorbell и один write memory barrier на весь батч, сокращая накладные расходы при отправке пакетов.

LLQ, write-combining и отправка в NIC

Другой способ уменьшить задержку – оптимизировать взаимодействие с памятью сетевого устройства.

Для этого можно использовать Low Latency Queue (LLQ) и write-combining, в том числе через доработку драйвера vfio-pci. Для ENA такие изменения дают заметный прирост производительности, а не просто небольшое улучшение latency.

Еще один подход – inline-push, который передает данные непосредственно в NIC, минуя обычный DMA-путь.

При стандартной отправке NIC считывает данные пакета из оперативной памяти хоста через DMA. При inline-push приложение передает данные через MMIO непосредственно в память устройства, сокращая количество операций на пути отправки.

4. Альтернативные способы передачи

Оптимизации DPDK, AF_XDP и NIC сокращают расходы существующих механизмов. Другой вариант – использовать альтернативный транспорт или перенести его реализацию в оборудование.

RDMA/RoCE и аппаратный мультикаст

Еще один вариант – использовать RDMA/RoCE вместо UDP и аппаратный мультикаст на коммутаторе.

В такой схеме отправителю не нужно создавать отдельную отправку для каждого получателя: стоимость отправки не растет линейно вместе с N. Но результат зависит от аппаратной поддержки в инфраструктуре. В AWS подобная схема не переносилась напрямую, поскольку мультикаст добавлял дополнительный хоп.

FPGA: транспорт в железе

Еще один подход – перенести реализацию транспорта с CPU на FPGA. Например, для этого можно использовать Alveo U50. В отличие от программной реализации, FPGA позволяет обрабатывать и передавать данные непосредственно на аппаратном уровне.

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

5. Fan-out: масштабирование отправки

При переходе к нескольким получателям появляется дополнительная задача – масштабировать рассылку без роста задержки. Нагрузка отправителя растет, порядок и способ отправки влияют на latency каждого consumer.

Один TX burst вместо множества отправок

При последовательной отправке пакетов растет количество обращений отправителя к NIC для каждого получателя. При большом fan-out это ограничивает производительность. Сократить накладные расходы можно батчингом: вместо отдельного вызова rte_eth_tx_burst подготовить пакеты для всех получателей и отправить их одним вызовом. Это снижает количество операций отправки и риск упереться в пакетный потолок NIC.

Ротация получателей

Порядок отправки влияет на latency. При последовательной рассылке одна отправка занимает u, тогда задержка последнего получателя получается примерно на (N − 1) × u больше, чем у первого. Если отправитель всегда соблюдает один и тот же порядок, первый Consumer получает данные раньше остальных, а последний ждет отправки всем предыдущим. В результате между получателями возникает постоянный перекос задержек.

Чтобы устранить его, можно менять порядок отправки для каждого пакета. Например, первый пакет отправлять начиная с Consumer 1, второй – с Consumer 2 и так далее.

Формально для пакета с номером i начинаем с получателя i % N, затем переходим к (i + 1) % N.

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

Relay tree вместо прямого unicast

Но что, если не пытаться оптимизировать последовательную отправку, а изменить саму топологию рассылки? При прямом unicast нагрузка на отправителя растет вместе с количеством получателей.

Relay tree позволяет снизить пакетную нагрузку на отправителя при увеличении числа получателей. Например, для fan-out 1→10 можно сравнить прямой unicast и relay tree, отдельно измерив задержку, которую добавляет дополнительный хоп.

Так можно оценить, компенсирует ли снижение нагрузки на отправителя стоимость передачи через промежуточные узлы.

Но оптимизация fan-out решает только часть задачи. Помимо минимальной задержки, важно учитывать потери пакетов и способы их восстановления без существенного увеличения latency.

6. Потери пакетов и адаптация протокола

В решениях чаще всего встречались эти три подхода к восстановлению данных:

  • NACK – запрашивает повторную отправку потерянного пакета дополнительным RTT
  • FEC – добавляет избыточные данные, которые могут восстановить потери
  • Piggybacking – добавляет к новым пакетам информацию о предыдущих

Выбор механизма восстановления зависит от характера потерь: сеть может терять как отдельные пакеты, так и целые серии подряд.

Важно учитывать burst-потери

Пакеты не всегда теряются независимо друг от друга. Например, в течение короткого окна в 10 мкс могут пропасть сразу несколько пакетов подряд. Если отправить оригинал и дубликат одновременно, сеть может потерять оба.

Один из способов избежать этого – разносить оригинал и восстановительные данные по времени. Интервал можно задавать не только в микросекундах, но и количеством отправленных пакетов. Например, хранить в ring buffer N последних сообщений и к каждому i-му пакету добавлять информацию для восстановления сообщения i−N.

Также можно отправлять дубликат спустя несколько датаграмм после оригинала. В экспериментах такой подход сокращал количество невосстановленных событий на порядок по сравнению с немедленным дублированием.

Дублирование только при свободных ресурсах

Дополнительные копии пакетов данных повышают устойчивость к потерям, но и создают дополнительную нагрузку на сеть. Можно использовать оппортунистическое дублирование – отправлять дополнительные копии только в периоды простоев. Это сократит количество дубликатов при нагрузке и освободит место для основного потока данных.

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

Reed-Solomon FEC и NACK

Еще один способ восстановления потерь – комбинировать FEC и NACK.

Reed-Solomon FEC позволяет восстанавливать потерянные данные без повторной отправки пакетов. Для ускорения вычислений можно использовать SIMD-оптимизацию на GFNI, а перемежение данных с учетом длины серии потерь помогает справляться с burst-потерями.

Если FEC не смог восстановить данные, получатель отправляет NACK – запрос на повторную передачу потерянного пакета.

Динамическое изменение протокола

Еще одна идея – не отправлять фиксированное количество дополнительных данных для восстановления потерь, а менять его в зависимости от того, как часто и каким образом сеть теряет пакеты.

Для проверки такого подхода можно использовать три сценария:

  • Чистый канал без потерь
  • Независимые потери отдельных пакетов
  • Серии последовательных потерь (burst-потери), моделируемые цепью Маркова

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

Это возвращает нас к центральной идее: эффективность механизма зависит от условий. Чтобы сделать вывод, необходимо корректно измерить результат.

7. Измерения latency: можно ли доверять цифрам?

В low-latency системах особенно важны хвостовые перцентили – p99.99 и p99.999. Чем дальше мы уходим в хвост распределения, тем больше требований возникает к измерениям.

Синхронизация часов

При измерении one-way latency на разных машинах недостаточно просто вычесть два timestamp. Если часы отправителя и получателя неточно синхронизированы, погрешность может быть больше выигрыша от оптимизации. В AWS для синхронизации часов лучше использовать PTP. При этом важно экспериментально проверять точность синхронизации в конкретных условиях. Например, Chrony может не обеспечить необходимую точность.

Среди альтернативных подходов:

  • Собственный механизм синхронизации с коррекцией частоты через adjtimex
  • Отказ от измерения one-way latency в пользу RTT, который вообще не требует синхронизации часов

Но тут возникают другие проблемы - дополнительная дисперсия из-за двух отправок, накладные расходы на обратную пересылку, несимметичность канала. Rule of thumb – оптимизируй one-way latency по хорошо синхронизированным часам, после этого делай fine tuning по RTT.

Измерение задержки по стадиям

Чтобы не связывать каждую внутреннюю задержку с проблемой межмашинной синхронизации, полезно разложить путь сообщения на стадии:

ShmRing → Sender → сеть → Receiver → Consumer.

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

Сколько наблюдений нужно для хвостовых перцентилей?

Для p99.99 нужны выборки порядка миллиона сообщений, для p99.999 – порядка десяти миллионов. Но количество сообщений – только одна часть задачи.

Другая частая ошибка – усреднение перцентилей отдельных прогонов. Среднее по p99.99 может скрывать редкие события из отдельных экспериментов. Поэтому для общей оценки корректнее объединять исходные наблюдения из сопоставимых прогонов, а не просто усреднять рассчитанные квантили.

Инструментация сама меняет измерения

Еще одна проблема – сбор статистики непосредственно на hotpath. В нескольких решениях участники самостоятельно обнаружили, что fprintf на каждое сообщение и реаллокации буферов для измерений способны раздувать дальние квантили на порядки. Поэтому инструментацию нужно организовать так, чтобы она не искажала измеряемый процесс.

8. Культура экспериментов: от гипотезы к выводу

Все предыдущие разделы – это набор инженерных гипотез. Каждая из них может быть полезной в одном окружении и не дать результата в другом. Более того, оптимизации не всегда независимы. Поэтому важно понимать их совместное влияние.

Что сохранять в эксперименте

В сильных работах участники фиксировали не только финальный результат, но и сам процесс исследования:

  • Сырые данные экспериментов
  • Описание окружения и параметров запуска
  • Хэши бинарных файлов для валидации корректности запуска
  • Формулировка гипотезы
  • Результаты запусков и их анализ
  • Вывод – гипотеза подтверждена или опровергнута, который можно проверить по данным

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

Контроль порядка и условий тестов

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

  • Парные тесты – для сравнения конфигураций с учетом порядка запусков
  • Предполетные проверки – для отмены измерений, если состояние хоста не соответствует требованиям
  • Перенос прерываний NIC – для настройки обработки сетевых событий
  • Выбор типа инстанса на основе измерений – чтобы оценивать влияние оборудования на latency, например, чтобы получатели не делили NIC и кэш, создавая дополнительные искажения
  • Тюнинг ОС через Ansible или другие инструменты – для воспроизводимости настроек окружения

Часто встречаемые ограничения решений

В ходе разбора встречались и типичные проблемы:

  • Замеры только на loopback
  • Тривиальный fan-out с отдельным системным вызовом для каждого получателя, без sendmmsg и упаковки событий в датаграмму
  • Отсутствие обработки потерь, особенно при батчинге нескольких событий в одну датаграмму
  • Недостаточный анализ синхронизации часов
  • Аллокации и копирования на hotpath
  • Инструментация без возможности compile-time отключения с заметным влиянием на результат

9. Вместо вывода

В Low Latency Data Transfer Challenge участники экспериментировали с разными уровнями системы: от compile-time специализации и управления памятью до сетевых драйверов, FPGA и изменения архитектуры fan-out.

Но практически за каждой технической находкой стоит один и тот же вопрос: как проверить, что это действительно работает?

Неправильный prefetch может увеличить latency. Изменение режима освобождения буферов может сократить редкие спайки, но потребует другой организации очередей. Дублирование повышает устойчивость к одним моделям потерь и добавляет лишнюю нагрузку при других.

А две успешные оптимизации не обязательно окажутся успешными вместе.

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