Привет, Хабр! Меня зовут Михаил Степанов, я инженер-стажер группы функциональной верификации YADRO. Эта статья — логическое продолжение предыдущей, «Простая нейронная сеть на C++». Для лучшего понимания темы рекомендую сначала ознакомиться с ней.
В этих двух статьях я работал с синтетической задачей, решение которой нельзя применить в реальных системах. На всеобъемлющий анализ я не претендую, это просто академическое исследование. Оно поможет понять, с чего начать погружаться в тему ускорения нейросетей.
Какую задачу мы решаем
В предыдущей статье мы создали нейронную сеть, которая способна различать на картинке треугольник, квадрат и круг. Теперь нам нужно разработать микроархитектуру аппаратного вычислителя, который сможет запускать заранее обученную нейронную сеть.
Для реализации модели я выбрал язык SystemC, который реализован в виде библиотеки для C++, поэтому на него будет проще перенести код самой модели. Также хотелось выбрать архитектуру, которая позволит обрабатывать каждый нейрон независимо.
Что такое архитектура
Перед началом разработки архитектуры и микроархитектуры необходимо разобраться, что это такое.
Наиболее популярное определение архитектуры — это набор допустимых команд (ISA, Instruction Set Architecture), что отчасти верно. Но при разработке специализированных вычислителей на первый план могут выйти и прочие характеристики архитектуры.
Выходит, что архитектура — это более широкое понятие, чем набор допустимых команд. Более точное определение: архитектура — это набор видимых свойств, присущих системе. Такое определение позволяет проектировщику сосредоточить свое внимание именно на той части системы, которую он считает наиболее важной. Например, в случае архитектуры, которая приведена ниже, пользователь не зависит от конкретного набора команд. Дело в том, что интерфейс системы зависит от формата данных, которые хранятся в памяти. Поэтому важно четко описать последовательность и формат входных данных.
После разработки архитектуры следует заняться микроархитектурой — фактической реализацией необходимых свойств системы. По сути, оба определения взаимодополняют друг друга, потому что архитектуру также часто определяют как интерфейс между микроархитектурой и пользователем (программистом).
Разберем эталонную архитектуру на иллюстрации выше, чтобы лучше понять, о чем идет речь дальше в статье. Эталонная архитектура предлагает типовое решение для создания системы. Проектировщик при помощи «напильника» может подогнать ее для решения своей задачи.
В нашем случае эталонная архитектура описывает структурное взаимодействие некоторых компонентов системы, включая:
-
несколько независимых ядер, каждое из которых обладает независимой памятью для хранения входных данных, промежуточных значений и результатов,
-
контроллер ввода-вывода для работы с внешними устройствами,
-
общую память,
-
общую шину для передачи информации.
Архитектура вычислителя
Применим полученные знания на практике и доработаем эталонную архитектуру под решаемую задачу. Первый вариант переработанной архитектуры выглядел следующим образом:
Какие новые компоненты появились, если сравнивать две схемы выше:
-
управляющий модуль: нужен для оркестрации вычисления на независимых ядрах,
-
ядро активации: модуль для активации значений нейронов.
Ядра, обозначенные как PE(MUL), нужны для вычисления взвешенной суммы предыдущего слоя для переданного нейрона. Поскольку количество нейронов в слоях может различаться, это ядро будет повторять операции умножения и сложения несколько раз. Но активация применяется лишь к конечному значению и может быть выполнена за один такт, поэтому было принято решение вынести реализацию функции активации в отдельный модуль.
Такой подход позволяет не только сэкономить ресурсы (по сравнению с вариантом, когда каждое ядро имеет собственный модуль активации), но и заложить специальную возможность по замене функции активации.
Для работы управляющего модуля был составлен алгоритм работы:
-
Блок управления подает сигнал контроллеру ввода-вывода на считывание необходимых для работы данных в общую память и дожидается сигнала окончания.
-
Контроллер получает необходимые данные конфигурации из памяти и инициализирует счетчики.
-
Выбирается первое незанятое вычислительное ядро, подается информация о данных для загрузки в локальную память, подается сигнал о начале работы вычислительного ядра.
-
Проверяется готовность блоков, если такие есть, то значение активируется и записывается в общую память.
-
Проверяется счетчик нейронов. Если не все нейроны подсчитаны, то возвращаемся к шагу 3.
-
Подается сигнал на контроллер ввода-вывода о выгрузке результатов работы.
Со стороны пользователя системы необходимо лишь последовательно подать данные в нужном формате. Для этого была разработана схема хранения данных. То есть данные нужно предоставлять именно в таком порядке, как они будут располагаться в памяти.
Формат данных:
-
все данные о количестве нейронов — в виде целых чисел в дополнительном коде,
-
все входные значения нейронной сети, веса и возвращаемые значения — в виде вещественного числа с плавающей точкой.
Последовательность входных данных:
-
Количество входов нейронной сети (I).
-
Количество выходов нейронной сети (O).
-
Количество скрытых слоев (N).
-
N размеров скрытых слоев (H1, H2 … HN).
-
Веса входов i-го слоя:
-
веса входов первого слоя,
-
веса входов второго слоя,
-
…
-
веса входов последнего слоя.
-
-
Веса входов выходного слоя.
-
I чисел — входов нейронной сети.
В последовательности входных данных не описан формат ввода функции активации, так как нам не нужно охватывать все возможные конфигурации сетей. Поэтому мы используем фиксированную функцию активации. При необходимости можно добавить в последовательность данные и доработать приведенную ниже реализацию для загрузки любых функций активации.
Микроархитектура вычислителя
В этой статье я не буду углубляться в реализацию микроархитектуры шины, памяти и управляющего устройства, потому что в задаче важнее рассмотреть принципы работы вычислительного ядра и управляющего устройства.
Для вычислительного ядра реализация довольно примитивна. По сути, это ядро является вычислителем взвешенной суммы. Основная особенность заключается в наличии очередей входов и весов, которые подаются на умножители. Результаты с умножителей суммируются между собой и добавляются к текущему значению счетчика. Такие операции повторяются до тех пор, пока очереди входов и весов не опустеют. Это означает, что вход посчитан и можно его возвращать в качестве результата.
Реализация блока активации сложнее. Вообще, вычисление тригонометрических формул, делений не на константу и похожих операций — непростая задача. Написаны целые научные работы по оптимизации данных вычислений.
Предлагаю поступить проще. Как уже упоминалось раньше, мы не будем добавлять возможность реконфигурации функции активации. А вместо вычисления функции условно добавим таблицу значений (LUT, lookup table), по которым можно посчитать примерное значение функции в точке.
По каждому диапазону значений хранятся коэффициенты A и B линейной функции, которые аппроксимируют ее на заданном промежутке. Пример такой аппроксимации:
Моделирование на SystemC
Для успешного выполнения задачи помимо составления архитектуры вычислителя, необходимо сделать ее модель. Она будет вычислять выходы нейронной сети.
Существует множество средств симуляции, которые позволяют изучить свойства полученной модели. Для составления модели аппаратного ускорителя можно использовать язык Verilog и среду Vivado. Она имеет встроенный симулятор. Но в данном проекте у меня нет необходимости разрабатывать реальную аппаратуру. Также, исходный код самой нейронной сети был написан на C++, поэтому логичней будет использовать SystemC, более общий по назначению инструмент.
SystemC позиционируется как язык описания моделей для их верификации, который имеет множество удобных инструментов для описания именно аппаратных особенностей, но при этом не ограничивает программиста только ими. SystemC, с точки зрения установки и использования, является скорее фреймворком на C++, который умеет запускать симуляцию. У SystemC есть довольно удобоваримые примеры использования, по которым вы сможете разобраться с языком, так что не будем останавливаться на конкретных языковых конструкциях.
Написание кода
Все исходники проекта вы можете найти на моем github. В статье приведу самые значимые моменты реализации. Весь проект разбит на несколько файлов:
-
Activation — модуль активации.
-
ControllerUnit — модуль управления.
-
IOController — модуль ввода-вывода.
-
Localmem — общая память.
-
NeuralCore — вычислительное ядро.
-
Processor — модуль верхнего уровня, который объединяет в себе все части системы и коммутирует их между собой.
Для упрощения кода я не стал создавать отдельный модуль шины, все устройства коммутируются напрямую. С точки зрения реализации такое решение несколько упростило написание кода, но привело к следующим последствиям:
SC_MODULE(LocalMem)
{
sc_port<sc_signal_in_if<size_t>, local_mem_slave_count> addr_i;
sc_port<sc_signal_inout_if<float>, local_mem_slave_count> data_io;
sc_port<sc_signal_in_if<bool>, 1> wr_i;
sc_port<sc_signal_in_if<bool>, local_mem_slave_count> rd_i;
sc_in<bool> clk_i;
SC_HAS_PROCESS(LocalMem);
LocalMem(sc_module_name nm);
~LocalMem(){};
void bus_write();
void bus_read();
private:
float mem[local_mem_size];
std::pair<bool, float> prepared_write_queue[local_mem_slave_count];
};
Здесь создается отдельная шина для каждого из устройств, что должны читать данные из памяти. У нас таковых оказалось 8, в том числе вычислительные ядра и управляющий модуль. Плюсы такого подхода — каждое ядро может читать данные из памяти независимо. Но в реальной аппаратуре так делать я не рекомендую, поскольку вычислительных ядер может быть не семь, а сотня. В таком случае мы просто не сможем развести все эти каналы связи по кристаллу.
Также стоит обратить внимание на блок активации. Как уже упоминалось, здесь не была реализована реальная система с таблицей значений, а только вставлена «заглушка» конкретной функции активации.
void Activation::process()
{
while (1)
{
if (act_start_i.read())
{
float value = act_data_io->read();
wait();
act_data_io->write(1.0 / (exp(-value) + 1));
}
wait();
}
}
В остальном реализация соответствует описанной микроархитектуре.
Запуск модели
Для запуска модели были взяты данные о весах из нейронной сети, которая была описана в первой статье. Немного манипуляций с выводом, и вот мы получаем практически нечитаемый файл по формату нашей памяти:
Ничего страшного, подадим его на вход нашей модели, а дальше она сама сообразит.
Собираем исполняемый файл при помощи Make, запускаем и получаем вывод в консоль:
Вывод вероятностей должен точно совпадать с нейронной сетью, которая написана на C++, — иначе где-то была допущена ошибка. В нашем случае и еще в нескольких итерациях запуска вывод полностью совпал, поэтому записываем результаты скорости выполнения. Задача выполнена.
Заключение
Результаты, полученные во время симуляции, говорят, что вычислитель дольше загружал данные, чем реально выполнял какую-то работу. Такой ускоритель вряд ли бы стал продуктовым решением. Но в ходе работы мы рассмотрели важные аспекты проектирования, которые вы можете перенести на свою область и применить в прикладных задачах.
Если вам захочется поэкспериментировать, то исходная нейронная сеть лежит в репозитории. Все ее параметры настраиваются, также добавлена функция для автоматической генерации текста входного файла.
Автор: Mankeyy