.. _sec_parameterserver: Серверы параметров ================== По мере перехода от одного GPU к нескольким GPU, а затем к нескольким серверам, содержащим множество GPU, которые могут быть распределены по разным стойкам и сетевым коммутаторам, наши алгоритмы распределённого и параллельного обучения должны становиться значительно более сложными. Детали имеют значение, поскольку различные типы соединений обладают совершенно разной пропускной способностью (например, NVLink может обеспечивать до 100 ГБ/с через 6 линий в подходящих условиях, PCIe 4.0 (16-линейный) — 32 ГБ/с, а даже высокоскоростная сеть Ethernet 100 Гбит/с даёт лишь 10 ГБ/с). В то же время было бы неразумно ожидать, что специалист по статистическому моделированию станет экспертом в области сетей и систем. Основная идея параметр-сервера была предложена в :cite:t:`Smola.Narayanamurthy.2010` в контексте распределенных моделей со скрытыми переменными. Затем в :cite:t:`Ahmed.Aly.Gonzalez.ea.2012` были описаны семантики push и pull, а в :cite:t:`Li.Andersen.Park.ea.2014` — описание системы и открытая библиотека. В дальнейшем мы рассмотрим компоненты, необходимые для обеспечения эффективности. Обучение с параллелизацией данных --------------------------------- Рассмотрим подход к распределённому обучению с использованием параллелизма данных. В этом разделе мы будем использовать именно его, так как на практике он значительно проще в реализации. В настоящее время, когда видеокарты обладают достаточным объёмом памяти, практически не существует случаев (за исключением глубокого обучения на графах), в которых предпочтительнее использовать другие стратегии параллелизма. :numref:`fig_parameterserver` описывает вариант параллелизма данных, который мы реализовали в :numref:`sec_multi_gpu`. Ключевой особенностью является то, что агрегация градиентов происходит на одной видеокарте (GPU 0), после чего обновлённые параметры повторно рассылаются всем видеокартам. .. _fig_parameterserver: .. figure:: ../img/ps.svg Слева: обучение на одном GPU. Справа: вариант обучения на нескольких GPU: (1) вычисляются функция потерь и градиент, (2) все градиенты агрегируются на одном GPU, (3) происходит обновление параметров, и параметры повторно распределяются между всеми GPU. Если посмотреть на это задним числом, решение агрегировать на GPU 0 кажется довольно произвольным. В конце концов, мы могли бы так же легко агрегировать на CPU. Более того, мы можем даже решить агрегировать часть параметров на одном GPU, а остальные — на другом. При условии, что алгоритм оптимизации поддерживает такой подход, нет никаких реальных причин, почему мы не могли бы этого сделать. Например, если у нас есть четыре вектора параметров с соответствующими градиентами :math:`\mathbf{g}_1, \ldots, \mathbf{g}_4`, мы можем агрегировать градиенты на одном GPU для каждого :math:`\mathbf{g}_i` (:math:`i = 1, \ldots, 4`). Это рассуждение кажется произвольным и надуманным. В конце концов, математика везде одинакова. Однако мы имеем дело с реальным физическим оборудованием, где разные шины обладают разной пропускной способностью, как обсуждалось в :numref:`sec_hardware`. Рассмотрим реальный сервер с четырьмя GPU, описанный в :numref:`fig_bw_hierarchy`. Если он оснащен особенно мощной сетевой инфраструктурой, он может иметь сетевую карту 100 GbE. Более типичные значения находятся в диапазоне 1–10 Гбит/с с эффективной пропускной способностью от 100 МБ/с до 1 ГБ/с. Поскольку у процессоров недостаточно линий PCIe для прямого подключения ко всем GPU (например, потребительские процессоры Intel имеют 24 линии), нам нужен `мультиплексор `__. Пропускная способность от CPU по интерфейсу 16x Gen3 составляет 16 ГБ/с. Именно с такой скоростью *каждый* из GPU подключен к коммутатору. Это означает, что эффективнее осуществлять обмен данными между устройствами. .. _fig_bw_hierarchy: .. figure:: ../img/bw-hierarchy.svg Сервер с четырьмя GPU. Для простоты предположим, что размер градиентов составляет 160 МБ. В этом случае передача градиентов со всех трёх оставшихся GPU на четвёртый занимает 30 мс (каждая передача длится 10 мс = 160 МБ / 16 ГБ/с). Добавив ещё 30 мс на обратную передачу весов, мы получаем общую задержку в 60 мс. Если же отправлять все данные на CPU, мы понесём дополнительные 40 мс, поскольку *каждый* из четырёх GPU должен отправить данные на CPU, что в сумме даёт 80 мс. Наконец, предположим, что мы можем разделить градиенты на 4 части по 40 МБ каждая. Теперь мы можем агрегировать каждую из этих частей на отдельном GPU *одновременно*, поскольку PCIe-коммутатор обеспечивает полную пропускную способность между всеми линиями. Вместо 30 мс это занимает 7,5 мс, что даёт общую задержку в 15 мс для операции синхронизации. Таким образом, в зависимости от того, как мы синхронизируем параметры, одна и та же операция может занимать от 15 до 80 мс. :numref:`fig_ps_distributed` изображает различные стратегии обмена параметрами. .. _fig_ps_distributed: .. figure:: ../img/ps-distributed.svg Стратегии синхронизации параметров. Обратите внимание, что у нас есть ещё один инструмент для повышения производительности: в глубокой сети вычисление всех градиентов от верхнего уровня к нижнему занимает определённое время. Мы можем начать синхронизацию градиентов для некоторых групп параметров ещё до того, как завершим их вычисление для других. Подробности о том, как это реализовать в `Horovod `__, см. например, в :cite:t:`Sergeev.Del-Balso.2018`. Синхронизация по кольцу ----------------------- При синхронизации на современном оборудовании для глубокого обучения мы часто сталкиваемся со значительно специализированной сетевой топологией. Например, объекты AWS p3.16xlarge и NVIDIA DGX-2 имеют структуру соединений, показанную на рисунке :numref:`fig_nvlink`. Каждый графический процессор (GPU) подключен к центральному процессору (CPU) через интерфейс PCIe, который обеспечивает пропускную способность не более 16 ГБ/с. Кроме того, каждый GPU имеет 6 соединений NVLink, каждое из которых способно передавать данные со скоростью 300 Гбит/с в двунаправленном режиме. Это составляет около 18 ГБ/с на одно соединение в каждом направлении. Короче говоря, совокупная пропускная способность NVLink значительно выше, чем пропускная способность PCIe. Вопрос заключается в том, как использовать её наиболее эффективно. .. _fig_nvlink: .. figure:: ../img/nvlink.svg Связь NVLink на серверах с 8 GPU V100 (изображение предоставлено NVIDIA). Оказывается, оптимальная стратегия синхронизации заключается в разбиении сети на два кольца и их использовании для прямой синхронизации данных :cite:`Wang.Li.Liberty.ea.2018`. :numref:`fig_nvlink_twoloop` показывает, что сеть можно разбить на одно кольцо (1-2-3-4-5-6-7-8-1) с удвоенной пропускной способностью NVLink и на одно (1-4-6-3-5-8-2-7-1) с обычной пропускной способностью. Разработка эффективного протокола синхронизации в этом случае нетривиальна. .. _fig_nvlink_twoloop: .. figure:: ../img/nvlink-twoloop.svg Декомпозиция сети NVLink на два кольца. Рассмотрим следующий мысленный эксперимент: имея кольцо из :math:`n` вычислительных узлов (или GPU), мы можем передавать градиенты от первого узла ко второму. Там они суммируются с локальным градиентом и передаются дальше, к третьему узлу, и так далее. После :math:`n-1` шагов итоговый градиент оказывается в последнем посещённом узле. То есть время агрегации градиентов растёт линейно с увеличением числа узлов. Однако такой подход крайне неэффективен. Ведь в любой момент времени связь поддерживает только один узел. Что, если разбить градиенты на :math:`n` частей и начать синхронизацию части :math:`i`, начиная с узла :math:`i`? Поскольку размер каждой части составляет :math:`1/n`, общее время теперь равно :math:`(n-1)/n \approx 1`. Иными словами, время, затрачиваемое на агрегацию градиентов, *не растёт* при увеличении размера кольца. Это весьма поразительный результат. :numref:`fig_ringsync` иллюстрирует последовательность шагов на :math:`n=4` узлах. .. _fig_ringsync: .. figure:: ../img/ringsync.svg Кольцевая синхронизация между 4 узлами. Каждый узел начинает передавать части градиентов своему левому соседу, пока собранный градиент не окажется у его правого соседа. Если мы используем тот же пример синхронизации 160 МБ между 8 GPU V100, то получаем приблизительно :math:`2 \cdot 160 \textrm{MB} / (3 \cdot 18 \textrm{GB/s}) \approx 6 \textrm{ms}`. Это лучше, чем использование шины PCIe, даже несмотря на то, что мы задействуем 8 GPU. Обратите внимание, что на практике эти показатели немного хуже, поскольку фреймворки глубокого обучения часто не способны объединять коммуникации в крупные пакетные передачи. Обратите внимание, что существует распространённое заблуждение, будто кольцевая синхронизация принципиально отличается от других алгоритмов синхронизации. Единственное отличие заключается в том, что путь синхронизации несколько сложнее по сравнению с простой древовидной структурой. Обучение на нескольких машинах ------------------------------ Распределённое обучение на нескольких машинах добавляет ещё одну сложность: нам необходимо взаимодействовать с серверами, соединёнными через сеть с относительно низкой пропускной способностью, которая в некоторых случаях может быть на порядок медленнее. Синхронизация между устройствами — задача нетривиальная. Ведь разные машины, выполняющие код обучения, будут работать с незначительно различающейся скоростью. Следовательно, нам необходимо *синхронизировать* их, если мы хотим использовать синхронную распределённую оптимизацию. :numref:`fig_ps_multimachine` иллюстрирует, как происходит распределённое параллельное обучение. 1. На каждой машине считывается (другой) батч данных, который распределяется между несколькими GPU и передаётся в их память. Предсказания и градиенты вычисляются для каждого батча на GPU отдельно. 2. Градиенты со всех локальных GPU агрегируются на одном GPU (или их части распределяются между несколькими GPU). 3. Градиенты отправляются на ЦП. 4. ЦП отправляют градиенты центральному серверу параметров, который агрегирует все градиенты. 5. Суммированные градиенты затем используются для обновления параметров, а обновленные параметры рассылаются обратно на отдельные процессоры. 6. Эта информация отправляется на одну (или несколько) GPU. 7. Обновленные параметры распределяются между всеми GPU. .. _fig_ps_multimachine: .. figure:: ../img/ps-multimachine.svg Многомашинное распределённое параллельное обучение с использованием нескольких GPU. Каждая из этих операций кажется довольно простой. И действительно, их можно эффективно выполнять *внутри* одной машины. Однако, когда мы переходим к нескольким машинам, становится очевидным, что центральный сервер параметров становится узким местом. В конце концов, пропускная способность каждого сервера ограничена, поэтому для :math:`m` рабочих время отправки всех градиентов на сервер составляет :math:`\mathcal{O}(m)`. Мы можем преодолеть это ограничение, увеличив количество серверов до :math:`n`. В этом случае каждый сервер должен хранить только :math:`\mathcal{O}(1/n)` параметров, поэтому общее время обновлений и оптимизации сокращается до :math:`\mathcal{O}(m/n)`. Согласование этих чисел обеспечивает постоянное масштабирование независимо от количества рабочих. На практике мы используем *одни и те же* машины как для рабочих, так и для серверов. :numref:`fig_ps_multips` иллюстрирует эту архитектуру (подробности см. в :cite:`Li.Andersen.Park.ea.2014`). В частности, обеспечение работы нескольких машин без неоправданных задержек является нетривиальной задачей. .. _fig_ps_multips: .. figure:: ../img/ps-multips.svg Вверху: один сервер параметров является узким местом, поскольку его пропускная способность ограничена. Внизу: несколько серверов параметров хранят части параметров, обеспечивая совокупную пропускную способность. Хранилища ключ-значение ----------------------- Реализация шагов, необходимых для распределённого обучения на нескольких GPU, на практике нетривиальна. Именно поэтому целесообразно использовать общую абстракцию, а именно *хранилище ключ–значение* с переопределённой семантикой обновления. На многих рабочих узлах и многих GPU вычисление градиента :math:`i` может быть определено как .. math:: \mathbf{g}_{i} = \sum_{k \in \textrm{workers}} \sum_{j \in \textrm{GPUs}} \mathbf{g}_{ijk}, где :math:`\mathbf{g}_{ijk}` — часть градиента :math:`i`, распределённого по GPU :math:`j` рабочего :math:`k`. Ключевой особенностью этой операции является то, что она представляет собой *коммутативное свёртывание* (commutative reduction), то есть преобразует множество векторов в один, и порядок применения операции не имеет значения. Это отлично подходит для наших целей, поскольку нам не требуется (и не нужно) тонко контролировать, когда именно будет получен каждый градиент. Кроме того, отметим, что эта операция независима для различных :math:`i`. Это позволяет нам определить две следующие операции: *push* (push), которая аккумулирует градиенты, и *pull* (pull), которая извлекает агрегированные градиенты. Поскольку у нас есть множество различных наборов градиентов (в конце концов, у нас много слоёв), нам нужно индексировать градиенты с помощью ключа :math:`i`. Это сходство с хранилищами ключ-значение, такими как то, которое было представлено в Dynamo :cite:`DeCandia.Hastorun.Jampani.ea.2007`, не случайно. Они также обладают многими схожими характеристиками, особенно в аспекте распределения параметров между несколькими серверами. Операции push и pull для хранилищ ключ-значение описываются следующим образом: - **push(key, value)** отправляет конкретный градиент (значение) от рабочего узла в общее хранилище, где значение агрегируется, например, путем суммирования. - **pull(key, value)** извлекает агрегированное значение из общего хранилища, например, после объединения градиентов от всех рабочих. Скрывая всю сложность синхронизации за простыми операциями push и pull, мы можем разделить ответственность между статистиками, которым важно выражать оптимизацию простыми терминами, и системными инженерами, которым приходится иметь дело со сложностью, присущей распределённой синхронизации. Резюме ------ - Синхронизация должна быть высокоадаптивной к конкретной сетевой инфраструктуре и условиям подключения внутри сервера. Это может существенно влиять на время, необходимое для синхронизации. - Кольцевая синхронизация может быть оптимальной для серверов p3 и DGX-2. Для остальных — возможно, нет. - Иерархическая стратегия синхронизации хорошо работает при добавлении нескольких серверов параметров для увеличения пропускной способности. Упражнения ---------- 1. Можно ли ещё больше увеличить кольцевую синхронизацию? Подсказка: сообщения можно отправлять в обоих направлениях. 2. Возможно ли организовать асинхронную коммуникацию (пока вычисления ещё продолжаются)? Как это влияет на производительность? 3. Что делать, если во время длительных вычислений откажет сервер? Как можно разработать механизм *отказоустойчивости*, чтобы избежать полного перезапуска вычислений? `Обсуждения `__