Одни и те же данные в 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) x numeric 25 17.0 / 20.2 0.84 30.6 / 30.4 1.01 text 18 7.9 / 21.9 0.36 14.5 / 28.5 0.51 nested 47 27.9 / 40.6 0.69 48.2 / 87.5 0.55 vector[4] 6 11.7 / 22.0 0.53 15.0 / 19.3 0.78 vector[25] 27 49.7 / 49.2 1.01 36.7 / 48.2 0.76 vector[100] 171 226.9 / 175.7 1.29 149.1 / 135.1 1.10 wide[10 fields] 110 42.4 / 54.9 0.77 55.9 / 96.8 0.58 string_vector[4] 43 24.8 / 46.2 0.54 32.2 / 69.3 0.47 string_vector[50] 465 207.6 / 367.4 0.57 358.9 / 656.0 0.55 all_strings[6] 94 35.4 / 65.5 0.54 42.5 / 118.3 0.36 all_numbers[8] 42 28.9 / 28.5 1.01 33.8 / 42.2 0.80 bytes[32] 17 20.9 / 17.8 1.17 19.6 / 25.2 0.78 int25[25 fields] 78 50.8 / 48.2 1.05 83.0 / 71.4 1.16 str25[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.