Оптимизация сети

Введение

В этом туториале мы рассмотрим такую возможность библиотеки PuzzleLib, как оптимизацию нейронных сетей за счет фиксации размерности входного тензора сети. Подобное ускорение работает как с обучением, так и с инференсом. Скрипт называется OptimizeNet.py, находится в папке TestLib библиотеки PuzzleLib. Мы замерим время обучения на одном батче неоптимизированной VGG-16 и оптимизированной и покажем, что подобная оптимизация действительно ускоряет обучение.

Методы оптимизации

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

  • Оптимизация графа нейронной сети (fusion) - в большинстве популярных нейросетевых архитектурах стандартизирован набор используемых модулей (Conv, MaxPool, Activation, BatchNorm и т.д.). Благодаря тому что мы знаем архитектуру сети после ее обучения, мы можем оптимизировать различные комбинации модулей из архитектуры с точки зрения вычисления инференса сети;
  • Конвертация данных для вычислений в нейронных сетях в числа половинной точности (half precision) - обычно данные для вычислений в нейронных сетях в Python имеют тип float32. То есть каждое число представляется в виде дробного с определенным количеством знаков после запятой и занимает в оперативной памяти 4 байта. Зачастую, такая точность после запятой не нужна. При этом, перемножение больших матриц, состоящих из float32 элементов, является достаточно трудоемкой операцией. Перевод данных из float32 в float16 сокращает вычисления без потерь в точности работы инференса нейронной сети. Каждое число с типом float16 занимает в оперативной памяти 2 байта. В итоге, при таком подходе в лучшем случае получается ускорение примерно в 2 раза;
  • Квантизация данных для вычислений в нейронных сетях - по аналогии с предыдущим пунктом происходит конвертация данных из float32 в int8. При этом есть, в основном, незначительные потери в точности работы инференса сети. Каждое число с типом int8 занимает в оперативной памяти всего 1 байт. В итоге, при таком подходе в лучшем случае получается ускорение инференса примерно в 4 раза.

С применением этих подходов можно ознакомиться в туториале Конвертация в TensorRT engine

Запуск скрипта

Откройте скрипт OptimizeNet.py, находящийся в папке TestLib. Проверьте, что скрипт запускается и доходит до конца, не упав. Если скрипт падает, значит, вам нужно разобраться с установкой PuzzleLib (у вас может не работать CUDA, может отсутствовать какая-нибудь из Python-библиотек).

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

Начало скрипта

Скрипт начинается с импортирования библиотеки numpy - основной Python-библиотеки для работы с многомерными тензорами

import numpy as np

Далее происходит импорт из библиотеки PuzzleLib всех необходимых нам классов и функций. Следующая строчка импортирует вспомогательную функцию бекэнда gpuarray, необходимую для правильного размещения тензоров на GPU:

from PuzzleLib.Backend import gpuarray

Дальше импортируется функция timeKernel, которая используется для засекания времени выполнения заданной операции. Основными параметрами являются объект операции, по которой производится замер, и количество итераций для замера looplength:

from PuzzleLib.Backend.Benchmarks import timeKernel

Эта строчка импортирует функцию подгрузки одной из реализованных в библиотеке архитектур нейросетей - VGG:

from PuzzleLib.Models.Nets.VGG import loadVGG

Здесь мы подгружаем модули, с помощью которых будет обучаться нейронная сеть:

from PuzzleLib.Optimizers import SGD
from PuzzleLib.Cost import CrossEntropy
from PuzzleLib.Handlers import Trainer

Оптимизаторы содержат реализации алгоритмов для обучения сетей, в костах хранятся реализации функций потерь (стоимости ошибок).

Хэндлеры - обработчики, которые являются вспомогательными объектами для обучения, валидации и стандартного вычисления выхода нейросети.

Далее переходим в код основной функции main(), исполнение которой вызывается внизу скрипта:

def main():
  net = loadVGG(None, "16")

Здесь мы подгружаем архитектуру нейронной сети семейства VGG и инициализируем ее.

Значение параметра 16 задает количество слоев сети. В PuzzleLib реализованы VGG с 11, 16 или 19 слоями. Любые другие значения для данного параметра вызовут вывод сообщения об ошибке.

  batchsize = 16
  size = (batchsize, 3, 224, 224)

В первой строке мы устанавливаем размер батча. Размер батча отвечает за то, сколько объектов (изображений) за раз будет передаваться сети для обучения. В данном случае мы выставляем значение 16.

Во второй строке мы формируем кортеж, содержащий размерности данных, подаваемых в нейросеть.

Первое число кортежа обозначает размер батча, второе число - количество карт в объекте (изображении) (глубина), третье и четвертое - высота и ширина объекта.

Объектом является тензор размера size, и аналогом ему в реальных условиях могло бы быть изображение с идентичными размерностями (все изображения представляются в виде подобных тензоров).

В этом блоке кода формируются обучающие данные:

  batch = np.random.normal(size=size).astype(dtype=np.float32)
  batch = gpuarray.to_gpu(batch)

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

С помочью функции np.random.normal из библиотеки numpy формируется батч трехмерных тензоров в соответствии с размерностями, определенными на предыдущем шаге.

Функция np.random.normal заполняет тензор заданной размерности случайными числами нормального распределения.

После формирования батчей на CPU, они переносятся на GPU при помощи функции to_gpu. Все дальнейшие вычисления будут происходить на GPU.

В следующем блоке формируются метки классов для каждого объекта батча. Числа, являющиеся метками классов для объектов, формируются функцией random.randint из numpy в диапазоне от 0 до 999 и хранятся в формате np.int32:

  labels = np.random.randint(low=0, high=1000, size=(batchsize, ), dtype=np.int32)
  labels = gpuarray.to_gpu(labels)

Таким образом, мы сопоставили каждому объекту из батча свой класс (от 0 до 999) и сымитировали формирование настоящей обучающей выборки. После синтеза множества ответов мы переносим его на GPU аналогичным переносу данных образом.

Подготовка к обучению сети

Теперь, когда у нас есть данные и архитектура, пришла пора перейти к обучению нейронной сети.

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

  optimizer = SGD()
  optimizer.setupOn(net)

Для обучения сети еще нужна функция, оценивающая, насколько сеть ошибается при классификации входного объекта. Инициализируем ее:

  cost = CrossEntropy(maxlabels=1000)

Это функция кросс-энтропии. Параметр maxlabels отвечает за количество выходов сети. Мы рассматриваем этот вектор как распределение вероятностей по 1000 классам объектов (вероятность, что рассматриваемый объект класса 0, ... , вероятность, что рассматриваемый объект класса 999).

Правильные ответы для каждого объекта известны, поэтому мы можем сравнить предсказание сети с ними. Кросс-энтропия делает эту операцию, выдавая число, показывающее, на сколько сеть ошиблась.

Теперь инициализируем тренера.

  trainer = Trainer(net, cost, optimizer)

Тренер - это объект, реализующий процесс обучения, в ходе которого он обращается к оптимизатору для пересчета весов нейросети в соответствии с функцией стоимости ошибки. Подробнее можно прочитать о нем тут.

В данный момент тренер только инициализирован, но ничего не делает. Чтобы процесс обучения начался, надо вызвать специальный метод train этого объекта (что мы и сделаем далее).

Обучение сети

Мы не будем задавать количество эпох, валидационную и тестовую выборки. Для нашей цели (сравнение времени обучения неоптимизированной и оптимизированной сетей) хватит нашего батча.

Используем функцию timeKernel, чтобы засечь время выполнения операции обучения сети при ее текущей архитектуре на заданном количестве данных. Длину цикла для тестирования устанавливаем в 100 итераций. Дополнительно указываем в тексте лога, что эти данные получены до оптимизации сети:

  print("Started benchmarking %s ..." % net.name)
  timeKernel(
      trainer.train, args=(batch, labels), looplength=100, logname="Before optimizing %s" % net.name, normalize=True
  )

Используем функцию оптимизации сети по ее размерности. Она вызывается рекурсивно. Итерация проходит по каждому узлу (модулю, операции) графа вычислений сети и вызывает сама себя от текущего shape данных:

  net.optimizeForShape(size)

После оптимизации вновь запускаем timeKernel с такими же параметрами для сравнения времени. В логе указываем, что данные получены после оптимизации сети:

  timeKernel(
      trainer.train, args=(batch, labels), looplength=100, logname="After optimizing %s" % net.name, normalize=True
  )

Оптимизированная сеть обучается быстрее, т.к. проход по одному батчу выполняется быстрее, чем у неоптимизированной сети.

Вот вы и освоили базовый навык оптимизации нейронных сетей!