
Чтение сериализованных данных — это инфраструктурный налог, который платит каждый сервис при получении информации из внешних источников, например по сети или с диска. В индустрии для схематизированных данных стандартом де‑факто стал Protobuf, и чаще всего этот налог выражается в существенных затратах CPU на его парсинг. В продвинутых случаях парсинг пытаются заменить на значительно более дешёвую, но при этом куда менее удобную работу с zero‑copy представлением FlatBuffers.
Мы открыли исходники YaFF (Yet Another Flat Format) — формата, который убирает этот налог, не заставляя отказываться от Protobuf. На масштабе Яндекса это особенно важно, потому что менять такие базовые вещи, как формат, дорого и больно. Поэтому YaFF изначально спроектирован как альтернативный wire format для существующих экосистем Protobuf (и в перспективе FlatBuffers). Это позволяет дёшево и бесшовно встраиваться в существующие проекты, не переписывая десятки тысяч строк кода.
Как это работает на практике, мы покажем на примере Яндекс Рекламы: в рекомендательной системе, где каждый из сотен тысяч запросов обрабатывает десятки тысяч объектов, нужно особое внимание к представлению данных. Благодаря YaFF мы смогли постепенно, шаг за шагом, оптимизировать систему и без дорогих рефакторингов сэкономить 10–20% CPU в масштабах крупных рантаймов.
В этой статье:
поговорим про налог на сериализацию и разберём, почему связка Protobuf и FlatBuffers не работает на больших масштабах;
проведём deep dive во внутреннее устройство FlatBuffers и поймём его фундаментальные компромиссы;
изучим, как устроен YaFF внутри, как в нём лежат байты и за счёт чего получается построить эффективное представление Protobuf;
обсудим, как единая объектная модель упрощает жизнь продуктовым разработчикам и ML‑инженерам;
расскажем про выкладку YaFF в опенсорс и о том, как начать с ним экспериментировать.
Сразу оговоримся: на наших масштабах schemaless‑форматы вроде JSON не рассматриваются из‑за избыточности и отсутствия строгих контрактов. Речь в статье пойдёт исключительно о бинарных схематизированных форматах.
Типичный путь потока данных в системе
В архитектуре современных высоконагруженных бэкендов данные проходят три разных этапа. Каждый из них накладывает свои, зачастую взаимоисключающие, требования к формату сериализации.
Первый этап — документоориентированное хранилище. Это глобальное состояние системы и источник истины для эталонной модели данных. Нагрузка здесь носит OLTP‑характер: точечные обновления или запись небольшими батчами. От формата на этом уровне требуется компактность и гибкость эволюции схемы. Бизнес‑сущности неизбежно разрастаются до сотен связанных структур с тысячами полей, и формат должен нативно это поддерживать.
Второй этап — монолитные или микросервисные рантаймы, где исполняется продуктовая логика. В отличие от хранилища, рантаймы оперируют в условиях жёстких ограничений по времени ответа и борются за максимальную пропускную способность. Формат данных здесь обязан обеспечивать высокую скорость чтения. Накладные расходы на аллокации памяти или распаковку данных становятся критичными, при этом разработчикам необходим безопасный и эргономичный API.
Третий этап — аналитика и ML‑пайплайны. Сюда стекаются огромные объёмы логов из рантаймов для офлайн‑обработки, переобучения моделей и сбора статистики. Случайный доступ к данным здесь не нужен. Главными метриками на этом этапе являются максимальная степень сжатия и скорость последовательного сканирования огромных массивов.
На ранних стадиях жизни проекта универсальности Protobuf хватает, чтобы закрывать потребности всех трёх этапов. Однако по мере кратного роста как нагрузки, так и объёма данных эта универсальность начинает обходиться слишком дорого: налог на сериализацию и десериализацию начинает съедать двузначные проценты CPU, которые транслируются в десятки тысяч физических ядер.
В этот момент архитектура неизбежно расслаивается в сторону специализированных решений: хранилище остаётся на Protobuf для описания модели данных; аналитика переезжает на колоночные форматы (Parquet, Arrow), а performance‑critical‑рантаймы требуют перехода на zero‑copy инструменты — например, FlatBuffers или даже сырые структуры на C++.
Проблема перекладывания полей
Хотя специализированные форматы решают проблемы производительности на своих этапах, на стыках систем они порождают новый, теперь уже организационный налог — необходимость постоянной трансляции данных.
Рассмотрим типичный сценарий: ML‑инженер или продуктовый разработчик хочет добавить фичу. Когда формат единый, это несложно. Но с зоопарком форматов добавление одного поля превращается в кросс‑командный квест:
добавить поле в Protobuf‑описание модели данных в хранилище;
договориться с командой подготовки данных, чтобы поле прокинули до рантаймов;
синхронизироваться с дата‑инженерами, чтобы поле корректно доехало до логов.
В результате архитектура обрастает слоем конвертеров, адаптеров и кастомной логики трансляции. Каждый такой слой является потенциальным источником багов, требующим отдельного тестирования и ресурсов на поддержку. Сложность доставки данных через все слои системы увеличивает Time‑to‑Market новых фич и тормозит продуктовые эксперименты.
В идеальном мире хочется сочетать несочетаемое: вернуть простоту ранних стадий проекта, где единая Protobuf‑схема выступала источником правды для всей системы, но при этом под капотом получить высокую производительность специализированных форматов. Именно этот архитектурный вызов и привёл нас к созданию YaFF.
В рамках этой статьи мы сфокусируемся на самом горячем стыке: переходе данных из хранилища в рантайм. Поскольку в Яндексе все performance‑critical рантаймы написаны на C++, дальнейший разбор будет идти в контексте этого языка.
Точно ли нельзя обойтись без Protobuf?
Мы не будем подробно останавливаться на попытках «выжать максимум» из стандартного Protobuf. Да, использование арен снижает фрагментацию кучи, а string_view API избавляет от лишних аллокаций при копировании строк. На первых этапах масштабирования этого действительно хватает. Но фундаментальную проблему формата это не решает: данные всё равно нужно парсить.
Чтобы понять, где проходит граница применимости Protobuf, давайте посмотрим на цифры. Например, один из горячих сервисов Рекламы обрабатывает порядка 4000 RPS с одного хоста, и на каждый запрос ему необходимо проанализировать несколько сотен баннеров. Это даёт нам миллионы объектов в секунду на одну машину. Гонять такие объёмы по сети физически невозможно, поэтому весь шард — а это 30 миллионов баннеров весом около 50 ГБ — должен лежать локально.
Если хранить эти данные в Protobuf, то на выбор есть два одинаково плохих варианта:
Парсить на лету: десериализация миллиона объектов в секунду сожжёт кучу CPU.
Парсить на старте: можно распарсить 50 ГБ в C++‑структуры при запуске. Но тогда старт реплики займёт десятки минут (что особенно плохо при падениях) и потратит много RAM.
В performance‑critical системах единственный жизнеспособный паттерн для таких объёмов — это memory‑mapped files. Мы кладём файл на диск (или в tmpfs) и прозрачно отображаем его в виртуальной памяти. Но для этого формат должен быть собственно отображаемым: байты на диске должны лежать ровно в том же виде, в котором процессор ожидает увидеть их в памяти.
Даже на нагрузках на порядок ниже, где данные ещё можно гонять по сети, отображаемый формат даёт огромный профит. Он высвобождает CPU, а локальный кэш ответов автоматически становится отображаемым индексом, который переживает рестарты сервиса без долгого прогрева.
Способность формата позволять чтение данных напрямую, без промежуточного копирования и парсинга — это и есть фундамент zero‑copy парадигмы. Именно здесь на сцену выходит её самый известный представитель в мире C++ — FlatBuffers.
FlatBuffers — это же Protobuf без десериализации?
В сообществе FlatBuffers часто воспринимается практически как Protobuf без десериализации. Иллюзия их взаимозаменяемости подкрепляется общим происхождением (оба формата созданы в Google), схожим синтаксисом схем, набором типов и тулингом.
Даже официальная документация FlatBuffers прямо заявляет об их сходстве:
“Protocol Buffers is indeed relatively similar to FlatBuffers, with the primary difference being that FlatBuffers does not need a parsing/unpacking step to a secondary representation before you can access data, often coupled with per‑object memory allocation”.
Более того, если заглянуть в справку компилятора flatc, можно обнаружить флаг ‑proto, который обещает автоматическую трансляцию.proto‑файлов в .fbs:
“‑proto: Expect input files to be.proto files (protocol buffers). Output the corresponding.fbs file...”.
Кажется, что решение проблемы перекладывания данных найдено: берём наши эталонные proto‑схемы из хранилища, скармливаем их компилятору FlatBuffers и получаем zero‑copy рантайм.
Но на практике попытка такого бесшовного переезда оборачивается болью. Несмотря на внешнее сходство, семантически это два фундаментально разных мира, и просто натянуть FlatBuffers поверх устоявшейся Protobuf‑модели не получится. Откуда берутся различия, мы поговорим чуть позже. А пока пройдёмся по самым основным проблемам, с которыми вы столкнётесь как пользователь, если попытаетесь использовать FlatBuffers как прямую замену Protobuf.
FlatBuffers — это НЕ Protobuf без десериализации!
Схемы
Различия начинаются уже на уровне схемы данных. Она задаёт долгосрочный контракт, в котором формат определяет не только структуру здесь и сейчас, но и правила её эволюции. Флаг ‑proto позволяет сделать разовую конвертацию .proto‑файла в .fbs. Но поддерживать их синхронное развитие дальше не получится.
Правила эволюции схем во FlatBuffers жёстче. Новые поля разрешено добавлять строго в конец сообщения. Удалять поля из схемы запрещено: их можно лишь помечать как deprecated. В Protobuf же вы вольны удалять поля из середины, безопасно резервируя их идентификаторы через ключевое слово reserved.
Давайте посмотрим, к чему приводит это семантическое несоответствие на практике:
Исходный Protobuf (user.proto)
message User { reserved 2; // Удалённое поле string email = 3; int32 id = 1; }
Результат flatc ‑proto (user.fbs)
table User { email: string; // Неявный id: 0 id: int; // Неявный id: 1 }
Что произошло в этом примере? Компилятор flatc проигнорировал reserved и отбросил оригинальные теги Protobuf, назначив идентификаторы просто по порядку полей. Из‑за этого реализовать совместимый автоматический конвертер не получится: критичная метаинформация об эволюции схемы безвозвратно утеряна.
И это лишь один из ярких примеров несовместимости. При попытке поддерживать единую модель данных вы столкнётесь с целым классом подобных проблем на уровне схем.
Пользовательский API
Схема данных — это лишь половина контракта. Вторая, не менее важная часть — сгенерированный C++ API, с которым разработчики взаимодействуют каждый день. И здесь разрыв между экосистемами Protobuf и FlatBuffers становится ещё более очевидным.
В крупных системах модели данных неизбежно обрастают глубокой вложенностью. В мире Protobuf разработчики привыкли к безопасным цепочкам доступа. Если промежуточного объекта не существует, Protobuf прозрачно вернёт ссылку на дефолтный инстанс. FlatBuffers же предоставляет более низкоуровневый API: любой пропущенный вложенный объект возвращается как нулевой указатель.
В условиях сильно разреженных данных, где разные продуктовые срезы заполняют разные поддеревья схемы, не писать проверки и надеяться на устные договорённости нельзя. Пропуск хотя бы одной проверки рано или поздно приведёт к разыменованию нулевого указателя в рантайме.
Код с использованием Protobuf
uint32_t total_score = req.user().stats().score() + req.context().device().score();
Код с использованием FlatBuffers
uint32_t total_score = 0; if (auto user = req->user()) { if (auto stats = user->stats()) { total_score += stats->score(); } } if (auto context = req->context()) { if (auto device = context->device()) { total_score += device->score(); } }
В результате бизнес‑логика тонет в инфраструктурном бойлерплейте. Код становится хрупким, трудночитаемым и дорогим в ревью.
Перекладывание всё ещё с нами
Даже если вы закроете глаза на семантические конфликты схем и смиритесь с бойлерплейтом при чтении, вас будет ждать ещё один источник проблем — сериализация. Стандартный компилятор flatc умеет (пусть и с оговорками) транслировать схемы, но он не генерирует код для конвертации самих данных из Protobuf‑объекта во FlatBuffers‑буфер.
Это значит, что ручное перекладывание полей никуда не исчезает, и придётся писать и поддерживать конвертеры самостоятельно.
Начало нашего пути и его трудности
Все описанные выше проблемы лежат в плоскости интерфейсов и контрактов, а значит, их можно обойти дополнительными слоями абстракции.
В нашей первой итерации мы решили пойти именно по этому пути: написать собственный слой кодогенерации поверх FlatBuffers, чтобы натянуть на него семантику Protobuf. С помощью костылей и хаков такой подход не идеально, но работал:
проблему reserved‑полей мы обошли генерацией фейковых dummy‑полей — поскольку старые данные туда больше не пишутся, бинарная совместимость сохраняется (хотя это и недокументированная возможность);
проблему хрупкого API мы закрыли генерацией умных C++ оберток, которые прятали под капотом все проверки на nullptr и прозрачно возвращали дефолтные значения;
проблему ручной сериализации мы решили автоматической генерацией мапперов, которые сами собирали FlatBuffers‑буфер из Protobuf‑объекта в нужном порядке.
Первый наш подход к проблеме сам по себе тянет на небольшую статью, но сейчас подробно на нём останавливаться нет времени.
А что с производительностью?
Решив проблемы эргономики на уровне компилятора, мы упёрлись в ограничение, которое невозможно обойти кодогенерацией, — проблемы с производительностью.
Глобально можно выделить два профиля работы с данными в рантаймах:
Сетевое взаимодействие: данные приходят по сети (объекты из внешних хранилищ или ответы внешних сервисов), и главная цель — избавиться от CPU‑налога на десериализацию.
Чтение локальных индексов: данные лежат в больших mmap‑индексах, и главная цель тут — выжимать максимальную пропускную способность при жёстких ограничениях по времени ответа.
В сервисах, которые в основном занимаются чтением индексов из памяти (как в предыдущем примере), часто узким местом становится не процессор, а пропускная способность шины памяти. Ядра простаивают в ожидании данных из RAM, что выражается в падении IPC (Instructions Per Cycle).
В первом классе задач подхода FlatBuffers обычно оказывается достаточно, но на индексных нагрузках его накладные расходы оказываются слишком заметными. Чтобы понять, почему так происходит, давайте заглянем под капот формата на примере бенчмарка с иерархическими данными.
Симулируем production‑нагрузку
Для объективной оценки накладных расходов нам необходим синтетический тест, точно аппроксимирующий паттерны доступа реальной бизнес‑логики. В нашем репозитории собран целый набор различных бенчмарков, но для этой статьи мы выбрали самый показательный — тест на чтение глубоко вложенных иерархий.
Для этого используем следующую схему:
message Leaf { optional uint64 a = 1; // Поля 2..9 } message Intermediate { // Поля 1..8 optional Leaf leaf = 9; } message Root { // Поля 1..8 optional Intermediate intermediate = 9; };
Полезная нагрузка бенчмарка предельно проста: мы спускаемся по дереву объектов и суммируем скалярные поля в самом глубоком листе:
sum += root->intermediate()->leaf()->a(); // Поля 2..8 sum += root->intermediate()->leaf()->i();
Несмотря на кажущуюся простоту, этот тест измеряет одну из основных частей любого формата сериализации — эффективность представления составных структур данных (message в Protobuf, table во FlatBuffers, struct в C++).
В качестве эталона мы будем использовать сырые C++‑структуры. Есть два пути, чтобы физически смоделировать иерархию: заинлайнить вложенные объекты или хранить их по указателю. В рамках статьи мы фокусируемся на подходе с указателями. Это обеспечивает более честное сравнение, так как точнее отражает семантику опциональных полей Protobuf и FlatBuffers.
Можно выделить два сценария работы со структурами:
случайный доступ, когда из большого множества структур читается небольшое количество полей, — в этом случае производительность определяется количеством чтений из различных кэш‑линий и предсказуемостью этих чтений;
локальный доступ, когда из одной структуры читается большое множество полей, — здесь на первый план выходят накладные расходы формата и эффективность сгенерированного кода.
В реальных системах встречаются оба сценария, и нужно эффективно справляться с любым из них. При проектировании мы учитывали оба варианта: полные бенчмарки для каждого из паттернов реализованы и доступны в нашем репозитории. Тем не менее для детального разбора в статье мы выбрали локальный доступ по двум причинам. Прежде всего, это идеологически более правильный cache‑aware паттерн, к которому стремятся при оптимизации высоконагруженных систем. Кроме того, такой подход позволяет подробно рассмотреть, как на производительность влияют все операции, а не только случайные чтения.
Собирать бенчмарк будем с помощью Clang 20.1.8 со всеми оптимизациями, запускать на AMD EPYC 7713. В результате для структур против FlatBuffers получаем следующие результаты:
Benchmark |
CPU (ns) |
BM_Hierarchical_Raw |
8,16 |
BM_Hierarchical_FlatBuffers |
36,9 |
Получаем разницу в 4,5 раза, что сходится с нашими наблюдениями в реальных рантаймах. Чтобы понять, откуда берётся такой разрыв, нужно заглянуть под капот FlatBuffers, а именно в устройство представления составных типов.
Как работает FlatBuffers
Подход FlatBuffers строится вокруг концепции vtable. Любое сообщение начинается с 4-байтового смещения до vtable. Структура vtable представляет собой компактный словарь метаинформации, состоящий из 2-байтовых слотов: в первом слоте лежит размер vtable, далее идут смещения от начала таблицы до самих данных, строго по порядку id полей.

У такого подхода с таблицей несколько преимуществ: он позволяет записывать поля в любом порядке и при этом правильно их выравнивать, позволяет пропускать неинициализированные поля и сохранять об этом информацию, а также изменять схему, добавляя поля в конец. При этом смещение в начале сообщения позволяет дедуплицировать vtable: в случае повторения два сообщения могут указывать в одно место.
Однако расплатой за это служит повышенный объём метаинформации и сложный алгоритм чтения. Чтобы получить значение всего одного поля, нам нужно:
Прочитать стартовое смещение и перейти к vtable.
Прочитать размер vtable и проверить, попадает ли наше поле в границы. Именно тут работает магия обратной совместимости: если запрашиваемый id лежит за пределами размера, то это значит, что новый код читает старое сообщение и можно возвращать дефолт.
Прочитать из слота 2-байтовое смещение нужного поля и проверить на пустоту. Если значение равно нулю, то поле не инициализировано и снова можно возвращать дефолт.
Наконец, можно возвращаться в начальное сообщение и читать сами данные: скаляр или новое смещение на вложенный объект. Причём в случае вложенного объекта добавляется ещё один переход по смещению.
Казалось бы, достаточно красивый алгоритм, но на самом деле стало понятно, почему он так сильно сжигает перф по сравнению с C++‑структурами.

Во‑первых, это чистый оверхед от количества инструкций. Доступ к полю C++‑структуры транслируется в одно чтение памяти по константному смещению. Здесь же, чтобы добраться до полезной нагрузки, мы делаем четыре чтения, два ветвления и арифметику.
Во-вторых, этот алгоритм плохо ложится на конвейер инструкций процессора. Все четыре чтения зависимые, то есть чтобы прочитать данные на втором шаге, нужно сначала дождаться данных, загружаемых на первом шаге, и так далее. Кроме того, в случае больших сообщений как минимум два чтения, а то и все четыре могут быть разнесены по разным кэш-линиям. В итоге получается, что никакого out-of-order исполнения тут получить нельзя, а читаются, по сути, рандомные куски памяти, которые постоянно вымывают кэш процессора.
Компилятор плохо понимает FlatBuffers
Помимо связанных чтений из разных частей памяти, которые забивают конвейер инструкций процессора, при работе с FlatBuffers также возникают сложности на уровне компилятора при проведении TBAA (Type‑Based Alias Analysis).
Для проверки этого утверждения можно сравнить производительность двух версий суммирования.
Прямое суммирование
s += root->intermediate()->leaf()->a(); s += root->intermediate()->leaf()->b(); // ... s += root->intermediate()->leaf()->i();
Ручное кэширование
const auto* l = root->intermediate()->leaf(); s += l->a(); s += l->b(); // ... s += l->i();
Интуитивно кажется, что код выше должен работать примерно с одинаковой производительностью и выбор стиля — скорее, дело вкуса. Однако на самом деле разница на бенчмарке практически в два раза.
Benchmark |
Cache Chains |
CPU (ns) |
BM_Hierarchical_Raw |
false |
8,12 |
BM_Hierarchical_Raw |
true |
8,24 |
BM_Hierarchical_FlatBuffers |
false |
36,8 |
BM_Hierarchical_FlatBuffers |
true |
20,7 |
Это происходит из‑за того, что в стандартной реализации FlatBuffers на C++ любое составное сообщение реализуется через подобие Flexible Array Member, а чтение в итоге сводится к reinterpret_cast к указателю на нужный тип. То есть, грубо говоря, сгенерированный код делает следующее:
Объявление типа таблицы
struct I { // Field accessors; private: // ... uint8_t data_[1]; }
Код с использованием FlatBuffers
struct O { // ... const I* GetInner() const { // Calculates pointer p based on data_; return reinterpret_cast<const I*>(p); } // ... }
Из‑за такого type‑punning подхода алгоритмы TBAA не дают оптимизатору достаточно сильных фактов, и итоговый Alias Analysis LLVM часто возвращает консервативный вердикт MayAlias. В комбинации с ветвлениями это сильно ограничивает оптимизатор. У механизмов CSE/GVN нет права переиспользовать вычисленные адреса вложенных структур между обращениями, так как нет математического доказательства безопасности и неизменности памяти поверх условных переходов.
То есть частое использование цепочек вызовов, нормальное в мире Protobuf, сильно снижает производительность, из‑за того, что и так длинная последовательность чтения поля мультиплицируется на каждый уровень вложенности.
Такое поведение можно провалидировать либо с помощью чтения ассемблера, где мы увидим повторяющийся паттерн чтения вложенных структур, либо запустив LLVM aa‑eval на сгенерированном LLVM IR и посмотрев ответы Alias Analysis.
Сгенерированный ассемблер
# root->intermediate() # resolve vtable from data_ movsxd rdx, dword ptr [rdi] mov rax, rdi sub rax, rdx neg rdx # check vtable size movzx esi, word ptr [rax] cmp si, 21 # jump skipped # read offset and check movzx eax, word ptr [rdi + rdx + 20] test rax, rax # jump skipped # read offset to intermediate lea rcx, [rdi + rax] mov eax, dword ptr [rdi + rax] add rax, rcx # resolves leaf from rax and reads data # ... # resolves intermediate and leaf # again and again movzx eax, word ptr [rdi + rdx + 20] test rax, rax # jump skipped # ... movzx eax, word ptr [rdi + rdx + 20] test rax, rax # jump skipped
Результат Alias Analysis
// txt Function: SumFbs: 92 pointers, 0 call sites // The MayAlias response is used // whenever the two pointers might // refer to the same object. MayAlias: i16* %add.ptr.i.i.i.i.i, i32* %root MayAlias: i16* %add.ptr.i.i.i.i, i32* %root MayAlias: i32* %add.ptr.i.i.i, i32* %root MayAlias: i32* %add.ptr.i.i.i, i16* %add.ptr.i.i.i.i.i MayAlias: i32* %add.ptr.i.i.i, i16* %add.ptr.i.i.i.i MayAlias: i32* %cond.i.i.i, i32* %root // ... MayAlias: i16* %add.ptr.i.i.i.i.i356, i64* %add.ptr.i.i378 MayAlias: i16* %add.ptr.i.i.i.i360, i64* %add.ptr.i.i378 MayAlias: i32* %add.ptr.i.i.i364, i64* %add.ptr.i.i378 MayAlias: i64* %add.ptr.i.i378, i32* %cond.i.i.i358 MayAlias: i16* %add.ptr.i.i.i.i370, i64* %add.ptr.i.i378 MayAlias: i16* %add.ptr.i.i.i374, i64* %add.ptr.i.i378
А почему так получилось?
При взгляде на штрафы производительности, вызванные структурой сообщений, возникает закономерный вопрос: зачем инженеры Google выбрали столь сложный и многослойный дизайн? Ответ кроется в фундаментальном законе software engineering: любой дизайн строится на компромиссах.
Архитектура FlatBuffers — это осознанный выбор в пользу компактности сериализованного представления в ущерб чистой скорости доступа. Каждое решение здесь закрывает конкретную проблему:
Вынесение vtable в отдельную структуру по смещению позволяет переиспользовать её для массива однотипных данных. Если в сообщении тысяча одинаковых структур, их смещения будут указывать на один и тот же участок памяти.
Слоты vtable развязывают логический id поля и его физический адрес. Это позволяет записывать поля в любом порядке, строго соблюдая правила выравнивания памяти, что критично для работы на любом железе.
Если поле не инициализировано, оно просто не занимает место в основной структуре. Пустой слот в vtable обходится всего в 2 байта, а для отсутствующих полей парсеру даже не обязательно знать их тип. Хотя структура vtable технически позволяет удалять поля из середины схемы, стандарт FlatBuffers это строго запрещает, хотя именно на этой лазейке мы в своё время смогли построить наш подход с кодогенерацией.
Хранение размера самой vtable предоставляет элегантный механизм для forward/backward‑совместимости при добавлении новых полей.
Проблема контекста
Чтобы понять эти trade‑offs, нужно немного погрузиться в историю. Изначально FlatBuffers разрабатывался для нужд мобильного геймдева. Отсюда и его оригинальные trade‑offs: необходимость поддерживать зоопарк мобильных процессоров вынуждает агрессивно добавлять выравнивания, а ограниченные ресурсы мобильных устройств — экономить сеть и память.
Таким образом, FlatBuffers это ни разу не «просто Protobuf, который не нужно парсить», а инструмент с понятным кругом задач, который никогда не задумывался для обработки потоков данных в серверных рантаймах.
Мы поняли, что инструмента, который был бы действительно «zero‑copy Protobuf для высоконагруженных бэкендов», на рынке просто не существует. Поэтому мы начали проектировать свой.
Время делать ещё один плоский формат
Новый формат мы назвали YaFF — Yet Another Flat Format. Но главный вопрос был не в названии, а в границе совместимости: насколько сильно мы готовы менять существующую экосистему ради zero‑copy чтения.
Ответ оказался простым: мы не хотим ничего менять, а хотим максимально переиспользовать устоявшуюся Protobuf‑экосистему. Почему так? Protobuf уже прочно встроен во многие проекты: вокруг него есть схемы, кодогенерация, правила эволюции, ревью, тесты и большое количество бизнес‑логики. Построить рядом новую экосистему можно, но переезд в неё будет слишком дорогим.
Переиспользуем готовые схемы
Первое фундаментальное решение в YaFF: у формата не будет собственного языка описания схем. Источником истины остаётся обычный.proto‑файл.
Опыт с FlatBuffers хорошо показал, почему отдельная схема становится проблемой. У одной и той же бизнес‑сущности появляются два описания:
proto — для хранилища и общего контракта данных;
fbs — для рантайм‑представления.
Дальше эти схемы нужно развивать синхронно: добавлять поля в двух местах, следить за совместимостью и поддерживать конвертеры.
Поэтому в YaFF схема остаётся одна: разработчик продолжает описывать данные в Protobuf, как он делал это раньше, а всю магию zero‑copy сериализации под капотом должен взять на себя YaFF — никакого переучивания разработчиков и двойной работы.
Но если proto‑схема остаётся источником истины, недостаточно поддержать только структуру сообщения, YaFF должен повторять и модель эволюции Protobuf‑схем. Это означает, что безопасные миграции Protobuf, например добавление поля со свободным id или удаление поля из середины схемы с последующим reserved, должны оставаться безопасными и в YaFF. Иначе единый контракт перестаёт быть единым: одна и та же схема начинает вести себя по‑разному в разных слоях системы.
Переиспользуем готовый пользовательский API
Со схемами разобрались: источником истины остаётся.proto‑файл. Следующий вопрос: как с этими данными будет взаимодействовать разработчик.
Переиспользование схем решает только часть проблемы миграции. В большой кодовой базе не менее важно сохранить привычный способ доступа к данным: если каждое чтение поля требует переписать прикладную логику, внедрение может стать дороже выигрыша от оптимизации. Поэтому миграция на YaFF должна быть практически бесшовной для кода, который читает данные. В идеале программист вообще не должен замечать, что под капотом вместо материализованного Protobuf‑объекта лежит бинарный буфер YaFF.
Однако из‑за строгой типизации C++ полностью сохранить те же типы невозможно. В zero‑copy‑режиме YaFF не должен материализовывать данные в обычные Protobuf‑классы. А для чтения данных напрямую из буфера YaFF придётся в любом случае генерировать свой набор классов.
То есть когда у нас есть существующий код с Protobuf, мы хотим, чтобы YaFF‑представление использовалось так же. А в идеале прикладной код вообще не должен знать, какое представление ему передали:
Код с Protobuf
uint32_t GetScore(const proto::User& u) { if (!u.has_stats()) { return 0; } return u.stats().score(); }
Код с YaFF
uint32_t GetScore(const yaff::User& u) { if (!u.has_stats()) { return 0; } return u.stats().score(); }
Универсальный код
template <class U> uint32_t GetScore(const U& u) { if (!u.has_stats()) { return 0; } return u.stats().score(); }
Из этого следуют требования к API, который будет генерировать YaFF:
Интерфейсная совместимость: имена getter’ов, has_-методы, enum-значения и семантика доступа должны быть максимально близки к Protobuf.
Шаблонная совместимость: разработчик должен иметь возможность писать generic‑код, который будет одинаково компилироваться и работать с Protobuf‑ и YaFF‑представлениями.
Двусторонняя конвертация: YaFF должен уметь строить zero‑copy буфер из существующего Protobuf‑объекта и при необходимости восстанавливать из этого буфера обычный Protobuf‑объект.
Первые два свойства снижают стоимость миграции прикладного кода. Последнее особенно важно для постепенного внедрения. Не все части системы одинаково чувствительны к стоимости десериализации. Где‑то выгодно читать данные напрямую из YaFF‑буфера, а где‑то проще продолжать работать с обычным Protobuf: например, в менее нагруженных сервисах, инструментах отладки или языках, для которых zero‑copy API ещё не реализован. Поэтому здесь хорошо работает возможность в любой момент перейти из мира YaFF в мир Protobuf.
// Service 1: data preparation proto::User pb = LoadUser(); auto buffer = yaff::SerializeUser(pb); Service 2: buffer can be obtained from an external source const auto& user = ReadRoot<yaff::User>(buffer); uint32_t score = GetScore(user); // Restoring proto for transfer to service 3 proto::User restored; user.ParseTo(restored);
Неизменяемость сериализованного представления
Следующее фундаментальное решение касается модели записи: после построения YaFF‑буфер неизменяем. На первый взгляд это кажется серьёзным ограничением. Но для сценариев высоконагруженного рантайма это хороший компромисс.
Во‑первых, поддержка изменения значений плохо сочетается с компактным zero‑copy представлением. Как только в схеме появляются строки, массивы или вложенные объекты переменного размера, мы вынуждены решать, куда записывать новое значение, что делать со старым участком памяти и как не сломать смещения на остальные данные. Это приводит к фрагментации данных, что увеличивает потребление памяти.
Во‑вторых, в высоконагруженном рантайме данные почти всегда только читаются: они либо лежат в больших локальных индексах, либо приходят из внешних систем и проходят через бизнес‑логику без изменений. Неизменяемый буфер можно безопасно и эффективно читать из нескольких потоков, не думая про синхронизацию и инвалидацию кэш‑линий. Обычно если в системе речь заходит о необходимости zero‑copy чтений, то и на примитивах синхронизации мы точно хотим экономить.
Если изредка данные всё же нужно изменять, эту логику лучше вынести на уровень выше: построить новый буфер или накладывать дельту из другого буфера прямо во время чтения в рантайме. Сам сериализованный буфер остаётся простым, компактным и оптимизированным под основной сценарий — быстрое чтение.
YaFF как альтернативный wire format для Protobuf
Если собрать предыдущие решения вместе, идею YaFF можно сформулировать одной фразой: это альтернативный wire format для Protobuf, поддерживающий zero‑copy чтение.
Ключевая идея в том, что мы не меняем модель данных. Protobuf‑схемы остаются источником истины, правила эволюции схем остаются Protobuf‑совместимыми, а пользовательский API максимально похож на привычный Protobuf API. Меняется только физическое представление: данные можно хранить в бинарном буфере, из которого рантайм читает поля напрямую, без этапа десериализации.
Такой дизайн сохраняет мост между двумя мирами. YaFF‑буфер можно построить из обычного Protobuf‑объекта, а при необходимости из YaFF‑буфера можно восстановить Protobuf‑объект обратно. Благодаря этому YaFF не требует одномоментной миграции всей системы. Его можно внедрять как прозрачную оптимизацию в тех модулях, где парсинг и аллокации действительно становятся узким местом, оставляя прочие компоненты на обычном Protobuf.
Сохраняем базовую zero‑copy модель FlatBuffers
FlatBuffers не подходит нам как готовая экосистема, но базовая модель zero‑copy формата очень универсальная, и YaFF её также использует. Сгенерированный код строит плоский буфер, а сгенерированный API читает поля напрямую из него. Скалярные значения хранятся прямо в объектах, а данные переменного размера — строки, массивы и вложенные сообщения — в виде смещения.
Основная сложность переносится в layout. Нужно решить, как именно разложить скаляры, вложенные объекты и метаинформацию так, чтобы одновременно сохранить Protobuf‑семантику, поддержать эволюцию схем и сделать чтение максимально дешёвым.
Учитываем серверное окружение
При разработке YaFF мы учитывали целевое окружение. Формат спроектирован не как универсальное представление для произвольного железа, а как рантайм‑формат для серверного железа. Поэтому YaFF хранит поля плотно и не добавляет байты для выравнивания внутри сериализованного представления.
Выравнивание упрощает переносимость и гарантирует дешевизну доступа на широком наборе платформ, поэтому оно естественно для более универсальных форматов. В нашем случае набор платформ ограничен современными серверными x86_64 и ARM, которые в типичных сценариях эффективно справляются с невыровненным чтением.
Плотная укладка может сделать отдельные чтения дороже, например если поле пересекает границу кэш‑линии. Но для больших индексов в памяти критична не только скорость одного чтения, но и общий объём данных, который проходит через кэши и память. Избавляясь от выравнивания, мы снижаем этот объём и, следовательно, нагрузку на шину памяти. В итоге мы выигрываем больше, чем теряем на редких невыровненных чтениях.
Это решение хорошо сочетается с неизменяемостью YaFF‑буфера. После построения формат только читается, поэтому мы не платим за сложности записи в невыровненные поля и не создаём дополнительную нагрузку на поддержание когерентности кэшей.
Flat Layout: почти C++‑структура
Отправная точка для YaFF — обычные C++‑структуры. В них доступ к полю наиболее эффективный: расположение поля известно на этапе компиляции, а чтение превращается в одно обращение к памяти.
В первом варианте сериализованного представления, который называется Flat Layout, пытаемся сохранить свойство эффективного доступа, добавив минимальную поддержку эволюции схемы.
Этого можно достигнуть с помощью довольно простого действия: добавить к плотной C++‑структуре двухбайтовый заголовок, в который записать максимальный id поля, который есть в буфере, плюс единица.

Таким образом объём метаинформации по сравнению с сырой структурой станет равен 2 байтам, что очень компактно. Алгоритм чтения получается предельно простым: читаем заголовок, сравниваем id читаемого поля с заголовком, если id больше, то возвращаем значение по умолчанию, а иначе читаем данные.

Например, заголовок, равный четырём, означает, что в буфере есть поля с id меньше четырёх. Новый код, который пытается прочитать поле 5, сразу получит значение по умолчанию. А поле 2 читается напрямую из данных по заранее известному смещению.
Суммарно этот подход осуществляет два чтения и одно ветвление. В отличие от FlatBuffers с четырьмя чтениями и двумя ветвлениями, здесь в два раза меньше операций и нет цепочки зависимых чтений из памяти.
На иерархическом бенчмарке это даёт ускорение в 2–2,5 раза:
Benchmark |
Cache Chains |
CPU (ns) |
BM_Hierarchical_Raw |
false |
8,08 |
BM_Hierarchical_Raw |
true |
8,17 |
BM_Hierarchical_FlatBuffers |
false |
37,0 |
BM_Hierarchical_FlatBuffers |
true |
20,5 |
BM_Hierarchical_YaFF_Flat |
false |
14,6 |
BM_Hierarchical_YaFF_Flat |
true |
9,8 |
В варианте с кэшированием цепочек вызовов Flat Layout оказывается близок к C++‑структурам: 9,8 ns против 8,17 ns. Разница остаётся из‑за проверки заголовка (которую неизбежно нужно делать) и проблемы с Alias Analysis в общем случае, к которой мы вернёмся позже.
Но у этой простоты есть цена. Flat Layout работает быстро именно потому, что смещение каждого поля вычисляется статически. Для поля с id = N это означает, что layout должен знать размеры всех предыдущих полей (N − 1) и сохранять их место в физическом представлении. Поле из середины схемы нельзя просто убрать: иначе сместятся все последующие поля, а добавлять можно только в конец. Дальше нужно понять, как совместить это ограничение с Protobuf‑семантикой — удалением полей, reserved и presence.
Flat Layout: добавляем presence
Одной из важных частей Protobuf является presence‑семантика. Если поле отсутствует, то при чтении getter возвращает значение по умолчанию, заданное в схеме. Кроме того, поля делятся на два класса:
implicit presence: формат не сохраняет отдельно факт того, что поле было инициализировано;
explicit presence: факт инициализации сохраняется явно, а в API появляются has_‑методы.
Конкретная семантика зависит от типа поля и версии Protobuf. Например, repeated‑поля всегда работают в implicit‑модели, вложенные сообщения используют explicit presence, а поведение скаляров зависит от версии и наличия модификатора optional. В YaFF нам нужно поддержать такое же многообразие комбинаций.
С возвратом значения по умолчанию всё достаточно просто. Если проверка id < header не проходит, поле находится за пределами записанного layout, и значение по умолчанию можно просто вкомпилировать в соответствующую ветку getter. Если проверка проходит, getter читает значение из буфера.
Для второго случая можно было бы явно записывать значение по умолчанию в буфер. Но есть более удобное представление: хранить значение поля как XOR с его значением по умолчанию. Вычислительно это почти бесплатная операция, а для полей с нулевым значением по умолчанию она превращается в noop. Зато мы получаем единое физическое представление неинициализированного значения: набор нулевых байтов. Такие пропуски хорошо сжимаются даже быстрыми кодеками вроде LZ4, а в перспективе их проще переиспользовать при эволюции layout.
const auto encoded = value ^ default; // value ^ 0 == noop; const auto value = encoded ^ default; // encoded ^ 0 == noop;
Flat Layout по умолчанию не хранит presence‑информацию: для полей с implicit presence достаточно вернуть корректное значение из getter. Поэтому если в сообщении нет explicit‑полей, layout остаётся тем же.
Для explicit presence нужна дополнительная информация о наличии поля, которую можно закодировать битмаской. Но она требуется только has_‑методам, getters не обращаются к битмаске и продолжают читать данные напрямую, восстанавливая значение через XOR со значением по умолчанию.
Битмаску мы кладём слева от заголовка сообщения, чтобы потенциально не смещать полезные данные в другую кэш‑линию. Так мы сохраняем эффективность Flat Layout: на алгоритме чтения вообще никак не отражается факт наличия битмаски.
Битмаску тоже не нужно хранить всегда. Для has_‑метода значение, отличное от значения по умолчанию, уже доказывает presence: такое поле не могло появиться из отсутствующего значения. Дополнительный бит нужен только для единственного неоднозначного состояния — когда explicit‑поле было задано, но равно значению по умолчанию. Поэтому presence‑битмаска появляется в буфере только тогда, когда при сериализации встречается хотя бы одно такое поле.

Flat Layout: внутренняя динамика
Опциональная presence‑битмаска требует от layout важного свойства: динамического выбора стратегии чтения. В рантайме нужно понять, откуда брать presence‑информацию — из битмаски или из самого значения поля. Любая такая динамика требует метаинформации, по которой выполняется dispatch, и дополнительных ветвлений.
Для has_‑методов это приемлемо: они вызываются заметно реже обычных getters. Но основной алгоритм чтения значения трогать нельзя. Flat Layout был построен вокруг быстрого чтения, и presence не должна добавить в этот путь новые операции.
Поэтому флаг наличия битмаски мы кодируем прямо в двух младших битах заголовка. То есть комбинация 00 кодирует отсутствие битмаски, а 01 — её наличие (второй бит нам понадобится позже).
Сама граница layout хранится в оставшихся 14 битах. Это даёт примерно 16 тысяч полей в одном сообщении. Формально Protobuf допускает до 65 535 полей, но для переносимых схем практический предел ниже: например, Java‑кодогенерация упирается в ограничения JVM уже на нескольких тысячах полей. Поэтому такой обмен почти не ограничивает реальные сообщения, но позволяет не добавлять отдельную метаинформацию.
Главное — обычные getters не получают дополнительной работы. Идентификатор поля известен во время кодогенерации, поэтому константа для сравнения с header заранее кодируется в том же формате, и младшие биты никак не влияют на сравнения.
Дополнительный dispatch появляется только в has_: если флаг битмаски установлен, метод читает presence‑bit; если нет — выводит presence из значения, сравнивая его со значением по умолчанию.
Flat Layout: минимальная self‑describing метаинформация
У Protobuf есть важное свойство: сериализованное представление частично описывает само себя. Без схемы мы не восстановим полноценный объект, но сможем разобрать поток на поля: увидеть идентификатор поля, wire type и границы значений переменной длины. Это позволяет парсеру пропускать неизвестные поля и делает возможной безопасную эволюцию схем. В YaFF нужен похожий механизм.
Как мы уже упоминали ранее, чтобы статически вычислить смещение для поля с id = N, во Flat Layout, нужно знать размеры всех предыдущих полей (N − 1). Если удаляется поле из середины, то мы уже не можем вычислить размеры смещений для полей, следующих за удалённым. Для записи новых данных это не проблема: после такого изменения писатель может выбрать другой layout, о котором поговорим дальше. Но читатель должен оставаться обратно совместимым и уметь читать старые данные, которые были записаны ещё как Flat Layout.
Поэтому в сообщении нужно сохранить минимальную метаинформацию о размере каждого поля. При генерации кода чтения поля известно, какие удалённые идентификаторы находятся перед ним. Это позволяет заранее сгенерировать чтение только той метаинформации, которая нужна для расчёта реального смещения поля.
Цена такого подхода — некоторое количество дополнительных чтений. Но, во‑первых, удаление полей происходит нечасто, и обычно это одно‑два поля. Во‑вторых, такие чтения генерируются только для полей, следующих за удалёнными. А в‑третьих, этот механизм будет использоваться только при чтении старых данных новым кодом, то есть на период миграции. Таким образом, дополнительных чтений будет немного, они не создадут заметной нагрузки, а основной путь чтения Flat Layout останется прежним.
Раз без метаинформации не обойтись, нужно сделать её компактной. Используем тот факт, что вариантов размера поля всего три:
1 байт: bool‑скаляры;
4 байта: 32-битные скаляры и смещения;
8 байт: 64-битные скаляры.
Значит, размер поля можно закодировать двумя битами. Эти данные мы храним рядом с presence‑битмаской, а их наличие кодируем вторым битом в заголовке. То есть первые два бита заголовка кодируют размер метаинформации для одного поля в битах:
Значение в заголовке |
Объём метаинформации для одного поля |
Содержимое метаинформации |
00 |
0 бит |
— |
01 |
1 бит |
Presence поля |
10 |
2 бита |
Размер поля |
11 |
3 бита |
Presence и размер поля |
Так Flat Layout адаптивно сохраняет только ту метаинформацию, которая нужна конкретному сообщению. Если нет explicit presence и необходимости удалять поля из середины, метаинформация сводится к базовому заголовку. Если нужны has_‑методы, появляется presence‑битмаска. Если нужно читать старые буферы после удаления полей, появляется информация о физических размерах полей.
В результате Flat Layout поддерживает ключевые свойства wire format Protobuf, но для самой популярной операции чтения сохраняет эффективность, близкую к сырым C++‑структурам.
Sparse Layout: решаем проблему разреженности
Flat Layout оптимизирован под один профиль — плотные данные и максимально горячее чтение. Его эффективность держится на том, что смещение каждого поля известно статически. Но это же свойство определяет и главный недостаток: физический размер сообщения зависит от максимального id, а не от количества заполненных полей.
Если объект содержит поле с id = N, Flat Layout резервирует участки памяти для всех предыдущих id. И здесь мы сталкиваемся с двумя уровнями проблемы.
Разреженные данные
Во‑первых, участки нужно резервировать для незаполненных полей. В плотном случае большинство полей заполнены, поэтому оверхед незначительный. Но если реально инициализированных полей мало, то сообщение по большей части состоит из пустых байтов, которые мы вынуждены хранить и передавать.
Чтобы увидеть этот эффект отдельно от остальных факторов, возьмём синтетическую схему: 50 полей по 64 бита и случайное заполнение с разной плотностью.

Flat Layout быстро насыщается: как только среди заполненных полей появляется большой id, формат вынужден хранить все предыдущие поля. Поэтому при низкой и средней плотности он заметно проигрывает FlatBuffers по размеру; в этом тесте перелом наступает только ближе к высокой заполненности, около 75%.
На уровне одного объекта разница может выглядеть небольшой: десятки или сотни байт. Но в индексах рантайма она умножается на миллионы объектов с десятками вложенных структур и становится уже существенной.
Разреженные схемы
Во‑вторых, когда в Protobuf‑схеме появляется пропуск в id, мы уже не можем вычислить смещения для полей за пропуском, поэтому записывать данные во Flat Layout нельзя.
Для самых горячих данных можно дисциплинированно относиться к схеме и не допускать пропусков в id, а при удалении поля оставлять в схеме метаинформацию о его размере. Но в системе, как мы отмечали, существуют нагрузки на порядок меньше, не требующие такой эффективности чтения.
Поэтому YaFF предоставляет второй режим — Sparse Layout. Его преимущества и недостатки похожи на FlatBuffers: мы добавляем таблицу метаинформации, что позволяет хранить только реально присутствующие поля. Но в отличие от прямого перехода на FlatBuffers, мы остаёмся в той же proto‑схеме и том же protobuf‑like API. Такой layout медленнее предыдущего, но зато лучше работает с произвольными Protobuf‑схемами, разреженными объектами и сценариями, где важнее размер и простота эволюции.
Sparse Layout: развиваем идеи vtable из FlatBuffers
Для разреженных данных таблица метаинформации — довольно естественный компромисс. Если мы не хотим хранить отсутствующие поля, нужно где‑то сохранить информацию о том, какие поля присутствуют и где лежат их значения. Sparse Layout использует ту же базовую идею, что и vtable во FlatBuffers, но адаптирует её под свойства YaFF.
Сообщение начинается с того же двухбайтового заголовка, что и Flat Layout. Перед ним лежит смещение на таблицу метаинформации:

Общий заголовок важен, так как позже он позволит динамически переключаться между Flat Layout и Sparse Layout. А то, что размер хранится в сообщении, а не в таблице метаинформации, даёт локальность: в случае с большой таблицей мы получим на один cache miss меньше. Кроме того, это позволяет дедуплицировать таблицы не только целиком, но и по общему префиксу, так как нам больше не мешает размер в первом поле.

Дальше мы используем то, что нам не нужно уметь писать поля в буфер в любом порядке. YaFF строит буфер сгенерированным кодом из уже заполненного Protobuf‑сообщения. Значит, писатель может записывать поля в фиксированном по id порядке. Тогда смещения полей, которые идут первыми, будут небольшими, и можно сэкономить место под их значения.
Максимальный физический размер поля в YaFF равен 8 байтам. Поэтому смещения для первого 31 поля гарантированно помещаются в один байт каждое: даже в худшем случае это 31 × 8 = 248. С 32-го поля такой гарантии уже нет, поэтому для остальных полей используются двухбайтовые слоты.
Ширина слота в таблице зависит только от id поля и известна во время кодогенерации. Getter не тратит ресурсы в рантайме, чтобы понять, сколько байт читать: нужный размер уже зашит в сгенерированный код.
По сути, это статический аналог идеи varint из Protobuf: младшие поля получают более компактное представление метаинформации. Только вместо динамического парсера varint мы используем фиксированный layout и знание схемы во время компиляции.
На иерархическом бенчмарке Sparse Layout ожидаемо оказывается близок к FlatBuffers:
Benchmark |
Cache Chains |
CPU (ns) |
BM_Hierarchical_FlatBuffers |
false |
36,8 |
BM_Hierarchical_FlatBuffers |
true |
21,1 |
BM_Hierarchical_YaFF_Sparse |
false |
36,9 |
BM_Hierarchical_YaFF_Sparse |
true |
19,3 |
Sparse Layout использует примерно тот же класс алгоритма, что и FlatBuffers: чтение поля проходит через таблицу метаинформации и смещение. Поэтому на маленьком объёме данных, когда данные помещаются в кэш, а основные ресурсы тратятся на инструкции, ветвления и зависимые чтения, результат получается ожидаемо близким. Но основная ценность Sparse Layout — не в ускорении на таком сценарии, а в более компактном представлении метаинформации и совместимости с семантикой Protobuf.
Sparse Layout: reserved поля
В Sparse Layout часть Protobuf‑семантики поддерживается проще, чем во Flat Layout, потому что таблица метаинформации уже описывает присутствие поля и его положение в буфере.
Нулевой слот означает, что поле отсутствует; ненулевой слот указывает на данные и одновременно кодирует presence, даже если в поле явно записано значение по умолчанию. Размер значения можно восстановить по соседним слотам: следующее ненулевое смещение задаёт границу текущего поля.
Главное преимущество проявляется на reserved и пропусках в id. Во Flat Layout для удалённого поля нужно знать его физический размер, иначе сместятся все последующие поля. В Sparse Layout тип удалённого поля не нужен: пропущенный id — это просто пустой слот фиксированного размера в таблице.
Обычно этого достаточно: большие пропуски в id встречаются редко, а пустой слот «стоит» один или два байта. Если разрыв всё же становится слишком большим, кодогенератор может отловить это и предложить задать компактный внутренний layout‑id через proto‑опцию, сохранив исходный Protobuf‑идентификатор как внешний контракт.
Dynamic Layout: ещё одна почти бесплатная динамика
Теперь у нас есть два представления с разными компромиссами. Flat Layout даёт максимально дешёвое чтение для плотных данных и самых горячих мест. Sparse Layout служит гибким fallback для разреженных объектов и произвольных Protobuf‑схем.
Осталось объединить их так, чтобы выбор layout не замедлил самый важный сценарий. Здесь мы используем принцип, похожий на кодирование Хаффмана: самые частые случаи должны получать самые короткие пути исполнения. Для чтения поля иерархия такая:
Поле есть во Flat Layout → главный happy path.
Поле отсутствует во Flat Layout → менее частый случай: данные плотные, новые поля появляются редко.
Sparse Layout → осознанный fallback, где мы платим дополнительной метаинформацией за размер и гибкость.
Поэтому алгоритм выбора должен быть устроен так, чтобы первый случай не получил дополнительных операций по сравнению с обычным Flat Layout.
Для этого мы снова используем общий двухбайтовый заголовок. Наличие дополнительной метаинформации во Flat Layout кодируют именно младшие биты, чтобы не влиять на первое сравнение. А теперь мы, наоборот, хотим, чтобы первое сравнение одновременно проверяло две вещи: перед нами действительно Flat Layout и нужное поле попадает в записанную границу layout. Так давайте использовать старший бит!
Из‑за этого под границу layout остаётся ещё меньше полезных битов, и максимальное число полей уменьшается примерно до 8 тысяч. На практике это не становится ограничением по тем же причинам, что и раньше: переносимые Protobuf‑схемы обычно упираются в ограничения кодогенерации, например JVM, существенно раньше.
В итоге алгоритм следующий:
constexpr auto limit = ((id << 2) | 0x8000); if (limit < Header_) { return ReadFieldFlat<T>(id); } if (Header_ & 0x8000) { return dflt; } return ReadSparseField<T>(id);
Если первое сравнение прошло, мы сразу читаем поле по статическому смещению без дополнительных проверок относительно Flat Layout. Если не прошло, но заголовок всё ещё помечен как Flat Layout, поле отсутствует и getter возвращает значение по умолчанию. И только в последнюю очередь уходим в Sparse Layout.
Для Sparse Layout эта динамика почти бесплатна: алгоритм получения поля начинается с чтения заголовка, который мы уже прочитали. Добавляется несколько ветвлений, но не увеличивается число обращений к памяти.
Итоговый бенчмарк с учётом Dynamic Layout выглядит так:
Benchmark |
Cache Chains |
CPU (ns) |
BM_Hierarchical_Raw |
false |
8,16 |
BM_Hierarchical_Raw |
true |
8,17 |
BM_Hierarchical_FlatBuffers |
false |
37,1 |
BM_Hierarchical_FlatBuffers |
true |
21,1 |
BM_Hierarchical_YaFF_DynFlat |
false |
14,4 |
BM_Hierarchical_YaFF_DynFlat |
true |
9,71 |
BM_Hierarchical_YaFF_DynSparse |
false |
40,3 |
BM_Hierarchical_YaFF_DynSparse |
true |
20,8 |
Эти числа подтверждают, что Dynamic Layout почти не добавляет цены к основным путям чтения. Flat‑путь остаётся таким же быстрым, как и раньше, а Sparse остаётся сравнимым с FlatBuffers.
Несколько деталей про сгенерированный API
Получившийся layout содержит необходимую для семантики Protobuf информацию, генерация над ним protobuf‑like API для zero‑copy‑доступа, а также методов для сериализации является довольно прямолинейной задачей. Самая интересная часть здесь — безопасная и эффективная поддержка цепочек доступа, о которых мы говорили ранее.
В Protobuf такой код безопасен: если промежуточное сообщение отсутствует, getter возвращает default instance, а не нулевой указатель. В YaFF нам нужен аналогичный объект. Это двухбайтовый заголовок с установленным Flat‑битом и нулевой границей layout. В таком объекте для любого поля проверка присутствия не проходит, поэтому getter сразу возвращает значение по умолчанию. Так отсутствующее вложенное сообщение можно читать без материализации объекта и без каскада проверок на разыменования нулевого указателя.
Другая задача — сделать цепочки доступа понятными для оптимизатора. Если код несколько раз обращается к одному и тому же промежуточному объекту, например req.context().user(), хорошо бы вычислить этот accessor один раз и переиспользовать результат при чтении нескольких полей.
Для этого сгенерированные функции для чтения буфера помечаются gnu::pure. Документация описывает этот атрибут так:
“Many functions have no effects except the return value and their return value depends only on the parameters and/or global variables. Such a function can be subject to common subexpression elimination and loop optimization”.
Для YaFF этот контракт хорошо подходит: буфер неизменяем, мы только читаем данные и не меняем состояние. Поэтому атрибут даёт оптимизатору хорошую локальную подсказку: повторный вызов с теми же аргументами не имеет побочных эффектов и вычисляет значение из той же памяти.
При этом gnu::pure не гарантирует, что цепочка всегда будет схлопнута. Это лишь один из сигналов для оптимизатора, а итог зависит от окружающего кода: Alias Analysis, видимых записей между вызовами и того, удалось ли доказать неизменность релевантной памяти. Но во многих случаях этого достаточно, чтобы заставить компилятор переиспользовать промежуточные вычисления и не проходить одну и ту же цепочку заново для каждого поля.
В результате с учётом всех доработок получаем близкое к сырым C++‑структурам поведение для заметной части случаев использования и хороший fallback для остальных случаев.

YaFF — динамический zero‑copy wire format для Protobuf
На этом история про укладку байтов заканчивается. Мы хотели читать Protobuf‑данные без десериализации, но не потерять proto‑схему как контракт, привычный API, значения по умолчанию, presence, reserved и безопасную эволюцию схем.
Для этого YaFF использует не один универсальный layout, а адаптивную модель представления данных. Flat Layout отвечает за самые горячие и плотные данные: минимум метаинформации и алгоритм чтения, близкий к доступу в C++‑структуре. Sparse Layout отвечает за разреженные объекты и свободную эволюцию схем: таблица метаинформации, хранение только присутствующих полей. А Dynamic Layout связывает их вместе, сохраняя эффективность Flat Layout.
Снаружи это не меняет модель разработки: разработчик по‑прежнему видит proto‑схему и protobuf‑like API. Внутри YaFF выбирает физическое представление под данные: быстрое и плотное для самых горячих сервисов, гибкое и компактное для остальных случаев.
Таким образом, для разработчика это всё ещё Protobuf, для рантайма — zero‑copy представление, оптимизированное под реальные данные.
Как попробовать YaFF
YaFF подключается к C++‑проекту через CMake или Conan: кодогенератор настраивается рядом с обычным Protobuf и работает напрямую с вашими существующими proto‑схемами. Быстро пройти путь от описания схемы до zero‑copy‑чтения поможет наш Quick Start.
Код YaFF выложен на GitHub под лицензией Apache 2.0. Там же вы найдёте подробную документацию, примеры и бенчмарки.
Проект в начале пути и активно развивается, так что местами наверняка найдутся баги, мы их обязательно поправим. Если вы наткнётесь на баг или поймёте, что формату не хватает важной фичи: смело заводите Issue. А если готовы предложить улучшение сами: мы всегда рады вашим пул‑реквестам.
Заключение: интерфейс данных не обязан определять их физическое представление
Главный вывод из YaFF выходит за рамки конкретного формата. Для данных, ровно так же, как и для кода, интерфейс не обязан определять реализацию.
Мы привыкли воспринимать Protobuf‑экосистему как монолит: proto‑схема, wire format и парсинг. YaFF разделяет эти слои. Схемы остаются контрактом между командами и сервисами, а Protobuf‑like API остаётся привычным интерфейсом для разработчика. Сериализованное представление становится отдельным бэкендом, который можно выбирать под конкретную нагрузку.
Выше мы подробно разобрали два таких бэкенда — Flat Layout и Sparse Layout, для zero‑copy‑доступа в рантайме. Но мы уже работаем над реализацией следующего шага: колоночного layout внутри YaFF. Он встраивается в ту же модель как ещё один бэкенд: большие repeated‑поля с произвольной вложенностью можно хранить компактно в форме, более эффективной для аналитики и ML‑пайплайнов.
Возможность сохранять общий proto‑контракт и подключать специализированные бэкенды именно там, где они окупаются, особенно важна для больших систем, когда нельзя остановить мир и переписать десятки тысяч мест в коде ради нового формата.
Protobuf остаётся языком, на котором система договаривается о данных. YaFF делает физическое представление этих данных заменяемым и оптимизируемым слоем. Будем рады, если вы попробуете YaFF в деле и поделитесь своими впечатлениями!
Комментарии (15)

unreal_undead2
17.06.2026 09:39В нашем случае набор платформ ограничен современными серверными x86_64 и ARM, которые в типичных сценариях эффективно справляются с невыровненным чтением.
Но отлаживать код локально на макбуке, насколько понимаю, уже не получится.

GDV_Fox Автор
17.06.2026 09:39Речь в посте скорее про то, что мы не завязываемся на поддержку нетиповых для бэкенда архитектур (старые ARM и различные embedded-платформы).
На макбуках реализуется ARMv8+ (который уже умеет эффективно работать с невыровненным чтением), так что там всё работает корректно. В CI-матрицу пока не добавляли, но локально тестировали.
unreal_undead2
17.06.2026 09:39Я не про процессор, а про значение бита SCTLR_EL1.A - видел, что в Darwin-based OS его принято выставлять в 1. Возможно, сейчас не актуально - но в любом случае прозрачная обработка невыровненных доступов на ARM64 зависит от настроек операционки.
В целом согласен, что в тяжёлых нагрузках выигрыш в производительности ценой отказа от переносимости уместен.

GDV_Fox Автор
17.06.2026 09:39Да, действительно нужно учитывать ещё настройки OS. Сейчас, насколько получилось нагрепать, по дефолту не включается A бит на macOS.
Мы пока пошли по безопасному пути с memcpy, поэтому оставили этот момент на откуп компилятору. Пока не подводит из-за ограниченной зоны применения, возможно, переключимся на подход с альтернативными способами (подобный lz4), если где-то всё-таки очень нужно будет.
Локально с clang на макбуке у меня чтения транслируются в обычные load, так что предполагается SCTLR_EL1.A равным нулю.
unreal_undead2
17.06.2026 09:39Да, полазил там же - не вижу, где вообще используется SCTLR_A_ENABLED.

altexy
17.06.2026 09:39Используем proto и flatbuffers, страдаем )
Планируется ли реализация для golang?

Void-Cowboy
17.06.2026 09:39я правильно понимаю, ваш подход позволяет уйти от вечного выбора между proto и flatbuffers опираясь напрямую на прото-схемы?
для поддержки "старых прото-сериализаций" нужно будет все пересобирать, я правильно понимаю?
как скоро будет реализация на Го и насколько будет соизмеримый выигрыш? Не будет ли проблем если юзать паралельно с gRPC (данные в плоской базе бинарно, а уже обмен по gRPC летает в потоке) или прямой "проброс" между прото-схемами и вашей реализацией не планируется?

GDV_Fox Автор
17.06.2026 09:39Да, в этом и идея: оставаться на proto-схеме и получить zero-copy чтение с mmap-совместимым форматом.
Если нужно вернуться в обычный protobuf, придётся переложить данные из YaFF обратно в protobuf wire format. Для этого мы генерируем по схеме функции конвертации, так что по коду это просто, но не бесплатно по перформансу.
Конкретные сроки про Go пока не готовы назвать. Выигрыш ожидаем, что будет сопоставим с переездом с обычного protobuf на zero-copy формат (то есть основной эффект даст само отсутствие парсинга). Точнее можно будет сказать с бенчмарками.Плоская база в памяти и передача данных как раз удачный юзкейс YaFF, у нас он активно используется. Когда нужно переварить большой объём (например, отфильтровать много кандидатов по запросу) используем плоское представление, а когда выбрали небольшое подмножество, его уже не так дорого сконвертировать в protobuf и отправить дальше.

Void-Cowboy
17.06.2026 09:39спасибо за ответ, подписался
буду ждать вашу новую статью уже с го-реализацией и обзором
Starche
А какие плюсы по сравнению с Cap'n'proto?
GDV_Fox Автор
Спасибо за вопрос, в посте действительно про Cap'n'proto не упоминали.
Основных отличия два:
YaFF целится быть расширением для Protobuf экосистемы, у Cap'n'proto своя экосистема с отдельной схемой и правилами. Поэтому переход на Cap'n'proto (как и на FlatBuffers) для крупного сервиса означает полноценную сложную миграцию, с YaFF тут проще за счет переиспользования proto-схем и конвертации с proto.
YaFF не привязывается к какому-то отдельному layout и заточен под адаптивность, в отличие от фиксированного варианта в Cap'n'proto. Сейчас реализована только пара layout с упором на скорость чтения, но следующим шагом перейдем к реализации вариантов с упором на компактность.
То есть это разные подходы в целом. У Cap'n'proto отдельная экосистема, а YaFF скорее расширение (в каком-то смысле даже кодек) для Protobuf.
Void-Cowboy
а он вышел с бесконечной бетты?
раз в год возращаюсь к нему что бы посмотреть изменилось ли чего, но как то все так же тухло