В этом посте я расскажу, как эффективно реализовать CNN в таких средах глубокого обучения, как TensorFlow и PyTorch.

Когда я писал свой собственный фреймворк для бинаризованных CNN на C ++, я ожидал, что он будет работать так же быстро, как PyTorch. Результат - моя реализация Conv была в 100 раз медленнее, чем PyTorch. Я даже сравнил количество FLOP в моем коде с PyTorch. Но счетчик FLOP был таким же.

После нескольких дней оптимизации моего кода на C ++ я понял, что это происходит медленнее, потому что в фреймворках машинного обучения свертки не реализованы так, как мы их визуализируем.

Несмотря на то, что в этом случае FLOP одинаковы, выборка данных из памяти намного медленнее и потребляет больше энергии при записи традиционных сверток. Фактические свертки реализуются после преобразований ядра im2col с помощью операций GEMM.

Если вы никогда не слышали о них раньше, значит, вы обратились по адресу. Мы демистифицируем их здесь.

Но прежде чем углубиться в это, взгляните на эту таблицу.

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

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

Таким образом, недостаточно просто быстро вычислить данные, если мы не можем получить их быстро. Начнем с некоторых основ:

Формат данных

Схема данных определяет, как многомерные матрицы хранятся в памяти. Для двумерного массива он может располагаться в порядке возрастания строк или столбцов.

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

Кеширование ЦП

ОЗУ может хранить большой объем данных, но поскольку это внешняя память, она работает довольно медленно (т.е. с большой задержкой). Кеш-память ЦП на порядки быстрее, но намного меньше. При попытке чтения или записи данных процессор проверяет, есть ли данные в кеше. Если это так, процессор будет читать или записывать в кэш вместо гораздо более медленной основной памяти. Каждый раз, когда мы извлекаем данные из основной памяти, ЦП автоматически загружает их и соседнюю память в кеш, надеясь использовать локальность ссылки.

Местонахождение ссылки

  • Пространственная локальность. Весьма вероятно, что обращение к любому последовательному хранилищу в памяти будет выполнено раньше. Вот почему, если требуется [i], ЦП также предварительно загружает в кеш a [i + 1], a [i + 2],…
  • Временное расположение. Также весьма вероятно, что если программа обратится к некоторым данным, на них снова будут ссылаться. Вот почему недавно использованные переменные после вычисления сохраняются в кеше.

Реализация свертки

Теперь мы должны оптимизировать наш код свертки таким образом, чтобы либо недавно использованные данные использовались снова, либо данные, расположенные последовательно рядом с этими данными, были использованы. Давайте сначала посмотрим на традиционную операцию свертки:

Давайте попробуем понять это на примере с входным изображением формы (4,4, 2) и фильтром формы (3,3, 2):

На приведенной выше диаграмме показано, как осуществляется доступ к памяти для первого окна операции свертки. Когда мы находимся в точке (0,0), ЦП загружает данные не только для точки (0,0), но и для следующих элементов строки кеша. Таким образом, не нужно загружать данные из памяти в точках (0,1) и (0,2), потому что эти элементы уже находятся в кеше. Но когда мы находимся в точке (0,2), следующий элемент [т.е. (1,0)] не находится в кеше - мы получаем промах в кеше, и процессор останавливается во время выборки данных.

Точно так же он продолжает останавливаться в точках (1,2), (2,2) и т. Д. Итак, im2col просто размещает элементы одного окна свертки в одной строке. Затем к ним можно будет последовательно обращаться из кеша без промахов. В результате все 18 элементов, необходимых для свертки, переупорядочиваются, как показано на изображении ниже:

В нашем примере будет четыре окна свертки. Преобразовав все в im2col и сложив их, мы получим следующий двумерный массив:

Как видите, теперь у нас 18x4 = 72 элемента вместо 32 элементов (как на исходном изображении). Эта избыточность - обратная сторона im2col, но, учитывая разницу в производительности, оно того стоит.

После перестановки изображения нам также нужно переставить фильтры (на практике они уже хранятся в таком виде). Предположим, у нас есть 5 фильтров из (3,3,2) - в этом случае каждый фильтр будет организован в один столбец из 3x3x2 = 18 элементов. Затем 5 из них складываются, чтобы получить матрицу (18, 5).

На этом этапе мы преобразовали это в простую задачу умножения двумерных матриц. И это очень распространенная проблема в научных вычислениях. Существуют библиотеки BLAS, такие как OpenBLAS, Eigen и Intel MKL для вычисления GEMM (General Element Matrix Multiplication). Эти библиотеки на протяжении многих десятилетий оптимизировались для умножения матриц и теперь используются во всех фреймворках, таких как TensorFlow, PyTorch и т. Д.

Умножение матрицы входных данных и фильтра дает результирующую матрицу формы (4,5). Это можно преобразовать в матрицу (2,2,5), которая является нашим ожидаемым размером.

Вывод

Наконец, после использования im2col, я смог получить скорость только в 2–3 раза медленнее, чем PyTorch. Но это еще не конец истории. Есть несколько других алгоритмов, которые могут помочь еще больше ускорить процесс, например:

  • Быстрая свертка Винограда - уменьшает количество умножений в 2,5 раза на фильтре 3x3. Однако хорошо работает только с маленькими фильтрами.
  • Быстрое преобразование Фурье. Повышает скорость только при очень больших фильтрах и размерах пакетов.
  • Алгоритм Штрассена - уменьшает количество умножений с O (N³) до O (N²,807). Но увеличивает требования к хранению и снижает числовую стабильность.

На практике для разных форм и размеров слоев могут использоваться разные алгоритмы (например, БПФ для фильтров более 5 × 5 и Виноград для фильтров 3 × 3 и ниже). Существующие библиотеки платформы, такие как MKL и cuDNN, динамически выбирают подходящий алгоритм для заданной формы и размера.

Примечание. В нашем примере размер изображения (4,4,2) был настолько мал, что все изображение могло поместиться в кеше, поэтому промахов в кеше не будет. Но если мы возьмем более крупные активации ввода, такие как (128, 128, 256), мы получим промахи.

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

Независимая редакция, Heartbeat спонсируется и публикуется Comet, платформой MLOps, которая позволяет специалистам по обработке данных и группам машинного обучения отслеживать, сравнивать, объяснять и оптимизировать свои эксперименты. Мы платим участникам и не продаем рекламу.

Если вы хотите внести свой вклад, отправляйтесь на наш призыв к участникам. Вы также можете подписаться на наши еженедельные информационные бюллетени (Deep Learning Weekly и Comet Newsletter), присоединиться к нам в » «Slack и подписаться на Comet в Twitter и LinkedIn для получения ресурсов, событий и гораздо больше, что поможет вам быстрее и лучше строить модели машинного обучения.