CONTRACT умеет писать protobuf-совместимый wire-формат — и в 20 из 28 замеров обогнал по скорости настоящий libprotobuf, включая его собственный сгенерированный protoc-код. Разбираем три инженерных решения, которые это дали, и два сценария, где не получилось. CONTRACT — это C++ библиотека сериализации без кодогена и рантайм-рефлексии: схема объявляется один раз, в самой C+±структуре, и работает сразу под разные форматы. Читать далее
Одни и те же данные в 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] (и на упаковке, и на распаковке) — подтверждённые проигрыши: обе формы с большим числом дешёвых элементов и без строк, которые обычно маскируют стоимость диспетчеризации/декодирования на элемент.
Слой 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 держит горячий путь маленьким ценой одного редкого вызова функции на холодном пути.
Многие сериализаторы сначала считают итоговый размер сообщения, потом выделяют буфер под этот размер, потом сериализуют. Так приходится делать, когда назначение записи — это кусок памяти фиксированного размера, который нужно выделить заранее.
Но если писать можно прямо в окно, которое либо уже достаточно большое, либо само способно расти по мере записи (см. пункт 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 отсекает лишнее ещё до кодогенерации).
Это работает для сериализации значения целиком — независимо от того, растёт окно само или уже достаточно большое. Но у 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 из самого кодирования.
Первые три пункта — про запись. На чтении был отдельный, более грубый баг производительности, который вскрылся не в момент разработки, а позже, при профилировании: поиск поля по номеру в 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.
| # | Наименование новости | Тональность | Информативность | Дата публикации |
|---|---|---|---|---|
| 1 | Дайджест C++: новости, полезные материалы и «свой язык» на десерт | 0 | 7.94 | 27-05-2026 |
| 2 | Делаем модуль на C++ для приложения React Native | 0 | 6.53 | 30-07-2026 |
| 3 | [Перевод] Сокращаем длительность компиляции проекта на Rust c 30 до 2 минут — пример с 1000 крейтов | 0 | 8.18 | 09-07-2026 |
| 4 | const fn в 2026: ваш компилятор втихаря исполняет Rust | 0 | 9.58 | 20-07-2026 |
| 5 | ИИ-Автопилот: поток принятых задач вырос в тринадцать раз | 1 | 8.82 | 31-07-2026 |
| 6 | Худший язык программирования всех времён /s | -2 | 6 | 01-07-2026 |
| 7 | Книга: «100 ошибок C++ и как их избежать» | 0 | 7.01 | 03-06-2026 |
| 8 | Шаблоны C++ как инструмент архитектуры: compile-time dispatch, type traits и type erasure | 5 | 7 | 28-06-2026 |
| 9 | [Перевод] Доверьтесь компилятору: C++23 против трюков из 90-х | 0 | 10.4 | 27-07-2026 |