Под управлением Kubernetes-платформы Deckhouse сейчас больше 1300 клиентских кластеров, значительную часть из которых обслуживают наши инженеры. Конечно, в каждом кластере развёрнута система мониторинга. До поры до времени мы использовали Prometheus, но он потреблял слишком много памяти и CPU: по нашим подсчётам, на его содержание уходило порядка 20 % от всех наших серверных ресурсов.
Это большие цифры и деньги, с чем нам не хотелось мириться. Поэтому мы разработали собственную систему мониторинга — Deckhouse Prom++. Это тот же самый Prometheus, только в разы оптимизированный по потреблению RAM и CPU. Это полностью Open Source-продукт, подходящий для любой инфраструктуры.
Меня зовут Пустовалов Владимир, я C++-разработчик в observability-команде Deckhouse. Моя основная специализация — разработка высоконагруженных системных приложений. В этой статье я покажу основные структуры данных, алгоритмы и этапы оптимизации Prom++, которые позволили нам сократить потребление RAM в хранилище данных на 89 %.
Дисклеймер: в тексте вас ждут cache-friendly-структуры данных, листинги на C++, битовые операции и прочие хардкорные вещи, без которых не может существовать ни один высокопроизводительный проект.

Оперативная память — это деньги
Зачем вообще считать байты RAM во времена, когда нейронные сети вовсю бороздят компьютерные пространства? Затем, что это деньги. Этот график потребления памяти до и после раскатки Deckhouse Prom++ принёс нам инженер одной крупной компании:

В начале дня потребление оперативной памяти на мониторинг — 3,8 ТБ. Затем инженер заменяет исполняемый бинарник Prometheus на Deckhouse Prom++. К вечеру после успешной выкатки и прогрева данных потребление снижается до 0,6 ТБ.
Это экономия порядка 12 000 000 рублей в год на ресурсах для мониторинга кластеров.
За счёт чего получилось добиться таких оптимизаций? Мы переписали ядро хранения и обработки горячих данных в Prometheus на С++. Но это не значит, что мы просто сменили язык разработки с Go на C++. Мы хорошенько проанализировали хранимые данные и разработали собственные алгоритмы и структуры данных. А «плюсы» позволили нам выжать максимум производительности из ресурсов железа.
Быстрый ликбез: хранилище данных Prometheus
Основной элемент в хранилище данных — это точка. В свою очередь точка — это структура из двух элементов:
Временная метка, или timestamp. Это время, которое измеряется в миллисекундах (
int64).Само значение метрики, или value. Оно описывается типом double.
Набор точек называется серией:
t1 2345.0 t2 2360.0 t3 2380.0 ... ...
У каждой серии есть свой уникальный автоинкрементный идентификатор ID (uint32).
В Prometheus точки в рамках серии хранятся в блоках — так называемых чанках. Время жизни чанка равно 2 часам и ограничено 240 точками.
В средних кластерах Kubernetes миллион серий, соответственно, счёт точкам идёт на 240+ миллионов за два часа. В более крупных кластерах количество серий измеряется уже десятками миллионов, а количество точек — миллиардами. И все эти данные желательно хранить в оперативной памяти, чтобы максимально быстро отдавать их по запросу пользователя.
Как мы проводим бенчмаркинг
Добавлю ещё немного важного контекста перед тем, как мы перейдём к оптимизациям в Deckhouse Prom++. А именно: пара слов о незаменимом процессе в разработке нашей системы мониторинга — о бенчмаркинге.
В коде Deckhouse Prom++ много бенчмарков. Они нужны, чтобы видеть изменения в производительности после очередной оптимизации алгоритмов: что мы выиграли или проиграли по RAM и CPU.
Для проведения бенчмаркинга мы:
арендовали сервер AMD Ryzen 9 3900 @ 3.1GHz, Ubuntu 22.04.4;
использовали библиотеку Google Benchmark;
делали множественный прогон бенчмарка, а затем выбирали минимальное время, за которое кодируется точка.
Для наших целей мы задампили данные среднего по объёму кластера, в котором было 1 208 872 серии и 241 984 632 точки. Цифры чуть отличаются от тех, что были в теоретическом примере выше — в реальных кластерах далеко не все серии имеют полный двухчасовой чанк.
Для представления хранимой точки на C++ используется простейшая структура из двух элементов:
struct SeriesSample { int64_t timestamp; double value; };
Если последовательно расположить в памяти все эти точки, то на их хранение потребуется примерно 3,8 ГБ RAM. Это просто огромные цифры.
В нашей кодовой базе есть класс DataStorage с функцией, которая возвращает размер потребляемой оперативной памяти. Так мы понимаем, сколько памяти занято хранилищем данных. И есть класс Encoder, который хитрым образом добавляет точку в вышеупомянутый класс.
class DataStorage { public: size_t allocated_memory() const noexcept; }; class Encoder { public: void encode(uint32_t series_id, int64_t timestamp, double value); };
Алгоритм бенчмарка следующий:
Считываем исходные данные из файла.
Кодируем их с замером времени.
Выводим потребление оперативной памяти и среднее время кодирования одной точки.
Командная строка запуска бенчмарка выглядит так:
nice -n -20 \ ./benchmark \ --benchmark_context=samples_file="samples.dat" \ --benchmark_repetitions=10
Мы запускаем бенчмарки под утилитой nice, чтобы поднять приоритет процесса в системе. Это позволяет ему получить больше процессорного времени, а нам — наиболее точные результаты по скорости работы алгоритма.
Реализация 1. Gorilla и свой вектор
Работу над оптимизацией мы начали с того, что изучили механизм хранения данных Prometheus. Он использует алгоритм Gorilla, разработанный Компанией-Которую-Нельзя-Называть-В-Текущих-Реалиях. Её инженеры разработали Gorilla-энкодер состоящий из:
энкодера значений (Gorilla Values encoder);
энкодера временны́х меток (Gorilla Timestamp encoder).
Давайте разберёмся, как они работают, чтобы у нас была стартовая точка для последующих оптимизаций. Начнём с энкодера значений.
Как работает энкодер значений метрик
Для примера закодируем последовательность из четырёх точек:
100.0, 1024.0, 200.0, 200.0
Первое значение 100.0 записываем в буфер как есть — это 64 бита (размер переменной типа double в C++).
Для второго значения 1024.0 используем следующую логику: берём XOR текущего и предыдущего значений:
В полученном результате мы ищем островок значащих бит. Находим первый значащий бит слева и первый значащий бит справа. Всё, что между ними, — это тот самый островок:

В нашем случае количество лидирующих нулей — 8, количество завершающих нулей — 48, длина островка — 8 бит. Если количество лидирующих или завершающих нулей изменилось по сравнению с предыдущим значением (как в нашем случае, так как изначально эти параметры инициализируются нулями), то мы записываем следующую информацию:
ключ
11, это 2 бита. Ключ указывает схему кодирования данных. В энкодере значений метрик на ключ выделено именно 2 бита, чтобы можно было закодировать все необходимые варианты;количество лидирующих нулей, это 5 бит. Показывает, сколько нулей идёт перед островком значащих бит, чтобы декодер мог восстановить исходные данные;
длину островка значащих бит, ещё 6 бит. Минимальная длина островка — 1 бит, потому что островок не может быть пустым, а максимальная — 64 бита, если все биты отличаются. 6 бит достаточно, чтобы закодировать весь этот диапазон;
сам островок, 8 бит.
В итоге получается 21 бит вместо исходных 64. Довольно компактно.
Идём дальше и кодируем значение третьей точки — 200.0. Также «ксорим» её с предыдущим значением и ищем островок значащих бит:

Количество лидирующих нулей — 8 (как и в предыдущей точке), количество завершающих нулей — 48 (тоже не изменилось), длина островка — 8 бит (не изменилось).
Позиция и длина островка значащих бит совпадают с предыдущими. А значит, заново сохранять количество лидирующих нулей и длину островка не нужно — декодер и так знает эти значения из предыдущего шага.
Поэтому для значения третьей точки мы записываем:
ключ
10, это 2 бита. Этот ключ сообщает декодеру: «Используй те же параметры островка, что и в прошлый раз, — позицию и длину менять не нужно»;сам островок, 8 бит. Только новые значащие биты, которые несут полезную информацию о значении метрики.
В итоге получается всего 10 бит вместо исходных 64.
Кодируем значение четвёртой точки — снова 200.0. «Ксорим» её с предыдущим значением:
Результат равен нулю — значение точки не изменилось. В таком случае мы записываем всего один бит — ключ 0.
С энкодером значений разобрались, перейдём к энкодеру временных меток.
Как работает энкодер временных меток
По традиции закодируем последовательность из четырёх точек. Возьмём простой учебный пример в секундах, в реальности timestamp в миллисекундах намного больше:
100, 150, 200, 251
На всякий случай напомню, что временные метки хранятся как int64.
Первое значение 100 записываем как varint (variable-length integer). Это способ кодирования целых чисел, использующий переменное количество байт. В зависимости от того, насколько значение большое, нам потребуется от 1 до 10 байт:
0–127 → 1 байт
128–16383 → 2 байта
16384–2097151 → 3 байта
...
Наш учебный пример совсем простой, на него понадобится 1 байт.
Для кодирования второго значения — 150 — необходимо рассчитать дельту, то есть из текущего значения вычесть предыдущее:
Дельту тоже запишем как varint. Она небольшая, потребуется 1 байт.
Кодируем третье значение — 200. Здесь уже рассчитываем дельту дельт (delta-of-delta). Для этого из текущего значения вычитаем предыдущее — находим дельту. А затем из этой дельты вычитаем предыдущую:
Дельта дельт нулевая. В этом случае мы пишем только ключ 0 — это 1 бит.
Кодируем четвёртое значение — 251. Также рассчитываем дельту дельт:
Результат не равен нулю, поэтому нужно записать не только ключ, как в предыдущем случае, но и само значение. Для дельты дельт в Gorilla используется специальная схема кодирования с переменной длиной ключа.
Находим значащие биты числа 1:

И выбираем ключ согласно таблице:
Количество значащих бит |
Ключ |
≤ 4 |
|
≤ 14 |
|
≤ 17 |
|
Остальное |
|
У нас 1 значащий бит (≤ 4), поэтому согласно таблице используем ключ 10 (2 бита) и записываем количество значащих бит (4 бита). Итого: 6 бит.
В действительности временные метки хранят миллисекунды, что по сути является большим числом, но дельты дельт между ними обычно стабильно небольшие. И даже когда первое значение кодируется через varint (несколько байт), благодаря дельте дельт последующие значения часто требуют несколько бит. Это колоссальная экономия.
Столкновение с реальностью
В статье инженеров Компании-Которую-Нельзя-Называть-В-Текущих-Реалиях о Gorilla говорится, что для кодирования 240 точек в среднем необходимо 1,37 байта на точку.

В нашей практике это не совсем так: на 240 точек нужны в среднем 2 байта на точку. Откуда взялась разница? Во-первых, в статье для временных меток использовали секунды, а мы используем миллисекунды — числа на порядок выше, и при кодировании их через varint требуется больше байт. Во-вторых, за годы, которые прошли с момента публикации статьи, инженеры стали несколько иначе хранить данные.
Если для кодирования каждой точки нужны 2 байта, то по идее для всего кластера, который взят для бенчмаркинга, ожидаемый объём потребления памяти 2 × 241 984 682 = 461,54 МБ. Но на практике это тоже не так: после реализации Gorilla-энкодера и прогона бенчмаркинга у нас получилось 545,46 МБ.
Память |
Время кодирования точки |
|
Raw |
3,78 ГБ |
|
Gorilla ожидание |
461,54 МБ |
|
Gorilla реальность |
545,46 МБ |
31,75 нс |
Откуда взялся оверхед в 83,92 МБ? Давайте разбираться.
Для кодирования точек серии нужно хранить состояние энкодеров. Оно занимает 48,42 МБ:
struct EncoderState { TimestampEncoderState ts_state; ValuesEncoderState values_state; uint8_t sample_count; BitSequence bit_sequence; }; sizeof(EncoderState) * 1 208 872 (кол-во серий) = 48,42 МБ
Вторая проблема — это аллокация памяти с запасом. Состояния энкодеров для серии хранятся в std::vector, аналог Slice в Go. Это удобный контейнер: например, у каждой серии есть свой ID, и он является индексом в этом векторе. Сам по себе вектор — это хорошо оптимизированная структура данных. Скорость записи в вектор — это амортизированная константа, скорость доступа к элементу — константа. А ещё такая структура данных является cache-friendly для процессора.
Но std::vector оперирует в своей работе двумя вещами: size и capacity. Size в нашем случае — это количество серий, а capacity — количество элементов, под которое вектор нааллоцировал памяти. И что Slice в Go, что std::vector в С++ аллоцируют память ×2 от текущего размера. В нашем случае неиспользуемая память составила ~35,5 МБ:

Для нас это слишком много, поэтому пришлось реализовать свой вектор.
Пишем свой вектор
В нашем BareBones::Vector основной упор сделан на механику аллокации памяти. Вместо стандартного удвоения размера используется адаптивная стратегия:
Рост на 50 % с округлением до 32 байт, если итоговый размер памяти < 256 байт (только для объектов с
sizeof< 8 байт).Рост на 50 % с округлением до 256 байт, если итоговый размер памяти < 4096 байт.
В остальных случаях рост на 10 % с округлением до 4096 байт.
Естественно, в этом случае нам приходится делать частые аллокации, и, как следствие, мы получаем большую просадку производительности. Но это известная проблема, для решения которой уже есть множество готовых кастомных аллокаторов памяти. Мы собрали их список, провели бенчмарки и выявили победителя. Им оказался Jemalloc — аллокатор памяти, оптимизированный для снижения фрагментации и работы на многопроцессорных системах. (Кстати, угадайте, сотрудник какой компании его разработал.)
После реализации своего вектора получились следующие цифры:
Память |
Время кодирования точки |
|
Raw |
3,78 ГБ |
|
Gorilla std::vector |
545,46 МБ |
31,75 нс |
Gorilla BareBones::Vector |
512,21 МБ |
26,93 нс |
Мы не только отыграли 33,25 МБ памяти, но и ускорили время кодирования точки на 4,82 нс. Казалось бы, цифра микроскопическая. Но если учесть, что нам необходимо закодировать 241 миллион точек, то полученное ускорение позволяет сэкономить секунды процессорного времени.
Реализация 2. Хранилище временных меток и вектор с дырками
До этого момента за вычетом собственного вектора мы использовали то, что уже есть в Prometheus. Пришло время двигаться дальше, а именно присмотреться к данным, которые мы храним.
Довольно быстро мы заметили, что временные метки в сериях дублируются:

Состояние внутри хранилища временных меток — это набор закодированных таймстемпов.
Представим, что у нас есть четыре серии:
Серия # 1 |
Серия # 2 |
Серия # 3 |
Серия # 4 |
Первая точка в сериях указывает на одно и то же состояние — 100. Вторая точка серий также указывает на одинаковые состояния, но здесь есть важный нюанс: предыдущее состояние уже никем не используется, поэтому его нужно удалить. На третьей точке пути части серий расходятся — появляются два раздельных состояния. А на четвёртой точке уже и первые две серии расходятся по состояниям. И, конечно, все предыдущие состояния нужно удалять.

Получается, что нам нужна структура данных, которая была бы так же эффективна, как BareBones::Vector, но позволяла делать быстрые удаления. Удаление в векторе работает за O(n) — для миллионов элементов это означает необходимость сдвигать огромные объёмы данных при каждом удалении. Идея с вектором не подходит.
Поэтому мы сделали вектор с дырками (VectorWithHoles). Это тот же самый вектор, но со специальной переменной next_hole — она хранит индекс первого удалённого элемента.
Как это работает:
Когда удалённых элементов нет, в
next_holeзаписываем константуNO_HOLE.При удалении первого элемента в
next_holeзаписываем его индекс, например1, а на месте удалённого элемента — индекс следующей «дырки» (пока её нет, значение будетNO_HOLE).При удалении второго элемента старое значение
next_holeзаписывается на место нового удалённого элемента, аnext_holeобновляется на индекс новой «дырки».Так образуется цепочка: каждая «дырка» указывает на предыдущую.
Выглядит запутанно, но по сути это связанный список на индексах без дополнительной аллокации памяти:

1, а затем — элемент с индексом 4Вот результаты прогона бенчмарков после реализации дедупликации временных меток:
Память |
Время кодирования точки |
|
Raw |
3,78 ГБ |
|
Gorilla BareBones::Vector |
512,21 МБ |
26,93 нс |
Timestamp storage |
336,12 МБ |
26,01 нс |
На этом этапе нам удалось сэкономить 176,09 МБ памяти и ускорить время кодирования точки ещё на 0,61 нс.
Чем помог С++
Чем в этом случае хорош и полезен C++? В языке есть конструкция union, которая позволяет расположить переменные разных типов на одной области памяти. Размер такого «объединения» равен размеру максимального элемента:
union VectorItem { Item item; uint32_t next_hole; }; sizeof(VectorItem) == max(sizeof(Item), sizeof(uint32_t))
На другом языке реализация была бы сложнее. Например, на Go пришлось бы расчехлять магию с unsafe.Pointer.
Реализация 3. Разные энкодеры значений метрик
Мы продолжили смотреть, что происходит с данными. И обратили внимание, что существуют серии, которые хранят всего одно значение — константу. Что удивительно, таких серий большинство — 66 %.
Для кодирования такой серии нужны всего лишь 8 байт или даже 4 байта, если значение целочисленное:
struct DoubleConstantEncoder { const double value; // 8 байт }; struct Uint32ConstantEncoder { const uint32_t value; // 4 байта };
Затем мы заметили, что есть серии, в которых хранятся лишь два значения. Их 2 %. Такую серию можно закодировать, сохранив две константы:
struct TwoDoubleConstantEncoder { const double value1; const double value2; const uint8_t value1_count; };
А ещё мы обратили внимание, что в 27 % серий хранятся монотонно возрастающие целочисленные значения. Это счётчики. Их очень удобно кодировать с помощью уже знакомого вам Gorilla Timestamp encoder и дельты дельт:
class AscIntegerEncoder { GorillaTimestampEncoder encoder; BitSequence stream; };
Оставшиеся 5 % серий мы кодируем силами Gorilla Values encoder:
struct GorillaEncoder { GorillaValuesEncoder encoder; BitSequence stream; };
Сейчас в Deckhouse Prom++ реализовано восемь типов энкодеров. Но мы продолжаем над ними работать, и уже есть идеи по новым типам:
uint32_t-константа;
float32_t-константа;
double-константа;
two-double-константа;
AscInt-последовательность;
AscInt then ValuesGorilla;
ValuesGorilla;
Gorilla (Timestamp + Value).
Переход с одного энкодера на другой
Самое интересное — это то, как происходит переход из одного типа энкодера в другой. Для примера закодируем несколько точек. Первая в любом случае будет закодирована как константа. И пока по серии будет приходить та же самая точка, мы будем оставаться на том же энкодере. Как только придёт отличное от предыдущего значение, мы перейдём на двухконстантный энкодер и останемся на нём, пока будет приходить та же константа:

Если дальше приходит точка со значением, отличным от предыдущего, появляется несколько вариантов для перехода.
Рассмотрим первый, когда последовательность возрастает и она целочисленная. Например, значение метрики было 150, а стало 200. В таком случае мы перейдём на AscInt-энкодер и останемся на нём, пока значение метрики либо логика последовательности не меняется:

Как только приходит точка, которая нарушает логику, мы переключаемся на энкодер AscInt then ValuesGorilla. Это составной тип энкодера, в котором данные кодируются сначала как AscInt, а затем как ValuesGorilla.

Вернёмся к состоянию, когда у нас был двухконстантный энкодер, и рассмотрим второй вариант. Приходит точка, значение которой не удовлетворяет условиям возрастающей последовательности. Например, было 150, а стало 101. И тут появляется два новых варианта развития событий.
В данном случае мы анализируем временную метку серии. Если она уникальна, то есть никакие другие серии не содержат той же последовательности временных меток, то мы переходим на «полную» Gorilla (Timestamp + Value). В рамках серии кодируем и временные метки и значения метрик вместе, так будет дешевле с точки зрения памяти.
Если временная метка где-то дублируется — в хранилище временных меток есть такие же последовательности таймстемпов, — то мы переходим на ValuesGorilla и до конца остаёмся на этом энкодере.

Результаты реализации:
Память |
Время кодирования точки |
|
Raw |
3,78 ГБ |
|
Gorilla BareBones::Vector |
512,21 МБ |
26,93 нс |
Timestamp storage |
336,12 МБ |
26,01 нс |
Value encoders |
151,31 МБ |
32,56 нс |
Мы отыграли 184,81 МБ памяти, но получили просадку в 6,5 нс на кодирование одной точки. Почему произошло последнее? У нас появилась логика анализа последовательности точек, логика переходов, перекодирования. Мы всегда маневрируем между производительностью и потреблением оперативной памяти. В данном случае мы сочли, что потеря 6,5 нс на кодирование точки ради выигрыша 185 МБ оправдана.
Распределение энкодеров
Давайте посмотрим на распределение энкодеров по типам:
Энкодер |
Количество серий |
Память |
uint32_t-константа |
756 070 |
2,88 МБ |
float32_t-константа |
1273 |
< 1 МБ |
double-константа |
29 651 |
< 1 МБ |
two-double-константа |
22 577 |
< 1 МБ |
AscInt-последовательность |
324 180 |
65,66 МБ |
AscInt then ValuesGorilla |
8426 |
2,08 МБ |
ValuesGorilla |
66 501 |
54,11 МБ |
Gorilla (Timestamp + Value) |
194 |
< 1 МБ |
Можно заметить, что чаще всего у нас встречается энкодер uint32_t-констант — 756 тысяч серий. Но в то же время они занимают меньше 3 МБ оперативной памяти, что очень мало. Самыми тяжеловесными являются серии с AscInt-энкодером: 324 тысячи и 65,66 МБ памяти.
Чем помог С++
Может показаться, что размер подобной структуры — это 17 байт:
struct CompactStruct { const double value1; // 8 байт const double value2; // 8 байт const uint8_t value1_count; // 1 байт };
Но на самом деле она займёт 24 байта. Так происходит потому, что компилятор обязательно выставит паддинг согласно правилам выравнивания переменных в архитектуре x64. И если представить, что таких структур нужно хранить миллионы, то мы на ровном месте теряем практически 7 МБ, а это для Deckhouse Prom++ много:
struct CompactStruct { const double value1; // 8 байт const double value2; // 8 байт const uint8_t value1_count; // 1 байт // padding 7 байт }; sizeof(CompactStruct) == 24 байта
«Плюсы» же позволяют использовать упакованные структуры — attribute((__packed__)). В том же Go такое делать нельзя. Благодаря их использованию мы приводим размер структуры к заветным 17 байтам и не расходуем память расточительно:
struct __attribute__((__packed__)) CompactStruct { const double value1; // 8 байт const double value2; // 8 байт const uint8_t value1_count; // 1 байт }; sizeof(CompactStruct) == 17 байт
Реализация 4. Выгрузка неиспользуемых данных
Мы храним огромное количество данных и в какой-то момент засомневались: неужели все эти данные запрашиваются в рамках двухчасового блока? Чтобы ответить на этот вопрос, необходимо разобраться с потребителями данных.
Первый потребитель — это механизм правил и алертов (rules and alerts). Он запрашивает серии из хранилища на постоянной основе. Если по серии срабатывает триггерное значение, то генерируется алерт, и ответственный инженер получает уведомление о том, что в системе что-то не так.
Вторым потребителем является сам инженер. Он открывает в браузере Grafana, вводит запросы и мониторит графики.

Оказалось, что при реальной эксплуатации запрашивается всего 6–8 % серий. То есть бо́льшую часть данных мы храним, но в рамках двухчасового блока они никому не нужны.
Если ещё раз посмотреть на распределение по энкодерам, то можно понять, что оптимизировать в первую очередь стоит те, размер буфера которых увеличивается вместе вместе с ростом количества точек в серии:
Энкодер |
Количество серий |
Память |
uint32_t-константа |
756 070 |
2,88 МБ |
float32_t-константа |
1273 |
< 1 МБ |
double-константа |
29 651 |
< 1 МБ |
two-double-константа |
22 577 |
< 1 МБ |
AscInt-последовательность |
324 180 |
65,66 МБ |
AscInt then ValuesGorilla |
8426 |
2,08 МБ |
ValuesGorilla |
66 501 |
54,11 МБ |
Gorilla (Timestamp + Value) |
194 |
< 1 МБ |
В идеале нужно как-то заменить RAM на ROM: хранить данные не в оперативной памяти, а на диске. Для этого можно сделать специальный механизм выгрузки, работающий по следующему алгоритму:
Каждые 5 минут создаём снимок (snapshot).
Выгружаем память энкодеров в снимок.
Помечаем серию как выгруженную.
Полученный снимок записываем на диск.
Представим память энкодера как некую последовательность байтов. При этом мы помним, что Gorilla-энкодеры оперируют битами, так что на картинке ниже один голубой квадрат — это полностью заполненный байт.

Наша задача — найти область для вырезания, то есть полностью заполненные байты. Именно их мы сбросим в снимок.

Остаётся только перенести незаполненный байт в начало. Размер буфера памяти при этом остался неизменным: мы его подрезали и отправили из RAM в ROM.

Посмотрим на результаты реализации. В этом бенчмарке мы считали, что запрашивается каждая десятая серия (10 %).
Память |
Время кодирования точки |
|
Raw |
3,78 ГБ |
|
Gorilla BareBones::Vector |
512,21 МБ |
26,93 нс |
Timestamp storage |
336,12 МБ |
26,01 нс |
Value encoders |
151,31 МБ |
32,56 нс |
Unused data unloading |
56,38 МБ |
28,50 нс |
Мы отыграли ещё 94,93 МБ оперативной памяти и ускорили кодирование точки на 4,06 нс. Последнее получилось за счёт того, что буферы энкодеров перестали расти и мы сократили количество вызовов аллокаций памяти.
Но как быть, если мы выгрузили серию, а затем её запросил кто-то из потребителей? Здесь алгоритм такой:
Считываем снимок с диска.
Восстанавливаем буфер энкодера.
Помечаем серию как используемую и больше не выгружаем.
Результаты на сегодняшний день
В нашей первой реализации с Gorilla и своим вектором для хранения 241,9 млн точек нам требовалось 512,21 МБ оперативной памяти. В текущей реализации — всего 56,38 МБ. Мы уже смогли уменьшить потребление оперативной памяти на 89 %, но считаем, что мониторинг должен быть бесплатным, поэтому продолжаем искать идеи для улучшения результатов.
Переходите на Deckhouse Prom++, чтобы сэкономить на мониторинге. Он опенсорсный и развёртывается так же, как стандартный Prometheus:
В Kubernetes — с помощью Prometheus Operator или через Helm-чарт, в зависимости от того, как был установлен ванильный Prometheus.
В классической инфраструктуре — с помощью Docker-образа или запуска бинарника.
Полезные ссылки:
Telegram-сообщество Prom++, где можно задать нам вопросы.
А глобальный вывод таков: очень важно понимать данные, с которыми ты работаешь.
P. S.
Читайте также в нашем блоге:
Комментарии (2)

ProFfeSsoRr
21.04.2026 09:28В dev кластеры поставили сразу после презентации, так и работает до сих пор, время от времени обновляю. Экономит ресурсы, спасибо :)
SilverTrouse
Классный проект, как показатель того что С++ слишком рано хоронить. Респект