Как мы сделали протобаф-адаптер CONTRACT быстрее libprotobuf

от автора

Одни и те же данные в C++ почти всегда приходится проводить через несколько разных форматов — debug-вывод, YAML, binary, compact, protobuf — и без общей схемы каждый формат заводит свой собственный маппинг полей, который со временем расходится с остальными.

CONTRACT решает это одной декларацией на все форматы сразу: схема данных объявляется один раз, прямо в типе, и работает уже на этапе компиляции — без runtime-рефлексии и без ручного маппинга под каждый формат:

struct Customer {    std::uint32_t id;    std::string name;    CONTRACT(Customer,        (id, 1),        (name, 2)    )};

Это и есть вся схема — не отдельный .proto-файл, не protoc, не сгенерированные .pb.h/.pb.cc, которые нужно тащить в репозиторий и перегенерировать на каждое изменение структуры. Тот же макрос управляет и формой C+±структуры, и wire-форматом ниже.

Один из адаптеров генерирует protobuf-совместимый wire-формат. Мы сверили его с настоящим libprotobuf (3.21.12): побайтовая идентичность wire-формата подтверждена на всех измеренных сценариях, включая знаковое расширение int32/int64. А по скорости — в большинстве сценариев CONTRACT оказался быстрее самого libprotobuf, обгоняя его собственный сгенерированный protoc-код: обычно 0.3x-0.8x времени на упаковку и 0.4x-0.9x на распаковку.

Дальше — конкретные инженерные решения, из-за которых так вышло, и один сценарий, где не вышло. Оконный ввод-вывод и отказ от прохода по размеру сообщения на самом деле одно и то же решение: как только можно писать прямо в окно, знать размер заранее просто незачем. Отдельно — то единственное место, где размер всё равно нужен заранее (так требует сам протокол), и как этот проход сделали дешёвым. И последний пункт вообще не про запись — про то, как чтение избавилось от квадратичной стоимости поиска поля по номеру.

Как это проверялось

Бенчмарк — benchmarks/protobuf_reference_benchmark.cpp, собирается опционально (-DCONTRACT_BENCH_WITH_PROTOBUF=ON, чтобы основная библиотека и тесты не тянули protobuf как зависимость). Он сравнивает CONTRACT и настоящий libprotobuf на одинаковых структурах и данных и сверяет wire-байты побайтово — то, что обе стороны отработали без ошибок, само по себе ничего не доказывает.

Спорные места переписывали в нескольких вариантах, гоняли через Clang, смотрели итоговый ассемблер и оставляли то, что реально измерялось быстрее — не то, что выглядело изящнее в исходнике.

Вот полная таблица по всем 14 сценариям (Clang 19, --iterations 500000, медиана из 15 независимых запусков поверх собственной медианы каждого запуска — так короткие эффекты не тонут в шуме одного прогона). Размер — в байтах, одинаков у обеих сторон в каждой строке; pack/unpack — в наносекундах на операцию, c/p — отношение CONTRACT к protobuf, x — сам коэффициент (больше 1 значит CONTRACT медленнее):

scenario           size      pack(c/p)     x      unpack(c/p)     xnumeric              25   17.0 /  20.2  0.84     30.6 /  30.4  1.01text                 18    7.9 /  21.9  0.36     14.5 /  28.5  0.51nested               47   27.9 /  40.6  0.69     48.2 /  87.5  0.55vector[4]             6   11.7 /  22.0  0.53     15.0 /  19.3  0.78vector[25]           27   49.7 /  49.2  1.01     36.7 /  48.2  0.76vector[100]         171  226.9 / 175.7  1.29    149.1 / 135.1  1.10wide[10 fields]     110   42.4 /  54.9  0.77     55.9 /  96.8  0.58string_vector[4]     43   24.8 /  46.2  0.54     32.2 /  69.3  0.47string_vector[50]   465  207.6 / 367.4  0.57    358.9 / 656.0  0.55all_strings[6]       94   35.4 /  65.5  0.54     42.5 / 118.3  0.36all_numbers[8]       42   28.9 /  28.5  1.01     33.8 /  42.2  0.80bytes[32]            17   20.9 /  17.8  1.17     19.6 /  25.2  0.78int25[25 fields]     78   50.8 /  48.2  1.05     83.0 /  71.4  1.16str25[25 fields]    272  120.2 / 192.2  0.63    152.8 / 446.2  0.34

Размер совпадает во всех 14 строках — это и есть та побайтовая идентичность wire-формата, о которой шла речь выше, не только для “среднего” сценария, а для каждого измеренного.

Одной таблицы с медианами недостаточно, чтобы честно сказать “быстрее” или “медленнее” — у самого измерения есть разброс. Посчитали его напрямую по тем же 15 независимым прогонам: типичный разброс отношения между прогонами — около ±8% от медианы. Взяли это как границу паритета — всё в пределах ±8% от 1.0 считаем “примерно поровну”, а не выигрышем или проигрышем.

Итог по всем 28 измерениям (14 сценариев × pack/unpack): 20 — уверенный выигрыш CONTRACT, 4 — паритет, 4 — проигрыш. По сценариям:

  • Выиграли: 9 из 14 строк на упаковке, 11 из 14 на распаковке.

  • Паритет: vector[25], all_numbers[8] и int25 на упаковке, numeric на распаковке. int25 на упаковке — граничный случай: на 5 прогонах он был чуть выше границы (1.08), на 15 — чуть внутри (1.05). Это не изменение в коде, а уточнение оценки за счёт большего числа замеров.

  • Проиграли: vector[100] и bytes[32] на упаковке; vector[100] и int25 на распаковке.

int25 на распаковке и vector[100] (и на упаковке, и на распаковке) — подтверждённые проигрыши: обе формы с большим числом дешёвых элементов и без строк, которые обычно маскируют стоимость диспетчеризации/декодирования на элемент.

1. Оконный ввод-вывод: писать сразу туда, куда данные и так должны попасть

Слой contract::io — это фасад над разными реализациями “окна”: один и тот же интерфейс prepare/commit на запись (и peek/consume на чтение) реализован и для простого фиксированного буфера (contract::io::window_output, include/contract/io/byte_window.hpp), и для растущего сетевого буфера поверх boost::beast::flat_buffer (include/contract/io/beast_window.hpp) — то есть для записи напрямую в буфер, из которого дальше пишут в сокет. Адаптер сериализации не знает и не обязан знать, во что именно он пишет: в заранее выделенный кусок памяти или в буфер, который сам растёт по мере записи.

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

Вместо этого writer пишет прямо в текущее окно через prepare/commit:

// Write straight into the window instead of a stack buffer + memcpy// (runtime-sized memcpy defeats inlining). 10 bytes always fits any// 64-bit varint.auto window = out_.prepare(10);if (window.size() >= 10) {    std::size_t count = 0;    std::uint64_t v = value;    while (v >= 0x80u) {        window[count] = static_cast<std::byte>((v & 0x7fu) | 0x80u);        ++count;        v >>= 7;    }    window[count] = static_cast<std::byte>(v);    ++count;    out_.commit(count);    return write_status::ok;}

prepare(10) резервирует до 10 байт в выходном окне (максимальный размер varint для 64-битного значения), байты кодируются прямо в этот участок, commit фиксирует реально записанное количество. Ни промежуточного буфера, ни копирования.

Есть нюанс: сам путь кодирования (write_varint_payload) должен оставаться маленьким и инлайнящимся, потому что он вызывается на каждое скалярное поле. А вот путь для случая, когда буфер закончился и нужно писать через промежуточный буфер (редкий, “граничный” случай) — наоборот, специально помечен noinline:

// Forced noinline: this is the rare boundary-crossing path. Left to the// compiler, its single call site gets it inlined back into// write_varint_payload, which then grows too large to inline itself at// its many call sites in a caller with lots of scalar fields.[[gnu::noinline]] write_status write_varint_payload_fallback(std::uint64_t value) {    ...}

Логика простая: у этой функции всего одна точка вызова, поэтому без явной пометки компилятор с радостью инлайнит её обратно в write_varint_payload — а после этого сам write_varint_payload разрастается настолько, что уже не инлайнится в местах, где вызывается по многу раз (сообщение с 25 полями, например). Явный noinline держит горячий путь маленьким ценой одного редкого вызова функции на холодном пути.

2. Следствие: не нужен предварительный проход по размеру сообщения

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

Но если писать можно прямо в окно, которое либо уже достаточно большое, либо само способно расти по мере записи (см. пункт 1), эта необходимость исчезает сама собой — не нужно знать итоговый размер сообщения до того, как начнёшь его записывать. Поэтому CONTRACT пишет поля сразу по ходу обхода контракта, без отдельного прохода “сначала посчитать, потом записать”:

template<class Object, std::size_t Index>write_status write_message_by_index(const Object& obj) {    using object_type = std::remove_cvref_t<Object>;    if constexpr (Index >= contract::field_count<object_type>()) {        return write_status::ok;    } else {        auto descriptor = contract::field_at<Index, object_type>();        const auto status = this->field(descriptor, obj);        if (status == write_status::error) {            return status;        }        return write_message_by_index<object_type, Index + 1>(obj);    }}

Каждое поле сериализуется сразу при обходе — компилятор разворачивает этот рекурсивный шаблон в плоскую последовательность вызовов на этапе компиляции (Index — compile-time константа, if constexpr отсекает лишнее ещё до кодогенерации).

3. Исключение: вложенным сообщениям всё равно нужен размер заранее

Это работает для сериализации значения целиком — независимо от того, растёт окно само или уже достаточно большое. Но у protobuf как формата есть исключение, которое не обойти никаким окном: вложенное сообщение (length-delimited) должно нести перед собой свою длину в байтах, а значит, эту длину нужно знать до того, как начнётся запись самих байт. Это требование самого wire-формата, а не буфера, так что без какого-то прохода здесь не обойтись в принципе — вопрос только в том, каким он будет.

Решение — тот же самый путь записи, только с “немым” выходом, который не пишет байты, а считает их:

struct counting_output {    void write(const void*, std::size_t size) noexcept {        position_ += size;    }    // ...private:    std::size_t position_ = 0;};template<class Value>std::optional<std::size_t> measure_encoded_size(const Value& value) {    counting_output sizing{};    writer<counting_output&> sizing_writer{sizing};    using value_type = contract::adapters::base::clean_t<Value>;    const auto status = codec<value_type>::write(sizing_writer, value);    if (status == write_status::error) {        return std::nullopt;    }    return sizing_writer.position();}

measure_encoded_size запускает ровно тот же codec<T>::write, что и настоящая сериализация, — отдельного кода для подсчёта размера, который мог бы со временем разъехаться с реальной записью, просто нет. Разница только в том, куда пишет counting_output: не в буфер, а в счётчик — байты прибавляются к позиции, а не сохраняются.

Здесь же ещё одна деталь, которая экономит реальную работу: даже кодирование varint для подсчёта размера не выполняется, если нужен только счётчик байт —

// counting_output only wants the byte count, not the actual bytes -// skip encoding entirely instead of building bytes just to discard them.if constexpr (std::is_same_v<std::remove_reference_t<Output>, counting_output>) {    out_.write(nullptr, detail::varint_byte_count(value));    return write_status::ok;}

varint_byte_count — чистая арифметика (сколько байт займёт varint-кодирование значения), без построения самих байт и без единого ветвления:

// Number of bytes a varint encoding of value takes up, without encoding it.// byte_count = ceil(bit_width(value) / 7), value|1 folds the value==0 case// (which needs 1 byte) into the same formula as value==1.constexpr std::size_t varint_byte_count(std::uint64_t value) noexcept {    const unsigned bits = 64u - static_cast<unsigned>(std::countl_zero(value | 1u));    return (bits + 6u) / 7u;}

Varint кодирует значение группами по 7 бит, значит число байт — это ceil(значащих_бит / 7). std::countl_zero (C++20, обычно одна аппаратная инструкция вроде lzcnt/clz) даёт число ведущих нулевых бит, откуда 64 - countl_zero(value) — это позиция старшего установленного бита, то есть и есть “значащие биты”. value | 1u — трюк на случай value == 0: без него countl_zero(0) дал бы 64 ведущих нулей и 0 значащих бит, а varint для нуля всё равно должен занять 1 байт; | 1u не меняет результат ни для одного ненулевого значения (младший бит и так может быть занят чем угодно), но для нуля превращает его в 1, давая те же “1 значащий бит → 1 байт”, что и для value == 1. (bits + 6u) / 7u — целочисленное округление вверх при делении на 7. В сумме — без циклов, без ветвлений, обычно одна инструкция подсчёта ведущих нулей плюс пара арифметических — там, где наивный вариант считал бы байты в цикле, повторяя >>= 7 из самого кодирования.

4. Поиск поля по id: от O(N²) к одному проходу

Первые три пункта — про запись. На чтении был отдельный, более грубый баг производительности, который вскрылся не в момент разработки, а позже, при профилировании: поиск поля по номеру в wire-формате был написан как рекурсивный шаблон, перезапускающий сравнение с нулевого индекса на каждое входящее поле:

template<class Object, std::size_t Index>static read_status read_field_by_number(    reader& in, Object& obj, std::uint32_t field_number, detail::wire_type wire){    if constexpr (Index >= contract::field_count<Object>()) {        // ... unknown field error ...    } else {        auto field = contract::field_at<Index, Object>();        if (static_cast<std::uint32_t>(field.id) == field_number) {            return in.read_field(field, obj, wire);        }        return read_field_by_number<Object, Index + 1>(in, obj, field_number, wire);    }}

Для сообщения с полями, идущими в wire-формате по возрастанию номера (обычный случай), это квадратичная стоимость: чтобы дойти до последнего поля, приходится каждый раз заново сравнивать с первого. На 25 полях это уже не “почти бесплатно”.

Заменили на dispatch_field_by_id<T>(id, fn) — одно fold-выражение вместо рекурсивного перезапуска, в форме, которую компилятор может свернуть в jump table так же, как обычный switch:

template<class T, class Fn, std::size_t... Is>[[gnu::always_inline]] constexpr bool dispatch_field_by_id_impl(    std::uint64_t id, Fn& fn, std::index_sequence<Is...>) {    bool found = false;    auto try_field = [&]<std::size_t Index>() {        if (found || static_cast<std::uint64_t>(field_at<Index, T>().id) != id) {            return;        }        fn(field_at<Index, T>());        found = true;    };    (try_field.template operator()<Is>(), ...);    return found;}

Без always_inline вся эта конструкция не стоила бы переписывания: компилятор разворачивает fold обратно в цепочку последовательных сравнений внутри read_message — по кодогенерации это неотличимо от старого рекурсивного варианта, просто выглядит компактнее в исходнике. Пометка в коде выглядит как стилистическая деталь, но именно она превращает fold в диспетчер, реально сворачивающийся в jump table на широких сообщениях.

Проверяли не на глаз: прогон бенчмарка против настоящего libprotobuf подтвердил отсутствие регрессий на всём наборе сценариев.

Где не получилось: int25 и vector[100]

Мы пробовали два альтернативных способа декодирования varint под эти два сценария — branchless SWAR-декодер на несколько байт сразу, и повторение собственного однобайтового быстрого пути libprotobuf. Оба на реальном распределении значений в этой кодовой базе измерились хуже, чем то, что уже было. Тащить более сложный декодер ради двух сценариев из четырнадцати показалось неоправданным — решили просто честно задокументировать границу применимости.

Итог

Секретного трюка тут нет — есть одна причина (окно вместо промежуточного буфера) и два прямых следствия: не нужен memcpy на кодировании поля, и не нужен предварительный проход по размеру сообщения, кроме единственного места, где его требует сам протокол, — а там дорогой проход просто заменили дешёвым. Плюс отдельная находка на чтении: линейный fold вместо квадратичного поиска поля. Вместе этого хватает, чтобы обогнать сгенерированный protoc-код почти везде, кроме пары честно задокументированных исключений.

Показательно, что происходит, когда совместимость с чужим форматом вообще не нужна: у CONTRACT есть ещё и binary-адаптер — свой собственный wire-формат, без оглядки на protobuf. Там, где не нужно подстраиваться под чужие проектные решения, contract-слой стоит буквально ноль поверх ручного C++ кода — ratio держится в районе 1.00 почти на всех типах и путях доступа. С protobuf всё сложнее именно потому, что формат чужой: у него свои ограничения на wire-уровне и убрать их, не сломав совместимость, нельзя. Если интересны детали binary-адаптера — пишите, разберём отдельно.

Репозиторий: Contract, include/contract/adapters/protobuf.hpp, docs/adapters/protobuf.md#performance, docs/reference/benchmarks.md#reference-result-snapshot.

ссылка на оригинал статьи https://habr.com/ru/articles/1064148/