Нейросети в трейдинге: Оптимизация Cross-Attention для анализа длинных последовательностей рынка (Основные компоненты)
Введение
В предыдущей статье мы начали рассмотрение современной архитектуры обработки длинных последовательностей — механизма внимания Stacked Target-to-History Cross Attention (STCA), предложенного в работе "Make It Long, Keep It Fast: End-to-End 10k-Sequence Modeling at Billion Scale on Douyin". Появление подобных моделей связано с практической необходимостью анализировать все более протяжённые последовательности данных. Во многих прикладных задачах длина наблюдаемой истории может достигать тысяч и даже десятков тысяч элементов. Подобная ситуация характерна для рекомендательных систем, потоковой аналитики и моделей, работающих с высокочастотными временными рядами. В этих условиях традиционные архитектуры трансформеров начинают сталкиваться с фундаментальным ограничением вычислительной сложности.
Классический механизм Self-Attention требует вычисления взаимодействия каждого элемента последовательности со всеми остальными элементами. Фактически это означает построение полной матрицы попарных взаимодействий. По мере увеличения длины последовательности размер такой матрицы растёт квадратично, а вместе с ним увеличиваются требования к памяти и вычислительным ресурсам. На практике это приводит к ситуации, когда значительная часть вычислительной мощности расходуется на обслуживание самой структуры внимания, и при работе с длинной историей подобная схема быстро становится узким местом всей модели.
Архитектура STCA предлагает иной взгляд на организацию механизма внимания: вместо анализа взаимодействия между всеми элементами последовательности модель рассматривает более прикладную постановку задачи. Существует текущий целевой элемент — своего рода запрос, и имеется накопленная историческая последовательность, содержащая контекст. Внимание строится между этим целевым элементом и всей историей наблюдений. История выступает источником информации, а целевой элемент определяет направление поиска релевантных зависимостей. Такая, на первый взгляд незначительная, перестановка ролей радикально меняет вычислительную структуру алгоритма. Квадратичная зависимость от длины последовательности исчезает и заменяется линейной, благодаря чему модель может масштабироваться на значительно более длинные последовательности без резкого роста вычислительных затрат.
С архитектурной точки зрения STCA представляет собой последовательность перекрёстных слоёв внимания, в которых целевой вектор постепенно обогащается информацией из исторической последовательности. Каждый слой выполняет роль фильтра, позволяющего модели выделять наиболее значимые элементы прошлого. В отличие от симметричного Self-Attention, где все элементы последовательности обрабатываются на равных условиях, структура вычислений здесь изначально асимметрична. История играет роль базы наблюдений, а целевой элемент выступает запросом к этой базе. Подобная организация оказывается особенно эффективной в задачах, где необходимо оценить текущее состояние системы на основе длинной цепочки предыдущих событий.

Дополнительную эффективность архитектуре придают предложенные авторами фреймворка инженерные оптимизации механизма внимания. В частности, изменение порядка некоторых линейных преобразований внутри вычислительного графа позволяет сократить количество промежуточных операций и уменьшить объём создаваемых тензоров. Подобные преобразования не меняют математическую сущность алгоритма, но заметно снижают вычислительную нагрузку и требования к памяти. В совокупности такие изменения делают архитектуру STCA особенно привлекательной для задач, связанных с обработкой длинных последовательностей.
Эти свойства делают предложенный подход интересным не только для рекомендательных систем, где он был разработан изначально, но и для других областей, связанных с анализом временных данных. Финансовые рынки представляют собой один из наиболее характерных примеров таких задач. Рыночная динамика формируется под влиянием длинной последовательности событий — изменений цены, объёмов торгов, структуры заявок и множества других факторов. Для корректного анализа подобных процессов модели необходимо учитывать значительный исторический контекст. При этом поток данных поступает непрерывно, а вычисления должны выполняться с высокой скоростью. В этих условиях способность алгоритма эффективно работать с длинной историей становится критически важным фактором.
Именно эта задача лежит в основе нашего проекта. Практическая цель заключается не только в теоретическом изучении новой модели, но и в создании полноценной программной реализации, пригодной для использования в реальных торговых системах. В практической части первой статьи мы реализовали прямой проход механизма Stacked Target‑to‑History Cross Attention (STCA) в виде кернела OpenCL-программы, добившись линейного по истории способа вычисления внимания без явного построения полной матрицы Score.
Однако этого недостаточно для практического применения: без корректного обратного прохода модель нельзя обучать на данных рынка. А значит — адаптировать под конкретные инструменты и режимы торговли. Главная инженерная проблема — аккумулировать градиенты по общему контексту X, который участвует одновременно во множестве комбинаций (запросов и голов внимания), не прибегая к дорогостоящим атомарным операциям и не отказываясь от преимуществ FlashAttention‑подхода. Цель этой работы — разработать и реализовать обратный проход для FlashAttention‑подобного STCA, восстанавливающий SoftMax через сохранённый LogSumExp и собирающий dQ и dX через локальные редукции внутри рабочих групп, сохраняя масштабируемость на длинных последовательностях.
Инженерные решения обратного прохода
Однако полноценное использование нейросетевой модели невозможно без реализации механизма обучения, а значит — без эффективного вычисления градиентов. Именно обратное распространение ошибки обеспечивает возможность адаптации параметров модели на основе наблюдаемых данных и лежит в основе большинства современных методов оптимизации. В архитектурах внимания этот этап часто оказывается более сложным, чем прямое вычисление, поскольку требует аккуратной работы с промежуточными величинами и правильной организации накопления градиентов.
В теоретическом описании механизма внимания вычисление градиентов выглядит достаточно прямолинейно. Выход внимания формируется как результат взвешенного суммирования значений, где веса определяются функцией SoftMax от скалярных произведений запросов и ключей. Соответственно, при обратном распространении ошибки необходимо вычислить градиенты по всем участвующим компонентам — запросам, ключам и значениям. Формально эта процедура сводится к последовательному применению правил дифференцирования для матричных операций и функции SoftMax. Однако при практической реализации на GPU подобная схема требует значительно более аккуратной организации вычислений.
Основная сложность связана с необходимостью корректного сбора и последующего распределения градиентов между всеми элементами, участвующими в вычислении внимания. Каждый элемент исторической последовательности вносит вклад в формирование выходного представления, а следовательно должен получить свою долю градиента при обратном распространении ошибки. При этом вычисления выполняются параллельно большим числом потоков, которые обрабатывают разные фрагменты данных. Это означает, что градиенты формируются локально в различных потоках и рабочих группах, после чего должны быть аккуратно объединены в единую согласованную структуру.
Ситуация дополнительно усложняется особенностями архитектуры STCA. В реализованной схеме один и тот же контекст истории используется одновременно для всех запросов и для всех голов внимания. Иными словами, элементы исторической последовательности участвуют в вычислениях множества независимых Attention-операций. В результате при обратном проходе каждый элемент истории должен аккумулировать вклад градиентов, приходящих от различных запросов и различных голов внимания. Такая структура вычислений естественным образом приводит к необходимости многократного накопления частичных градиентов.
В наивной реализации подобная задача обычно решается с помощью атомарных операций записи в глобальную память. Однако для GPU-архитектур такой подход оказывается крайне неэффективным. Большое число атомарных операций приводит к серьёзной потере производительности из-за конфликтов доступа к памяти и необходимости синхронизации потоков. Особенно заметно это становится при обработке длинных последовательностей, где число потенциальных конфликтов быстро растёт.
Поэтому при построении алгоритма обратного прохода для STCA основной задачей становится разработка такой схемы вычислений, которая позволяла бы аккумулировать градиенты без интенсивного использования атомарных операций и при этом сохраняла высокую степень параллелизма. Решение этой задачи требует более сложной организации вычислительного процесса.
В реализованном алгоритме эта проблема решается за счёт сочетания нескольких инженерных приёмов. Во-первых, вычисления организуются таким образом, чтобы большая часть частичных градиентов накапливалась локально внутри рабочих групп. Локальная память графического процессора обладает значительно меньшей задержкой доступа и позволяет эффективно выполнять операции редукции между потоками одной группы. Это позволяет собрать значительную часть промежуточных результатов без обращения к глобальной памяти.
Во-вторых, структура вычислений организована таким образом, чтобы минимизировать повторное вычисление промежуточных величин, необходимых для расчёта градиентов. В частности, значения, связанные с нормализацией SoftMax, восстанавливаются на основе сохранённых статистик, полученных во время прямого прохода. Такой подход позволяет избежать хранения полной матрицы внимания и одновременно сохранить возможность корректного вычисления производных.
Наконец, особая организация пространства задач позволяет распределить обработку различных компонент embedding-вектора между потоками рабочей группы. Вместо создания отдельного измерения параллелизма для каждой компоненты вектора, вычисления выполняются внутри циклов, а частичные результаты аккумулируются в локальных переменных потоков. Это позволяет эффективно использовать ресурсы вычислительного устройства и уменьшить число повторных вычислений.
В параметрах кернела MHFlashSTCAGrad передаётся целый набор глобальных буферов и параметров задачи. Буфер query содержит исходные векторы запросов, а query_gr предназначен для записи соответствующих градиентов. Аналогично буфер X содержит общий контекст последовательности, а X_gr используется для накопления градиентов по этим данным. Поскольку алгоритм реализует вариант FlashAttention, важную роль играет массив logsumexp, в котором сохранены нормализующие статистики SoftMax, вычисленные на этапе прямого прохода. Они позволяют корректно восстановить вероятности внимания без хранения полной матрицы Score. Буферы output и output_gr содержат соответственно результат прямого прохода и градиент ошибки, поступающий от последующего слоя модели.
Параметр dimension задаёт размер embedding-пространства. Переменные total_q и total_X определяют количество запросов и элементов контекста. Наконец, флаг mask_future включает режим каузальной маски, когда запросу разрешается взаимодействовать только с текущими и предыдущими элементами последовательности.
__kernel void MHFlashSTCAGrad(__global const float *query, __global float *query_gr, __global const float *X, __global float *X_gr, __global const float *logsumexp, __global const float *output, __global const float *output_gr, const int dimension, const int total_q, const int total_X, const int mask_future ) { const int id = get_global_id(0); const int local_id = get_local_id(1); const int h_id = get_global_id(2); const int total_loc = get_local_size(1); const int total_heads = get_global_size(2);
После объявления параметров кернел сразу определяет положение текущего потока в пространстве задач. Переменная id задаёт глобальный индекс по первому измерению. Именно это измерение используется для перебора элементов последовательности. И в данном случае мы меняем его функционал в зависимости от блока алгоритма. Вначале — это индекс запроса, а затем — контекста.
Переменная local_id — локальный индекс потока внутри рабочей группы по второму измерению. Он играет ключевую роль при распределении вычислений между потоками рабочей группы. Третье измерение глобального пространства используется для обработки различных голов внимания. Индекс текущей головы хранится в переменной h_id.
Далее вычисляется размер локальной группы потоков total_loc, а также общее количество голов внимания total_heads. Эти значения используются при распределении работы между потоками и при последующей редукции частичных результатов.
Следующий блок объявляет два массива локальной памяти: temp и temp4. Они используются как временные буферы для коллективных операций внутри рабочей группы. В отличие от глобальной памяти, доступ к локальной памяти значительно быстрее, поэтому именно через неё организуется накопление промежуточных градиентов.
__local float temp[LOCAL_ARRAY_SIZE];
__local float4 temp4[LOCAL_ARRAY_SIZE];
После подготовки инфраструктуры начинается вычисление первой группы производных — градиентов по векторам запросов. Этот блок активируется только для потоков, чей глобальный индекс меньше числа запросов. Тем самым каждому запросу соответствует собственная рабочая группа потоков операций.
В начале инициализируется переменная grad_q, которая будет накапливать итоговый градиент по одной компоненте вектора запроса. Индекс текущего запроса сохраняется в q_id. Далее вычисляется смещение shift_q, позволяющее перейти к нужному фрагменту массива query. Аналогичным образом вычисляется индекс элемента в массиве logsumexp.
//--- Query gradient: dQ[id, d] if(id < total_q) { float grad_q = 0.0f; const int q_id = id; const int shift_q = RCtoFlat(h_id, 0, total_heads, dimension, q_id); const int shift_lse = RCtoFlat(q_id, h_id, total_q, total_heads, 0); const float lse = IsNaNOrInf(logsumexp[shift_lse], 0.0f);
Значение lse извлекается из массива logsumexp. Эта величина представляет собой логарифм суммы экспонент для соответствующего Attention-распределения. В алгоритмах FlashAttention она используется для восстановления вероятностей SoftMax без необходимости хранить всю матрицу Score.
После этого начинается вычисление вспомогательной величины D. Она равна скалярному произведению градиента выхода и самого выходного вектора. Эта величина используется при дифференцировании SoftMax.
float D = 0; for(int d = 0; d < dimension; d += 4) { float4 g_d = IsNaNOrInf4((float4)( (d < dimension ? output_gr[shift_q + d] : 0.0f), ((d + 1) < dimension ? output_gr[shift_q + d + 1] : 0.0f), ((d + 2) < dimension ? output_gr[shift_q + d + 2] : 0.0f), ((d + 3) < dimension ? output_gr[shift_q + d + 3] : 0.0f) ), 0.0f); float4 o_d = IsNaNOrInf4((float4)( (d < dimension ? output[shift_q + d] : 0.0f), ((d + 1) < dimension ? output[shift_q + d + 1] : 0.0f), ((d + 2) < dimension ? output[shift_q + d + 2] : 0.0f), ((d + 3) < dimension ? output[shift_q + d + 3] : 0.0f) ), 0.0f); D += IsNaNOrInf(dot(g_d, o_d), 0.0f); }
Цикл по dimension выполняется с шагом 4. Такой шаг связан с использованием векторного типа float4, который позволяет обрабатывать сразу четыре компоненты embedding-вектора. Сначала загружается фрагмент градиента output_gr, затем соответствующий фрагмент выходного вектора output. После этого выполняется векторное скалярное произведение dot, результат которого добавляется к накопленному D. Такая организация вычислений позволяет эффективно использовать SIMD-возможности GPU.
После вычисления величины D начинается основной цикл по элементам контекста. Однако элементы обрабатываются не последовательно одним потоком, а распределяются между потоками рабочей группы. Для этого используется цикл по l_id с шагом total_loc. Каждый поток обрабатывает элемент с индексом x_id, равным сумме базового индекса и локального номера потока.
for(int l_id = 0; l_id < total_X; l_id += total_loc) { int x_id = l_id + local_id; float ds = 0; if(x_id < total_X && (mask_future == 0 || q_id <= x_id)) { const int shift_x = RCtoFlat(x_id, 0, total_X, dimension, 0); float score = 0; float dp = 0;
Если индекс находится в пределах последовательности и удовлетворяет условию каузальной маски, начинается вычисление вкладов этого элемента в градиент. Сначала определяется смещение shift_x, указывающее на нужный вектор контекста.
Затем векторное скалярное произведение между query и X вычисляет оценку score. Параллельно вычисляется градиент соответствующего коэффициента внимания dp, представляющий собой скалярное произведение градиента выхода и вектора контекста.
for(int d = 0; d < dimension; d += 4) { float4 q_d = IsNaNOrInf4((float4)( (d < dimension ? query[shift_q + d] : 0.0f), ((d + 1) < dimension ? query[shift_q + d + 1] : 0.0f), ((d + 2) < dimension ? query[shift_q + d + 2] : 0.0f), ((d + 3) < dimension ? query[shift_q + d + 3] : 0.0f) ), 0.0f); float4 x_d = IsNaNOrInf4((float4)( (d < dimension ? X[shift_x + d] : 0.0f), ((d + 1) < dimension ? X[shift_x + d + 1] : 0.0f), ((d + 2) < dimension ? X[shift_x + d + 2] : 0.0f), ((d + 3) < dimension ? X[shift_x + d + 3] : 0.0f) ), 0.0f); score += IsNaNOrInf(dot(q_d, x_d), 0.0f); float4 g_d = IsNaNOrInf4((float4)( (d < dimension ? output_gr[shift_q + d] : 0.0f), ((d + 1) < dimension ? output_gr[shift_q + d + 1] : 0.0f), ((d + 2) < dimension ? output_gr[shift_q + d + 2] : 0.0f), ((d + 3) < dimension ? output_gr[shift_q + d + 3] : 0.0f) ), 0.0f); dp += IsNaNOrInf(dot(g_d, x_d), 0.0f); } score /= sqrt((float)dimension); const float p = IsNaNOrInf(exp(clamp(score - lse, -120.0f, 0.0f)), 0.0f); ds = IsNaNOrInf(p * (dp - D), 0.0f); }
После завершения цикла по embedding-измерению значение score нормализуется делением на корень из размерности пространства. Затем восстанавливается вероятность внимания p. Она вычисляется как экспонента разности между score и lse. Дополнительное ограничение clamp защищает вычисление от переполнений экспоненты. Получив вероятность p, можно вычислить производную оценки внимания ds.
Однако полученное значение ds относится только к одному элементу последовательности. Чтобы получить вклад в градиент запроса, необходимо умножить его на соответствующий вектор контекста. Это выполняется во втором цикле по размерности embedding-вектора.
for(int d = 0; d < dimension; d += 4) { float4 x_d = (float4)0; if(x_id < total_X && (mask_future == 0 || q_id <= x_id)) { const int shift_x = RCtoFlat(x_id, 0, total_X, dimension, 0); x_d = IsNaNOrInf4((float4)( (d < dimension ? X[shift_x + d] : 0.0f), ((d + 1) < dimension ? X[shift_x + d + 1] : 0.0f), ((d + 2) < dimension ? X[shift_x + d + 2] : 0.0f), ((d + 3) < dimension ? X[shift_x + d + 3] : 0.0f) ), 0.0f); } float4 q_dg = LocalSum4(x_d * ds, 1, temp4);
Каждый поток формирует локальный вклад x_d*ds. Затем вызывается функция LocalSum4, которая выполняет редукцию значений между потоками рабочей группы. Таким образом суммируются вклады всех элементов последовательности.
Здесь стоит обратить внимание, что после редукции мы получаем лишь небольшую часть значений из общего embedding-вектора запроса. Однако мы ожидаем, что величина dimension будет не больше размера рабочей группы. Это позволяет нам накапливать значения в частых переменных соответствующих потоков рабочей группы.
int idx = local_id - d; if(idx >= 0 && idx < 4) grad_q += q_dg[idx]; } } if(local_id < dimension) query_gr[shift_q + local_id] = IsNaNOrInf(grad_q, 0.0f); }
Когда обработка всех элементов контекста завершена, поток записывает полученный градиент в массив query_gr. Запись выполняется только для потоков, индекс которых меньше размерности embedding-вектора. Это гарантирует, что каждая компонента градиента будет записана ровно один раз.
Вторая половина кернела вычисляет градиенты по элементам контекста X. Эта часть запускается только для первой головы внимания, поскольку контекст является общим для всех голов и градиенты аккумулируются совместно.
Логика вычислений здесь похожа на предыдущий блок, но роли запросов и контекста меняются местами. Для каждого элемента последовательности x_id создаётся аккумулятор grad_X.
//--- X gradients: dX[id, d] if(id < total_X && h_id == 0) { float grad_X = 0.0f; const int x_id = id; const int shift_x = RCtoFlat(x_id, 0, total_X, dimension, 0);
Важно подчеркнуть, что в архитектуре STCA предполагается число запросов значительно меньше длины исторического контекста. А число голов внимания обычно ограничено десятком-другим. Это значит, что произведение числа запросов на количество голов, как правило, не превышает размер рабочей группы. И прямое распределение потоков должно покрывать все вычисления.
Однако для создания универсального и масштабируемого решения мы вводим цикл с шагом, равным размеру рабочей группы total_loc. Индекс loc кодирует одновременно номера запроса и головы внимания, позволяя одному потоку последовательно обработать несколько комбинаций, если их больше числа потоков.
for(int l_id = 0; l_id < total_q * total_heads; l_id += total_loc) { int loc = l_id + local_id; int h = loc / total_q; int q_id = loc % total_q; float ds = 0; float p = 0;
Такой подход обеспечивает корректную обработку любых размеров данных и числа голов, не завися от конкретной конфигурации GPU, и сохраняет производительность за счёт локальных редукций и аккумулирования промежуточных значений.
Далее если комбинация запроса и головы внимания допустима и не нарушает каузальную маску, вычисляются оценка внимания score, величины dp и D.
if(h < total_heads && q_id < total_q && (mask_future == 0 || q_id <= x_id)) { const int shift_lse = RCtoFlat(q_id, h, total_q, total_heads, 0); const float lse = IsNaNOrInf(logsumexp[shift_lse], 0.0f); const int shift_q = RCtoFlat(h, 0, total_heads, dimension, q_id); float score = 0; float D = 0; float dp = 0; for(int d = 0; d < dimension; d += 4) { float4 q_d = IsNaNOrInf4((float4)( (d < dimension ? query[shift_q + d] : 0.0f), ((d + 1) < dimension ? query[shift_q + d + 1] : 0.0f), ((d + 2) < dimension ? query[shift_q + d + 2] : 0.0f), ((d + 3) < dimension ? query[shift_q + d + 3] : 0.0f) ), 0.0f); float4 x_d = IsNaNOrInf4((float4)( (d < dimension ? X[shift_x + d] : 0.0f), ((d + 1) < dimension ? X[shift_x + d + 1] : 0.0f), ((d + 2) < dimension ? X[shift_x + d + 2] : 0.0f), ((d + 3) < dimension ? X[shift_x + d + 3] : 0.0f) ), 0.0f); score += IsNaNOrInf(dot(q_d, x_d), 0.0f); float4 g_d = IsNaNOrInf4((float4)( (d < dimension ? output_gr[shift_q + d] : 0.0f), ((d + 1) < dimension ? output_gr[shift_q + d + 1] : 0.0f), ((d + 2) < dimension ? output_gr[shift_q + d + 2] : 0.0f), ((d + 3) < dimension ? output_gr[shift_q + d + 3] : 0.0f) ), 0.0f); dp += IsNaNOrInf(dot(g_d, x_d), 0.0f); float4 o_d = IsNaNOrInf4((float4)( (d < dimension ? output[shift_q + d] : 0.0f), ((d + 1) < dimension ? output[shift_q + d + 1] : 0.0f), ((d + 2) < dimension ? output[shift_q + d + 2] : 0.0f), ((d + 3) < dimension ? output[shift_q + d + 3] : 0.0f) ), 0.0f); D += IsNaNOrInf(dot(g_d, o_d), 0.0f); }
А затем вероятность внимания p и производная ds.
const float p = IsNaNOrInf(exp(clamp(score - lse, -120.0f, 0.0f)), 0.0f); if(p != 0.0f) ds = IsNaNOrInf(p * (dp - D), 0.0f); }
Вклад в градиент контекста складывается из двух частей. Первая часть q_d*ds соответствует производной оценки внимания по ключу. Вторая часть g_d*p появляется из производной взвешенной суммы значений. На каждой итерации цикла по dimension отдельный поток рабочей группы вычисляет градиенты 4 элементов embedding-вектора для своего запроса и головы внимания. После чего LocalSum4 аккумулирует эти значения в рамках рабочей группы.
for(int d = 0; d < dimension; d += 4) { float4 q_d = (float4)0; float4 g_d = (float4)0; if(h < total_heads && q_id < total_q && (mask_future == 0 || q_id <= x_id)) { const int shift_q = RCtoFlat(h, 0, total_heads, dimension, q_id); q_d = IsNaNOrInf4((float4)( (d < dimension ? query[shift_q + d] : 0.0f), ((d + 1) < dimension ? query[shift_q + d + 1] : 0.0f), ((d + 2) < dimension ? query[shift_q + d + 2] : 0.0f), ((d + 3) < dimension ? query[shift_q + d + 3] : 0.0f) ), 0.0f); g_d = IsNaNOrInf4((float4)( (d < dimension ? output_gr[shift_q + d] : 0.0f), ((d + 1) < dimension ? output_gr[shift_q + d + 1] : 0.0f), ((d + 2) < dimension ? output_gr[shift_q + d + 2] : 0.0f), ((d + 3) < dimension ? output_gr[shift_q + d + 3] : 0.0f) ), 0.0f); } float4 x_dg = LocalSum4(q_d * ds + g_d * p, 1, temp4);
Перед тем как перейти к следующей четверке элементов, промежуточные значения сохраняются в локальных float переменных соответствующего потока.
int idx = local_id - d; if(idx >= 0 && idx < 4) grad_X += x_dg[idx]; } }
Таким образом, каждый поток аккумулирует свой элемент embedding-вектора, что обеспечивает корректное суммирование градиентов по всем элементам вектора без необходимости создавать массив заранее.
Эта стратегия позволяет одновременно поддерживать высокую производительность за счёт параллелизма и корректно распределять градиенты ошибки по всем участникам процесса, сохраняя общий контекст для всех запросов и голов внимания.
После завершения обработки всех запросов итоговый градиент записывается в массив X_gr.
if(local_id < dimension) X_gr[shift_x + local_id] = IsNaNOrInf(grad_X, 0.0f); } }
Таким образом, кернел реализует полный цикл вычисления градиентов для механизма внимания, не создавая полной матрицы Score и не используя атомарные операции в глобальной памяти. Основная нагрузка переносится на локальные редукции внутри рабочих групп, что позволяет сохранить высокую эффективность вычислений даже при обработке длинных последовательностей и большого числа голов внимания.
Модуль кросс-внимания
После построения алгоритмов на стороне OpenCL-контекста, мы переходим к интеграции вычислений на уровне основной программы. Для этого создаётся новый объект CNeuronMHTHCrossAttention, который наследуется от CNeuronMTmixAttBlock. Это наследование отличается от оригинального подхода авторов STCA, однако не нарушает принципов фреймворка. В оригинальной архитектуре каждый запрос обрабатывается независимо, и внимание распределяется только внутри одного запроса и одной головы. В финансовых приложениях ситуация гораздо сложнее. Различные гипотезы о продолжении рыночного движения или о поведении участников рынка могут быть взаимодополняющими. А могут полностью исключать друг друга. Игнорирование этих взаимосвязей приводит к потере информации и снижению качества прогнозирования.
Чтобы учесть эти зависимости, перед блоком FeedForward вводится механизм взаимного внимания между запросами. Это решение позволяет каждому запросу учитывать влияние других запросов в рамках одной головы внимания, корректируя распределение градиентов и весов с учётом общей структуры входных данных. Использование в качестве родительского класса CNeuronMTmixAttBlock здесь критично. Он объединяет функционал дополнительного блока взаимного внимания и MoE FeedForward в одном объекте. Это упрощает управление памятью, синхронизацию потоков и повторное использование вычислений. Благодаря этому мы избегаем дублирования кода, уменьшаем накладные расходы и сохраняем логическую целостность модели.
class CNeuronMHTHCrossAttention : public CNeuronMTmixAttBlock { protected: uint iQUnits; uint iXUnits; uint iHeads; uint iXDimension; bool bMask; //--- CBufferFloat cLogSumExp; CLayer cPrepareQ; CLayer cW0; //--- virtual bool AttentionOut(void); virtual bool AttentionInsideGradients(void); //--- virtual bool feedForward(CNeuronBaseOCL *NeuronOCL) override { ReturnFalse; } virtual bool feedForward(CNeuronBaseOCL *NeuronOCL, CBufferFloat *Context) override; virtual bool calcInputGradients(CNeuronBaseOCL *prevLayer, CBufferFloat *SecondInput, CBufferFloat *SecondGradient, ENUM_ACTIVATION SecondActivation = None) override; virtual bool calcInputGradients(CNeuronBaseOCL *prevLayer) override { ReturnFalse; } virtual bool updateInputWeights(CNeuronBaseOCL *NeuronOCL) override { return true; } virtual bool updateInputWeights(CNeuronBaseOCL *NeuronOCL, CBufferFloat *Context) override { return true; } public: CNeuronMHTHCrossAttention(void) {}; ~CNeuronMHTHCrossAttention(void) {}; //--- virtual bool Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint dimension_q, uint units_q, uint heads, uint dimension_x, uint unit_x, uint embed_size, uint candidates, uint topK, ENUM_OPTIMIZATION optimization_type, uint batch); //--- virtual bool Save(const int file_handle) override; virtual bool Load(const int file_handle) override; //--- virtual int Type(void) override const { return defNeuronMHTHCrossAttention; } virtual void SetOpenCL(COpenCLMy *obj) override; virtual void TrainMode(bool flag) override; virtual void SetActivationFunction(ENUM_ACTIVATION value) override {}; //--- virtual bool WeightsUpdate(CNeuronBaseOCL *source, float tau) override; };
В методе инициализации осуществляется полное формирование внутренней структуры объекта и подготовка его к работе с OpenCL-контекстом. Сначала вызывается инициализация родительского класса, что обеспечивает корректное создание базовых структур и выделение памяти для общих компонентов блока внимания.
bool CNeuronMHTHCrossAttention::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint dimension_q, uint units_q, uint heads, uint dimension_x, uint unit_x, uint embed_size, uint candidates, uint topK, ENUM_OPTIMIZATION optimization_type, uint batch) { if(!CNeuronMTmixAttBlock::Init(numOutputs, myIndex, open_cl, dimension_q, units_q, heads, embed_size, candidates, topK, optimization_type, batch)) ReturnFalse;
Это важно, потому что родительский класс управляет основной логикой блока, а также обеспечивает совместимость с OpenCL.
Далее метод присваивает значения локальным переменным.
iQUnits = units_q;
iXUnits = unit_x;
iHeads = heads;
iXDimension = dimension_x;
bMask = false;
Флаг bMask инициализируется как false, так как предполагается работа только с историческим контекстом. Кроме того, скорее всего, не включены в контекст.
Следующий шаг — инициализация блока cPrepareQ, который отвечает за подготовку embedding-векторов запросов. Как предлагают авторы фреймворка STCA, запросы сначала проецируются в пространство голов внимания с помощью обучаемой матрицы WQ. Роль данной матрицы в нашем случае выполняют параметры свёрточного слоя.
cPrepareQ.Clear(); cW0.Clear(); cPrepareQ.SetOpenCL(OpenCL); cW0.SetOpenCL(OpenCL); //--- uint index = 0; uint head_size = (dimension_q + heads - 1) / heads; CNeuronSpikeConvBlock* conv = new CNeuronSpikeConvBlock(); if(!conv || !conv.Init(0, index, OpenCL, dimension_q, dimension_q, heads * head_size, units_q, 1, optimization, iBatch) || !cPrepareQ.Add(conv)) DeleteObjAndFalse(conv);
На следующем этапе полученные векторы каждой головы внимания проецируются в пространство контекста аналогичным образом.
index++; conv = new CNeuronSpikeConvBlock(); if(!conv || !conv.Init(0, index, OpenCL, head_size, head_size, dimension_x, units_q * heads, 1, optimization, iBatch) || !cPrepareQ.Add(conv)) DeleteObjAndFalse(conv);
Здесь следует обратить внимание на изменение количества элементов последовательности. Ведь мы независимо проецируем каждую голову внимания, а не запросы в целом.
Второй блок cW0 должен свернуть анализ независимых голов внимания в согласованное представление. Он строится аналогичным образом.
index++; CNeuronBaseOCL* neuron = new CNeuronBaseOCL(); if(!neuron || !neuron.Init(0, index, OpenCL, dimension_x * units_q * heads, optimization, iBatch) || !cW0.Add(neuron)) DeleteObjAndFalse(neuron); neuron.SetActivationFunction(None); index++; conv = new CNeuronSpikeConvBlock(); if(!conv || !conv.Init(0, index, OpenCL, dimension_x, dimension_x, head_size, units_q * heads, 1, optimization, iBatch) || !cW0.Add(conv)) DeleteObjAndFalse(conv); index++; conv = new CNeuronSpikeConvBlock(); if(!conv || !conv.Init(0, index, OpenCL, heads * head_size, heads * head_size, dimension_q, units_q, 1, optimization, iBatch) || !cW0.Add(conv)) DeleteObjAndFalse(conv);
В завершение метода инициализации выделяется буфер cLogSumExp, который используется для хранения значений логарифма суммы экспонент на каждом шаге вычисления внимания. Буфер инициализируется нулевыми значениями и создаётся в OpenCL-контексте, что позволяет мгновенно получать доступ к этим данным из кернелов обратного прохода.
if(!cLogSumExp.BufferInit(units_q * heads, 0) || !cLogSumExp.BufferCreate(OpenCL)) ReturnFalse; //--- return true; }
Таким образом, метод инициализации формирует полностью готовый к работе объект CNeuronMHTHCrossAttention. Архитектура сохраняет принципиальную совместимость с оригинальным STCA, но при этом учитывает особенности работы с финансовыми временными рядами. Небольшое число запросов, ограниченное количество голов внимания и необходимость аккумулировать градиенты по элементам embedding-векторов в рамках рабочей группы OpenCL. Это обеспечивает высокую эффективность и универсальность реализации, позволяя объекту корректно работать с реальными рыночными сценариями и сложными стратегиями прогнозирования.
Алгоритм метода прямого прохода демонстрирует практически линейный характер вычислений с остаточными связями, типичными для классического Cross-Attention. Такой подход позволяет сохранять структурную целостность данных и обеспечивает стабильное распространение информации между запросами и анализируемыми представлениями. Мы предлагаем читателю самостоятельно ознакомиться с его деталями. Полный код класса доступен во вложении и в публичном репозитории.
Заключение
В статье мы закрыли ключевой инженерный блок, отделявший быстрый inference STCA от полноценно обучаемого компонента. На уровне OpenCL-программы реализован кернел MHFlashSTCAGrad, который восстанавливает вероятности SoftMax по сохранённым значениям LogSumExp. Вычисляет градиенты по запросам (dQ) и общему контексту (dX) без построения полной матрицы Score и массового использования атомарных записей в глобальной памяти. Это достигнуто за счёт использования локальных редукций внутри рабочих групп.
Параллельно создан каркас модуля CNeuronMHTHCrossAttention, унаследованного от CNeuronMTmixAttBlock. Он включает подготовку проекций по головам, буфер LogSumExp и интерфейсы для интеграции кернела в общий граф обучения.
Практический результат — готовый комплект артефактов (градиентный кернел, модуль интеграции и сопутствующие буферы), позволяющий переводить STCA‑блок в режим обучения на длинной истории без квадратичных накладных расходов и без упора в атомики. В следующих материалах мы продемонстрируем интеграцию этих компонентов в реальные торговые модели, их обучение и тестирование на исторических данных.
Ссылки
- Make It Long, Keep It Fast: End-to-End 10k-Sequence Modeling at Billion Scale on Douyin
- Другие статьи серии
Программы, используемые в статье
| # | Имя | Тип | Описание |
|---|---|---|---|
| 1 | Study.mq5 | Советник | Советник офлайн-обучения моделей |
| 2 | StudyOnline.mq5 | Советник | Советник онлайн-обучения моделей |
| 3 | Test.mq5 | Советник | Советник для тестирования модели |
| 4 | Trajectory.mqh | Библиотека класса | Структура описания состояния системы и архитектуры моделей |
| 5 | NeuroNet.mqh | Библиотека класса | Библиотека классов для создания нейронной сети |
| 6 | NeuroNet.cl | Библиотека | Библиотека кода OpenCL-программы |
Проект представлен по ссылке.
Предупреждение: все права на данные материалы принадлежат MetaQuotes Ltd. Полная или частичная перепечатка запрещена.
Данная статья написана пользователем сайта и отражает его личную точку зрения. Компания MetaQuotes Ltd не несет ответственности за достоверность представленной информации, а также за возможные последствия использования описанных решений, стратегий или рекомендаций.
Торговые инструменты на языке MQL5 (Часть 10): Разработка системы отслеживания стратегии с визуальными уровнями и показателями эффективности
Автоматизация торговых стратегий на MQL5 (Часть 21): Улучшение торговли на основе нейронных сетей с помощью адаптивных темпов обучения
Алгоритм искусственного поискового роя — Artificial Searching Swarm Algorithm (ASSA)
Преодоление ограничений машинного обучения (Часть 6): Эффективная кросс-валидация исторической памяти рынка
- Бесплатные приложения для трейдинга
- 8 000+ сигналов для копирования
- Экономические новости для анализа финансовых рынков
Вы принимаете политику сайта и условия использования