"Машинное обучение"

Как Go помог мне ускорить вычисления с машинным обучением

TL; DR: (T) oo (L), что вы (D) не хотите (R) читать?

Хорошо, я получил вашу поддержку: здесь я поделюсь своей историей использования языка Go, чтобы сократить время вычислений машинного обучения моего проекта с одной недели до менее 24 часов. . Чтобы уложиться в срок, я должен был представить свои результаты в течение 3 дней.

Достаточно убедительно? : D Тогда следуйте!

Предыстория

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

Однако, как и у любого другого аспиранта, у меня была одна серьезная проблема: ВРЕМЯ!

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

  1. Откажитесь от целевой конференции и займитесь другой.
  2. Найдите способ увеличить скорость моих вычислений.

Какой был мой выбор? Сам факт, что вы это читаете, говорит сам за себя.

Основным инструментом, который я использую для разработки и реализации своих экспериментов, является PyTorch. Это очень стабильная и простая для понимания структура, позволяющая использовать мощь NVIDIA графических процессоров для задач машинного обучения. Я разработал свой метод и уже получил результаты. Однако для базовых методов я предпочел сэкономить время, используя стабильную, хорошо написанную и хорошо документированную библиотеку. Однако возникла проблема: библиотека разработала один из методов (давайте просто назовем его too_long_method) с использованием только NumPy без какой-либо реализации с ускорением на GPU. too_long_method состоит из очень большого количества итераций в длинном цикле. Таким образом, для одной точки данных это займет около 15 минут. Чтобы иметь возможность вычислить результаты для всех моих данных, я запустил код в нескольких экземплярах. Однако это был не вариант. Даже в игровой системе с 8 физическими ядрами такая конфигурация займет 7 дней.

Возможные решения?

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

  1. Перепишите код too_long_method в PyTorch. (НЕ ВАРИАНТ!: поскольку разработка и отладка потребуют гораздо больше времени и вызовут путаницу и разочарование.)
  2. Использование облачных сервисов для разделения вычислительной нагрузки на множество компьютеров. (НЕ ВАРИАНТ!: Это будет стоить мне дорого. К тому же, чтобы понять, как использовать эти определенные облачные сервисы, потребуется время.)
  3. Каким-то образом запускайте коды параллельно, используя встроенные в PyTorch возможности многопроцессорной обработки (НЕ ВАРИАНТ!: Хотя я пробовал это сначала, но вскоре понял, что это не очень поможет, по причинам, которые я объясню позже.)
  4. Запустите код в нескольких экземплярах (но в отличие от предыдущего раза, на мини-пакетах данных) (МОЙ ВЫБОР !!! Я обнаружил, что намного проще запускать несколько экземпляров программ и заставлять ОС справляться с нагрузкой. параллелизма и многопоточности от моего имени.)

Моя дорожная карта

Я решил запустить свой код в нескольких экземплярах. С этой целью я написал два фрагмента кода с двумя основными функциями:

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

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

Общую архитектуру всей системы можно изобразить следующим образом:

Я пробовал следующие сценарии, и мне не удалось добиться правильной работы с той производительностью, которую я ожидал:

  • Возможности многопроцессорной обработки в PyTorch: проблема, с которой я столкнулся, пытаясь решить проблему таким образом, заключалась в том, что мне приходилось управлять разделением точек данных между различными процессами, которые требовали разного времени для завершения. Плюс ко всему мне казалось, что увеличение количества подпроцессов не увеличит пропускную способность всей системы. Однако было очевидно, что ресурсы системы, такие как ядра процессора и оперативная память, используются не так, как я ожидал. (В любом случае, никогда не забывайте! У меня не было много времени на поиски возможных решений этой проблемы. Так что, возможно, я ошибался.)
  • Пытался написать продюсера на python. Однако я заметил, что он не может запускать внешние экземпляры программы больше, чем виртуальное количество процессоров. Я видел в этом узкое место, поскольку ресурсы ЦП все еще были доступны, несмотря на то, что код работал с максимальной нагрузкой. Я искал различные конфигурации, связанные с интерпретатором Python, однако не мог придумать никаких возможных узких мест на стороне Python.

Играя с описанными выше сценариями в течение нескольких часов безрезультатно, я утомился. Итак, я решил написать код производителя на языке программирования, отличном от python. К счастью, несколько лет назад я прочитал и узнал о Go Lang. И я знал о его встроенных возможностях для обработки параллелизма. Итак, я решил попробовать.

Некоторые концепции программирования, которые вы хотите знать:

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

Параллелизм: одновременное выполнение нескольких задач (по сути, одной и той же задачи, но для разных фрагментов данных) на разных процессорах. Главное в параллелизме состоит в том, что в каждый отдельный момент каждый из процессоров, участвующих в параллельном процессе, самостоятельно работает с отдельным фрагментом данных.

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

Они говорят, что картинка стоит тысячи слов:

Вот несколько слов и определений из языка Go:

Goroutine: встроенная в Go структура, представляющая собой легкий поток. Он обеспечивает параллелизм в программах Go.

Канал: можно рассматривать как конвейер данных, в который горутины могут отправлять данные, а также могут получать данные из него. Таким образом, позволяя горутинам работать с фрагментами данных вместе. Каналы могут иметь длину произвольного размера для обеспечения очереди объектов данных определенного типа (буферизованный канал).

Задача: это не технический термин, относящийся к какому-либо языку или контексту. Здесь под словом «задача» я имею в виду код Python, который выполняет конкретный трудоемкий метод машинного обучения с заданным фрагментом данных.

Хорошо, теперь мы увидим код?

Я опубликовал код в моей учетной записи github, чтобы вы могли видеть его напрямую.

Код начинается с функции main (). Общая структура бесконечного цикла for в основной функции - это просто шаблон проектирования состояний для предоставления пользователю набора опций меню. Самая важная часть, с которой начинается вся магия, следующая:

Давайте рассмотрим его важные части построчно:

В строке 3 создается группа ожидания, необходимая для параллелизма в Go. Вся магия происходит из строки 8! Сначала создается буферный канал для обработки задач (tasks_chan). Это можно рассматривать как очередь «первым пришел - первым обслужен» (FIFO), к которой можно получить доступ из разных горутин. Для моего приложения после нескольких запусков кода я нашел число 32 как оптимальное количество рабочих. то есть наибольшее количество рабочих процессов, которые ни один из процессов не остановил бы из-за нехватки вычислительных ресурсов, а использование памяти достигло бы максимально возможного значения без каких-либо исключений памяти ни в одном из подпроцессов.

В следующих строках функция call_workers () фактически создает пул горутин и передает им ссылку на tasks_chan. Чтобы у каждого из них был доступ к очереди задач. Фактически, каждая из этих горутин запускает функцию spawn_worker () в отдельном потоке:

Когда код достигает ключевого слова go, выполнение не останавливается. Скорее, он продолжает выполнение основного потока. Итак, цикл for продолжает работать до тех пор, пока не будут вызваны все рабочие горутины. Каждому из рабочих присваивается номер. Чтобы мы могли отслеживать статус каждого рабочего в терминале. Мы можем увидеть, что эта функция завершила выполнение, как только напечатает «Все рабочие в сети».

Давайте посмотрим на функцию spawn_worker и посмотрим, что происходит при ее вызове:

Первая строка в этой функции выполняется после полного выполнения всего тела функции. Так что давайте на мгновение забудем об этом. В строке 4 начинается бесконечный цикл for. Внутри цикла for есть специальная структура select - case. Любой из операторов case, который запускается первым, выполняется его тело. Например, если есть какие-либо объекты, доступные для взятия из канала задач, срабатывает кейс в строке 6. И еще одна функция (spawn_pytorch) вызывается для выполнения задачи (в строке 9). После завершения задачи оператор break в строке 15 завершает оператор select. Таким образом, бесконечный цикл for переходит к следующей итерации, а оператор select запускается с самого начала.

С другой стороны, после ввода оператора select - case, если прошло 10 секунд и ни один другой case не был выполнен, то запускается оператор case в строке 17. В этом случае функция возвращается. На этом этапе первая строка функции (т.е. defer wg.Done ()) уведомляет группу потоков (wg) о своем завершении.

Итак, вначале, когда call_workers начинает порождать воркеров в отдельных горутинах, все сгенерированные воркеры начинают ждать 10 секунд, пока полностью не прекратят выполнение.

Оглядываясь назад на основную функцию, как только все рабочие будут готовы после вызова call_workers, вызывается функция generate_tasks (). Нам также необходимо передать в эту функцию канал задач. Эта функция начинает заполнять буфер задач задачами до тех пор, пока буфер не заполнится. Как только буфер заполнен, он блокирует выполнение основного потока в строке 8 (или в строке 15 в конце).

Итак, пока этот поток генерирует задачи и отправляет их в канал, рабочие горутины начинают читать задачи, как только они видят любые задачи, доступные в канале. Вот как Go решает все эти сложности за вас!

Теперь, когда общая структура того, как работает эта программа, стала более ясной, давайте перейдем к более подробным деталям выполнения задачи. В строке 9 функции spawn_worker вызывается функция spawn_pytorch (). Фактически он запускает командный файл как внешнее приложение. Пакетный файл содержит команды для настройки виртуальной среды pytorch. А затем фактическое выполнение файла python, содержащего логику машинного обучения (например, too_long_method, remmeber?). Спецификации задачи: task_index и batches_count. Эти числа также определяют, какой фрагмент набора данных, каждый экземпляр too_long_method должен учитывать при вычислении.

spawn_pytorch на самом деле определяется следующим образом: (объяснение приходит позже)

В строках 5–14 определяется внешний командный объект, готовый к запуску командного файла с именем: python_job.bat.

В строках с 15 по 18 task_index и batches_count определены как аргументы объекта команды.

В строках с 19 по 24 команда действительно выполняется.

Как будто человек вызывает этот код Python с заданными аргументами в виртуальной среде PyTorch. Если в процессе происходит какая-либо ошибка, функция возвращает false. В противном случае возвращается истина.

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

Нет сомнений в том, что в программировании есть множество способов достичь определенной цели. Однако именно так я столкнулся с проблемой и смог ускорить свои вычисления, выполнение которых могло занять более 7 дней подряд, до менее 24 часов. Сила языка Go наряду с его преимуществами в последние годы вызвала большой интерес. Это был еще один повод поделиться своей историей. Пожалуйста, дайте мне знать ваше мнение об этой статье и не стесняйтесь обращаться ко мне, если вы обнаружите какие-либо проблемы в этой статье.

Использованная литература:

[1] https://github.com/k-timy/go_producer_consumer

[2] https://www.zdnet.com/article/developers-say-googles-go-is-most-sought-after-programming-language-of-2020/