d2l 12.5–12.6: обучение на нескольких GPU
Полный перевод разделов «Обучение на нескольких GPU» и «Краткая реализация для нескольких GPU» китайского издания «Погружение в глубокое обучение» (версия на PyTorch): три способа разделить обучение между видеокартами, параллелизм по данным с нуля (scatter, allreduce, train_batch) и кратко через nn.DataParallel на ResNet-18 — с примечаниями о DDP и о машине с 4 GPU без NVLink.
12.5 Обучение на нескольких GPU
Заголовок раздела «12.5 Обучение на нескольких GPU»До сих пор мы обсуждали, как эффективно обучать модели на CPU и GPU, в разделе 12.3 показали, как фреймворки
глубокого обучения автоматически распараллеливают вычисления и обмен данными между CPU и GPU, а в разделе 5.6 — как
командой nvidia-smi вывести список всех доступных GPU компьютера. Но мы не обсуждали, как на самом деле
распараллелить обучение. Можно ли как-то разделить данные между несколькими устройствами так, чтобы всё правильно
работало? В этом разделе подробно показано, как с нуля обучать сеть параллельно; для этого понадобится
стохастический градиентный спуск по мини-батчам (см. раздел 11.5). Дальше мы покажем, как обучать сеть параллельно
с помощью высокоуровневых API (см. раздел 12.6).
Как разделить задачу
Заголовок раздела «Как разделить задачу»Начнём с простой задачи компьютерного зрения и чуть устаревшей сети. В сети несколько свёрточных слоёв и слоёв пулинга, а в конце, возможно, несколько полносвязных слоёв — она очень похожа на LeNet (LeCun и др., 1998) или AlexNet (Krizhevsky и др., 2012). Предположим, у нас несколько GPU (2 у настольного сервера, 4 на AWS g4dn.12xlarge, 8 на p3.16xlarge, 16 на p2.16xlarge). Мы хотим разделить обучение так, чтобы получить хорошее ускорение и при этом обойтись простыми и воспроизводимыми проектными решениями. В конце концов, несколько GPU дают больше и памяти, и вычислительной мощности. Коротко: для мини-батча обучающих данных, которые нужно классифицировать, у нас есть следующие варианты.
Первый способ — разделить сеть между GPU. То есть каждый GPU получает данные, входящие в определённый слой, обрабатывает их несколькими последующими слоями и отправляет на следующий GPU. Так можно обрабатывать данные сетью большего размера, чем поместилась бы на один GPU. К тому же объём занимаемой памяти (memory footprint) каждого GPU хорошо контролируется — это лишь часть памяти всей сети.
Однако плотная синхронизация между интерфейсами GPU может стать проблемой, особенно когда вычислительная нагрузка слоёв плохо сбалансирована, а также когда на стыках слоёв нужно передавать много данных (например, активации и градиенты; объём может превысить пропускную способность шины GPU). Кроме того, для разбиения очень важен порядок вычислительно тяжёлых операций — лучшее исследование на эту тему: Mirhoseini и др. (2017); по сути это остаётся трудной задачей, и неясно, можно ли на конкретной задаче добиться хорошего линейного масштабирования. В итоге этот способ не рекомендуется, если только фреймворк или операционная система сами не умеют связывать несколько GPU вместе.
Второй способ — разделить работу внутри слоя. Например, вместо того чтобы считать 64 канала на одном GPU, распределить задачу на 4 GPU, каждый из которых вычисляет 16 каналов. Для полносвязных слоёв так же можно разделить число выходных нейронов. На рис. 12.5.1 показан такой дизайн: эта стратегия применялась для GPU с очень маленькой памятью (тогда — 2 ГБ). Когда каналов или нейронов не слишком мало, это хорошо повышает производительность. Кроме того, поскольку доступная память растёт линейно, несколько GPU могут обрабатывать всё более крупные сети.
Рис. 12.5.1 в оригинале (img/alexnet-original.svg): параллелизм модели в исходном дизайне AlexNet, вызванный нехваткой памяти GPU, — сеть разрезана на две половины по каналам, каждая на своём GPU, с обменом данными между ними на некоторых слоях.
Однако здесь нужно очень много синхронизации, или барьерных операций (barrier operation), поскольку каждый слой зависит от результатов всех остальных. К тому же передавать нужно, возможно, даже больше данных, чем при разбиении слоёв между GPU. Поэтому из-за стоимости пропускной способности и сложности мы этот способ тоже не рекомендуем.
Последний способ — разделить данные между несколькими GPU. В этом случае все GPU выполняют одну и ту же работу, но над разными наблюдениями. После обработки каждого мини-батча градиенты агрегируются по GPU. Этот способ самый простой и применим в любой ситуации; синхронизация нужна только после обработки каждого мини-батча. Причём уже посчитанные градиенты параметров можно начинать передавать, пока другие ещё вычисляются. И чем больше GPU, тем больше данных в мини-батче — тем выше эффективность обучения. Но добавление GPU не позволяет обучать более крупные модели.
flowchart LR subgraph A["Сеть по GPU<br/>(конвейер)"] A1["GPU 1: слои 1–2"] --> A2["GPU 2: слои 3–4"] end subgraph B["Внутри слоя<br/>(тензорный параллелизм)"] B1["GPU 1: каналы 1–32"] B2["GPU 2: каналы 33–64"] end subgraph C["По данным"] C1["GPU 1: вся сеть,<br/>половина батча"] C2["GPU 2: вся сеть,<br/>половина батча"] C1 <-. "сумма градиентов" .-> C2 end
На рис. 12.5.2 сравниваются разные способы параллелизма на нескольких GPU. В целом, если памяти GPU хватает, параллелизм по данным — самый удобный. Подробное описание разбиения при распределённом обучении — в Li и др. (2014). В ранние годы глубокого обучения память GPU была острой проблемой, но сегодня, кроме совсем особых случаев, она решена. Дальше мы сосредоточимся на параллелизме по данным.
Параллелизм по данным
Заголовок раздела «Параллелизм по данным»Пусть в машине GPU. Для обучаемой модели каждый GPU независимо хранит полный набор её параметров, хотя значения параметров на всех GPU одинаковы и синхронизированы. Например, рис. 12.5.3 показывает обучение с параллелизмом по данным при .
Рис. 12.5.3 в оригинале (img/data-parallel.svg): мини-батч делится пополам; каждый GPU считает свою половину градиента, градиенты складываются, сумма рассылается обоим GPU, и каждый обновляет свою копию параметров.
В общем случае параллельное обучение на GPU идёт так:
- на каждой итерации случайный мини-батч делится на частей и поровну распределяется по GPU;
- каждый GPU по своей части мини-батча вычисляет потерю и градиенты параметров модели;
- локальные градиенты всех GPU агрегируются — получается стохастический градиент текущего мини-батча;
- агрегированный градиент рассылается обратно каждому GPU;
- каждый GPU по этому стохастическому градиенту обновляет полный набор параметров, который он хранит.
На практике учтите: при обучении на GPU размер мини-батча нужно увеличить в раз, чтобы у каждого GPU была та же нагрузка, что и при обучении на одном GPU. Поэтому на 16-GPU сервере можно заметно увеличить мини-батч, и, возможно, придётся соответственно увеличить скорость обучения. Учтите также, что пакетную нормализацию из раздела 7.5 нужно адаптировать — например, хранить для каждого GPU отдельные параметры пакетной нормализации.
Дальше мы покажем обучение на нескольких GPU на простой сети.
%matplotlib inlinefrom d2l import torch as d2limport torchfrom torch import nnfrom torch.nn import functional as FПростая сеть
Заголовок раздела «Простая сеть»Возьмём (немного изменённую) LeNet из раздела 6.6 и определим её с нуля, чтобы подробно показать обмен параметрами и синхронизацию.
# Инициализируем параметры моделиscale = 0.01W1 = torch.randn(size=(20, 1, 3, 3)) * scaleb1 = torch.zeros(20)W2 = torch.randn(size=(50, 20, 5, 5)) * scaleb2 = torch.zeros(50)W3 = torch.randn(size=(800, 128)) * scaleb3 = torch.zeros(128)W4 = torch.randn(size=(128, 10)) * scaleb4 = torch.zeros(10)params = [W1, b1, W2, b2, W3, b3, W4, b4]
# Определяем модель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
# Функция потерь — перекрёстная энтропияloss = nn.CrossEntropyLoss(reduction='none')Синхронизация данных
Заголовок раздела «Синхронизация данных»Для эффективного обучения на нескольких GPU нужны две базовые операции. Во-первых, нужно раздать параметры
нескольким устройствам и подключить к ним градиенты (get_params). Без параметров сеть на GPU не посчитать.
Во-вторых, нужно складывать параметры между устройствами — то есть нужна функция allreduce.
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.
new_params = get_params(params, d2l.try_gpu(0))print('b1 веса:', new_params[1])print('b1 градиент:', new_params[1].grad)Поскольку вычислений ещё не было, градиенты параметров пока нулевые. Теперь пусть у нас есть вектор, распределённый
по нескольким GPU; функция allreduce ниже складывает все векторы и рассылает результат всем GPU. Обратите
внимание: чтобы функция работала, данные нужно копировать на устройство, где накапливается результат.
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)Проверим: создадим на разных устройствах векторы с разными значениями и агрегируем их.
data = [torch.ones((1, 2), device=d2l.try_gpu(i)) * (i + 1) for i in range(2)]print('до allreduce:\n', data[0], '\n', data[1])allreduce(data)print('после allreduce:\n', data[0], '\n', data[1])Распределение данных
Заголовок раздела «Распределение данных»Нужна простая вспомогательная функция, которая поровну распределяет мини-батч по нескольким GPU. Например, при двух GPU мы хотим, чтобы каждый получил половину данных. Встроенной функцией фреймворка это писать удобнее и короче, поэтому попробуем её на матрице .
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, которая разделяет сразу и данные, и метки.
#@savedef split_batch(X, y, devices): """Разделить X и y по нескольким устройствам""" assert X.shape[0] == y.shape[0] return (nn.parallel.scatter(X, devices), nn.parallel.scatter(y, devices))Обучение
Заголовок раздела «Обучение»Теперь можно реализовать обучение на нескольких GPU для одного мини-батча. Для синхронизации данных между GPU
используем только что разобранные вспомогательные функции allreduce и split_and_load. Писать какой-то особый
код для параллелизма не нужно: внутри мини-батча вычислительные графы на разных устройствах не зависят друг от
друга, поэтому выполняются параллельно «автоматически».
def train_batch(X, y, device_params, devices, lr): X_shards, y_shards = split_batch(X, y, devices) # Потерю считаем на каждом 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: # обратное распространение — на каждом GPU отдельно l.backward() # Складываем все градиенты со всех GPU и рассылаем результат всем GPU with torch.no_grad(): for i in range(len(device_params[0])): allreduce( [device_params[c][i].grad for c in range(len(devices))]) # Параметры модели обновляем на каждом GPU отдельно for param in device_params: d2l.sgd(param, lr, X.shape[0]) # здесь используем размер полного мини-батчаТеперь можно определить функцию обучения. Она немного отличается от функций из предыдущих глав: нужно выделить
GPU и скопировать все параметры модели на все устройства. Понятно, что каждый мини-батч обрабатывается на
нескольких GPU функцией train_batch. Точность модели мы считаем только на одном GPU, а остальные в это время
простаивают; это относительно неэффективно, зато удобно и код короче.
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)] # Копируем параметры модели на num_gpus GPU 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: # Обучение на нескольких GPU для одного мини-батча train_batch(X, y, device_params, devices, lr) torch.cuda.synchronize() timer.stop() # Оцениваем модель на GPU 0 animator.add(epoch + 1, (d2l.evaluate_accuracy_gpu( lambda x: lenet(x, device_params[0]), test_iter, devices[0]),)) print(f'точность на тесте: {animator.Y[0][-1]:.2f}, {timer.avg():.1f} с/эпоха, ' f'на {str(devices)}')Посмотрим, насколько хорошо всё работает на одном GPU. Сначала возьмём размер батча 256 и скорость обучения 0,2.
train(num_gpus=1, batch_size=256, lr=0.2)Оставим размер батча и скорость обучения прежними и увеличим число GPU до 2: точность на тесте будет примерно такой же, как в предыдущем эксперименте. С точки зрения поиска оптимума алгоритм при разном числе GPU одинаков. К сожалению, никакого заметного ускорения здесь нет: модель слишком маленькая, да и датасет тоже. На таком датасете наш простой способ обучения на нескольких GPU страдает от огромных накладных расходов Python. В будущем нам встретятся более сложные модели и более сложные способы параллелизма. А пока посмотрим, что произойдёт на датасете Fashion-MNIST.
train(num_gpus=2, batch_size=256, lr=0.2)- Обучение глубокой сети можно разделить между несколькими GPU разными способами: между слоями, внутри слоёв или по данным. Первые два требуют тщательной оркестровки передачи данных, а последний — самая простая стратегия.
- Обучение с параллелизмом по данным само по себе несложно; оно повышает эффективность обучения за счёт увеличения эффективного размера мини-батча.
- При параллелизме по данным данные делятся между несколькими GPU, каждый GPU выполняет свои прямой и обратный проходы, затем все градиенты агрегируются в один, и результат рассылается всем GPU.
- При большем мини-батче скорость обучения нужно немного увеличить.
Упражнения
Заголовок раздела «Упражнения»- При обучении на GPU увеличьте размер батча с до , то есть пропорционально числу GPU.
- Сравните точность модели при разных скоростях обучения. Как скорость обучения должна масштабироваться с ростом числа GPU?
- Реализуйте более эффективную функцию
allreduceдля агрегации разных параметров на разных GPU. Почему она эффективнее? - Реализуйте подсчёт точности на тесте на нескольких GPU.
12.6 Краткая реализация на нескольких GPU
Заголовок раздела «12.6 Краткая реализация на нескольких GPU»Реализовывать параллельные вычисления с нуля для каждой новой модели — скучно. К тому же полезно оптимизировать средства синхронизации ради высокой производительности. Ниже покажем, как сделать это высокоуровневыми API фреймворка. Математика и алгоритм — те же, что в разделе 12.5. Для запуска кода этого раздела нужны как минимум два GPU.
from d2l import torch as d2limport torchfrom torch import nnПростая сеть
Заголовок раздела «Простая сеть»Возьмём сеть содержательнее, чем LeNet из раздела 12.5, но всё ещё лёгкую и быстро обучаемую. Мы выбрали ResNet-18 (He и др., 2016). Поскольку входные изображения маленькие, мы её немного изменили. Отличие от раздела 7.6: в начале используются меньшие ядро свёртки, шаг и дополнение, а слой max-пулинга убран.
#@savedef resnet18(num_classes, in_channels=1): """Немного изменённая модель ResNet-18""" def resnet_block(in_channels, out_channels, num_residuals, first_block=False): blk = [] for i in range(num_residuals): if i == 0 and not first_block: blk.append(d2l.Residual(in_channels, out_channels, use_1x1conv=True, strides=2)) else: blk.append(d2l.Residual(out_channels, out_channels)) return nn.Sequential(*blk)
# В модели меньшие ядро свёртки, шаг и дополнение, а слой max-пулинга убран net = nn.Sequential( nn.Conv2d(in_channels, 64, kernel_size=3, stride=1, padding=1), nn.BatchNorm2d(64), nn.ReLU()) net.add_module("resnet_block1", resnet_block( 64, 64, 2, first_block=True)) net.add_module("resnet_block2", resnet_block(64, 128, 2)) net.add_module("resnet_block3", resnet_block(128, 256, 2)) net.add_module("resnet_block4", resnet_block(256, 512, 2)) net.add_module("global_avg_pool", nn.AdaptiveAvgPool2d((1,1))) net.add_module("fc", nn.Sequential(nn.Flatten(), nn.Linear(512, num_classes))) return netИнициализация сети
Заголовок раздела «Инициализация сети»Мы будем инициализировать сеть внутри цикла обучения. Методы инициализации можно повторить в разделе 4.8.
net = resnet18(10)# Получаем список GPUdevices = d2l.try_all_gpus()# Сеть мы инициализируем в коде обученияОбучение
Заголовок раздела «Обучение»Как уже говорилось, чтобы обучение шло эффективно и параллельно, код обучения должен выполнять несколько базовых функций:
- инициализировать параметры сети на всех устройствах;
- при проходе по датасету распределять мини-батчи по всем устройствам;
- параллельно на всех устройствах считать потерю и её градиент;
- агрегировать градиенты и соответственно обновлять параметры.
Наконец, параллельно считать точность и сообщать итоговое качество сети. Кроме того, что данные нужно делить и агрегировать, код обучения очень похож на реализации из предыдущих глав.
def train(net, 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)] def init_weights(m): if type(m) in [nn.Linear, nn.Conv2d]: nn.init.normal_(m.weight, std=0.01) net.apply(init_weights) # Размещаем модель на нескольких GPU net = nn.DataParallel(net, device_ids=devices) trainer = torch.optim.SGD(net.parameters(), lr) loss = nn.CrossEntropyLoss() timer, num_epochs = d2l.Timer(), 10 animator = d2l.Animator('epoch', 'test acc', xlim=[1, num_epochs]) for epoch in range(num_epochs): net.train() timer.start() for X, y in train_iter: trainer.zero_grad() X, y = X.to(devices[0]), y.to(devices[0]) l = loss(net(X), y) l.backward() trainer.step() timer.stop() animator.add(epoch + 1, (d2l.evaluate_accuracy_gpu(net, test_iter),)) print(f'точность на тесте: {animator.Y[0][-1]:.2f}, {timer.avg():.1f} с/эпоха, ' f'на {str(devices)}')Посмотрим, как это работает на практике. Для разминки обучим сеть на одном GPU.
train(net, num_gpus=1, batch_size=256, lr=0.1)Теперь обучим на 2 GPU. По сравнению с LeNet из раздела 12.5 модель ResNet-18 гораздо сложнее. Вот где видно преимущество параллелизма: вычисления занимают заметно больше времени, чем синхронизация параметров. Поскольку накладные расходы на параллелизм теперь менее существенны, масштабируемость модели улучшается.
train(net, num_gpus=2, batch_size=512, lr=0.2)- Сеть может автоматически оцениваться на одном GPU (на котором есть данные).
- Сеть на каждом устройстве нужно сначала инициализировать, и только потом обращаться к её параметрам на этом устройстве, иначе будет ошибка.
- Алгоритм оптимизации автоматически агрегирует градиенты с нескольких GPU.
Упражнения
Заголовок раздела «Упражнения»- В этом разделе используется ResNet-18. Попробуйте разное число эпох, размеры батча и скорости обучения, а также больше GPU. Что будет, если попробовать на 16 GPU (например, на экземпляре AWS p2.16xlarge)?
- Иногда устройства имеют разную вычислительную мощность: можно одновременно использовать GPU и CPU. Как тогда распределять работу? Почему?
От переводчика: как это делают сегодня и что запустить у себя
Заголовок раздела «От переводчика: как это делают сегодня и что запустить у себя»Минимальный каркас того же обучения на DDP (запуск: torchrun --nproc_per_node=4 train_ddp.py):
# train_ddp.py — набросок переводчика; модель и данные возьмите из примеров вышеimport osimport torchimport torch.distributed as distfrom torch import nnfrom torch.nn.parallel import DistributedDataParallel as DDPfrom torch.utils.data import DataLoaderfrom torch.utils.data.distributed import DistributedSamplerimport torchvisionfrom torchvision import transformsfrom d2l import torch as d2l # нужен для d2l.Residual внутри resnet18
dist.init_process_group("nccl") # torchrun сам задаёт RANK, WORLD_SIZE, LOCAL_RANKlocal_rank = int(os.environ["LOCAL_RANK"]) # номер карты для этого процесса: 0, 1, 2 или 3torch.cuda.set_device(local_rank)
train_set = torchvision.datasets.FashionMNIST( root="./data", train=True, download=(local_rank == 0), transform=transforms.ToTensor())dist.barrier() # остальные процессы ждут, пока процесс 0 скачает данныеsampler = DistributedSampler(train_set) # каждый процесс получает свою 1/4 датасетаloader = DataLoader(train_set, batch_size=128, sampler=sampler) # 128 на карту → 512 всего
model = resnet18(10).cuda(local_rank) # функция resnet18 из раздела 12.6model = DDP(model, device_ids=[local_rank])opt = torch.optim.SGD(model.parameters(), lr=0.2)loss_fn = nn.CrossEntropyLoss()
for epoch in range(10): sampler.set_epoch(epoch) # новое перемешивание на каждой эпохе for X, y in loader: X, y = X.cuda(local_rank), y.cuda(local_rank) opt.zero_grad() loss_fn(model(X), y).backward() # здесь DDP делает all-reduce градиентов opt.step() if local_rank == 0: print(f"эпоха {epoch + 1} готова")
dist.destroy_process_group()Словарик
Заголовок раздела «Словарик»| Русский | English |
|---|---|
| параллелизм по данным | data parallelism |
| параллелизм модели | model parallelism |
| конвейерный параллелизм | pipeline parallelism |
| видеопамять | GPU memory |
| синхронизация | synchronization |
| барьерная операция | barrier |
| агрегация, редукция | aggregate / reduce |
| рассылка (broadcast) | broadcast |
| ускорение | speedup |
| линейное масштабирование | linear scaling |
| пропускная способность | bandwidth |