.. _sec_multi_gpu: Обучение на нескольких GPU ========================== До сих пор мы обсуждали, как эффективно обучать модели на процессорах и графических ускорителях. Мы даже показали, как фреймворки глубокого обучения позволяют автоматически распараллеливать вычисления и обмен данными между ними в :numref:`sec_auto_para`. Мы также продемонстрировали в :numref:`sec_use_gpu`, как вывести список всех доступных графических ускорителей на компьютере с помощью команды ``nvidia-smi``. Однако мы *не* рассматривали, как именно распараллелить обучение в глубоком обучении. Вместо этого мы лишь вскользь упоминали, что данные можно как-то распределить между несколькими устройствами и заставить их работать. В данном разделе мы заполним эти пробелы и покажем, как обучать нейронную сеть параллельно, начиная с нуля. Вопросы о том, как использовать возможности высокоуровневых API, отложены до :numref:`sec_multi_gpu_concise`. Мы предполагаем, что вы знакомы с алгоритмами стохастического градиентного спуска на мини-батчах, такими как описанные в :numref:`sec_minibatch_sgd`. Разделение задачи ----------------- Рассмотрим простую задачу компьютерного зрения и несколько устаревшую архитектуру сети, например, состоящую из нескольких слоёв свёртки и пулинга, а в конце — возможно, из нескольких полносвязных слоёв. То есть начнём с сети, которая выглядит довольно похоже на LeNet :cite:`LeCun.Bottou.Bengio.ea.1998` или AlexNet :cite:`Krizhevsky.Sutskever.Hinton.2012`. Имея несколько графических процессоров (2 на настольном сервере, 4 на экземпляре AWS g4dn.12xlarge, 8 на p3.16xlarge или 16 на p2.16xlarge), мы хотим разделить обучение таким образом, чтобы обеспечить значительное ускорение, одновременно опираясь на простые и воспроизводимые решения. Ведь несколько графических процессоров увеличивают как *память*, так и *вычислительные* возможности. Короче говоря, у нас есть следующие варианты, учитывая мини-батч обучающих данных, который мы хотим классифицировать. Во-первых, мы можем разделить сеть между несколькими GPU. То есть каждый GPU принимает в качестве входных данные, поступающие на определённый слой, обрабатывает их через ряд последующих слоёв, а затем передаёт данные следующему GPU. Это позволяет обрабатывать данные с помощью более крупных сетей по сравнению с тем, что может обработать один GPU. Кроме того, объём потребляемой памяти на каждый GPU можно хорошо контролировать (он составляет лишь часть от общего объёма памяти, требуемого для всей сети). Однако интерфейс между слоями (а значит, и между GPU) требует строгой синхронизации. Это может быть нетривиальной задачей, особенно если вычислительная нагрузка на слоях не сбалансирована должным образом. Проблема усугубляется при использовании большого количества GPU. Интерфейс между слоями также требует передачи больших объёмов данных, таких как активации и градиенты. Это может превысить пропускную способность шин GPU. Кроме того, вычислительно емкие, но последовательные операции сложно разделить. См., например, :cite:t:`Mirhoseini.Pham.Le.ea.2017`, где предпринята попытка решить эту задачу. Это остается сложной проблемой, и неясно, возможно ли достичь хорошего (линейного) масштабирования на нетривиальных задачах. Мы не рекомендуем этот подход, если только фреймворк или операционная система не обеспечивают отличной поддержки объединения нескольких GPU в цепочку. Во-вторых, мы можем разделить работу по слоям. Например, вместо вычисления 64 каналов на одном GPU мы можем распределить задачу между четырьмя GPU, каждый из которых генерирует данные для 16 каналов. Аналогично, для полносвязного слоя мы можем разделить количество выходных единиц. :numref:`fig_alexnet_original` (взято из :cite:t:`Krizhevsky.Sutskever.Hinton.2012`) иллюстрирует этот подход, который был использован для работы с GPU, имевшими очень небольшой объём памяти (2 ГБ на то время). Это обеспечивает хорошее масштабирование вычислений при условии, что количество каналов (или единиц) не слишком мало. Кроме того, несколько GPU могут обрабатывать всё более крупные сети, поскольку доступная память масштабируется линейно. .. _fig_alexnet_original: .. figure:: ../img/alexnet-original.svg Параллелизм моделей в оригинальной архитектуре AlexNet был обусловлен ограниченной памятью GPU. Однако требуется *очень большое* количество операций синхронизации или барьеров, поскольку каждый слой зависит от результатов всех остальных слоёв. Кроме того, объём передаваемых данных потенциально может быть ещё больше, чем при распределении слоёв по GPU. Поэтому мы не рекомендуем этот подход из-за его высокой пропускной нагрузки и сложности. Наконец, мы можем разделить данные между несколькими GPU. Таким образом, все GPU выполняют одну и ту же работу, хотя и над разными наблюдениями. Градиенты агрегируются между GPU после каждого мини-батча обучающих данных. Это самый простой подход, который можно применять в любой ситуации. Нам нужно синхронизироваться только после каждого мини-батча. Тем не менее крайне желательно начинать обмен параметрами градиентов, пока другие ещё вычисляются. Кроме того, большее количество GPU позволяет использовать большие размеры мини-батчей, что повышает эффективность обучения. Однако добавление большего числа GPU не позволяет нам обучать более крупные модели. .. _fig_splitting: .. figure:: ../img/splitting.svg Параллелизм на нескольких GPU. Слева направо: исходная задача, разбиение сети, послойное разбиение, параллелизм данных. На рисунке :numref:`fig_splitting` показано сравнение различных способов распараллеливания на нескольких GPU. В целом, параллелизм данных является наиболее удобным подходом при условии наличия GPU с достаточным объёмом памяти. Подробное описание разбиения для распределённого обучения см. в :cite:`Li.Andersen.Park.ea.2014`. В начале развития глубокого обучения память GPU была проблемой. Однако сейчас эта проблема решена во всех случаях, кроме самых необычных. В дальнейшем мы сосредоточимся на параллелизме данных. Параллелизм данных ------------------ Предположим, что на машине имеется :math:`k` GPU. Для каждой модели, подлежащей обучению, каждый GPU будет поддерживать полный набор параметров модели независимо, хотя значения параметров на всех GPU будут идентичными и синхронизированными. В качестве примера :numref:`fig_data_parallel` демонстрирует обучение с использованием параллелизма данных при :math:`k=2`. .. _fig_data_parallel: .. figure:: ../img/data-parallel.svg Вычисление стохастического градиентного спуска для мини-батча с использованием параллелизма данных на двух GPU. В общем случае обучение выполняется следующим образом: - На любой итерации обучения, при заданном случайном мини-батче, мы разбиваем примеры в батче на :math:`k` частей и распределяем их поровну между GPU. - Каждая видеокарта вычисляет функцию потерь и градиент параметров модели на основе полученного ей подмножества мини-батча. - Локальные градиенты каждого из :math:`k` GPU агрегируются для получения текущего стохастического градиента мини-батча. - Агрегированный градиент перераспределяется между всеми GPU. - Каждая видеокарта использует этот стохастический градиент мини-батча для обновления полного набора параметров модели, которые она хранит. Обратите внимание, что на практике при обучении на :math:`k` GPU мы *увеличиваем* размер мини-батча в :math:`k` раз, чтобы каждый GPU выполнял тот же объём работы, что и при обучении на одном GPU. На сервере с 16 GPU это может существенно увеличить размер мини-батча, и нам, возможно, придётся соответствующим образом увеличить скорость обучения. Также отметим, что батч-нормализация в :numref:`sec_batch_norm` требует корректировки, например, путём использования отдельных коэффициентов батч-нормализации для каждого GPU. В дальнейшем мы будем использовать тестовую сеть для иллюстрации обучения на нескольких GPU. .. raw:: latex \diilbookstyleinputcell .. code:: python %matplotlib inline import torch from torch import nn from torch.nn import functional as F from d2l import torch as d2l Пример сети ----------- В качестве примера мы используем сеть LeNet, описанную в :numref:`sec_lenet` (с небольшими изменениями). Мы реализуем её с нуля, чтобы подробно проиллюстрировать обмен параметрами и их синхронизацию. .. raw:: latex \diilbookstyleinputcell .. code:: python # Initialize model parameters scale = 0.01 W1 = torch.randn(size=(20, 1, 3, 3)) * scale b1 = torch.zeros(20) W2 = torch.randn(size=(50, 20, 5, 5)) * scale b2 = torch.zeros(50) W3 = torch.randn(size=(800, 128)) * scale b3 = torch.zeros(128) W4 = torch.randn(size=(128, 10)) * scale b4 = torch.zeros(10) params = [W1, b1, W2, b2, W3, b3, W4, b4] # Define the model def lenet(X, params): h1_conv = F.conv2d(input=X, weight=params[0], bias=params[1]) h1_activation = F.relu(h1_conv) h1 = F.avg_pool2d(input=h1_activation, kernel_size=(2, 2), stride=(2, 2)) h2_conv = F.conv2d(input=h1, weight=params[2], bias=params[3]) h2_activation = F.relu(h2_conv) h2 = F.avg_pool2d(input=h2_activation, kernel_size=(2, 2), stride=(2, 2)) h2 = h2.reshape(h2.shape[0], -1) h3_linear = torch.mm(h2, params[4]) + params[5] h3 = F.relu(h3_linear) y_hat = torch.mm(h3, params[6]) + params[7] return y_hat # Cross-entropy loss function loss = nn.CrossEntropyLoss(reduction='none') Синхронизация данных -------------------- Для эффективного многопроцессорного **обучения** нам необходимы две базовые операции. Во-первых, нам нужно уметь распределять список параметров между несколькими устройствами и прикреплять градиенты (``get_params``). Без параметров невозможно выполнить оценку сети на GPU. Во-вторых, нам нужна возможность суммировать параметры между несколькими устройствами, то есть нам требуется функция ``allreduce``. .. raw:: latex \diilbookstyleinputcell .. code:: python def get_params(params, device): new_params = [p.to(device) for p in params] for p in new_params: p.requires_grad_() return new_params Давайте попробуем, скопировав параметры модели на один GPU. .. raw:: latex \diilbookstyleinputcell .. code:: python new_params = get_params(params, d2l.try_gpu(0)) print('b1 weight:', new_params[1]) print('b1 grad:', new_params[1].grad) Поскольку вычислений ещё не производилось, градиент по параметру смещения по-прежнему равен нулю. Теперь предположим, что у нас есть вектор, распределенный между несколькими GPU. Следующая ``allreduce`` функция суммирует все векторы и рассылает результат обратно на все GPU. Обратите внимание, что для этого необходимо скопировать данные на устройство, аккумулирующее результаты. .. raw:: latex \diilbookstyleinputcell .. code:: python def allreduce(data): for i in range(1, len(data)): data[0][:] += data[i].to(data[0].device) for i in range(1, len(data)): data[i][:] = data[0].to(data[i].device) Проверим это, создав на разных устройствах векторы с разными значениями и агрегировав их. .. raw:: latex \diilbookstyleinputcell .. code:: python data = [torch.ones((1, 2), device=d2l.try_gpu(i)) * (i + 1) for i in range(2)] print('before allreduce:\n', data[0], '\n', data[1]) allreduce(data) print('after allreduce:\n', data[0], '\n', data[1]) Распределение данных -------------------- Нам нужна простая вспомогательная функция для **равномерного распределения мини-батча между несколькими GPU**. Например, при использовании двух GPU мы хотим, чтобы половина данных копировалась на каждый из них. Поскольку это удобнее и лаконичнее, мы воспользуемся встроенной функцией фреймворка глубокого обучения, чтобы протестировать её на матрице :math:`4 \times 5`. .. raw:: latex \diilbookstyleinputcell .. code:: python data = torch.arange(20).reshape(4, 5) devices = [torch.device('cuda:0'), torch.device('cuda:1')] split = nn.parallel.scatter(data, devices) print('input :', data) print('load into', devices) print('output:', split) Для последующего повторного использования определим функцию ``split_batch``, которая разделяет данные и метки. .. raw:: latex \diilbookstyleinputcell .. code:: python #@save def split_batch(X, y, devices): """Split `X` and `y` into multiple devices.""" assert X.shape[0] == y.shape[0] return (nn.parallel.scatter(X, devices), nn.parallel.scatter(y, devices)) Обучение -------- Теперь мы можем реализовать **многопроцессорное обучение на одном мини-батче**. Его реализация в первую очередь основана на подходе параллелизма данных, описанном в этом разделе. Мы будем использовать вспомогательные функции, которые мы только что обсудили, ``allreduce`` и ``split_and_load``, для синхронизации данных между несколькими GPU. Обратите внимание, что нам не нужно писать какой-либо специальный код для достижения параллелизма. Поскольку вычислительный граф не имеет никаких зависимостей между устройствами в пределах одного мини-батча, он выполняется параллельно *автоматически*. .. raw:: latex \diilbookstyleinputcell .. code:: python def train_batch(X, y, device_params, devices, lr): X_shards, y_shards = split_batch(X, y, devices) # Loss is calculated separately on each GPU ls = [loss(lenet(X_shard, device_W), y_shard).sum() for X_shard, y_shard, device_W in zip( X_shards, y_shards, device_params)] for l in ls: # Backpropagation is performed separately on each GPU l.backward() # Sum all gradients from each GPU and broadcast them to all GPUs with torch.no_grad(): for i in range(len(device_params[0])): allreduce([device_params[c][i].grad for c in range(len(devices))]) # The model parameters are updated separately on each GPU for param in device_params: d2l.sgd(param, lr, X.shape[0]) # Here, we use a full-size batch Теперь мы можем определить функцию обучения. Она немного отличается от тех, что использовались в предыдущих главах: нам необходимо распределить GPU и скопировать все параметры модели на все устройства. Очевидно, что каждая мини-выборка обрабатывается с помощью функции ``train_batch`` для работы с несколькими GPU. Для удобства (и лаконичности кода) мы вычисляем точность на одном GPU, хотя это *неэффективно*, поскольку остальные GPU простаивают. .. raw:: latex \diilbookstyleinputcell .. code:: python def train(num_gpus, batch_size, lr): train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size) devices = [d2l.try_gpu(i) for i in range(num_gpus)] # Copy model parameters to `num_gpus` GPUs device_params = [get_params(params, d) for d in devices] num_epochs = 10 animator = d2l.Animator('epoch', 'test acc', xlim=[1, num_epochs]) timer = d2l.Timer() for epoch in range(num_epochs): timer.start() for X, y in train_iter: # Perform multi-GPU training for a single minibatch train_batch(X, y, device_params, devices, lr) torch.cuda.synchronize() timer.stop() # Evaluate the model on GPU 0 animator.add(epoch + 1, (d2l.evaluate_accuracy_gpu( lambda x: lenet(x, device_params[0]), test_iter, devices[0]),)) print(f'test acc: {animator.Y[0][-1]:.2f}, {timer.avg():.1f} sec/epoch ' f'on {str(devices)}') Давайте посмотрим, как это работает на одном GPU. Сначала мы используем размер батча 256 и скорость обучения 0,2. .. raw:: latex \diilbookstyleinputcell .. code:: python train(num_gpus=1, batch_size=256, lr=0.2) Сохраняя размер батча и скорость обучения неизменными и увеличив количество GPU до двух, мы видим, что точность на тестовом наборе данных примерно не изменилась по сравнению с предыдущим экспериментом. Что касается алгоритмов оптимизации, они остаются теми же. К сожалению, здесь не удаётся добиться значимого ускорения: модель слишком мала; кроме того, у нас небольшой набор данных, где наш несколько примитивный подход к реализации обучения на нескольких GPU страдал от существенных накладных расходов Python. В дальнейшем мы будем сталкиваться с более сложными моделями и более изощрёнными способами распараллеливания. Тем не менее давайте посмотрим, что произойдёт на Fashion-MNIST. .. raw:: latex \diilbookstyleinputcell .. code:: python train(num_gpus=2, batch_size=256, lr=0.2) **Резюме** ---------- - Существует несколько способов распределения обучения глубокой сети между несколькими GPU. Мы можем разделить их по слоям, между слоями или по данным. Первые два варианта требуют строго скоординированной передачи данных. Параллелизм данных — самая простая стратегия. - Параллельное обучение с использованием данных — простой подход. Однако для обеспечения эффективности оно увеличивает эффективный размер мини-батча. - При параллелизме данных данные распределяются между несколькими GPU, где каждый GPU выполняет собственные операции прямого и обратного распространения, после чего градиенты агрегируются, а результаты рассылаются обратно на GPU. - Для больших минипакетов можно использовать несколько большие скорости обучения. Упражнения ---------- 1. При обучении на :math:`k` GPU измените размер мини-батча с :math:`b` на :math:`k \cdot b`, то есть увеличьте его в число раз, равное количеству GPU. 2. Сравните точность при различных скоростях обучения. Как она масштабируется с увеличением числа GPU? 3. Реализуйте более эффективную функцию ``allreduce``, которая агрегирует параметры на разных GPU. Почему она более эффективна? 4. Реализуйте вычисление точности на тестовой выборке для нескольких GPU. `Обсуждения `__