Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

12.3 Автоматическое распараллеливание

Фреймворки глубокого обучения (например, MXNet, PaddlePaddle и PyTorch) автоматически строят вычислительный граф в бэкенде. Благодаря графу система знает все зависимости и может по выбору выполнять параллельно несколько независимых задач, чтобы ускорить вычисления. Например, на рис. 12.2.2 из раздела 12.2 две переменные инициализируются независимо друг от друга, поэтому система может выполнить эти операции параллельно.

Обычно одна операция использует все вычислительные ресурсы CPU или одного GPU. Например, даже если на машине несколько процессоров CPU, операция dot задействует все ядра (и потоки) всех CPU. То же относится к одному GPU. Поэтому распараллеливание почти не даёт выигрыша на компьютере с одним устройством, а на нескольких устройствах оно становится важным. Хотя распараллеливание обычно применяют к нескольким GPU, добавление локального CPU тоже немного повышает производительность. Например, в работе (Hadjis et al., 2016) обучение, которое одновременно использует GPU и CPU, применяется к моделям компьютерного зрения. Благодаря удобству фреймворка автоматического распараллеливания ту же цель можно достичь несколькими строками кода Python. Обсуждение автоматических параллельных вычислений в основном посвящено параллельным вычислениям на CPU и GPU, а также распараллеливанию вычислений и коммуникации.

Обратите внимание, что для запуска экспериментов из этого раздела нужно как минимум два GPU.

MXNet:

from d2l import mxnet as d2l
from mxnet import np, npx
npx.set_np()

PyTorch:

from d2l import torch as d2l
import torch

PaddlePaddle:

from d2l import paddle as d2l
import warnings
warnings.filterwarnings("ignore")
import paddle
import numpy as np

12.3.1 Параллельные вычисления на GPU

Начнём с определения эталонной тестовой нагрузки: функция run выполняет умножений «матрица на матрицу», а данные для них размещаются в двух переменных (x_gpu1 и x_gpu2), которые находятся на разных выбранных устройствах.

MXNet:

devices = d2l.try_all_gpus()
def run(x):
    return [x.dot(x) for _ in range(50)]

x_gpu1 = np.random.uniform(size=(4000, 4000), ctx=devices[0])
x_gpu2 = np.random.uniform(size=(4000, 4000), ctx=devices[1])

PyTorch:

devices = d2l.try_all_gpus()
def run(x):
    return [x.mm(x) for _ in range(50)]

x_gpu1 = torch.rand(size=(4000, 4000), device=devices[0])
x_gpu2 = torch.rand(size=(4000, 4000), device=devices[1])

PaddlePaddle:

devices = d2l.try_all_gpus()
def run(x, index=0):
    paddle.set_device(f"gpu:{index}")
    return [x.matmul(x) for _ in range(50)]
    
data = np.random.rand(4000, 4000)
x_gpu1 = paddle.to_tensor(data, place=devices[0])
x_gpu2 = paddle.to_tensor(data, place=devices[1])

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

PyTorch: Теперь используем функции для обработки данных. Чтобы кэш не повлиял на итоговый результат, перед измерением нужно прогреть устройство: выполнить один проход. Функция torch.cuda.synchronize() ждёт завершения вычислений всех ядер во всех потоках CUDA-устройства. Функция принимает аргумент device, который задаёт, какое устройство нужно синхронизировать. Если device равен None (значение по умолчанию), используется текущее устройство, которое возвращает current_device().

PaddlePaddle: Теперь используем функции для обработки данных. Чтобы кэш не повлиял на итоговый результат, перед измерением прогреваем устройство: выполняем один проход. Функция paddle.device.cuda.synchronize() ждёт завершения вычислений всех ядер во всех потоках CUDA-устройства. Функция принимает аргумент device, который задаёт, какое устройство нужно синхронизировать. Если device равен None (значение по умолчанию), используется текущее устройство, которое возвращает current_device().

MXNet:

run(x_gpu1)  # Прогрев устройства
run(x_gpu2)
npx.waitall()  

with d2l.Benchmark('время GPU1'):
    run(x_gpu1)
    npx.waitall()

with d2l.Benchmark('время GPU2'):
    run(x_gpu2)
    npx.waitall()

PyTorch:

run(x_gpu1)
run(x_gpu2)  # Прогрев устройства
torch.cuda.synchronize(devices[0])
torch.cuda.synchronize(devices[1])

with d2l.Benchmark('время GPU1'):
    run(x_gpu1)
    torch.cuda.synchronize(devices[0])

with d2l.Benchmark('время GPU2'):
    run(x_gpu2)
    torch.cuda.synchronize(devices[1])

PaddlePaddle:

run(x_gpu1, 0)
run(x_gpu2, 1)  # Прогрев устройства
paddle.device.cuda.synchronize(devices[0])
paddle.device.cuda.synchronize(devices[1])

with d2l.Benchmark('время GPU1'):
    run(x_gpu1, 0)
    paddle.device.cuda.synchronize(devices[0])

with d2l.Benchmark('время GPU2'):
    run(x_gpu2, 1)
    paddle.device.cuda.synchronize(devices[1])

MXNet: Если удалить waitall между двумя задачами, система сможет автоматически выполнить вычисления на двух устройствах параллельно.

PyTorch: Если удалить synchronize между двумя задачами, система сможет автоматически выполнить вычисления на двух устройствах параллельно.

PaddlePaddle: Если удалить synchronize между двумя задачами, система сможет автоматически выполнить вычисления на двух устройствах параллельно.

MXNet:

with d2l.Benchmark('GPU1 & GPU2'):
    run(x_gpu1)
    run(x_gpu2)
    npx.waitall()

PyTorch:

with d2l.Benchmark('GPU1 & GPU2'):
    run(x_gpu1)
    run(x_gpu2)
    torch.cuda.synchronize()

PaddlePaddle:

with d2l.Benchmark('GPU1 & GPU2'):
    run(x_gpu1, 0)
    run(x_gpu2, 1)
    paddle.device.cuda.synchronize()

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

12.3.2 Параллельные вычисления и коммуникация

Во многих случаях данные нужно переносить между разными устройствами: между CPU и GPU или между разными GPU. Например, при распределённой оптимизации нужно переносить данные, чтобы объединить градиенты с нескольких ускорителей. Смоделируем этот процесс: выполним вычисления на GPU, а затем скопируем результат обратно на CPU.

MXNet:

def copy_to_cpu(x):
    return [y.copyto(npx.cpu()) for y in x]

with d2l.Benchmark('выполнение на GPU1'):
    y = run(x_gpu1)
    npx.waitall()

with d2l.Benchmark('копирование на CPU'):
    y_cpu = copy_to_cpu(y)
    npx.waitall()

PyTorch:

def copy_to_cpu(x, non_blocking=False):
    return [y.to('cpu', non_blocking=non_blocking) for y in x]

with d2l.Benchmark('выполнение на GPU1'):
    y = run(x_gpu1)
    torch.cuda.synchronize()

with d2l.Benchmark('копирование на CPU'):
    y_cpu = copy_to_cpu(y)
    torch.cuda.synchronize()

PaddlePaddle:

def copy_to_cpu(x):
    return [paddle.to_tensor(y, place=paddle.CPUPlace()) for y in x]

with d2l.Benchmark('выполнение на GPU1'):
    y = run(x_gpu1, 0)
    paddle.device.cuda.synchronize()
    
with d2l.Benchmark('копирование на CPU'):
    y_cpu = copy_to_cpu(y)
    paddle.device.cuda.synchronize()

MXNet: Этот способ неэффективен. Заметим, что части y можно начать копировать на CPU ещё до того, как вычислена остальная часть списка. Например, при вычислении градиентов для минибатча градиенты одних параметров готовы раньше, чем градиенты других. Поэтому выгодно начать передавать данные по шине PCI-Express, пока GPU ещё работает. Чтобы смоделировать этот сценарий, удалим waitall между этими двумя частями.

PyTorch: Этот способ неэффективен. Заметим, что части y можно начать копировать на CPU ещё до того, как вычислена остальная часть списка. Например, при вычислении (обратного распространения) градиентов для минибатча градиенты одних параметров готовы раньше, чем градиенты других. Поэтому выгодно начать передавать данные по шине PCI-Express, пока GPU ещё работает. В PyTorch такие функции, как to() и copy_(), принимают явный параметр non_blocking. Если он равен True, вызывающий код может не дожидаться синхронизации, когда она не нужна. Установим non_blocking=True, чтобы смоделировать этот сценарий.

PaddlePaddle: Этот способ неэффективен. Заметим, что части y можно начать копировать на CPU ещё до того, как вычислена остальная часть списка. Например, при вычислении (обратного распространения) градиентов для минибатча градиенты одних параметров готовы раньше, чем градиенты других. Поэтому выгодно начать передавать данные по шине PCI-Express, пока GPU ещё работает.

MXNet:

with d2l.Benchmark('выполнение на GPU1 и копирование на CPU'):
    y = run(x_gpu1)
    y_cpu = copy_to_cpu(y)
    npx.waitall()

PyTorch:

with d2l.Benchmark('выполнение на GPU1 и копирование на CPU'):
    y = run(x_gpu1)
    y_cpu = copy_to_cpu(y, True)
    torch.cuda.synchronize()

PaddlePaddle:

with d2l.Benchmark('выполнение на GPU1 и копирование на CPU'):
    y = run(x_gpu1)
    y_cpu = copy_to_cpu(y)
    paddle.device.cuda.synchronize()

Общее время двух операций меньше суммы времён их частей. Заметим разницу с параллельными вычислениями: здесь операции используют разные ресурсы, а именно шину между CPU и GPU. Фактически мы можем одновременно выполнять вычисления и коммуникацию на двух устройствах. Как было сказано выше, между вычислением и копированием есть зависимость: сначала нужно вычислить y[i], и только потом его можно скопировать на CPU. К счастью, система может копировать y[i-1], пока вычисляется y[i], что сокращает общее время выполнения.

Наконец, в этом разделе приведён пример вычислительного графа и его зависимостей для простого двухслойного многослойного перцептрона, обучаемого на CPU и двух GPU, как показано на рис. 12.3.1. Вручную распланировать полученную параллельную программу было бы очень трудно. Именно здесь проявляется преимущество графового вычислительного бэкенда, который выполняет оптимизацию.

Рис. 12.3.1. Вычислительный граф двухслойного многослойного перцептрона на одном CPU и двух GPU и его зависимости

Резюме

  • Современные системы содержат множество устройств, таких как несколько GPU и несколько CPU. Их можно использовать параллельно и асинхронно.
  • В современных системах есть и разнообразные ресурсы коммуникации, например PCI Express, хранилище (обычно твердотельный накопитель или сетевое хранилище) и сетевая пропускная способность. Для достижения наибольшей эффективности их тоже можно использовать параллельно.
  • Бэкенд может повысить производительность, автоматически распараллеливая вычисления и коммуникацию.

Упражнения

  1. В функции run, определённой в этом разделе, выполняется восемь операций, и между ними нет зависимостей. Спроектируйте эксперимент, чтобы проверить, будут ли фреймворки глубокого обучения выполнять их параллельно автоматически.
  2. Когда объём работы одной операции достаточно мал, распараллеливание может помочь даже на одном CPU или GPU. Спроектируйте эксперимент, который это проверит.
  3. Спроектируйте эксперимент, в котором параллельные вычисления и коммуникация используются одновременно на CPU и GPU.
  4. Используйте отладчик, например Nsight от NVIDIA, чтобы проверить, что код работает так, как задумано.
  5. Спроектируйте и проведите эксперименты с более сложными вычислительными задачами с зависимостями по данным, чтобы проверить, можно ли повысить производительность и при этом получить правильный результат.

MXNet: Обсуждение

PyTorch: Обсуждение

PaddlePaddle: Обсуждение

Литература

  • Hadjis, S., Zhang, C., Mitliagkas, I., Iter, D., & Ré, C. (2016). Omnivore: An optimizer for multi-device deep learning on CPUs and GPUs. arXiv preprint arXiv:1606.04487.