Миллионы метрик, чистый код, но аллокатор показывает в разы больше памяти, чем должно занимать «полезное» содержимое. Профайлер светит на парсинг, а вы гадаете: куда деваются мегабайты?

Привет, меня зовут Глеб Шигин, я С++-разработчик в команде Deckhouse Observability. В этой статье я покажу, как мы искали скрытый расход памяти в Scraper для Prometheus и шаг за шагом сжимали служебные структуры метрик.

Вы узнаете:

  • как мы хотели избежать копирования, но всё равно просели по памяти;

  • почему не нужно хранить короткие строки как длинные;

  • как знание своих данных приводит к простым, но эффективным оптимизациям;

  • как выиграть и по памяти, и по скорости.

Если вы когда-нибудь упаковывали данные «впритык» и не хотели потерять в скорости — эта статья для вас.

Все описанные в статье оптимизации реализованы в Prom++ — оптимизированной версии Prometheus от команды Deckhouse, которая полностью совместима с оригиналом по форматам данных и API, но использует более эффективные алгоритмы хранения. ​​Его ключевая особенность — способность обрабатывать те же объёмы данных, что и оригинальный Prometheus, но потреблять до 10 раз меньше оперативной памяти. 

Про архитектуру Prom++ подробнее рассказывали в одной из прошлых статей.

Репозиторий на GitHub

Содержание:

Зачем Scraper нужен в Prom++

В Prom++ за сбор данных отвечает компонент Scraper. Он регулярно опрашивает серверы и приложения, получает HTTP-ответ в текстовом формате Prometheus или OpenMetrics и строит промежуточное представление для записи в TSDB.

Так устроен Prometheus изнутри. Слева — источники метрик: поды в Kubernetes, приложения, сервисы. Service Discovery находит, что опрашивать, а Scraper с заданным интервалом (30 секунд) ходит к каждому таргету, получает метрики в текстовом формате и после Relabel складывает их в TSDB — базу временных рядов. Дальше Query Engine принимает PromQL-запросы и отдаёт данные в Grafana для графиков или в Alertmanager для алертов. Ruler по расписанию прогоняет правила и тоже может писать результаты обратно в TSDB или в RemoteWrite — для отправки данных во внешние хранилища.
Так устроен Prometheus изнутри. Слева — источники метрик: поды в Kubernetes, приложения, сервисы. Service Discovery находит, что опрашивать, а Scraper с заданным интервалом (30 секунд) ходит к каждому таргету, получает метрики в текстовом формате и после Relabel складывает их в TSDB — базу временных рядов. Дальше Query Engine принимает PromQL-запросы и отдаёт данные в Grafana для графиков или в Alertmanager для алертов. Ruler по расписанию прогоняет правила и тоже может писать результаты обратно в TSDB или в RemoteWrite — для отправки данных во внешние хранилища.

Метрики в 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 на несколько бенчмарков.

Бенчмарк

Что измеряет

Зачем нужен

Parser

Лексический разбор текстового формата в токены

Показывает нижнюю границу стоимости парсинга

ScraperParse

Полный проход Scraper: чтение текста, построение markup

Показывает цену подготовки данных для шардов

ScraperRead

Чтение готовой разметки одним шардом

Показывает, насколько быстро шарды работают с уже подготовленными данными

PlainScraperTime

Разница: ScraperParseParser

Выделяет стоимость логики 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 получили такую картину:

Стенд

Parse, мс

ScraperParse, мс

PlainScraperTime, мс

ScraperRead, мс

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 получилась такая статистика:

Что измеряли

Результат

Почему это важно

Наличие __name__

Есть в 100 % метрик

Лейбл имени метрики можно помечать флагом, а саму строку __name__ хранить как константу 

Количество лейблов на метрику

min 1, max 10, avg 7

Весь диапазон умещается в 1 байт

Длина имен и значений лейблов

min 0, max 78, avg 10

Весь диапазон умещается в 1 байт

Явный timestamp

Отсутствует у всех метрик в файле

Можно хранить 1 копию default timestamp и в метриках пометить, что у них timestamp отсутствует

Значение метрики (sample value) всегда приходит как double — это 8 байт. Однако можно предположить, что далеко не все приходящие метрики по своей природе умещаются только в тип double. В реальных данных часто встречаются:

  • счётчики «не очень больших» сущностей (узлы, потоки, ядра, машины, версии);

  • целочисленные значения (потребляемая память, измерение времени запроса в наносекундах). 

Отсюда можно предположить, что value не обязательно хранить строго в 8 байтах, если конкретное число представимо более компактным типом. Мы собрали статистику по sample value — и вот что у нас получилось:

Что измеряли

Результат

Почему это важно

uint8_t value

54,31 %

Самый частый путь: 1 байт

uint16_t value

22,90 %

Помещается в 2 байта

uint32_t value

12,00 %

Занимает 4 байта

value == 0

6,86 %

Можно вообще не хранить — достаточно маркера или enum

float value

~0 %

Здесь в статистике оказалось всего 4 числа, но это всё ещё 4 байта против 8

double value

3,92 %

Только эти значения требуют типа double

На основе этих данных мы сформулировали несколько идей, где можно сэкономить память:

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" это могло бы выглядеть так:

Поле

Глобальные смещения

Локальные смещения

Размер

name.offset

1 245 032

32

1 байт

name.length

3

3

1 байт

value.offset

1 245 037

37

1 байт

value.length

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:

Код

Значение

Сколько байт читать дальше

0000

zero

0 байт, сразу знаем, что 0

0001

uint8

1 байт

0010

uint16

2 байта

0011

uint32

4 байта

0100

NaN

0 байт, сразу знаем, что NaN

1000

float

4 байта

1001

double

8 байт

Бит 7 показывает, есть ли timestamp в исходной строке. Если бит равен 0, reader подставляет timestamp по умолчанию. Если 1, сразу после value читаем ещё 8 байт под int64_t.

Пример: value = 42, timestamp в строке отсутствует.

  1. 42 помещается в uint8_t, поэтому в младших битах дескриптора напишем код 0001.

  2. timestamp отсутствует, значит старший бит дескриптора остаётся 0.

  3. Сам дескриптор получается 0b00000001.

  4. Пишем 1 байт — сам дескриптор.

  5. После дескриптора пишем 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 раза. 

Скорость на этой итерации ещё не была финальной: мы проверили, как себя показывает запись разметки, но код для её чтения пока что отсутствует, а кодирование данных уже даёт дополнительную нагрузку на горячий путь. Поэтому замеры производительности ниже стоит читать как промежуточную диагностику, а не как итоговый результат оптимизации.

Стенды

Parse, мс

ScraperParse, мс

PlainScraperTime, мс

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 вычитываем значение семпла.

Посмотрим на наши бенчмарки:

Parse, мс

ScraperParse, мс

PlainScraperTime, мс

ScraperRead, мс

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 байт, что, честно говоря, некорректно. Но защита от подобных аномалий и строгая валидация входных данных — это уже тема для отдельного разговора.

В итоге получаем следующие числа на бенчмарках:

Parse, мс

ScraperParse, мс

PlainScraperTime, мс

ScraperRead, мс

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), чтобы ускорить создание разметки.

Предвыделение памяти в байтовом буфере

Здесь мы добавили немного ручного управления памятью. В какой-то момент компактное кодирование уже экономило память, но запись всё ещё часто дёргала контейнер: при добавлении каждого лейбла нужно было проверять, хватает ли места в байтовом буфере.

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

Дальше алгоритм простой: перед записью метрики резервируем в байтовом буфере место по верхней границе, а затем кодируем, сколько получится. Если реальный размер оказался меньше оценки, хвост остаётся и будет использован следующими метриками. Перед следующей метрикой буфер снова дорасширяется только до новой верхней оценки.

Так мы перестаём проверять и расширять контейнер на каждом лейбле и делаем это только на границе метрики. Метрик на порядок меньше, чем отдельных полей разметки, поэтому на горячем пути становится меньше обращений к контейнеру и меньше шансов задеть аллокатор. В худшем случае лишний запас равен ошибке оценки для последней метрики: мы зарезервировали место по верхней границе, а записали меньше. Для файла на десятки тысяч метрик это небольшой хвост.

Итоги и выводы

После ревью и рефакторинга мы получаем следующие финальные числа:

Parse, мс

ScraperParse, мс

PlainScraperTime, мс

ScraperRead, мс

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 ScraperParse

46,74 мс

36,04 мс

x1,30 быстрее

x86-64 ScraperRead

1,36 мс

1,26 мс 

x1,08 быстрее

ARM64 ScraperParse

60,10 мс

53,41 мс

x1,13 быстрее

ARM64 ScraperRead

2,16 мс

1,37 мс 

x1,57 быстрее

Мы не применяли универсальное сжатие: такие алгоритмы обычно ничего не знают о данных, которые кодируют, и вынуждены хорошо работать на общем случае. У нас другая ситуация: формат метрик известен заранее, а статистика по реальным данным показывает устойчивые распространенные случаи: __name__ есть всегда, длины лейблов обычно малы, timestamp может отсутствовать у всех метрик, а большинство значений семплов — целые числа или константы.

Поэтому мы не усложняли формат ради редких сценариев, а сделали быстрыми самые частые: короткие смещения и длины, компактные значения семплов, отсутствие timestamp. Общий случай также есть, но он считается маловероятным. В итоге оптимизации получились простыми, локальными и проверяемыми на бенчмарках: разметка стала почти в 2,5 раза компактнее, а чтение после доработок осталось быстрым.

Профилирование сузило задачу до конкретного участка: фазы парсинга и буфера разметки. Дальше оптимизация стала арифметикой: посчитать, сколько памяти действительно требуют поля, заменить фиксированный формат на компактный и проверить результат бенчмарками.

P. S.

Читайте также в нашем блоге:

Комментарии (0)