Предыстория

Несколько лет назад Аурига по заданию известного медицинского стартапа разрабатывала решение, связанное с параллельной обработкой нескольких потоков видеоданных. Данные имели критическое значение для успеха малоинвазивной хирургической операции и являлись единственным источником информации для хирурга. Результатом обработки каждого кадра была полоска шириной в один пиксель. Требовалось добиться следующих характеристик передачи данных: синхронная обработка параллельных потоков данных с общей частотой 30 кадров в секунду, 400 мс на прохождение кадра от драйвера устройства до дисплея врача.

Существовали следующие ограничения:

  • Поток создавался узкоспециализированным аппаратным устройством, имевшим драйвер только под Windows

  • Алгоритмы обработки кадра реализовались командой заказчика на Python версии 3.7

Концепт решения

Во время работы аналоговое устройство генерировало 16 потоков данных. Драйвер устройства писал их в необработанном виде в кольцевой буфер в неблокирующем режиме работы.

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

Вся вычислительно‑сложная математика (Гильбертово преобразование, преобразования Фурье, адаптивная фильтрация и прочие преобразования) проводились в модулях на Python с помощью библиотек numpy и scipy. Из‑за наличия GIL не приходилось рассчитывать на истинную многопоточность. Поэтому приходилось запускать отдельные экземпляры обработчика на Python в виде отдельного процесса ОС для каждого потока данных.

Итоговые сильно похудевшие данные передавались в UI на PyQt.

Первая итерация решения

Математика на тот момент обсчитывала кадр примерно за 150 мс, в оставшееся время (250мс) требовалось уложить все остальные этапы от драйвера до изображения на экране. Тесты производительности нескольких разных очередей MQ (ActiveMQ, Mosquitto, RabbitMQ, ZeroMQ) показали недостаточную стабильность и скорость передачи данных — происходила рассинхронизация независимых потоков, и по проектным требованиям приходилось полностью отбрасывать весь срез остальных кадров с других потоков, что приводило к заметным провалам в визуализации и не могло обеспечить достаточный уровень качества для медицинского изделия.

По первоначальной оценке передаваемых данных, требовалось обеспечить приём и передачу суммарного потока в 25 Мбит/с и размером кадра в 100 килобайт.

С таким потоком вполне могло справиться соединение через TCP‑сокеты, которое легко реализовать с обоих сторон потока — и на С++, и на Python. Тестовые прогоны показали приемлемую производительность и стабильность. Однако продолжавшиеся исследования в области оптической части устройства потребовали передачи и обработки большего объёма данных.

Потребовалось значительно увеличить детализацию считываемых данных, увеличив размер одного кадра со 100 килобайт до 1.1 мегабайта, что увеличило поток данных до 26 Гбит/с., а время обработки одного кадра — до 250–300 мс. В то время, как на стенде заказчика TCP сокет показывал максимальную скорость 1.7 Гбит/с на синтетических тестах.

Несоответствие на порядок располагаемых и требуемых скоростей передачи данных приводило к мгновенному переполнению буферов TCP и последующей каскадной потере пакетов. Требовалось найти новое решение.

Вторая итерация

Следующим кандидатом на среду для передачи были именованные или анонимные каналы. Синтетический тест на стенде показал скорость порядка 21.6 Гбит/с, что вплотную приблизилось к требованиям. Однако уже при старте реализации возникли технические сложности.

Проблемы именованных каналов для передачи большого потока данных

Скорость получения данных превышала скорость передачи, что привело к неконтролируемому росту буферов анонимных каналов, которые были ограничены только доступной памятью. На суточных стресс‑тестах сначала заканчивалась оперативная память, система начинала активно использовать своп до тех пор, пока не заканчивался и своп, что приводило к зависанию стенда.

В процессе работы по этой же причине время прохождения пакета данных по каналу было непредсказуемым — от 50 до 150 мс, что опять же приводило к рассинхронизации каналов и потере данных.

Процессы, считавшие математику, сильно грузили процессор Intel Xeon Gold 40 ядер. Операционной системой они распределялись по разным ядрам таким образом, что каждому обработчику кадра доставалось отдельное ядро, и несколько оставшихся ядер отдавались на нужды операционной системы.

На фоне всего вышесказанного появилась ещё одна, ранее не встречавшаяся проблема: минут через 10–15 после запуска на всех занятых ядрах нагрузка резко взлетала с ~80% до 100%, а время обработки одного кадра при этом увеличивалось в два раза с приемлемых 300 мс до 600–700 мс.

Для изучения этой проблемы использовались инструменты Intel vTune и Windows Performance Toolkit с WPR (Windows Performance Recorder) и WPA (Windows Performance Analyzer).

Анализ снятого event trace log показал резкое увеличение времени выполнения системных вызовов KeZeroPages, что помогло понять, что происходит. Большое спасибо статье Hidden Costs of Memory Allocation

При освобождении региона памяти, ранее выданного процессу, операционная система заполняет его нолями в целях обеспечения безопасности.

Делается это в низкоприоритетном системном процессе, который запущен, по наблюдениям, на самом последнем ядре и тихо делает своё дело — но только до тех пор, пока он успевает занулить всю освобождаемую память.

Как только он перестаёт с этим справляться — задача зануления памяти начинает выполняться в контексте использовавшего его процесса.

Итого у нас имелось:

  • большой объём кадра

  • буферизация потока в памяти

  • множество вызовов NumPy с созданием временных переменных, содержащих в себе обрабатываемый пакет данных

  • сильная нагрузка на ядра

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

Рефакторинг работы с NumPy заметно снизил количество производимой «грязной» памяти, но полностью проблему не решил. Требовалась быстрая и экономная передача данных на стыке C++ и Python, где объём передаваемых данных был максимальным. Альтернатива в виде портирования математики из Python в C++ значительно не укладывалась ни во временные ограничения, ни в бюджет проекта.

Требования к передаче данных

Реализация собственного IPC протокола — задача хоть и не часто встречающаяся, но вполне известная. Немного экзотики добавляли только разные языки программирования. По результатам исследований пришли к следующим требованиям:

  • Использование кольцевого буфера в shared memory

  • Фиксированный размер сообщения, задаваемый при старте приложения.

  • Эксклюзивный издатель / эксклюзивный подписчик

  • Неблокируемая запись, отбрасывание сообщений при переполнении буфера

  • Блокирующее чтение

  • Никаких требований к безопасности: работа в изолированной системе.

Кастомный IPC

В реализации собственного IPC использовался пакет PyWin32 для работы с семафорами через win32api, что позволило получить доступ к одному и тому же семафору из независимых приложений. Для координации доступа к одному буферу, расположенному в shared memory, использовалось два семафора: один на запись и один на чтение.

Тестовые прогоны на стенде показали, что буфера длиной в 5 элементов достаточно для сглаживания джиттера передаваемых данных. В редких случаях отсутствия свободного места в буфере, отправляемые данные отбрасывались без блокирования очереди записи.

Примитивность, как протокола, так и передаваемых данных позволила достичь средней скорости передачи одного кадра за 5 мс. Синтетический тест показал нам максимальную пропускную способность порядка 84.7 Гбит/с, с большим запасом перекрывая требования по объёму передаваемых данных и времени доставки.

Выводы

Пройдя путь от использования типовых рекомендуемых протоколов передачи данных для межпроцессного взаимодействия, решение эволюционировало до разработки собственного протокола. Прототипирование и написание собственных синтетических тестов позволило избежать множественных итераций в разработке и неприятных сюрпризов на тестах в составе целевого устройства. Стоит также отметить, что особенности каждой ОС оказывают критическое влияние на итоговую производительность на высоконагруженных задачах. Последующий перенос решения на Linux‑подобную ОС прошел уже без сюрпризов. Также не дали заметного результата попытки изменения конфигурации процессора в BIOS и ручное управление назначением ядер процессам. Итогом можно сказать, что для специфических задач требуются специфические решения.

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


  1. fk0
    00.00.0000 00:00
    +3

    Обработка в реальном времени, видео. Питон. У меня когнитивный диссонанс. Мне кажется, что-то здесь лишнее:

    Вся вычислительно-сложная математика (Гильбертово преобразование, преобразования Фурье, адаптивная фильтрация и прочие преобразования) проводились в модулях на Python с помощью библиотек numpy и scipy. Из-за наличия GIL не приходилось рассчитывать на истинную многопоточность...

    Кажется слова автора это подтверждают. Есть какой-то миф, что мол любая математика делается на питоне. Погодите. Лет 15 назад это ещё был фортран. Который прекрасно вызывается из C/C++. И самое смешное, что в питоне под капотом тот же C/C++/Fortran.

    ActiveMQ, Mosquitto, RabbitMQ, ZeroMQ...

    А оно всё зачем нужно в пределах одной вычислительной машины? Там разделяемой памяти достаточно. И любого механизма побудки второго процесса. Семафора, сокета, пайпа. Что угодно. Вплоть до сигналов. В виднах есть специальный объект "событие" (event).

    Использование кольцевого буфера в shared memory

    Это сразу было очевидным решением. Всё остальное, особенно ZeroMQ и т.п. -- какая-то странность.


    1. reatfly
      00.00.0000 00:00
      -1

      Ну можно еще попробовать кастомный user-level tcp stack какой-нибудь :) И общение через shared memory.


    1. s_platov Автор
      00.00.0000 00:00
      +3

      Вот смотрите: у заказчика есть математик, который знает Питон и не знает C++. Математик пишет алгоритм, который решает задачу, но делает это медленно и не оптимально с точки зрения программиста. Заказчик обращается к нам и ставит задачу: сделать, чтобы быстро. И при этом он не готов и не хочет платить за портирование всей математики в плюсы, потому что математик вот он, у них, и дальнейшие правки без нас ему делать придётся.

      Пробы различных MQ были направлены на сокращение времени разработки. Если есть существующее и отлаженное решение без фатальных недостатков - то зачем изобретать свой велосипед?


      1. Tzimie
        00.00.0000 00:00

        Портировать надо в Julia


  1. fk0
    00.00.0000 00:00
    +1

    Непонятно зачем у вас два семафора. Я ж говорю, достаточно двух атомарных указателей (индексов, ибо адресные пространства разные) чтения и записи и механизма побудки читателя (заснувшего на пустом буфере). Зачем второй семафор для писателя? Он всегда приходит, находит следующий буфер (либо уходит ни с чем), пишет, двигает указатель записи (и потенциально будит читателя). Читатель просто читает (либо спит, если указатель чтения начинает обгонять указатель записи), двигает указатель чтения.


    1. s_platov Автор
      00.00.0000 00:00
      +1

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


  1. AndrewSu
    00.00.0000 00:00

    А почему бы не вызывать питоновский код напрямую из c++, например, через pybind11? Вообще никакого IPC не будет.


    1. s_platov Автор
      00.00.0000 00:00

      Специфика разработки для медицины очень сильно усложняет и затягивает процесс интеграции стороннего софта. Заказчик посчитал переход на стоковый питон 3.8 нецелесообразным, а тут целая опенсорсная либа.


      1. AndrewSu
        00.00.0000 00:00
        +1

        Библиотека pybind11 это как пример, можно и без неё вызов python кода из c++ сделать, напрямую используя python.lib, просто, более муторно.

        З.Ы. Даже самому интереснее, насколько это быстрее IPC.
        З.З.Ы. Не знаю requirements.txt для проекта у вас, но очень вероятно, что в нём есть что-то зависящее от pybind11, т.к. этот способ связывания python <-> c++ очень распространён сейчас.


        1. s_platov Автор
          00.00.0000 00:00

          У нас получились два абсолютно независимых друг от друга приложения, которые знать ничего не знали друг о друге, да и разработка велась двумя группами программистов - плюсовики пилили одно приложение, питонисты - другое. Единственная логическая связь была посредством обсуждаемого IPC. То есть, писатель пишет в буфер, а кто именно оттуда будет читать - для него не имеет значения. Симметрично и читатель - он читает из буфера, и всё равно, кто туда пишет.

          Спасибо за подсказку про pybind11, возьму на карандаш.


  1. ol2000
    00.00.0000 00:00

    Что то подобное я делал давно для обработки row GPS данных с микросхемы радиоприемника под Windows Mobile на телефоне. Изначально использовался стандартный системный DMA драйвер для передачи. Но при этом терялись данные. Немного, пара бит, но этого было достаточно для рассинхронизации фреймов.

    Тогда просто напрямую инициализировал DMA контроллер с тем чтобы данные поступали в кольцевой буфер из последовательного порта через scatter/gather механизм.