Если вы программируете на Python и не используете параллелизм там, где это возможно, скорее всего, вы не сделаете столько, сколько могли бы. Используя параллелизм, мы можем сократить время, необходимое для получения ответов, давая нам больше времени, чтобы выйти на улицу, выгулять собаку и понюхать цветы. Верный своей форме, Python предоставляет простой и интуитивно понятный интерфейс для настройки параллельных рабочих процессов. Однако включать их в нашу работу может быть пугающе, поскольку неправильное использование этих инструментов может привести к неожиданным ошибкам в результатах или ошибкам, которые трудно отследить, а в некоторых случаях мы действительно можем замедлить работу наш код радикально. В этой статье мы научимся беречь наше драгоценное время, используя библиотеку Python concurrent, лучшие практики и некоторые подводные камни, которых следует избегать. Мы рассмотрим как ThreadPoolExecutor, так и ProcessPoolExecutor, а также дадим советы, как узнать, когда использовать тот или иной.

Набор данных Woodscape

В качестве примера использования мы рассмотрим изучение набора данных беспилотных транспортных средств Woodscape от Valeo, который был собран с помощью четырех камер типа рыбий глаз, фиксирующих объемный обзор трех разных транспортных средств в европейских регионах. Относительно небольшой размер и простая файловая схема этого набора данных делают его идеальным для данного исследования.

Статистика набора данных

Всякий раз, когда мы работаем с набором данных машинного обучения, всегда лучше сначала изучить его особенности с помощью статистического анализа. Это связано с тем, что все наборы данных будут разными, то есть они будут смещены в зависимости от операционной области, в которой они были собраны. Понимание этих отклонений проинформирует нас о том, где могут быть слабые места в нашем обучении модели, и даст нам представление об адаптации. мы должны внести в процедуру обучения, чтобы учесть их. Например, производительность сетей обнаружения объектов может страдать из-за проблем дисбаланса во входных данных, и в зависимости от характера этих дисбалансов можно использовать различные стратегии выборки для смягчения их негативных последствий. Аналогично, производительность сети семантической сегментации пострадает, если не принимать во внимание классовый дисбаланс в данных.

Краткое введение в параллелизм

Итак, очевидно, что нам нужно оценить статистику нашего набора данных, чтобы получить наилучшие результаты обучения. Но прежде чем настраивать этот массивный цикл for, перебирающий все обучающие данные, подумайте, должны ли итерации этого цикла обмениваться информацией или оказывать какое-либо влияние друг на друга, потому что это ключевой вопрос при развертывании параллелизма. В этом случае мы хотим перебрать каждый обучающий кадр набора данных и собрать некоторую информацию о каждом из них. Простым подходом может быть создание словаря, DataFrame pandas или какой-либо другой структуры данных и заполнение ее во время итерации, а затем вычисление статистики по конечному результату. Это, безусловно, то, что нам нужно сделать, но на самом деле нам не нужно объединять информацию из отдельных кадров, пока мы не закончим собирать ее всю, а это означает, что мы можем распараллелить анализ кадров.

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

  • Многопроцессорность действительно распараллелена. По умолчанию количество процессов равно количеству логических ядер вашего процессора, а задачи выполняются изолированно и без перерывов в соответствующих ядрах. Количество процессов можно настроить в зависимости от вашего варианта использования, например, если ваши задачи особенно интенсивны в вычислительном отношении, вы можете рассмотреть возможность установки количества процессов в соответствии с количеством физических, а не логических ядер ЦП. Для менее интенсивных задач, которые выигрывают от параллелизма, таких как ввод-вывод, вы можете установить количество процессов выше количества логических ядер, чтобы каждое ядро ​​отвечало за более чем одну задачу, между которыми оно будет циклически переключаться (переключение контекста). Однако, если вы думаете о назначении на одно ядро ​​более одной задачи, вероятно, лучше использовать Многопоточность.
  • Многопоточность на самом деле не является параллельной. Фактически, в любой момент времени выполняется только один поток, но ваш процессор быстро переключается между этими потоками, выполняя небольшую работу над каждым по мере его выполнения. Естественно задаться вопросом: как это приводит к повышению производительности? Разве все эти переключения контекста между выполнением работы не должны замедлять весь процесс? Это справедливый вопрос, потому что ответ на самом деле положительный, если единственная работа, которую выполняет ваша программа Python. Пример может помочь разобраться в этой путанице. Представьте, что вам нужно написать свое имя на 10 листах бумаги, расположенных по кругу вокруг вас. Если ваше имя состоит из 5 букв и вы используете многопоточный подход, переключаясь на следующий лист бумаги каждый раз, когда пишете одну букву, вы будете переключать контекст 50 раз, теряя время, поскольку было бы проще написать все 5 букв на каждой странице и вместо этого меняйте положение только 10 раз. Многопоточный подход в этом примере бесполезен, поскольку вы единственный, кто несет ответственность за выполнение любой работы на каждой станции. Вместо этого представьте, что вы участвуете в соревновании по поеданию суши на время, в окружении 10 поваров, каждый из которых готовит для вас в общей сложности 5 кусочков суши, но они могут готовить вас только по одному. Если вы съедите кусок суши от одного шеф-повара, это сэкономит время, чтобы съесть кусочки, готовые на других станциях, пока вы ждете, пока они приготовят следующий кусок. Теперь быстрое переключение контекста работает в ваших интересах, потому что вы не тратите время на ожидание на каждой станции, пока кто-то другой выполнит свою работу, и в процессе разблокируете других поваров. Если перенести этот пример обратно в компьютерный мир, то если мы ждем, пока один поток загрузит содержимое файла, нам лучше пойти и получить результаты от других потоков, пока мы ждем.

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

Загрузите раму и осмотрите

Давайте загрузим и визуализируем первый кадр в наборе данных, чтобы впервые взглянуть на данные. В этом уроке мы предполагаем, что набор данных Woodscape загружен локально по адресу ./woodscape/. Семантические маски хранятся в виде файлов .png с числовыми именами, соответствующими соответствующим кадрам RGB.

['00000_FV.png', '00001_FV.png', '00002_FV.png', '00003_FV.png', '00004_FV.png']
['00000_FV.png', '00001_FV.png', '00002_FV.png', '00003_FV.png', '00004_FV.png']

Наложение семантической маски для проверки загрузки данных

Чтобы убедиться, что наши данные выровнены, давайте быстро наложим RGB и семантическую маску.

Изучение статистики

Теперь, когда мы понимаем нашу схему данных, мы можем накопить некоторую статистику.

Количество пикселей класса

Самый фундаментальный вопрос, который мы можем задать в отношении данных обучения семантической сегментации, — это то, как выглядит распределение пикселей классов, то есть относительное количество пикселей (примеров) для каждого класса в наборе данных. Это покажет, как нам следует спланировать процесс обучения, чтобы получить максимальную производительность от нашей модели. Давайте выясним, есть ли у нас семантический дисбаланс классов.

Определим нашу функцию очистки

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

Тестирование функции очистки на одном кадре

{'void': 877494,
 'road': 310862,
 'lanemarks': 1489,
 'curb': 5641,
 'person': 13124,
 'rider': 0,
 'vehicles': 27608,
 'bicycle': 0,
 'motorcycle': 0,
 'traffic_sign': 262}

Делать что-то медленно

Чтобы прояснить это, нам нужно сравнить, насколько медленно компилируется эта информация с использованием цикла for для перебора всех кадров.

start time: 1692347509.286457
end time: 1692347770.425536
Gathering data took 261.1390788555145 seconds. (4.35 minutes)

Сбор этой информации с помощью цикла for занял ~260 секунд, это почти 4 с половиной минуты. Представьте, если бы наш набор данных был намного больше…

Принесите ThreadPoolExecutor

Теперь давайте выделим ThreadPoolExecutor и посмотрим, сможем ли мы это улучшить. Мы можем итеративно отправлять запросы в пул потоков, что даст нам набор фьючерсов, которые являются обещаниями результатов, которые еще не вернулись. Использование функции as_completed позволяет нам получить эти результаты, как только они станут доступны, не дожидаясь остальных, точно так же, как если бы вы съели готовый кусок суши, пока ждете, пока приготовятся остальные.

start time: 1692347770.444041
end time: 1692347826.534175
Gathering data took 56.09013390541077 seconds. (0.93 minutes)

Это значительно быстрее! Мы тратим менее 1/4 времени, необходимого для выполнения цикла for.

Выполнение того же теста с ProcessPoolExecutor

ProcessPoolExecutor использует процессы вместо потоков, поэтому количество этих процессов ограничено количеством ядер ЦП. Тем не менее, это может быть намного быстрее для очень сложных в вычислительном отношении задач. Наша задача — суммировать семантические маски, что находится где-то посередине в вычислительном масштабе, поэтому не сразу очевидно, что будет быстрее, и нам следует попробовать оба варианта.

Чтобы использовать ProcessPoolExecutor в блокноте Jupyter, нам нужно загрузить функцию из файла Python. Мы можем легко экспортировать его с помощью следующей ячейки.

start time: 1692347875.697177
end time: 1692347941.897485
Gathering data took 66.20030808448792 seconds. (1.1033384680747986 minutes)

Мы видим, что использование истинного распараллеливания с ProcessPoolExecutor привело к несколько более медленным результатам, поэтому мы можем заключить, что наша функция достаточно легковесна для использования потоков, а не процессов.

Наблюдение за распределением классов

Теперь, когда у нас есть количество пикселей, мы можем увидеть, есть ли в наших данных дисбаланс классов.

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

Классовые тепловые карты

Теперь давайте попробуем что-нибудь потяжелее и посмотрим, как сравниваются наши два варианта из пакета concurrent.futures. В этом примере мы будем накапливать тепловые карты по классам, расширяя маски полусегментов до того же количества измерений, что и классы, где каждая маска затем представляет собой двоичную маску для этого класса. Это позволит нам суммировать все эти большие массивы вместе и получить тепловые карты по классам, показывающие, где классы появляются чаще всего.

Определите нашу функцию очистки маски

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

Делать это медленно

Давайте запустим цикл for, чтобы получить базовые данные о том, сколько времени потребуется для последовательной работы.

start time: 1692348388.025281
end time: 1692349306.348945
Gathering data took 918.3236639499664 seconds. (15.31 minutes)

Ой, на этот раз наш анализ займет около 15 минут. Это не будет хорошо масштабироваться, если наш набор данных будет большим. Нам понадобится использовать некоторую форму параллелизма.

Проверка тепловой карты

Давайте посмотрим на тепловую карту для классов «человек» и «транспортные средства».

Пробую ThreadPoolExecutor

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

Cannot execute code, session has been disposed. Please try restarting the Kernel.



The Kernel crashed while executing code in the the current cell or a previous cell. Please review the code in the cell(s) to identify a possible cause of the failure. Click <a href='https://aka.ms/vscodeJupyterKernelCrash'>here</a> for more info. View Jupyter <a href='command:jupyter.viewOutput'>log</a> for further details.

Только многопоточный ввод-вывод

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

start time: 1692349727.674081
end time: 1692350332.025268
Gathering data took 604.3511869907379 seconds. (10.07 minutes)

Эта корректировка не позволила нам выйти из-под контроля, и у нас осталось всего 10 минут, чтобы обработать полный набор данных. Это улучшение на 33% по сравнению с циклом for показывает нам, что простое многопоточность ввода-вывода может значительно ускорить работу, но мы можем добиться еще большего. Давайте вернемся к потоковой обработке тяжелой функции, но на этот раз мы собираемся создать очередь для измерения наших отправок в пул потоков.

Использование отправки пула потоков в очереди

start time: 1692351512.391178
end time: 1692351843.313716
Gathering data took 330.9225380420685 seconds. (5.52 minutes)

Посмотри на это! Мы сократили время до 5 с половиной минут, это 55% времени, которое потребовалось для запуска теста только ввода-вывода, и только 33% времени для запуска исходного цикла for.

Тестирование с помощью ProcessPoolExecutor

Давайте посмотрим, что может предложить нам подход к организации очередей с использованием ProcessPoolExecutor во времени. Опять же, нам нужно написать файл Python с функцией, которую мы хотим распараллелить.

Чтобы наш процессор не зависал, давайте установим для max_workers значение на 1 меньше, чем количество процессоров. Обратите внимание: для сравнения мы используем тот же размер очереди, что и выше, но для пулов процессов может быть более эффективно использовать очереди меньшего размера. Во время тестирования этого примера я обнаружил, что разница незначительна, поэтому я оставляю ее равной 100. Не стесняйтесь экспериментировать сами!

start time: 1692351843.368718
end time: 1692352992.659966
Gathering data took 1149.2912480831146 seconds. (19.15 minutes)

О, Боже. На самом деле мы оказались в худшем положении с точки зрения времени, чем в случае с простым циклом for, с гораздо большей сложностью кода, а наш процессор гораздо сильнее перегружался во время выполнения задания. Очевидно, что многопроцессорная обработка здесь не подходит, и ThreadPoolExecutor является абсолютным победителем!

Заключение

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

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

Заключительные мысли

В этом исследовании мы увидели, что параллелизм может значительно ускорить наши рабочие процессы. Однако, если мы не будем осторожны, это также может вызвать множество проблем, включая проблемы OOM и замедления, которые мы видели здесь. Демонстрация выходит за рамки данной статьи, но пользователи должны быть очень осторожны с любыми глобальными объектами, с которыми взаимодействуют потоки или процессы при использовании параллелизма, и по возможности сохранять полную независимость активности в своих потоках. Если мы будем помнить об этих простых правилах, то сможем с уверенностью внедрить параллелизм в наших рабочих процессах и у нас останется еще больше жизни, чтобы потратить ее на поедание суши!