惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Cisco Talos Blog
Cisco Talos Blog
V
V2EX
C
Check Point Blog
GbyAI
GbyAI
D
Docker
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
B
Blog RSS Feed
H
Hackread – Cybersecurity News, Data Breaches, AI and More
N
Netflix TechBlog - Medium
T
Troy Hunt's Blog
博客园 - Franky
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
Microsoft Security Blog
Microsoft Security Blog
P
Privacy & Cybersecurity Law Blog
WordPress大学
WordPress大学
The Cloudflare Blog
S
SegmentFault 最新的问题
Latest news
Latest news
Microsoft Azure Blog
Microsoft Azure Blog
P
Proofpoint News Feed
I
InfoQ
博客园 - 【当耐特】
NISL@THU
NISL@THU
A
About on SuperTechFans
T
Tailwind CSS Blog
酷 壳 – CoolShell
酷 壳 – CoolShell
The Hacker News
The Hacker News
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
Scott Helme
Scott Helme
雷峰网
雷峰网
C
CXSECURITY Database RSS Feed - CXSecurity.com
Security Latest
Security Latest
V
Vulnerabilities – Threatpost
Security Archives - TechRepublic
Security Archives - TechRepublic
A
Arctic Wolf
Hacker News: Ask HN
Hacker News: Ask HN
N
News and Events Feed by Topic
IT之家
IT之家
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
aimingoo的专栏
aimingoo的专栏
T
Threat Research - Cisco Blogs
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
阮一峰的网络日志
阮一峰的网络日志
SecWiki News
SecWiki News
大猫的无限游戏
大猫的无限游戏
S
Security Affairs
The Register - Security
The Register - Security
www.infosecurity-magazine.com
www.infosecurity-magazine.com
L
LINUX DO - 热门话题
T
Tor Project blog

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
Путь к миллиону точек: как я переписывал плоттер три раза, прежде чем он перестал лагать
ostapWoodland · 2026-06-22 · via Все публикации подряд на Хабре

Путь к миллиону точек: как я переписывал плоттер три раза, прежде чем он перестал лагать

9 мин

519

Путь к миллиону точек: как я переписывал плоттер три раза, прежде чем он перестал лагать

Или: как 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 измерений(что в дальнейшем будет использовано в архитектуре хранения данных)

Тесты проводил на следующих платформах:

  1. Основной рабочий ноутбук(Ryzen 7 5xxx).

  2. Одноплатник LattePanda(Intel N5105).

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

Если кто то вдруг дочитал до конца – премного благодарен и буду рад фидбеку