Мы тут выложили Protobuf Table-Driven Parser.



Основная идея, что для каждого отдельного типа Protobuf мы генерировали огромный кусок кода, и он рос линейно/супер линейно с количеством полей в протобуфе.



Рост происходил из-за того, что компилятору мы давали код в духе





auto [tag, wire_format] = ReadTag(ptr);

if (tag == kMessageProtoTagNumber) {

ptr = ParseMessage(ptr);

}

if (tag == kIntProtoTagNumber) {

ptr = ParseVarInt32(ptr);

}



И так далее. В итоге был огромный кусок кода и компилятор уставал аллоцировать регистры, делать push, pop регистров при входе/выходе из вложенных функций, компиляция росла. Я писал об этом аж 1.5 года назад https://t.me/experimentalchill/95 про аттрибут [[musttail]]. И теперь то самое начало давать свои плоды.



Так как формат фиксированный, мы можем просто брать функцию из значения тега и вызывать её, сохраняя все регистры и заставив компилятор не делать push/pop. 6 параметров регистров мы спрятали PROTOBUF_TC_PARAM_DECL.



#define
PROTOBUF_TC_PARAM_DECL \

::proto2::MessageLite *msg, const char *ptr, \

::proto2::internal::ParseContext *ctx, \

::proto2::internal::TcFieldData data, \

const ::proto2::internal::TcParseTableBase *table, uint64_t hasbits



В итоге оно выглядит как



data.read_tag<type>();

data.read_data<type>();

ptr += size_read;

SetField(data.offset(), ptr);

has_bits |= 1 << data.index_idx();

ReadInstruction()

dispatch to next Instruction from table



Пример можно посмотреть здесь.



Плюсы, которые мы получили:



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

2. У нас нет переполнения стека из-за отсутствия push/pop, теперь мы поддерживаем сообщения любой вложенности

3. По перфу нейтрально -- выиграли в push/pop, проиграли в статичности функций

4. Легче понимать профили бинарей, что они парсят на уровне сообщений

5. Только 1 имплементация парсинга на всех

6. Мы сможем менять формат протобуфов даже на ходу



Как мы сможем его менять? Скажем, при типе int32 мы пишем varint. Если число отрицательное, мы пишем 5 байт из-за того, что отрицательные числа наполнены единицами. А что если мы хотим написать его в типе sint32, чтобы писать меньше? В прошлые разы мы генерировали парсинг на основании типа и формата поля, то есть цикл парсинга мог воспринимать тип только строго.



Теперь мы можем писать int32, скажем, как



1. Если положительное, просто в varint

2. Если отрицательное, в отрицательном varint

3. Цикл парсинга всё поймёт, так как привязки к типу нет, и число в память напишется как напишется

4. Мы сэкономили байты и не проиграли в парсинге



Такие оптимизации можно делать даже на ходу -- алгоритм сериализации можно подменить так же.



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



Пройдёт 3-5 лет, и мы сможем так делать по дефолту (build horizon). Пока -- под опцией.