Нейросети постепенно и незаметно проникают во все уголки нашей жизни. От огромных сеток, которые могут обыгрывать в шахматы чемпионов мира или вести беседу на уровне интеллигентного человека, до совсем маленьких, реагирующих на голосовые команды или выделяющих лица на фотографии.
И если для первых логично использовать специализированную аппаратуру, то вторые вполне могут работать на обычных микроконтроллерах.
В этой статье поделюсь нашим опытом запуска нейросетей на DSP процессоре фирмы «Миландр» К1967ВН044, тем более что в новой ревизии появился Ethernet и можно организовать быстрый обмен данными, например, с ПК.
Предпосылки
Большинство читателей, наверняка, усмехнулись и спросили себя: «А почему не…» и дальше произнесли что-нибудь экзотическое, заведомо неподходящее для нейросетевых задач. Но не стоит спешить.
Начнём с простого. Вот давайте посмотрим на каноническую структуру типичной нейросети:
Есть коэффициенты, есть сумматор… А теперь посмотрим на типичную структурную схему цифрового фильтра. Я возьму для примера типовой КИХ-фильтр:
Как в таких случаях принято говорить: «найдите десять различий». Те же умножители, тот же сумматор. Понятно, что структура нейросети приведена уж очень каноническая, реальность сильно отличается. Но уже отсюда видно, что внутренние блоки сигнальных процессоров могут оказаться прекрасными нейроускорителями. Собственно, чтобы подтвердить или опровергнуть эти мысли, и возникла идея попробовать всё сделать на сигнальном процессоре.
А теперь переходим от такого наглядного, но «натянутого» примера к более реальным аргументам.
Важным фактором, по понятным причинам, будет то, что это отечественный продукт, причём его можно купить прямо сейчас. Мы работаем не с программной моделью и не чисто на кончике пера, а с реальным оборудованием.
Если взглянуть на архитектуру выбранного процессора, то мы увидим интересные и многообещающие особенности.
Начнём с тактовой частоты. С одной стороны, по сравнению с лидерами рынка, она выглядит скромно – около 300 МГц. Но это всё-таки вполне на уровне «обычных» микроконтроллеров, а ведь простые нейросети вполне на них работают.
Далее, память – тут уже интереснее. Опять же, кто-то может сказать, что её всего 1.5 МБ. Но ведь это какая память! Благодаря 256-битной шине возможны чтение/запись 32 байт за один такт, что уже гораздо выше возможностей типичного микроконтроллера. При этом имеется и внешняя память, объём которой может составлять десятки или даже сотни мегабайт. И хотя она существенно медленнее внутренней, но находится в том же адресном пространстве, так что при необходимости оба типа памяти можно свободно смешивать при вычислениях.
Теперь – набор инструкций. Очевидно, что для обработки такого скоростного потока данных скалярных инструкций окажется недостаточно – нужны векторные. И они имеются, причём в очень широком ассортименте. Например, процессор умеет за один такт выполнить восемь шестнадцатибитных умножений с получением тридцатидвухбитных результатов. То же самое относится и к другим типичным арифметическим операциям, но не только. Есть и такие, которые обычно отсутствуют, например, векторная упаковка/распаковка чисел разной разрядности и т.п.
Особо стоит отметить, что числа с плавающей точкой – это «родной» формат данных для DSP, так что на них возможно использовать нейросети без квантования.
Ещё одна особенность – способность выполнять до четырёх инструкций за такт. Разумеется, не произвольных, а таких, которые не мешают друг другу. Тем не менее на практике линии из двух-трёх инструкций встречаются достаточно часто.
Фирменная «фишка» DSP процессоров фирмы «Миландр» – это наличие четырёх высокоскоростных LINK-портов. И дело не только в скорости, например, у Ethernet скорость тоже может быть высокой. Но в данном случае речь идёт о передаче не пакетов (с большими накладными расходами на формирование и разбор), а блоков памяти непосредственно между процессорами. То есть можно образовывать матрицы из десятков процессоров, которые будут работать параллельно.
При практической работе, помимо вычислений нам понадобятся интерфейсы для связи с внешним миром. И тут у выбранного DSP тоже всё хорошо: имеется большой набор вариантов, таких как Ethernet, UART, I2C, и т.п. В частности, на штатную плату для разработчика можно подключить экран, камеру и прочую периферию.
Ожидания
Итак, всё вышесказанное позволяет надеяться, что данный процессор позволит получить результаты существенно лучшие, нежели способен обеспечить «обычный» микроконтроллер.
Причём, чтобы было интереснее, мы выбрали в качестве соперника процессор x86 Intel, который хоть и не является специализированным нейроускорителем (соревноваться с ними было бы слишком самонадеянно), но всё-таки обладает сильными чертами.
И речь идёт не только о тактовой частоте, которая, разумеется, у него на порядок выше.
Ведь процессор от Intel имеет многоуровневую систему больших кешей, так что его память, скорее всего, окажется тоже на уровне.
Ну и векторные инструкции в нём имеются, ещё со времён MMX, который появился давным-давно и с тех пор несколько раз развивался.
В общем, мы взяли обычную нейросеть (SsdSlim), которую в дальнейшем будем считать эталоном, адаптировали её код для компилятора MSVS и получили время выполнения 30 мс на процессоре Intel i7 3.4 ГГц.
Рассмотрим чуть подробнее, откуда берётся код для запуска на процессоре. Сами нейросети обычно распространяются в собственных форматах, которые описывают архитектуру, содержат веса, но для выполнения требуются специальные средства. Мы же используем открытый компилятор нейросетей Apache TVM, который умеет по описанию сети генерировать код для заданной платформы, в том числе и портабильный вариант, который собирается обычным Си компилятором.
Для работы на нашем процессоре мы использовали среду разработки от Миландра CM-LYNX, которую можно сравнить с MSVC по набору возможностей, то есть графическая оболочка, редактор, компилятор, отладчик и т.п.
Теперь надо повторить запуск на целевом процессоре и сравнить результаты.
Промежуточные результаты
Это оказалось не совсем просто. Казалось бы, мы использовали код, который порождает TVM, то есть портабильный Си. Но возникают проблемы, часть из которых опишу.
Первое затруднение: внутренняя память разбита на 6 банков, между которыми имеются «разрывы». Таким образом, невозможно работать с массивом длиннее 256 КБ. А в коде сети есть тензоры с размерами больше 300 КБ. Если обсчитывать их во внешней памяти, то о производительности можно забыть сразу.
Однако существует простое решение.
Мы придумали объект «блок памяти с дыркой». Он описывается тремя числами: адрес начала, размер первой части, размер промежутка между первой и второй. И выясняется, что адресация в таком блоке чрезвычайно эффективна: достаточно проверить, выходит ли индекс за пределы первой части, и, если да, добавить к нему размер промежутка.
Причём на ассемблере (о котором ещё пойдёт речь) это потребует всего двух инструкций: сравнения и условного сложения, то есть не требуется даже перехода.
И вот, теперь большой тензор располагается в двух банках (даже не обязательно соседних), а адресация выглядит классической, линейной, за счёт класса-обёртки.
Более того, если всё-таки такой тензор не поместился во внутреннюю память целиком, возможно, туда поместится его часть, и мы получим ускорение хотя бы частично.
Кстати, в процессе возникла задача распределять память из нескольких несмежных блоков. У нас есть распределитель, который умеет работать с непрерывным блоком, но его модификация представлялась нежелательной. Возникла идея: при инициализации создать такую структуру данных, чтобы «дырки» с точки зрения распределителя выглядели захваченными блоками. Поскольку на самом деле их никто не захватывал, то их никто никогда и не освободит. Также никто не попробует получить доступ к содержимому. Таким образом, исходный алгоритм полагает, что у него по-прежнему один непрерывный блок памяти, только фрагментированный, и не требует доработки.
Как и следовало ожидать, все данные во внутреннюю память, в итоге, не поместились. Мы были к этому готовы: внутреннюю память следует использовать в качестве кеша. Причем в данном случае алгоритм нейросети очень хорошо для этого подходит: вычисления выполняются по слоям, и каждый слой требует свои данные. Сделаем простую реализацию с выталкиванием во внешнюю память данных, к которым дольше всего не было доступа, и получим время выполнения менее одной секунды.
Теперь давайте посмотрим, чего удалось добиться на текущем этапе.
Вот пример распознавания небольшого ролика. В левом окошке проигрывается исходное видео, оттуда вырезается кадр и отправляется на плату. Как только он готов, мы показываем его в правом окне. Такой механизм стоит очень дорого, поскольку приходится сохранять кадр в виде файла, который плата забирает себе для обработки, потом создаёт файл с результатом и т.д. Но, тем не менее, что-то уже получилось…
Планы на будущее
Таким образом, отставание на данный момент составляет примерно 25 раз. Вспомним, что у нас тактовая частота более чем в 10 раз ниже, так что разница, обусловленная возможностями процессоров, пока оказывается менее чем в 3 раза в пользу Intel.
Однако его компилятор наверняка «выжимает» из аппаратуры если не максимум возможного, то близко к тому.
А мы ещё толком не начинали.
Дело в том, что если присмотреться к коду после TVM, то мы увидим две особенности.
- Код весьма однообразный. Количество самих нейросетевых операций невелико, и обычно они сводятся к вложенным циклам, которые обходят тензор по осям и выполняют некоторое вычисление во внутреннем цикле.
- Ассемблерный код, который получается для внутреннего цикла, может быть переписан вручную в виде функции, принимающей на входе конкретные параметры и работающей гораздо быстрее, чем это удается компилятору. Мы реализовали для примера несколько характерных вариантов и получили ускорение около трёх раз.
Таким образом, если покрыть ассемблерными реализациями хотя бы самые «тяжёлые» операции, то можно рассчитывать «догнать» Intel (с учётом разницы в тактовой частоте).
Для этого можно пойти двумя путями.
На первом этапе можно написать скрипт, который преобразует generic код TVM. Дело в том, что исходный код жёстко отформатирован (ведь он является результатом автогенерации), так что не требуется разбор Си-грамматики, достаточно обнаружить и разобрать некий набор шаблонов.
Ну а лучший (но существенно более трудоёмкий) подход – это реализовать в TVM отдельный backend для платформы CM-LYNX, который сам вставит вместо внутренних циклов вызовы ассемблерных функций.
Наконец, есть множество чисто технических моментов для оптимизации, например, организовать загрузку данных из внешней памяти для следующего слоя через DMA, пока считается текущий, организовать обмен данными через Ethernet и т.п.
Выводы
Процессор фирмы «Миландр» ВН044 вполне подходит для эффективного исполнения нейросетей. Конечно, будем реалистами, соревноваться с NVIDIA не получится, но свою нишу занять может. Открытым остается вопрос: какой выигрыш получится от применения многопроцессорной архитектуры? Как покажет себя модуль с четырьмя, восемью, или даже большим количеством процессоров «на борту»?