3 совета для ускорения нейронных сетей
Зачастую, сталкиваясь с задачей ускорения нейросети, инженер теряется в спектре методов, архитектур и советов на Medium. Мы выделили 3 совета, которые упростят ваше погружение в тему.
Совет №1: выбирайте модель с учетом баланса между скоростью и качеством
Представьте, что вы используете ResNet-50, обученный на датасете ImageNet. Находите метод прунинга, который показывает, что при ускорении на 70% по FLOPs точность сети будет 71.18 (самая последняя строка).
Да это ж круто! Но, давайте посмотрим, что получим в абсолютных единицах.
Количество операций сложения-умножения, или FLOPs (Floating Point Operations), или MAC (Multiply-Add Cumulation) в ResNet-50 равняется 4144.85 * 10^6. Количество операций сложения-умножения в MobileNet-v2 — 300 * 10^6.
При ускорении мы получили (1 — 0.3) * 4144.85 * 10^6 = 2901.395 * 10^6 операций.
Точность ускоренной сети — 71.18, точность MobileNet-v2 — 72.
В итоге, получили модель c меньшей точностью и большей вычислительной сложностью. А значит потратили вычислительные ресурсы и время впустую!
💡 Чтобы не терять время зря, постарайтесь заранее оценить желаемую производительность и выпишите несколько сценариев ускорения модели. Например, возможно, не стоит ускорять ResNet в 20 раз, а лучше ускорить MobileNet в 2 раза.
Совет №2: профилируйте пайплайн, прежде чем ускорять
Представьте ситуацию: сеть поджали, а время работы приложения, частью которого она является, не изменилось. При этом архитектура изменилась. В чем может быть причина?
Как вы могли догадаться, в данном случае инференс сети не является самой длительной частью пайплайна. Но как узнать это заранее? Тут на помощь приходит PyTorch Profiler.
Например, есть следующая нейронная сеть из одного слоя:
class MyModule(nn.Module):
def __init__(self, in_features: int, out_features: int, bias: bool = True):
super(MyModule, self) .__init__()
self.linear = nn.Linear(in_features, out_features, bias)
def forward (self, input, mask):
with profiler.record_function("LINEAR PASS"):
out = self.linear (input)
with profiler.record function ("MASK INDICES"):
threshold = out. sum(axis=1).mean().item)
hi_idk = np.argwhere(mask.cpu().numpy() > threshold)
hi_idx = torch.from_numpy(hi_idk).cuda()
return out, hi_idx
Создаем её инстанс и «прогреваем» GPU:
model = MyModule(500, 10).cuda()
input = torch.rand(128, 500).cuda()
mask = torch.rand((500, 500, 500), dtype=torch.double).cuda()
# warm-up
model (input, mask)
Обратите внимание: всегда следует «прогревать» (warm-up) свою карточку перед вычислениями.
💡 Warm-up нужно использовать, т.к. при простое GPU снижается уровень энергопотребления. В таком режиме графический процессор отключает часть оборудования, в том числе подсистемы памяти, внутренние подсистемы или даже вычислительные ядра и кэш. Вызов любой программы, которая пытается взаимодействовать с карточкой, приведет к загрузке и/или инициализации драйвера графического процессора.
Работает это долго и может иметь задержку до 3 секунд. Если мы, например, измеряем время для сети, которое занимает 10 миллисекунд для одного примера, выполнение более 1000 примеров может привести к тому, что большая часть нашего времени будет потрачена на инициализацию GPU. Конечно, мы не хотим измерять такие побочные эффекты, в реальности на проде GPU всегда включен. Поэтому сначала делаем “прогрев”, а после выполняем замеры.
Инференс сети представлен ниже:
with profiler.profile(with_stack=True, profile_memory=True) as prof:
out, idx = model (input, mask)
Если вывести результаты профайлера, то можно увидеть, что большую часть времени занимает копирование памяти с CPU на GPU (cudaMemcpuAsync
).
Поэтому для ускорения пайплайна этот перенос необходимо сократить.
💡 Есть и другие неочевидные места, которые могут влиять на пайплайн. Например, входная предобработка картинок (декодирование .jpeg для снимков высокого разрешения, например, спутниковых) или сохранение результатов работы на HDD-диск.
Совет №3: используйте фьюзинг слоев
В нейронной сети есть операции, которые можно объединить в одну. Такой метод иногда называют фьюзингом (fusing — слияние) или сплавлением. Рассмотрим самый частый пример со сверткой (Conv) и Batch Normalization (BN).
Напомним, что BN подстраивает распределения, которые были внутри сети, так, чтобы те имели среднее
\( \mu_{B} = \frac{1}{m} \sum_{i=1}^{m} \textbf{x}^{(i)} \)
\( \sigma^2_{B} = \frac{1}{m} \sum_{i=1}^{m} (\textbf{x}^{(i)} – \mu)^2 \)
\( \hat{\textbf{x}}^{(i)} = \frac{\textbf{x}^{(i)} – \mu_{B}}{\sqrt{\sigma^2_{B} + \epsilon}} \)
\( \textbf{y}^{(i)} = \gamma \hat{\textbf{x}}^{(i)} + \beta \)
Важно помнить, что \( \mu_{B}, \sigma^{2}_{B} \) на инференсе не вычисляются, а используются уже посчитанные в процессе обучения. Таким образом, можно просто раскрыть скобки в последней формуле:
\( \textbf{y}^{(i)} = \gamma \hat{\textbf{x}}^{(i)} + \beta = \frac{\hat{\textbf{x}}^{(i)} – \mu_{B}}{\sqrt{\sigma^2_{B} + \epsilon}}*\gamma + \beta = \frac{\hat{\textbf{x}}^{(i)}\gamma}{\sqrt{\sigma^2_{B} + \epsilon}} + \beta — \frac{\mu_{B}\gamma}{\sqrt{\sigma^2_{B} + \epsilon}} \)
Далее вспоминаем, что \( x^{(i)} \) — не просто какой-то тензор, а результат свертки или применения полносвязного слоя. Предлагается просто “сплавить” веса этого слоя и масштабирующий коэффициент, который получился в предыдущей формуле:
\( x^{(i)} = z^{(i)}*W \)
\( \textbf{y}^{(i)} = \frac{\textbf{x}^{(i)}\gamma}{\sqrt{\sigma^2_{B} + \epsilon}} + \beta — \frac{\mu_{B}\gamma}{\sqrt{\sigma^2_{B} + \epsilon}} = z^{(i)}\frac{W\gamma}{\sqrt{\sigma^2_{B} + \epsilon}} + \beta — \frac{\mu_{B}\gamma}{\sqrt{\sigma^2_{B} + \epsilon}} = z^{(i)}\hat{W} + \hat{b} \)
\( \hat{W} = \frac{W\gamma}{\sqrt{\sigma^2_{B} + \epsilon}} \)
\( \hat{b}= \beta — \frac{\mu_{B}\gamma}{\sqrt{\sigma^2_{B} + \epsilon}} \)
В итоге, получим абсолютно идентичный результат при уменьшении количества вычислений!
Применить сплавление из коробки можно на этапе сохранения в onnx — основной формат для сохранения моделей перед инференсом на любом из фреймворков (TensorRT, OpenVINO и прочие). Нужные аргументы в функции выделены жирным шрифтом.
# Input to the model
x = torch. rand(batch_size, 1, 224, 224, requires_grad=True)
torch_out = torch_model (x)
# Export the model
torch.onnx. export(
torch_model, # model being run
x, # model input (or a tuple for multiple inputs)
"super_resolution.onnx",# where to save the model (can be a file or file-like object)
export_params=True, # store the trained parameter weights inside the model file
opset version=10, # the ONNX version to export the model to
**do_constant_folding=True, # whether to execute constant folding for optimization**
input_names = ['input'], # the model's input names
output_names = ['output'], # the model's output names
)
Если же вы делаете инференс на PyTorch (что, кстати, зря), то можете воспользоваться скриптом, который подготовили держатели репозитория.
💡 Но нужно быть аккуратным с этой техникой!
Например, есть два полносвязных слоя. У первого матрица весов имеет следующую форму: \( W_1.shape = [M, R], \) \( W_2.shape = [R, N] \). Вышло так, что между ними нет функции активации. Опустим, почему, однако сеть переучивать не хочется. Два этих слоя можно сплавить в один, который будет иметь размер \( W_{fold}.shape = [M, N] \).
Посчитаем эффект от такой процедуры в виде MAC-ов. Пусть на вход приходит тензор \( X.shape=[BS, M] \).
Тогда отношения MAC-ов \( \frac{MAC_{origin}}{MAC_{fold}} = \frac{BS*MR+BS*NR}{BS*MN} = \frac{R}{N} + \frac{R}{M} \).
Если \( R >> M \) или \( R >> N \), то это имеет смысл, но если наоборот, то сеть станет работать медленнее. Получается, если бы мы могли “расфьюзить” некоторые слои, то она бы ускорилась? Да!
Но это уже выходит за рамки текущей статьи, а обзор решений займет отдельную лекцию. Кстати, такая есть в нашем курсе по ускорению 🙂
Заключение
Мы дали 3 рекомендации, которые могут быть полезны при ускорении нейронных сетей. На первый взгляд, советы могут показаться очевидными, но их часто упускают.
При этом не соблюдая первые два пункта можно потратить огромное количество времени впустую, а значит, затянуть сроки и, возможно, провалить проект.