- Принципы реализации пакетной нормализации
- Построение класса пакетной нормализации средствами MQL5
- Организация многопоточных вычислений в классе пакетной нормализации
- Реализация пакетной нормализации на Python
- Сравнительное тестирование моделей с использованием пакетной нормализации
Организация многопоточных вычислений в классе пакетной нормализации
Мы продолжаем работу над нашим классом пакетной нормализации CNeuronBatchNorm. В предыдущих разделах мы уже полностью реализовали функционал работы класса стандартными средствами MQL5. Для завершения работы над классом, согласно нашей концепции, остается дополнить его функциональность возможностью выполнения многопоточных математических операций с использованием технологии OpenCL. Напомню, реализацию данного функционала можно условно разделить на два подпроцесса:
- создание программы OpenCL;
- внесение изменений в методы основной программы для организации обмена данными с контекстом и вызова программы OpenCL.
Начинаем работу с создания программы OpenCL. Сначала реализуем кернел прямого прохода BatchNormFeedForward. В параметрах кернелу будем передавать указатели на четыре буфера и две константы:
- inputs — буфер исходных данных (результатов предыдущего слоя);
- options — буфер параметров нормализации;
- weights — буфер матрицы обучаемых параметров (назван по аналогии с буфером класса);
- output — буфер результатов;
- batch — размер пакета нормализации;
- total — размер буфера результатов.
__kernel void BatchNormFeedForward(__global TYPE *inputs,
|
Последний параметр необходим, потому что для оптимизации процесса вычислений мы используем векторные переменные типа TYPE4. Подобный подход позволяет распараллелить вычисления не на программном уровне, а на уровне микропроцессора. Использование вектора из четырех элементов типа double позволяет полностью заполнить 256-битный регистр микропроцессора и за один такт произвести вычисления над всем вектором. Таким образом, за один такт микропроцессора мы осуществляем операции над четырьмя элементами нашего массива данных. OpenCL поддерживает векторные переменные из 2, 3, 4, 8 и 16 элементов. Перед выбором размерности вектора ознакомьтесь с техническими характеристиками вашего оборудования.
В теле кернела мы сразу определяем идентификатор текущего потока. Он нам понадобится для определения смещения в буферах тензоров до анализируемых переменных.
И тут же проверяем размер пакета нормализации. Если он не больше единицы, просто копируем соответствующие элементы из буфера градиентов текущего слоя в буфер градиентов предыдущего слоя и прекращаем дальнейшее выполнение кернела.
int n = get_global_id(0);
|
Обратите внимание, что при вызове функции обращения значений тензора в векторное представление и обратно для параметра смещения в тензоре мы увеличиваем идентификатор потока в четыре раза. Это связано с тем, что при использовании векторных операций с TYPE4 каждый поток одновременно обрабатывает четыре элемента тензора. Следовательно, запущенных потоков будет в четыре раза меньше размера обрабатываемого тензора.
Если же размер пакета нормализации больше единицы, и при этом мы продолжаем выполнение программы, то необходимо определить смещение в буферах тензоров параметров нормализации с учетом идентификатора текущего потока и размера вектора осуществления операций (TYPE4)
int shift = n * 4;
|
Переходим непосредственно к выполнению нашего алгоритма. Сначала создадим вектор с анализируемыми исходными данными и посчитаем экспоненциальное среднее. Будем ориентироваться на предыдущее среднее значение и дисперсию для определения первой итерации. Разделим предварительно полученное значение для усреднения на размер пакета выборки только на второй и последующих итерациях. Это объясняется тем, что среднее значение из первого элемента и есть сам элемент.
После определения среднего значения найдем отклонение текущего значения от среднего и вычислим дисперсию выборки.
TYPE4 inp = ToVect4(inputs, shift, 1, total, 0);
|
Когда есть среднее значение и дисперсия выборки, можно легко посчитать нормализованную величину параметра.
TYPE4 nx = delt / sqrt(variance + 1e-37f); |
Далее, согласно алгоритму пакетной нормализации, необходимо осуществить сдвиг и масштабирование нормализованного значения. Но прежде хочу напомнить, что на начальном этапе мы инициализировали буфер матрицы обучаемых параметров нулевыми значениями. В таком виде мы получим 0 для всех значений независимо от полученного ранее нормализованного значения.
Поэтому проверяем наличие нулевого значения для коэффициента масштабирования и при необходимости заменяем его на единицу.
if(weights[shift_weights] == 0)
|
Обратите внимание, что мы проверяем на равенство нулю только первый элемент анализируемого вектора значений. При этом заменяем на единицу весь вектор. Такой подход считаю допустимым, так как ожидаю получить нулевые значения только при первом проходе. В этот момент у нас будут все элементы буфера равны нулю, и их нужно заменить. Дальше коэффициенты уже будут определены, в ходе обучения модели они будут оптимизироваться и, следовательно, будут отличны от нуля.
После такой нехитрой операции мы можем смело осуществить масштабирование и сдвиг.
TYPE4 res = ToVect4(weights, shift, 2, total * 2, 0) * nx +
|
Теперь остается лишь сохранить полученные данные в соответствующие элементы буферов. При этом мы сохраняем не только последний результат, но и необходимые нам промежуточные значения.
D4ToArray(options, mean, shift, 3, total * 3, 0);
|
На этом мы завершаем работу с кернелом прямого прохода BatchNormFeedForward и переходим к работе по созданию кернелов обратного прохода.
Для реализации алгоритма обратного прохода создадим два кернела, один для распределения градиента ошибки до уровня предыдущего слоя, а второй для распределения градиента ошибки до уровня матрицы обучаемых параметров.
Начнем мы с создания кернела распределения градиента ошибки через скрытый слой нейронной сети BatchNormCalcHiddenGradient. В параметрах данного метода будем передавать уже пять буферов данных и две константы:
- inputs — буфер исходных данных (результатов предыдущего слоя);
- options — буфер параметров нормализации;
- weights — буфер матрицы обучаемых параметров (назван по аналогии с буфером класса);
- gradient — буфер градиентов ошибки на уровне результатов текущего слоя;
- gradient_inputs — буфер градиентов ошибки на уровне результатов предыдущего слоя (в данном случае результат работы кернела);
- batch — размер пакета нормализации;
- total — размер буфера результатов.
__kernel void BatchNormCalcHiddenGradient(__global TYPE *options,
|
В начале кернела, как и в кернеле прямого прохода, определяем идентификатор текущего потока и проверяем размер пакета нормализации. Если размер пакета нормализации не больше единицы, то просто копируем градиенты ошибки из буфера текущего слоя в буфер предыдущего слоя и прекращаем выполнение кернела.
int n = get_global_id(0);
|
Если же размер пакета нормализации больше единицы, и при этом мы продолжаем выполнение операций кернела, то предстоит распределить градиент ошибки по всей цепочке от уровня результатов текущего слоя до уровня результатов предыдущего слоя. Напомню математические формулы, которые нам предстоит реализовать.
TYPE4 inp = ToVect4(inputs, shift, 1, total, 0);
|
После расчетов сохраняем результат операций и завершаем выполнение кернела.
D4ToArray(gradient_inputs, gx, shift, 1, total, 0);
|
На этом мы завершаем работу над первым кернелом реализации алгоритма обратного прохода нашего класса пакетной нормализации данных и переходим к заключительной фазе работы над программой OpenCL — созданию второго кернела обратного прохода распределения градиента ошибки до уровня матрицы обучаемых параметров BatchNormCalcDeltaWeights.
В параметрах данному кернелу будем передавать три буфера данных:
- options — буфер параметров нормализации;
- delta_weights — буфер градиентов ошибки на уровне матрицы обучаемых параметров (в данном случае результат работы кернела);
- gradient — буфер градиентов ошибки на уровне результатов текущего слоя.
__kernel void BatchNormCalcDeltaWeights(__global TYPE *options,
|
В данном кернеле нужно реализовать только две математические формулы:
Как видите, операции довольно простые и не потребуют много кода для реализации алгоритма. На это раз мы даже не стали использовать векторные операции.
В начале кернела определяем идентификатор текущего потока и вместе с ним смещение в буферах тензоров параметров нормализации. Смещение в буферах градиентов ошибки будет соответствовать идентификатору потока.
const int n = get_global_id(0);
|
Чтобы сократить обращение к глобальной памяти, сначала сохраним значение градиента ошибки в локальную переменную, а затем вычислим и сразу запишем в соответствующие элементы буфера накопления градиентов ошибки соответствующие значения текущего шага по формулам, указанным выше.
TYPE grad = gradients[n];
|
Как видите, градиенты ошибок записаны в соответствующие элементы буфера. А значит, задача, поставленная перед данным кернелом, выполнена, и мы можем завершить его работу.
Таким образом, мы с вами реализовали все три кернела для организации прямого и обратного проходов в нашем классе пакетной нормализации данных. Теперь можем перейти к внесению изменений в основную программу для организации обмена данными с контекстом OpenCL и вызова соответствующего кернела программы.
Начнем эту работу, как обычно, с создания констант работы с кернелами программы OpenCL. Переходим в файл defines.mqh и в начало добавляем константы идентификации кернелов программы.
#define def_k_BatchNormFeedForward 37
|
А затем добавляем идентификаторы параметров кернелов.
//--- прямой проход пакетной нормализации
|
//--- распределение градиента через слой пакетной нормализации
|
//--- распределение градиента до оптимизируемых параметров пакетной нормализации
|
На следующем этапе надо инициализировать новые кернелы в программе. Для этого переходим в метод инициализации программы OpenCL основного класса-диспетчера нашей модели CNet::InitOpenCL. Сначала изменяем общее количество используемых кернелов.
if(!m_cOpenCL.SetKernelsCount(40))
|
Теперь, когда кернелы созданы, а мы можем к ним обращаться, переходим к работе с методами нашего класса пакетной нормализации.
По уже сложившейся традиции начнем работу с метода прямого прохода. Мы вносим изменения только в части реализации алгоритма многопоточных операций с использованием технологии OpenCL. Весь остальной код метода остается без изменений.
В соответствии с алгоритмом подготовки к запуску кернела, нужно сначала передать все необходимые данные в память контекста OpenCL. Поэтому мы проверяем наличие созданных буферов в памяти контекста.
bool CNeuronBatchNorm::FeedForward(CNeuronBase *prevLayer)
|
На следующем шаге передаем в параметры кернела указатели на буферы данных и значения необходимых констант.
//--- передача параметров кернелу
|
После выполнения подготовительной работы приступаем к постановке кернела в очередь выполнения. Но прежде нужно заполнить два динамических массива. В одном заполняем размерность пространства задач, а во втором — смещение в каждом измерении пространства задач. Запускать кернел будем в одномерном пространстве задач с нулевым смещением. Количество запускаемых потоков будет в 4четыре раза меньше размерности тензора результатов текущего слоя. Но так как размерность тензора не всегда будет кратной четырем, а нам необходимо произвести вычисления для всех элементов тензора результатов, то мы предусмотрим дополнительный поток, который выполнит вычисления для «хвостовой» части тензора, которая не кратна четырем.
После расчета количества потоков и заполнения буферов вызовем метод постановки кернела в очередь выполнения.
//--- постановка в очередь выполнения
|
На этом мы завершаем работу с методом прямого прохода, в котором мы уже реализовали полный функционал, включая возможность организации параллельных вычислений на GPU с использованием технологии OpenCL.
Переходим к методам обратного прохода, в которых нам предстоит провести аналогичную работу. При реализации метода обратного прохода средствами MQL5 мы переопределили два метода. Следовательно, оба метода мы нужно дополнить функционалом многопоточных вычислений. Сначала добавим функционал в метод распределения градиента ошибки до предыдущего нейронного слоя CNeuronBatchNorm::CalcHiddenGradient. Как и в методе прямого прохода, сначала мы создаем необходимые буферы данных в контексте OpenCL.
bool CNeuronBatchNorm::CalcHiddenGradient(CNeuronBase *prevLayer)
|
Затем, согласно нашему алгоритму реализации многопоточных вычислений с использованием технологии OpenCL, мы передаем параметры вызываемого кернела.
//--- передача параметров кернелу
|
После передачи всех параметров мы готовим кернел к постановке в очередь выполнения. Напомню, что при создании кернела мы определили использование векторных операций с типом TYPE4. Соответственно, мы сокращаем в четыре раза количество выполняемых потоков. Вызываем метод постановки кернела в очередь.
//--- постановка в очередь выполнения
|
На этом мы завершаем работу с методом распределения градиента ошибки через скрытый слой CNeuronBatchNorm::CalcHiddenGradient. Нам остается повторить операции для второго метода обратного прохода CNeuronBatchNorm::CalcDeltaWeights.
И снова мы повторяем алгоритм постановки кернела в очередь. На этот раз кернел использует три буфера данных.
bool CNeuronBatchNorm::CalcDeltaWeights(CNeuronBase *prevLayer, bool read)
|
Затем передаем указатели на созданные буферы в параметры запускаемого кернела.
//--- передача параметров кернелу
|
Отправляем кернел в очередь выполнения. На этот раз количество потоков будет равно количеству элементов в тензоре результатов нашего слоя пакетной нормализации.
//--- постановка в очередь выполнения
|
На этом мы заканчиваем работу с нашим классом пакетной нормализации данных CNeuronBatchNorm. Он готов к использованию — сейчас в нем полностью реализован алгоритм нормализации данных. При этом мы повторили алгоритм в двух вариантах: стандартными средствами MQL5 и с использованию технологии многопоточных вычислений OpenCL. Тем самым мы предоставляем пользователю право выбора используемой технологии в соответствии с его требованиями.
Сейчас я предлагаю посмотреть на реализацию метода пакетной нормализации на языке Python.