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, поведение станет некорректным. Поэтому нужна фиксированная схема работы потоков и очередей.
RTE_ETH_TX_OFFLOAD_MBUF_FAST_FREE
Флаг позволяет оптимизировать возвращение mbuf в пул.
Ограничения: все mbuf в TX-очереди используют один mempool, а счетчик ссылок должен быть равен 1. Поэтому нельзя использовать один и тот же mbuf для отправки нескольким получателям.
Это пример, почему нельзя забывать о стоимости деаллокаций.
В 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. Изменение режима освобождения буферов может сократить редкие спайки, но потребует другой организации очередей. Дублирование повышает устойчивость к одним моделям потерь и добавляет лишнюю нагрузку при других.
А две успешные оптимизации не обязательно окажутся успешными вместе.
Поэтому главный результат инженерного поиска – не только финальный код, но и понимание того, почему он работает именно так.