Организация параллельных вычислений средствами OpenCL

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

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

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

Операции обратного прохода мы тоже можем перенести в мир параллельных вычислений. Разберем этапы обратного прохода.

Отклонение расчетных значений от эталонных на выходном слое нейронной сети легко разделить на отдельные потоки по каждому нейрону.

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

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

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

При кажущейся равнозначности подходов первый вариант имеет несколько недостатков. Так как нам предстоит складывать составляющие градиентов ошибки от разных нейронов последующего слоя, то перед началом операций необходимо обнулить текущее значении вектора. Это дополнительные затраты времени и ресурсов. Кроме того, есть и технические моменты. Работа с глобальной памятью медленнее работы с приватной памятью потока, поэтому нам желательно собрать значения в быстрой памяти и один раз перенести в глобальную. Самое проблемное место этого подхода в том, что в процессе работы велика вероятность возникновения ситуации, когда несколько потоков одновременно будут пытаться записать значения в один нейрон предыдущего слоя. А это крайне не желательно для нас.

По совокупности вышеизложенных факторов второй вариант становится более привлекательным для реализации.

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