Путь к миллиону точек: как я переписывал плоттер три раза, прежде чем он перестал лагать
Или: как embedded-разработчик случайно написал визуализатор временных рядов
Это моя первая статья и сразу на тему в которой я разбираюсь примерно никак. Ее можно воспринимать как условный "дневник разработчика".
Статья написана не без помощи LLM, от нее по большей части редактура. Прошу камнями не кидаться
Приятного чтения!
С чего всё началось
В миру я позиционирую себя как Embedded-разработчик, а как принято во многих местах в России разработчик встраиваемых систем - это инженер-разнорабочий. Написать firmware, развести не сложную PCB, поколдовать над ядром Linux, провести исследования датчиков с китайского завода, напаять концевиков, собрать тестовый стенд, а если еще и осталось время - по возможности спроектировать корпус для устройства и произвести его прототип.
И в этот(и так немаленький список) периодически добавляется потребность в написании ПО под Пк, для работы с разрабатываемыми устройствами/датчиками и т.д. В основном, это несложные внутренние консольные утилиты, которые помогают общаться с устройством, логгировать данные, калибровать датчики и все в таком духе.
Но иногда появляется потребность в визуализации. Пока речь идет о низкочастотных датчиках и малом количестве данных - все довольно просто, но как только данных становится больше, а частоты выше - всплывает множество нюансов. При 70 кГц через 10 секунд работы датчика у меня уже 700 000 точек. Через минуту – 4.2 миллиона. А пользователь при этом хочет масштабировать/панорамировать оси, выделять области, нажимать кнопки – и всё это должно отзываться мгновенно. Стандартный подход «передать всё в библиотеку» ломается очень быстро.
О моем пути в процессе разработки ПО для визуализации realtime измерений и о том с чем я столкнулся в процессе(как инженер с опытом в визуализации +- равным нулю) и пойдет речь в данной статье. Ниже – история трёх попыток решить эту задачу, с честным описанием того, что шло не так на каждом шаге.
Стек и окружение
Немного вводных для лучшего понимания контекста: GUI написан на C++ с использованием Dear ImGui и ImPlot поверх OpenGL(ES). Важный момент - визуализация писалась с расчетом работы с комфортным откликом и FPS как на довольно мощных платформах, так на старых ноутбуках и даже на ARM одноплатниках.
Целевой датчик: датчик расстояния RIFTEK RF603HS, имеет частоту выдачи измерений порядка 70 кГц, данные передаются UDP пакетами по 168 измерений(что в дальнейшем будет использовано в архитектуре хранения данных)
Тесты проводил на следующих платформах:
Основной рабочий ноутбук(Ryzen 7 5xxx).
Одноплатник LattePanda(Intel N5105).
Arm одноплатник(A40i GPU: Mali-400).
Попытка первая: «нарисуем всё»
Первая реализация была канонически простой. Есть vector<double> xs, ys, в него пишем все точки по мере поступления, в каждом кадре передаём в ImPlot:
ImPlot::PlotLine("sensor value", xs.data(), ys.data(), (int)xs.size());
Работает отлично. До тех пор, пока данных мало.
ImPlot без видимых проблем справляется с десятками и сотнями тысяч точек – но при подходе к миллиону производительность начинает падать. При 70 кГц миллион точек – это меньше 15 секунд работы датчика. То есть окно комфортной работы катастрофически мало.
Проблема двойная. Во-первых, ImPlot вынужден каждый кадр обходить весь массив и генерировать вершины для GPU. Во-вторых, на экране шириной 1920 px при отображении 2.1 млн точек за 30 секунд на каждый пиксель приходится более 1000 точек – GPU рисует их все, хотя пользователь видит одну линию. Это форма геометрического overdraw: тысячи вершин накрываются в одном пикселе, а результат тот же, что от одной.
Вывод: подход годится для прототипов и небольших объёмов. При 70 кГц он перестаёт справляться уже через 10-15 секунд работы датчика.
Попытка вторая: прореживание (stride decimation)
Очевидный следующий шаг – взять не все точки, а каждую N-ю. N вычисляется из соотношения количества видимых точек к ширине графика в пикселях. Математически чисто, логически понятно, реализуется за 20 минут. Я был доволен собой ровно до первого тестового сигнала с выбросами.
int stride = visible_points / (int)ImPlot::GetPlotSize().x; if (stride < 1) stride = 1; for (int i = first_visible; i < last_visible; i += stride) { xs.push_back(data[i].time); ys.push_back(data[i].value); } ImPlot::PlotLine("sensor value", xs.data(), ys.data(), (int)xs.size());
FPS вернулся. Zoom и pan работают плавно. Казалось бы, задача решена – и именно здесь начинается самое интересное.
Проблема: пики исчезают
На тестовом сигнале с периодическими острыми выбросами я заметил, что при определённых масштабах они просто пропадают с графика. Датчик честно фиксировал выброс – два-три сэмпла длиной, но на экране его не было. График улыбался мне гладкой красивой линией и врал.
Выброс длительностью 3–4 точки при шаге прореживания stride=10 пропадёт в 70–80% случаев – просто потому что «нужные» точки не попали в выборку. При 70 кГц короткое событие длительностью 100 мкс – это всего 7 точек. При stride=20 шанс его заметить ничтожен.
Для осциллографа или измерительного стенда это катастрофически плохо: пользователь смотрит на аккуратный график и не видит реального события. Попытка решить это усреднением по окну оказалась ещё хуже – среднее арифметическое математически убивает пики ещё эффективнее, чем прореживание.
Красота данных, обратно пропорциональная их честности.
Вывод: stride decimation неприемлем для измерительных сигналов с динамичными пиками. Нужен алгоритм, который гарантированно их сохраняет.
Попытка третья: min/max агрегация с фиксированным окном
Следующая идея: вместо выбора «каждой N-й точки» – выбирать минимум и максимум в каждом временном окне. Если в окне есть пик – он обязательно окажется либо как max, либо как min. Пики больше не теряются.
Этот подход известен как базовый MinMax, а его расширенная версия – алгоритм M4 – дополнительно сохраняет первую и последнюю точку каждого окна, что позволяет точнее восстановить характер нарастания/спада:
// Для каждого пиксельного бакета по X: double ymin = +INF, ymax = -INF; for (int i = bucket_start; i < bucket_end; i++) { if (data[i].value < ymin) ymin = data[i].value; if (data[i].value > ymax) ymax = data[i].value; }
Это работает надёжно. Выбросы видны. FPS хороший. Я снова был доволен собой, правда совсем недолго.
Новая проблема: zoom меняет всё
При каждом изменении масштаба нужно пересчитывать агрегаты для нового диапазона. Пока данных немного – незаметно. Но при нескольких миллионах точек (а их у нас 70 000 в секунду и буфер порядка 100млн) пересчёт агрегатов за весь видимый диапазон при каждом событии zoom/pan – это перебор всего массива данных прямо в UI-потоке. Интерфейс начинает «подвисать» именно в тот момент, когда пользователь активно с ним взаимодействует. Что несколько иронично.
Кроме того, возникает проблема долгой истории: если писать данные бесконечно, std::vector рано или поздно съест всю память. Нужно ограничивать объём хранимых данных – при этом сохраняя корректность агрегатов.
Вывод: простая MinMax с фиксированным окном – огромный шаг вперёд по сравнению со stride decimation, но при 70 кГц она упирается в производительность пересчёта слишком быстро.
Финальная архитектура: иерархическая агрегация
К финальному решению я пришёл через идею, хорошо известную в компьютерной графике: mip-map. Это был первый момент в проекте, когда мне пришлось всерьёз разбираться в том, как устроены текстуры в 3D-движках. Как embedded-разработчик я привык к тому, что «оптимизация» – это убрать сложную обработку из прерывании или или уменьшить количество обращений к медленной памяти. Оказывается, у графических программистов свои радости – и они тоже вполне логичны.
Идея простая: хранить данные сразу в нескольких уровнях детализации и выбирать нужный в зависимости от масштаба отображения. В первый раз, когда была озвучена данная идея, я отбросил ее как слишком сложную в реализации(а хотелось сделать все побыстрее и вроде уже вот-вот и готово), испробовал еще ряд подходов, комбинировал их между собой, но сдался и перешел к реализации финального алгоритма.
Структура хранения: кольцевой буфер чанков
Вместо одного большого массива точек, данные хранятся в кольцевом буфере чанков. Кольцевой буфер – структура родная и понятная любому embedded-разработчику. Здесь та же идея, только элементы буфера – не байты, а чанки с предвычисленными метаданными.
Размер чанка – 168 точек – выбран не произвольно: именно столько значений датчик RF603HS передаёт в одном UDP пакете. Это естественная граница: пришёл пакет – сформировался чанк, сразу вычислили ymin, ymax, starttime, endtime. Никакой дополнительной буферизации, никакого переупаковывания данных – протокол датчика и архитектура хранения идеально совпали.
typedef struct { dataPlotLine_t points[CHUNKSIZE]; // 168 точек = 1 пакет от датчика double ymin, ymax; // экстремумы чанка double starttime, endtime; // временной диапазон (~2.4 мс при 70 кГц) } Chunk;
При 70 кГц один чанк покрывает ~2.4 мс. Кольцевой буфер ёмкостью ~595k чанков даёт ~24 минуты истории – и автоматически вытесняет старые данные при заполнении. Старые данные уходят тихо и с достоинством.
Иерархия агрегатов
Поверх кольцевого буфера строится иерархия агрегатов – бинарное дерево по времени:
Level 0: [chunk_0] [chunk_1] [chunk_2] [chunk_3] ...
↑ 168 точек, ~2.4 мс каждый (при 70 кГц)
Level 1: [agg_0-1] [agg_2-3] ...
↑ min/max двух чанков, ~4.8 мс
Level 2: [agg_0-3] ...
↑ ~9.6 мс
...
Каждый агрегат хранит ymin, ymax, starttime, endtime. При добавлении нового чанка агрегаты поднимаются вверх по уровням инкрементально – без пересчёта всей истории:
void feed_element(dataPlott *dp, int level, double ymin, double ymax, double tstart, double tend, uint64_t firstid) { PartialAgg *pa = &dp->partial_aggs[level]; uint64_t needed = 1ull << level; if (pa->count >= needed) { AggregateChunk agg = { pa->ymin, pa->ymax, pa->starttime, tend, pa->firstchunkid }; dp->agg_deques[level].push_back(agg); pa->count = 0; feed_element(dp, level + 1, agg.ymin, agg.ymax, agg.starttime, tend, agg.firstchunkid); } }
Это классическая структура типа segment tree – только ориентированная на потоковое добавление данных в реальном времени. Когда я это понял, стало значительно спокойнее: за красивым словом «иерархическая агрегация» скрывается довольно привычная логика накопления и свёртки.
Выбор уровня при рендеринге
При отрисовке алгоритм смотрит на количество видимых чанков и ширину графика в пикселях – и выбирает уровень агрегации так, чтобы количество отрисовываемых элементов оставалось разумным:
int visible_chunks = last_ch - first_ch; double plot_width_px = ImPlot::GetPlotSize().x; int target_stride = (int)ceil((double)visible_chunks / plot_width_px); int render_level = 0; uint64_t stride = 2; while (render_level < dp->num_agg_levels && stride < (uint64_t)target_stride) { stride *= 2; render_level++; }
При небольшом zoom (видны сырые данные) – рисуем сырые точки через ImPlotPlotLine. При zoom out – переключаемся на агрегаты нужного уровня и рисуем min/max полосы. Переключение мгновенное: данные уже готовы, пересчёт не нужен.
Auto-follow и синхронизация по времени
Для режима «следования за живыми данными» ось X обновляется каждый кадр. Здесь есть неочевидная деталь: нельзя просто прибавлять DeltaTime к правой границе – джиттер в частоте поступления данных накапливается, и окно начинает «плыть» относительно реальных данных. Медленно, но верно, как часы без кварцевого резонатора.
Решение: синхронизировать окно по wall-clock времени последней полученной точки (lastrawwall = glfwGetTime()), а не просто сдвигать его кадр за кадром:
double now = glfwGetTime(); double last_target_width = dp->lastlimits.XMax - dp->lastlimits.XMin; newxmax = dp->lastlimits.XMax + ImGui::GetIO().DeltaTime * dp->calcdt; // dp->calcdt = реальный период данных, вычисленный из разницы wall-clock newxmin = newxmax - last_target_width;
Сравнение подходов: итоговая таблица
Критерий |
Наивный рендеринг |
Stride decimation |
MinMax (простая) |
Иерархическая агрегация |
FPS на 300k+ точек |
❌ Плохой |
✅ Хороший |
✅ Хороший |
✅ Отличный |
Сохранение пиков |
✅ Гарантировано |
❌ Потеря узких |
✅ Гарантировано |
✅ Гарантировано |
Инкрементальное добавление |
✅ Тривиально |
✅ Тривиально |
⚠️ Пересчёт окон |
✅ O(log N) за чанк |
Отклик на zoom/pan |
❌ Пересчёт всего |
⚠️ Пересчёт stride |
❌ Пересчёт агрегатов |
✅ Мгновенный |
Сложность реализации |
Минимальная |
Низкая |
Средняя |
Высокая |
Что в итоге
Путь от «нарисовать всё за раз» до работающей иерархической агрегации занял несколько итераций, и каждая из них была полезной – не в смысле «найти оптимальное решение», а в смысле понять, что именно ломается и почему.
Итоговая архитектура оказалась неожиданно похожей на то, как устроены временные ряды в промышленных системах мониторинга – задача диктует примерно одинаковые ограничения: инкрементальность, ограниченная память, мгновенный отклик на запросы произвольного диапазона.
Возможно весь этот процесс можно назвать изобретением велосипеда, но для меня(как для человека пишущем на преимущественно низком уровне и не разрабатывавшем сложных GUI-инструментов доселя) данная разработка оказалось весьма увлекательным занятием
Если кто то вдруг дочитал до конца – премного благодарен и буду рад фидбеку
Комментарии (10)

swame
23.06.2026 05:05Еще пращуры живущие на деревьях использовали для прореживания алгоритм swinging doors - пики не пропадут
https://habr.com/ru/articles/105652
Для хранения последовательностей в виде дельт для экономии памяти модно использовать алгоритмы VLQ или GVE c приращением

Kotofay
23.06.2026 05:05Это сжатие с потерями, не надо путать.

swame
23.06.2026 05:05Но автор додумался до MinMax тоже с потерями но хуже

Kotofay
23.06.2026 05:05Нет у него потерь, все отсчёты хранятся на дне LOD, в отличии от SwingingDoor где отсчёты с отклонениями ниже порога теряются.
А если сделать порог нулевым то начнутся те же проблемы что у ТС.

firegurafiku
23.06.2026 05:05Еще пращуры живущие на деревьях
Но автор додумался
Откуда это внезапное высокомерие по отношению к незнакомому человеку?
тоже с потерями но хуже
Почему вы решили, что в задаче автора предложенный вами алгоритм покажет себя лучше? Напомню, в статье речь не про прореживание данных ради экономии места, а про визуализацию временного ряда в реалтайме с масштабированием.

swame
23.06.2026 05:05Нет высокомерия. Показалось.
Каковы критерии "лучше"?
Я заговорил о более простом алгоритме. Не расписал его детально. Без "бинарных деревьев" и "агрегатов". Для показа графика не нужно больше точек, чем пикселей по горизонтальной оси на экране (при гладком графике меньше). Отрисовать отрезки графика намного дольше чем сделать предобработку. То есть предобработку нужно сделать до отрисовки. Не нужно гнать в графику "300 тыс" точек. Есть тут потери или нет - для качественной отрисовки swinging door достаточно. для отрисовки на полный экран - достаточно 1-2 тыс с запасом, для мини графиков как в видео - нескольких сотен. я бы хранил его структуру как буфер для быстрого построения графика. Для построения выборки по времени - можно вырезать из него часть для передачи. Если точек слишком много - можно построить следующую итерацию огрубив. Если нужно взять большую детализацию маленького промежутка времени - можно взять сырые данные из чанков, их тогда понадобится максимум штук 10 на полный экран. если эконония памяти не интересует (хотя это большой бонус) то swinging door дает уменьшение количества обрабатываемых точек без потери наглядности графика.

ostapWoodland Автор
23.06.2026 05:05По большей части в итоговой реализации так и работает: если на графике < n точек, рисуем сырые данные, если область расширили, то подбираем подходящий агрегат. Вся предобработка в агрегаты здесь происходит прямо во время приема данных от датчика. По сути просто несколько доп условий на измерение(помимо расчет реального значения из сырых данных датчика). В последствии никакая доп обработка не нужна, просто выбрать render level и область видимости

ostapWoodland Автор
23.06.2026 05:05Благодарю за материалы, по диагонали пробежался, на досуге почитаю подробнее.
Выше уже другие комментаторы отписали, но все же дополню своими мыслями: в данной задаче нет потребности сжимать данные, напротив - необходимо хранить все сырые точки.
MinMax используется исключительно для рендера, при большом количестве отрисовываемых точек. И так как при шастабировании график сжимается в линию, финальный алгоритм позволил достичь оптимальное соотношение производительности/информативности.
Возможно не самый лучший подход, но он позволил выполнить необходимую задачу
akardapolov
В хабе Визуализация данных, да еще про графики с time-series - скринкасты были бы к месту.
На худой конец - скриншоты того, что получилось.
ostapWoodland Автор
Окей, принял. Постараюсь ближе к выходным дополнить статью реальными результатами визуализации. Благодарю за фидбек