Миллионы метрик, чистый код, но аллокатор показывает в разы больше памяти, чем должно занимать «полезное» содержимое. Профайлер светит на парсинг, а вы гадаете: куда деваются мегабайты?
Привет, меня зовут Глеб Шигин, я С++-разработчик в команде Deckhouse Observability. В этой статье я покажу, как мы искали скрытый расход памяти в Scraper для Prometheus и шаг за шагом сжимали служебные структуры метрик.
Вы узнаете:
как мы хотели избежать копирования, но всё равно просели по памяти;
почему не нужно хранить короткие строки как длинные;
как знание своих данных приводит к простым, но эффективным оптимизациям;
как выиграть и по памяти, и по скорости.
Если вы когда-нибудь упаковывали данные «впритык» и не хотели потерять в скорости — эта статья для вас.
Все описанные в статье оптимизации реализованы в Prom++ — оптимизированной версии Prometheus от команды Deckhouse, которая полностью совместима с оригиналом по форматам данных и API, но использует более эффективные алгоритмы хранения. Его ключевая особенность — способность обрабатывать те же объёмы данных, что и оригинальный Prometheus, но потреблять до 10 раз меньше оперативной памяти.
Про архитектуру Prom++ подробнее рассказывали в одной из прошлых статей.
Содержание:
Зачем Scraper нужен в Prom++
В Prom++ за сбор данных отвечает компонент Scraper. Он регулярно опрашивает серверы и приложения, получает HTTP-ответ в текстовом формате Prometheus или OpenMetrics и строит промежуточное представление для записи в TSDB.

Метрики в Prom++ накапливаются в памяти в течение нескольких часов для последующей записи на диск в удобном для чтения формате. Получается «уровневое» хранение: последние данные хранятся в «голове» (с записью на диск в формате WAL), а через несколько часов конвертируются в исторические блоки, которые позже объединяются компактором в блоки большего объёма.
«Голова» предоставляет, по сути, две операции: append и query. При этом append-запросы должны линеаризовываться, то есть упорядочиваться с помощью барьеров. Сама операция добавления данных достаточно интенсивна по работе с памятью и вычислениям: при появлении новых серий их нужно добавить, в том числе в индексы для последующих чтений, а данные необходимо сжать для эффективного хранения. Кроме того, для лучшей утилизации многоядерных процессоров мы используем шардирование «головы» — каждый шард обрабатывает свой набор серий, определяемый через хеш от набора лейблов.
Подобные механизмы — шардирование и линеаризация write-запросов — часто используются в базах данных и не являются чем-то уникальным для Prom++. С другой стороны, это определяет жёсткие требования для контура записи в плане подготовки данных: максимум действий надо вынести на этап подготовки, то есть до барьера. При этом данные могут читаться многократно и параллельно в разных шардах.
В некоторых базах данных подготовка по сути представляет собой перевод данных из формата передачи (текстового) во внутреннее представление. К сожалению, в нашем случае это требует большого количества дополнительной памяти на дублирование строк. Поэтому мы решили сделать совмещённый формат: исходный текстовый буфер остаётся источником строк, а поверх него накладывается разметка этого буфера, позволяющая быстро перечитывать данные в удобном для нас формате.
Как устроена разметка? Числовое значение (sample value) и временная метка (timestamp) хранятся в ней напрямую, в виде чисел. А вот набор лейблов (имя метрики и пары «ключ-значение») представлен в виде проекции: мы храним не сами строки, а лишь их координаты (смещения и длины) в исходном текстовом буфере. Благодаря этому шарды могут многократно и параллельно извлекать нужные поля без повторного парсинга текста и без дублирования строк в памяти.
Вычисление хеша серии — это тоже ресурсоёмкая задача, поэтому мы вынесли её на этап подготовки: хэш считается ровно один раз в Scraper и сразу сохраняется прямо в разметку. Это идеально укладывается в концепцию «вынести все тяжёлые вычисления до барьера». В результате шарды получают read-only-доступ к уже подготовленным данным и не тратят ресурсы на повторный парсинг одного и того же буфера.
Подобный механизм легко переносится на другие промежуточные форматы (JSON, CSV и другие) и может быть использован для пакетной обработки данных в хранилищах, шардированных в рамках одного узла.
Цена такого решения — расход памяти на разметку. Именно его мы и решили оптимизировать. Но для начала немного расчётов.
Базовый формат разметки: из чего складывается расход памяти
В первой итерации мы сделали наивную реализацию разметочного буфера, которая показалась нам достаточно эффективной. Однако через полгода эксплуатации, когда мы в очередной раз решили посмотреть на профили памяти с желанием срезать ещё где-нибудь лишние 5–7 %, с удивлением обнаружили, что разметка сопоставима по размеру с исходным буфером. Это и стало отправной точкой для анализа.
Исходный формат данных
Данные передаются в виде текста, где каждая метрика записывается на отдельной строке. Посмотрим на типичную строку в формате Prometheus:
http_requests_total{job="api", method="GET", code="200"} 42 1714471200
Разберем, из чего состоит эта структура:
Имя временного ряда (
http_requests_total) — идентификатор того, что именно мы измеряем.Набор лейблов (
{job="api", ...}) — уточняющие пары «ключ-значение», задающие контекст метрики (эндпоинт, метод, статус).-
Sample (пример данных) — пара чисел, завершающих строку:
Value (
42) — числовое значение метрики в момент замера.Timestamp (
1714471200) — время измерения (в текстовом формате может отсутствовать, тогда используется время скрапинга).
Примечание
Значения лейблов (а в последних версиях формата и ключи лейблов) могут содержать любые UTF-8 символы. Поскольку все строки либо содержат ограниченный набор символов (без пробелов и спецсимволов), либо обёрнуты кавычками, экранируются только два символа: кавычка
"и обратная черта\, используемая для экранирования.То есть, последовательность
\"в исходном тексте представляем символ кавычки". При парсинге Scraper выполняет разэкранирование непосредственно в буфере, заменяя\"на":Например:
Было (во входном тексте):
metric{label="some \"complex\" value"} 42Стало (после разэкранирования в памяти):
metric{label="some "complex" value"} 42
После такой манипуляции длина строки уменьшается, но нам это не важно: сырой буфер парсится ровно один раз, а дальше система работает исключительно с разметкой.
Структура разметки
Чтобы не дублировать строки в памяти, Scraper хранит не сами данные, а только их координаты (смещение и длину) в исходном текстовом буфере. Строки описываются парой offset + length, где offset — это смещение от начала буфера, а length — количество байт.
Упрощённо baseline-разметка выглядела так:
struct MarkedString { uint32_t offset; // Смещение в исходном буфере. uint32_t length; // Количество байт. }; struct MarkedLabel { MarkedString name; // Координаты имени лейбла. MarkedString value; // Координаты значения лейбла. }; struct MarkedLabelSet { uint32_t count; // Количество лейблов. MarkedLabel labels[]; // Массив лейблов. }; struct MarkedMetric { uint64_t hash; // Кеш имени метрики. Sample sample; // Значение и временная метка. MarkedLabelSet label_set; // Все лейблы. }; struct Sample { double value; // Числовое значение. int64_t timestamp; // Временная метка. };
Для расчёта важна не форма C++-структур, а то, сколько байт записывается в буфер разметки.
Фиксированная часть на каждую метрику занимает 28 байт:
hash— 8 байт. Его используют шарды, чтобы быстро понять, кому из них принадлежит метрика;sample— 16 байт:double valueиint64_t timestamp;label_count— 4 байта.
После фиксированной части подряд пишутся лейблы. На один лейбл приходится 16 байт: он состоит из двух структур MarkedString (одна для имени, другая для значения).
Поскольку MarkedString — это пара uint32_t offset и uint32_t length, которая весит 8 байт, итого имеем 8 байт на имя + 8 байт на значение = 16 байт на лейбл.
Примечание
Для
offsetиlengthмы используем типuint32_t. Это вдвое компактнее, чем стандартныйstring_view: на 64-битной системе он занимает 16 байт (8 байт на указатель + 8 байт на длину), а наша структураMarkedString— всего 8 байт. Для большого количества лейблов эта экономия складывается в существенный выигрыш в памяти.
Тогда расход памяти на метрику можно записать как:
metric size = 28 + 16 * N = Х байт
где N — число лейблов у метрики.
По нашему опыту и собранной статистике в среднем это 7 лейблов. А вместе с фиксированной частью разметка будет занимать:
metric size = 28 + 16 * 7 = 140 байт
Примечание
Так как число лейблов в метрике не фиксировано, запись получается динамической. Первое, что приходит в голову — хранить эти лейблы в виде массива внутри каждой записи. Однако такой подход привёл бы к огромному количеству мелких аллокаций и сильной фрагментации памяти. Вместо этого мы решили по сути сериализовать эти массивы в один сплошной последовательный буфер. Это уменьшает накладные расходы на аллокации и улучшает локальность.
Схематично это можно представить так:
[ hash:8 ][ sample:16 ][ label_count:4 ] [ label.name.off:4 ][ label.name.len:4 ][ label.value.off:4 ][ label.value.len:4 ] [ label.name.off:4 ][ label.name.len:4 ][ label.value.off:4 ][ label.value.len:4 ] ...
Для метрики выше мы бы получили следующее. Обратите внимание, что имя метрики также является лейблом с особым ключом __name__, так что число лейблов на самом деле не 3, а 4.
[ hash ][ 42, 1714471200 ][ 4 ] [ R ][ R ][ 0 ][ 19 ] // __name__="http_requests_total" [ 20 ][ 3 ][ 25 ][ 3 ] // job="api" [ 31 ][ 6 ][ 39 ][ 3 ] // method="GET" [ 45 ][ 4 ][ 51 ][ 3 ] // code="200"
Символы [ R ][ R ] — это не данные, а специальный маркер: при чтении они распаковываются в константу __name__, что позволяет не хранить имя ключа в буфере.
Такой формат простой и быстрый для чтения: поля лежат на фиксированных смещениях. Но он платит одинаковую цену за маленькие и большие значения полей сдвига и длины лейблов.
Обнаружение проблемы: где прячется лишняя память
Спустя полгода эксплуатации мы решили посмотреть на профили в поиске мест, где ещё можно сэкономить. По метрикам jemalloc было видно, что процесс выделяет заметно больше памяти, чем должны занимать постоянные объекты: сериями, индексами и блоками хранения. Разница появлялась во время работы Scraper. Профилирование показало основной пик на фазе парсинга, когда Scraper строит промежуточную разметку. Этот буфер живёт достаточно долго, чтобы его расход стал заметным в общей статистике.
Чтобы не смешивать стоимость токенизации, построения markup и чтения результата, мы разложили работу Scraper на несколько бенчмарков.
Бенчмарк |
Что измеряет |
Зачем нужен |
|
Лексический разбор текстового формата в токены |
Показывает нижнюю границу стоимости парсинга |
|
Полный проход Scraper: чтение текста, построение markup |
Показывает цену подготовки данных для шардов |
|
Чтение готовой разметки одним шардом |
Показывает, насколько быстро шарды работают с уже подготовленными данными |
|
Разница: |
Выделяет стоимость логики Scraper поверх базового парсера |
Примечание
ScraperRead— это не полный проход по всем данным всеми шардами, а имитация работы одного шарда. Например, если шардов два, один читает метрики с чётным хешем, другой — с нечётным. Бенчмарк построен так, что мы читаем все чётные метрики.
Бенчмарки запускались на двух стендах с разной архитектурой:
Стенд |
Архитектура |
CPU |
RAM |
Особенности |
Тестовая машина на AMD |
x86_64 |
4 vCPU AMD EPYC-Rome |
8 ГБ |
KVM, виртуальной машине доступны лишь 4 ядра |
Тестовая машина на ARM64 |
aarch64 |
8-core ARM Neoverse-N1 |
16 ГБ |
Bare-metal ARM-машина |
В качестве входных данных мы использовали реальный scrape response kube-api.metrics с нашего stage-кластера. Это дамп с Kubernetes API server.
Характеристики дампа:
размер файла: 12,7 MБ;
количество строк с метриками: 84 513;
число лейблов на одну метрику: от 1 до 10;
среднее число лейблов: 7.
Пример строки из этого дампа:
apiserver_response_sizes_bucket{component="apiserver",group="batch",resource="jobs",scope="namespace",subresource="",verb="LIST",version="v1",le="1000"} 63
На baseline-версии Scraper получили такую картину:
Стенд |
|
|
|
|
Alloc, MБ |
% of Input |
x86-64 |
11,85 |
46,74 |
34,89 |
1,36 |
9,98 |
78,6 |
ARM64 |
21,03 |
60,10 |
39,07 |
2,16 |
9,98 |
78,6 |
Из baseline видно две вещи.
Во-первых, читать готовую разметку на порядки дешевле, чем строить её заново из текстового формата (доли миллисекунды против десятков миллисекунд). Это подтверждает исходную идею: Scraper один раз платит за подготовку данных, а шарды затем быстро читают уже построенный markup.
Во-вторых, сама разметка занимает значительное количество памяти. При дампе размером 12,7 МБ имеем разметку в почти 10 МБ. Получаем, что к исходному тексту добавляется сопоставимый по размеру слой служебных данных.
Отсюда цель оптимизации: сократить потребление памяти на разметку, не проиграв (а в идеале — выиграв) по времени парсинга и чтения.
Теперь можно переходить к оптимизации. Но прежде нужно понять, что можно сжать без потери информации. Для этого мы посмотрели на реальные метрики и сформулировали четыре гипотезы о том, какие данные в них избыточны.
От данных к гипотезам: где искать экономию памяти
Мы посмотрели на реальные данные, чтобы попытаться увидеть какие-нибудь возможности для оптимизации.
На kube-api.metrics получилась такая статистика:
Что измеряли |
Результат |
Почему это важно |
Наличие |
Есть в 100 % метрик |
Лейбл имени метрики можно помечать флагом, а саму строку |
Количество лейблов на метрику |
min 1, max 10, avg 7 |
Весь диапазон умещается в 1 байт |
Длина имен и значений лейблов |
min 0, max 78, avg 10 |
Весь диапазон умещается в 1 байт |
Явный |
Отсутствует у всех метрик в файле |
Можно хранить 1 копию |
Значение метрики (sample value) всегда приходит как double — это 8 байт. Однако можно предположить, что далеко не все приходящие метрики по своей природе умещаются только в тип double. В реальных данных часто встречаются:
счётчики «не очень больших» сущностей (узлы, потоки, ядра, машины, версии);
целочисленные значения (потребляемая память, измерение времени запроса в наносекундах).
Отсюда можно предположить, что value не обязательно хранить строго в 8 байтах, если конкретное число представимо более компактным типом. Мы собрали статистику по sample value — и вот что у нас получилось:
Что измеряли |
Результат |
Почему это важно |
|
54,31 % |
Самый частый путь: 1 байт |
|
22,90 % |
Помещается в 2 байта |
|
12,00 % |
Занимает 4 байта |
|
6,86 % |
Можно вообще не хранить — достаточно маркера или enum |
|
~0 % |
Здесь в статистике оказалось всего 4 числа, но это всё ещё 4 байта против 8 |
|
3,92 % |
Только эти значения требуют типа |
На основе этих данных мы сформулировали несколько идей, где можно сэкономить память:
1. Имя метрики (__name__) присутствует всегда. Но важно понимать: в исходном текстовом буфере подстроки __name__ не существует. Там есть только само имя метрики (например, http_requests_total). Внутри системы мы представляем это как пару __name__: ”http_requests_total”, поэтому у __name__ нет координат в исходном буфере. Достаточно сохранить только label.value.offset и label.value.length, а сам лейбл пометить специальным маркером, чтобы при чтении восстановить константный ключ __name__.
2. Длины ключей и значений лейблов «небольшие». На kube-api.metrics их длина не превышает 78 байт при среднем значении в 10 байт. Это значит, что label.name.length и label.value.length помещаются в 1 байт, тогда как baseline хранит их в 4 байтах.
3. Смещение строк лучше считать относительно начала метрики. Абсолютный offset растёт вместе с размером файла. Относительный offset живёт внутри одной строки метрики, поэтому обычно оказывается намного меньше и может подойти для переменной кодировки.
4. timestamp не всегда нужно хранить явно. В kube-api.metrics явного timestamp не было ни в одной метрике. Если timestamp отсутствует, система просто подставляет время скрейпа. И тогда вместо того, чтобы всегда тратить 8 байт, мы можем хранить лишь флаг, указывающий на наличие недефолтного timestamp, а также сам timestamp, если он присутствует.
Как работает наличие таймстемпов
Prometheus использует pull-модель сбора данных: не наблюдаемый сервис отправляет изменения по метрике в систему мониторинга, а система мониторинга с определённой периодичностью спрашивает у наблюдаемого сервиса значения метрик. Когда метрика — это атомарный счётчик в памяти, его значение при скрейпе реально соответствует его значению на момент скрейпа.
Но для сервисов, не поддерживающих такой API, необходимо использовать промежуточные шлюзы — так называемые экспортёры: легковесные сервисы, получающие состояние наблюдаемого сервиса через его API и предоставляющие эти данные в формате Prometheus. Так, например, есть node-exporter (шлюз для получения данных о состоянии хоста) или postgres-exporter (шлюз для PostgreSQL).
Тут возникает сложность: API наблюдаемого сервиса может не поддерживать оперативное получение данных, тогда экспортёр по внутреннему расписанию обновляет локальный кеш метрик и отдаёт при скрейпинге закешированные данные. В этом случае использовать таймстемп скрейпа некорректно, ведь кеш мог обновляться значительное время назад. Для этого и придумана механика с отдачей таймстемпа вместе со значением метрики, которая позволяет сохранить реальную картину изменения метрик с учётом промежуточного кеширования.
Как видно из применения, сервисы можно разделить на 2 категории: сервис, отдающий только свои метрики, и экспортёр (или сервис, содержащий логику экспортёра). В первом случае ни одна метрика не будет содержать явный таймстемп, во втором — значительное число (а то и большинство) метрик будет сопровождаться таймстемпом. Пример такого сервиса в Kubernetes-кластере — это метрики kubelet, содержащие данные, полученные с хоста от системы контейнеризации.
5. Не всем значениям метрики нужен полный тип double. 89,21 % значений — положительные целые, которые уместятся в 1, 2 или 4 байта. Также мы можем выделить константу 0 и специфичный для Prometheus NaN (Not a Number) — им нужен только флаг, по которому они явно идентифицируются. Конкретно в нашей статистике float не показал значительное количество примеров, но если добавление какого-то маркера нам ничего не стоит, то лишним не будет — потенциальная экономия в 4 байта.
Благодаря статистике у нас родилась простая идея: не хранить все поля в фиксированном размере, а кодировать их в зависимости от реального значения.
Перейдём от гипотез к реализации.
Первая оптимизация: Labels Varint
Первая оптимизация касается лейблов. В baseline каждый лейбл занимал 16 байт — четыре поля по 4 байта типа uint32_t. Но статистика выше показывает, что реальные длины строк намного меньше, а абсолютные смещения можно сократить, если сменить точку отсчёта.
Меняем точку отсчёта
В старом формате смещение считалось от начала файла с метриками, поэтому метрика в конце большого ответа могла иметь, например, name.offset = 1 245 032, хотя внутри самой строки нужный лейбл начинается, например, на 32-м байте.
Теперь мы меняем точку отсчёта:
Глобальное смещение: храним один раз на метрику (4 байта, uint32_t) — это смещение начала текущей строки метрики относительно начала файла.
Локальное смещение: каждый лейбл сохраняет смещения относительно начала своей строки метрики.
Чтобы избежать путаницы между текстом в кавычках и физическими строками в файле, договоримся о терминах:
Строка — часть исходного текстового буфера от одного переноса до другого (или от начала/до конца файла для первой и последней строки). Это физическая строка, представляющая одну метрику.
Атом — часть строки, которая используется как ключ или значение лейбла. Это либо последовательность символов без пробелов, либо текст, ограниченный кавычками (например,
"api").Символ — отдельная UTF-8-руна.
Итоговое смещение при восстановлении строк мы получим, сложив глобальное и локальное смещения.
Для лейбла job="api" это могло бы выглядеть так:
Поле |
Глобальные смещения |
Локальные смещения |
Размер |
|
1 245 032 |
32 |
1 байт |
|
3 |
3 |
1 байт |
|
1 245 037 |
37 |
1 байт |
|
3 |
3 |
1 байт |
Идея не в том, что все значения всегда будут маленькими. Идея в том, что формат больше не обязан платить 4 байта за каждое число. Если значение помещается в 1 байт, пишем 1 байт. Если нужно больше — 2, 3 или 4.
Переход к глобальному смещению как отдельному числу имеет смысл только в связке с кодированием атомов числами переменной длины. Если раньше два смещения (имени и значения) занимали в сумме 8 байт, то теперь у глобального смещения тип фиксирован — uint32_t — это 4 байта. А сверху ещё остались два локальных смещения — 2 байта в сумме. Итого — 6 байт против 8.
Но главное преимущество в другом: глобальное смещение одно на все атомы в метрике. То есть для метрики с 7 парами лейблов мы получаем (опираясь на статистику, мы ожидаем, что все смещения и длины уложатся в 1 байт каждый):
Было: 7 × 16 = 112 байт
Стало: 4 (глобальное) + 7 × 4 (локальные) = 32 байта
Добавляем дескриптор
Переход на числа переменной длины создаёт проблему при чтении: парсер не знает, где заканчивается одно число и начинается другое. Если в буфере записано, например, 5 байт, это может быть комбинация 2 + 3, 3 + 2, 1 + 4 или 4 + 1 байт.
Чтобы шард при чтении мог корректно распаковать данные, нам нужно заранее сообщить ему размер каждого из четырёх полей лейбла. И тут нам повезло: в байте ровно 8 бит, что позволяет выделить по 2 бита на каждое число — этого более чем достаточно.
Перед тем как записывать данные по лейблу в буфер, мы перед каждой четвёркой чисел кладём байт-дескриптор. В нём четыре 2-битных поля:
[ value.length ][ value.offset ][ name.length ][ name.offset ]
Каждая пара битов хранит размер соответствующего числа:
0b00 -> 0 байт 0b01 -> 1 байт 0b10 -> 2 байта 0b11 -> 4 байта
Нулевой размер (0b00) резервируем по отдельный специальный случай — __name__. Как мы говорили, это константа, и писать её координаты каждый раз расточительно. Поэтому для __name__ поля name.offset и name.length намеренно равны нулю. Такой комбинации больше ни у какого другого лейбла быть не может.
Далее мы выделяем две метки на наиболее распространённые случаи — 1 байт и 2 байта. Метрики, у которых строки лейблов могут потребовать 3 или 4 байта для длины и смещения (а это строки длиннее 65 535 байт!), мы считаем редкими и оставляем им сразу хранение в 4 байтах (0b11).
Для job="api" все четыре числа помещаются в 1 байт, поэтому дескриптор состоит только из значений 0b01, а весь лейбл занимает 5 байт:
Было: [name.off:4][name.len:4][value.off:4][value.len:4] = 16 байт Стало: [0b01010101][name.off:1][name.len:1][value.off:1][value.len:1] = 5 байт
Итог: вместо фиксированных 16 байт обычный лейбл теперь занимает от 5 до 17 байт. С учётом глобального смещения — от 9 до 21. В типичном случае коротких строк и небольших относительных смещений получается 9 байт. __name__ кодируется ещё компактнее — в 7 байт.
Худший случай занимает на 5 байт больше baseline, но он требует, чтобы все четыре поля одновременно заняли по 4 байта. В реальной практике с короткими лейблами это просто не встречается.
Вторая оптимизация: Sample Encoding
Вторая оптимизация касается Sample. В baseline он всегда занимал 16 байт:
[ value: double, 8 байт ][ timestamp: int64_t, 8 байт ]
Но по статистике kube-api.metrics такой формат почти всегда избыточен:
Явного
timestampможет не быть вообще, и тогда при чтении можно подставить время Scrape-запроса;Только 3,92 % значений требуют полноценного
double;89,21 % значений — положительные целые, которые помещаются в
uint8_t,uint16_tилиuint32_t;Зарезервированные константы (например,
0илиNaN), можно пометить в дескрипторе и не сохранять явно;
timestamp и value (double) — это состав «полезной нагрузки» (sample), которая всегда занимает 16 байт (8 на double, 8 на timestamp). Но как показала статистика, в реальных дампах эти 16 байт часто избыточны.
По аналогии с лейблами, перед sample также появляется свой байт-дескриптор:
bit 7 -> есть ли явный timestamp bits 4-6 -> зарезервированы bits 0-3 -> тип и размер value
Нижние четыре бита задают, как читать value:
Код |
Значение |
Сколько байт читать дальше |
|
|
0 байт, сразу знаем, что |
|
|
1 байт |
|
|
2 байта |
|
|
4 байта |
|
|
0 байт, сразу знаем, что |
|
|
4 байта |
|
|
8 байт |
Бит 7 показывает, есть ли timestamp в исходной строке. Если бит равен 0, reader подставляет timestamp по умолчанию. Если 1, сразу после value читаем ещё 8 байт под int64_t.
Пример: value = 42, timestamp в строке отсутствует.
42помещается вuint8_t, поэтому в младших битах дескриптора напишем код0001.timestampотсутствует, значит старший бит дескриптора остаётся0.Сам дескриптор получается
0b00000001.Пишем 1 байт — сам дескриптор.
После дескриптора пишем 1 байт полезной нагрузки — число
42.
Итого: 1 байт дескриптор + 1 байт полезной нагрузки = 2 байта вместо 16.
Худший случай также возможен: в новом формате double и явный timestamp занимают 1 + 8 + 8 = 17 байт. В нашем случае double потребовался только для 3,92 % значений, а timestamp отсутствовал всегда. То есть опять мы готовы проиграть в худших случаях, которых значительно меньше, чем тех, на которые были нацелены наши оптимизации.
Что получилось: замеры после оптимизации
Сначала мы проверили не финальную производительность, а саму жизнеспособность идеи компактного представления. Мы добавили в код две вышеописанные оптимизации и ответили на главный вопрос: сколько памяти можно выиграть, если перестать хранить все поля fixed-size-структурами?
На этом этапе нас интересовал только размер разметки. Он не зависит от архитектуры стенда, поэтому результат общий:
Формат |
Alloc, MБ |
% of input |
Изменение |
Baseline |
9,98 |
78,6 |
— |
Labels Varint + Sample Encoding |
4,02 |
31,6 |
x2,49 меньше |
Главный промежуточный результат — выигрыш по памяти. Расход упал с ~10 МБ до ~4 МБ, то есть в 2,5 раза.
Скорость на этой итерации ещё не была финальной: мы проверили, как себя показывает запись разметки, но код для её чтения пока что отсутствует, а кодирование данных уже даёт дополнительную нагрузку на горячий путь. Поэтому замеры производительности ниже стоит читать как промежуточную диагностику, а не как итоговый результат оптимизации.
Стенды |
|
|
|
Alloc, МБ |
% of Input |
x86-64 |
12,46 |
43,33 |
30,86 |
4,02 |
31,6 |
x86-64, coef |
~x1,0 |
x1,08 |
x1,20 |
x2,49 |
|
ARM64 |
20,56 |
65,34 |
44,77 |
4,02 |
31,6 |
ARM64, coef |
~x1,0 |
x0,92 |
x0,87 |
x2,49 |
Коэффициенты считаются относительно baseline: для времени значение больше 1 означает ускорение, для памяти — уменьшение расхода.
Такой результат объясним: мы уменьшили объём данных, которые пишем в разметку, но добавили работу по выбору размера поля, сборке байта-дескриптора, кодированию значения и проверке наличия временной метки.
ScraperRead на этом шаге мы не замеряли: декодирование нового формата ещё не было реализовано. То есть компактная запись уже работала, а полноценное чтение шардами мы добавили следующей итерацией.
Реализуем и ускоряем Read
После проверки идеи мы реализовали путь чтения (read-path) для нового формата. На первом этапе главной целью была корректность, а не скорость: нужно было убедиться, что шард может восстановить все необходимые данные по метрикам.
Логика чтения работает «в обратную сторону» относительно записи:
Читаем
varint— количество лейблов.Для каждого лейбла читаем байт-дескриптор → получаем размеры полей в байтах → читаем 4 числа → формируем два
string_viewна исходный текстовый буфер.Читаем следующий байт-дескриптор, определяющий, как читать Sample.
При необходимости читаем
timestamp(или заполняем значением по умолчанию).Через большой
switchвычитываем значение семпла.
Посмотрим на наши бенчмарки:
|
|
|
|
Alloc, МБ |
% of Input |
|
x86-64 |
11,38 |
40,63 |
29,25 |
6,15 |
4,02 |
31,6 |
x86-64, coef |
x1,04 |
x1,15 |
x1,19 |
x0,22 |
x2,49 |
|
ARM64 |
21,24 |
59,10 |
37,85 |
6,83 |
4,02 |
31,6 |
ARM64, coef |
x0,99 |
x1,02 |
x1,03 |
x0,32 |
x2,49 |
Запустив бенчмарки, мы увидели ожидаемую картину: память сэкономлена, но чтение стало медленным. Компактный формат уменьшил объём данных, но заставил CPU выполнять больше операций декодирования.
В этом случае проблему оказалось найти легко. Она скрывалась в декодировании 4 полей на каждый лейбл. С небольшими упрощениями, проблема была в такой функции:
uint32_t read_val(const char*& ptr, const uint8_t code) { uint32_t v = 0; uint8_t bytes = decode_size(code); // 0, 1, 2 или 4 if (bytes == 0) { return 0; } memcpy(&v, ptr, bytes); ptr += bytes; return v; };
Проблема была в избыточной универсальности. На уровне формата у нас всего четыре варианта размера: 0, 1, 2 или 4 байта. Но для компилятора code оставался обычным uint8_t, а значит, потенциально мог принимать любое значение от 0 до 255. В результате он генерировал более общий код, чем требовался реальному формату.
Решение: вместо общей функции в коде перебрать явно только те случаи, которые у нас реально могут встретиться:
uint32_t read_val(const char*& ptr, uint8_t code) { uint32_t v = 0; if (code == 0) { return 0; } if (code == 1) { std::memcpy(&v, ptr, 1); ptr += 1; return v; } if (code == 2) { std::memcpy(&v, ptr, 2); ptr += 2; return v; } std::memcpy(&v, ptr, 4); ptr += 4; return v; }
Во втором случае мы сами чётко говорим компилятору, что вариантов исполнения всего четыре.
Примечание
В эту функцию всё ещё можно передать значение >3, и оно просто обработается как чтение 4 байт, что, честно говоря, некорректно. Но защита от подобных аномалий и строгая валидация входных данных — это уже тема для отдельного разговора.
В итоге получаем следующие числа на бенчмарках:
|
|
|
|
Alloc, МБ |
% of Input |
|
x86-64 |
11,49 |
42,04 |
30,56 |
1,19 |
4,02 |
31,6 |
x86-64, coef |
x1,03 |
x1,11 |
x1,14 |
x1,14 |
x2,49 |
|
ARM64 |
21,15 |
66,58 |
45,43 |
1,52 |
4,02 |
31,6 |
ARM64, coef |
x0,99 |
x0,90 |
x0,86 |
x1,42 |
x2,49 |
Из-за избыточной универсальности компилятор генерирует код для лишних вариантов, хотя codec допускает только 0, 1, 2 или 4 байта. Явно прописав эти случаи, мы убираем лишнее и получаем более компактный ассемблер.
Основное уже сделано: мы выиграли в памяти и ускорили чтение. Оставалось лишь причесать код и подстроить его под ту статистику, которую показывают реальные данные. Вот те оптимизации, которые мы для этого применили.
Чтение Sample Value
Как мы узнали выше из анализа реального файла с метриками, распределение sample value по возможным типам, которые могут его вместить, совсем неравномерное:
89 % значений являются целыми числами;
54 % умещаются в 1 байт.
Поэтому во время чтения лучше заменить классический switch:
switch (type) { case SampleValueType::kZero: { ... } case SampleValueType::kUint32: { ... } case SampleValueType::kDouble: { ... } case SampleValueType::kUint8: { ... } case SampleValueType::kUint16: { ... } ... }
…на цепочку проверок:
if (type == SampleValueType::kUint8) [[likely]] { ... } else if (type == SampleValueType::kUint16) [[likely]] { ... } else if (type == SampleValueType::kUint32) [[likely]] { ... } else if (type == SampleValueType::kDouble) [[unlikely]] { ... } else [[unlikely]] { ... }
Обычный switch даёт компилятору свободу выбрать стратегию исполнения (например, indirect jump), что хорошо для равномерного распределения. Но статистика показала перекос: kUint8 и kUint16 встречаются чаще всего, а kDouble — редко.
Поэтому hot path сделан линейным: сначала проверяем самые частые типы и помечаем их как [[likely]], а редкие ветки помечаем [[unlikely]]. Для наших распределений значений семпла это показало лучший результат.
Частные случаи для байт-дескрипторов у лейблов
У нас есть ещё неиспользованная информация о данных:
Случай 1: имя метрики (__name__)
Оно есть у каждой метрики и часто стоит первым. Мы уже знаем, что имя этого лейбла закодировано как зарезервированное (0 байт), а его значение, согласно статистике, с высокой вероятностью уместится в 1 байт.
Это формирует уникальный паттерн дескриптора: 0b01000000. Если парсер видит это число, ему достаточно сделать всего одно чтение из буфера (1 байт), чтобы полностью восстановить лейбл с именем метрики.
Случай 2: короткие лейблы
Как мы выяснили ранее, большинство относительных смещений и длин строк укладываются в 1 байт. Если все четыре поля лейбла (смещение и длина имени, смещение и длина значения) занимают по 1 байту, дескриптор будет равен 0b01010101.
В этом случае вместо четырёх отдельных чтений по 1 байту мы можем сделать одно чтение блока из 4 байт (uint32_t), где каждый байт будет содержать соответствующее поле, необходимое для восстановления лейбла.
Эти же оптимизации мы применили и на этапе записи (ScraperParse), чтобы ускорить создание разметки.
Предвыделение памяти в байтовом буфере
Здесь мы добавили немного ручного управления памятью. В какой-то момент компактное кодирование уже экономило память, но запись всё ещё часто дёргала контейнер: при добавлении каждого лейбла нужно было проверять, хватает ли места в байтовом буфере.
Каждая метрика обрабатывается последовательно. При разборе текущей строки мы всё равно собираем её лейблы в локальный массив, который переиспользуется для следующих метрик. Он нужен для сортировки, удаления пустых лейблов и вычисления хеша, но для оптимизации важен другой факт: к моменту записи разметки мы уже знаем все лейблы текущей метрики и можем быстро посчитать верхнюю границу размера их кодирования.
Дальше алгоритм простой: перед записью метрики резервируем в байтовом буфере место по верхней границе, а затем кодируем, сколько получится. Если реальный размер оказался меньше оценки, хвост остаётся и будет использован следующими метриками. Перед следующей метрикой буфер снова дорасширяется только до новой верхней оценки.
Так мы перестаём проверять и расширять контейнер на каждом лейбле и делаем это только на границе метрики. Метрик на порядок меньше, чем отдельных полей разметки, поэтому на горячем пути становится меньше обращений к контейнеру и меньше шансов задеть аллокатор. В худшем случае лишний запас равен ошибке оценки для последней метрики: мы зарезервировали место по верхней границе, а записали меньше. Для файла на десятки тысяч метрик это небольшой хвост.
Итоги и выводы
После ревью и рефакторинга мы получаем следующие финальные числа:
|
|
|
|
Alloc, МБ |
% of Input |
|
x86-64 |
12,26 |
36,04 |
23,77 |
1,26 |
4,02 |
31,6 |
x86-64, coef |
x0,97 |
x1,30 |
x1,34 |
x1,08 |
x2,49 |
|
ARM64 |
20,43 |
53,41 |
32,98 |
1,37 |
4,02 |
31,6 |
ARM64, coef |
x1,03 |
x1,13 |
x1,18 |
x1,57 |
x2,49 |
После всех итераций разметка стала компактной: смещения — относительные и varint-кодированные, __name__ вынесен отдельно, sample хранится в 1–17 байт вместо фиксированных 16, timestamp пишется, только если он явно есть в дампе. Быстрый путь чтения для шардов работает.
Финальный итог:
Метрика |
Baseline |
Финальный вариант |
Итог |
Аллоцированная память |
9,98 МБ |
4,02 МБ |
x2,49 меньше |
x86-64 |
46,74 мс |
36,04 мс |
x1,30 быстрее |
x86-64 |
1,36 мс |
1,26 мс |
x1,08 быстрее |
ARM64 |
60,10 мс |
53,41 мс |
x1,13 быстрее |
ARM64 |
2,16 мс |
1,37 мс |
x1,57 быстрее |
Мы не применяли универсальное сжатие: такие алгоритмы обычно ничего не знают о данных, которые кодируют, и вынуждены хорошо работать на общем случае. У нас другая ситуация: формат метрик известен заранее, а статистика по реальным данным показывает устойчивые распространенные случаи: __name__ есть всегда, длины лейблов обычно малы, timestamp может отсутствовать у всех метрик, а большинство значений семплов — целые числа или константы.
Поэтому мы не усложняли формат ради редких сценариев, а сделали быстрыми самые частые: короткие смещения и длины, компактные значения семплов, отсутствие timestamp. Общий случай также есть, но он считается маловероятным. В итоге оптимизации получились простыми, локальными и проверяемыми на бенчмарках: разметка стала почти в 2,5 раза компактнее, а чтение после доработок осталось быстрым.
Профилирование сузило задачу до конкретного участка: фазы парсинга и буфера разметки. Дальше оптимизация стала арифметикой: посчитать, сколько памяти действительно требуют поля, заменить фиксированный формат на компактный и проверить результат бенчмарками.
P. S.
Читайте также в нашем блоге: