Стремление к быстродействию: оптимизация вычислений нейронных сетей на процессоре К1967ВН044 компании «Миландр»

В статье «Второе рождение DSP или запуск нейросетей на процессорах К1967ВН044 от «Миландр» мы рассмотрели в целом задачу адаптации нейросетей для DSP процессора К1967ВН044. Были вкратце описаны особенности процессора и возможные методы для эффективного его использования. В этой статье мы постараемся более детально представить один из таких методов, а именно – применение библиотеки ассемблерных функций для оптимального вычисления типичных операций, встречающихся в нейросетях.

Стремление к быстродействию: оптимизация вычислений нейронных сетей на процессоре К1967ВН044 компании «Миландр»

Поскольку теперь будут появляться примеры кода на ассемблере, придётся хотя бы в общих чертах его понимать. Как было совершенно справедливо отмечено, данный процессор является развитием архитектуры TigerSHARC, так что программисты, знакомые с ним, без труда узнают этот код. Для тех, кто не имел с ним дела, можно порекомендовать «Руководство по программированию» (https://ic.milandr.ru/upload/iblock/77f/77fac90e79704374aaccc4b44f3244d6.pdf), в котором дано подробное описание всех возможностей процессора, причём с учётом многочисленных доработок, выполненных фирмой «Миландр».

Впрочем, основные идеи кочуют из одного DSP в другой, так что такие особенности, как наличие большого количества регистров, выполнение операций с данными только на регистрах, «хитрые» инструкции и т.д. не должны вызвать удивления. Кроме того, по ходу дела будут даваться краткие пояснения.

Для начала вспомним, какие преимущества мы собирались использовать для ускорения вычислений:
1. широкая шина для доступа к памяти;
2. возможность выполнять несколько инструкций за такт;
3. наличие векторных инструкций;
4. наличие «продвинутых» инструкций, способных выполнять сложные операции.

Примеры

Быстрое копирование памяти

Давайте рассмотрим простейший пример – копирование блока памяти. Причём постараемся разобрать его достаточно подробно, так как в дальнейшем уже описанные приёмы будут упоминаться вскользь.

Итак, у нас классическая функция memcpy, которая принимает на входе адреса источника и приёмника, а также количество байт. И очевидная реализация заключается в том, чтобы считать байт в регистр, записать его по нужному адресу, сдвинуть указатели и уменьшить счётчик цикла. В общем случае по-другому и не сделать, но первая же идея заключается в том, что если, например, адреса выровнены по слову, а количество байт кратно четырём, то можно оперировать словами, что даст большой выигрыш по скорости. Разумеется, эту идею немедленно хочется развить до логического завершения: использовать максимально большие порции данных, обеспечив нужное выравнивание. И в нашем случае мы можем этого добиться, поскольку наши объекты достаточно велики, чтобы пойти на потери памяти при выравнивании, если это потребуется.

В результате код функции представляется таким:

_copy_i32v8:
    k5:4 = j5:4;  j4 = j4 + 4;;
    j5 = j5 + 4;  lc0 = j6;;
loop1:
    xr3:0 = q[j5 += 8];  yr3:0 = q[k5 += 8];;
    if nlc0e, jump loop1;  q[j4 += 8] = xr3:0;  q[k4 += 8] = yr3:0;;

Давайте рассмотрим его подробнее.

Во-первых, аргументы приходят на регистрах j4, j5 и j6. Как будет видно в дальнейшем, нам потребуется задействовать оба целочисленных модуля J и K, которые будут использоваться для адресации, так что мы копируем адреса источника и приёмника из J в K, причём делается это одной инструкцией. Более того, в той же линии (то есть за тот же такт) мы сдвигаем указатель приёмника (j4) на 4. Процессор позволяет читать и писать регистр в одной линии, при этом результатом чтения будет прежнее значение.

В следующей линии мы сдвигаем указатель источника (j5) и устанавливаем счётчик цикла.
Чему? Для этого смотрим на следующую линию.

В ней мы читаем в блок X сразу 4 слова по адресу j5 и 4 слова по адресу k5, причём одновременно выполняется автоинкремент обоих адресов на 8.

Теперь понятно, зачем потребовался начальный сдвиг на 4, а также ясно, что в качестве значения счётчика следует взять количество слов, поделенное на 8. При этом мы достигаем максимально возможной пропускной способности 256-битной шины.

Наконец, последняя линия записывает 8 слов по адресам j4 и k4, уменьшает счётчик цикла и, если он не равен нулю, выполняет переход в начало цикла. Этот переход помечен как предсказанный, так что сброса конвейера не происходит, и сам переход достаётся как бы «бесплатно».

Отвлечёмся на минуту и подумаем, способен ли компилятор сгенерировать такой код?

Ну, если у него будет информация, что адреса выровнены (это можно задать через атрибут в аргументах функции), а размер кратен аж 32-м…

Вот с последним обстоятельством беда, так как только программист может знать, какие размеры реально появляются. Компилятор, скорее всего, добавит к «быстрому» варианту проверку и «медленный» вариант, чтобы сохранить общность.

Но и это ещё не всё!

Процессор имеет особенность: после загрузки в модули X и Y данные становятся доступны не сразу, а только через такт. Так что в реальности внутри цикла возникает «пузырь», и один такт теряется.

Это легко обойти так:

    xr3:0 = q[j5 += 8];  yr3:0 = q[k5 += 8];;
    xr7:4 = q[j5 += 8];  yr7:4 = q[k5 += 8];;
    q[j4 += 8] = xr3:0;  q[k4 += 8] = yr3:0;;
    if nlc0e, jump loop8;  q[j4 += 8] = xr7:4;  q[k4 += 8] = yr7:4;;

Всё то же самое, только теперь мы последовательно читаем два раза по 8 слов, и первая порция готова к записи. Компилятор знает про «пузыри», и в данном случае попробовал бы в исходном варианте поставить между чтением и записью какую-то другую инструкцию, которую тут можно было бы выполнить без нарушения алгоритма, но поскольку таковой нету, он бы смирился с «пузырём». Рассчитывать на то, что он догадается выполнить такой своеобразный «unroll» было бы слишком самонадеянно.
Причём заметьте, это простейший пример – что-то будет дальше…

Однако наша задача ведь заключалась не в том, чтобы написать специализированный вариант memcpy! Пора взглянуть на Си код от TVM, который мы собираемся оптимизировать.

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

А функция слоя, в свою очередь, выделяет память под свои временные объекты, а дальше идут всякий циклы. Вот один такой:

  for (int32_t i1 = 0; i1 < 25; ++i1) {
    for (int32_t i2 = 0; i2 < 5; ++i2) {
      for (int32_t i3 = 0; i3 < 64; ++i3) {
        int32_t cse_var_1 = (((i1 * 320) + (i2 * 64)) + i3);
        ((int8_t*)pad_temp_let)[cse_var_1] = p0[cse_var_1];
      }
   }
}

Опять же – первый раз остановимся на нём подольше.

Мы видим систему из трёх вложенных циклов, причём внешние два – это простейшая форма «for», где переменная пробегает значения от нуля до константной границы с шагом 1. И это не случайность, наоборот, подавляющая часть кода именно так и выглядит. Тут нет ничего удивительного, ведь нейросеть работает с тензорами, так что основная работа – это обход тензора по осям (в данном случае он двумерный) и вычисление во внутреннем цикле некой формулы. И, если присмотреться, то мы увидим, что переменная cse_var_1 во внутреннем цикле является инвариантом, к которому просто добавляется значение переменной цикла i3. Далее она используется для индексации байтового массива, причём базовый сдвиг кратен аж 64, а размер просто равен 64.

Ба, да мы можем заменить внутренний цикл на вот такой вызов нашей функции:

      copy_i32v8((int32_t *)((int8_t*)pad_temp_let + i1 * 320 + i2 * 64),
                 (int32_t *)(p0 + i1 * 320 + i2 * 64),
                 64 / (2 * 8 * 4));

То есть мы вычисляем два адреса, убедившись, что выравнивание не нарушается, корректируем размер, и вместо 64-х байтовых итераций получаем всего несколько инструкций в ассемблерной реализации.

Теперь, надеюсь, понятна основная идея оптимизации.
Мы полагаем, что в коде TVM в большом количестве встречаются конструкции, которые различаются только некими параметрами, а смысл вычисления повторяется. И мы будем искать их, пробуя набор шаблонов при помощи специального конвертера, и заменять внутренние циклы на своеобразные интринсики, для которых написана эффективная реализация.

Прибавление к вектору константы

Вот чуть более сложный пример:

for (int32_t ax1_2 = 0; ax1_2 < 25; ++ax1_2) {
    for (int32_t ax2_2 = 0; ax2_2 < 5; ++ax2_2) {
      for (int32_t ax3_2 = 0; ax3_2 < 64; ++ax3_2) {
        int32_t cse_var_7 = (((ax1_2 * 320) + (ax2_2 * 64)) + ax3_2);
        ((int32_t*)conv2d_nhwc_let)[cse_var_7] = (((int32_t*)conv2d_nhwc_let)[cse_var_7] - 128);
      }
    }
  }

Тот, кто помнит предыдущий пример, мгновенно узнает тот же тензор, только данные теперь 32-битные, и выполняется не копирование, а вычитание 128 из каждого элемента. Это опять же типичная векторная операция, ядро которой может выглядеть так (это не самый оптимальный вариант):

  xr3:0 = q[j4 + 0];  yr3:0 = q[k4 + 0];;
    r1:0 = r1:0 + r5:4;;
    r3:2 = r3:2 + r7:6;;
    if nlc0e, jump loop6;  q[j4 += 8] = xr3:0;  q[k4 += 8] = yr3:0;;

Здесь в прологе (я не стал его приводить) число, которое следует прибавить ко всем элементам вектора размножается в регистрах xyr7:4, далее мы опять же читаем максимальную порцию данных.

Правда, разрядность вычислителей только 64 бит, так что требуется две инструкции на цикл, но каждая из них производит 4 32-битных сложения за такт. А если ещё доработать реализацию для устранения пузырей, то опять же можно ожидать значительного ускорения. Вызов для данного случая выглядит так:

 add_n_i32v8((int32_t *)conv2d_nhwc_let + ax1_2 * 320 + ax2_2 * 64,
                  -128,
                  64 / (8 * 1));

Умножение со сдвигом и округлением

Давайте пропустим всякие сложения/вычитания векторов, так как уже должно быть понятно, как они обрабатываются, и рассмотрим более сложный пример (я больше не выписываю внешние циклы):

for (int32_t i3_1 = 0; i3_1 < 64; ++i3_1) {
        int32_t cse_var_6 = (((i1_1 * 320) + (i2_1 * 64)) + i3_1);
        ((int32_t*)conv2d_nhwc_let)[cse_var_6] = ((int32_t)(((((int64_t)((int32_t*)conv2d_nhwc_let)[cse_var_6]) * ((int64_t)((int32_t*)fused_nn_conv2d_subtract_add_constant_6_let)[i3_1])) + ((int64_t)1 << ((int64_t)((((int32_t*)fused_nn_conv2d_subtract_add_constant_8_let)[i3_1] + 31) - 1)))) >> ((int64_t)(((int32_t*)fused_nn_conv2d_subtract_add_constant_8_let)[i3_1] + 31))));
      }

Выглядит весьма запутанно, так что лучше выписать выражение с короткими именами:

*arg1 = ((int64_t)*arg1 * (int64_t)*arg2 + (1LL << (*arg3 + 30))) >> (*arg3 + 31);

Теперь становится понятно, что мы вычисляем 64-битное произведение двух векторов, причём из результатов берём только старшие биты (важно: для каждого элемента – своё количество), да ещё и округляем.

Может ли компилятор эффективно векторизовать такой цикл? Вообще-то должен, так как вся информация у него есть, но посмотрим на код, написанный вручную:

_op5_v8:  // void op5_v2(int32_t *arg1, const int32_t *arg2, const int32_t *arg3, unsigned qty);
    k7:4 = j7:4;  j4 = j4 + 4; r13 = r13 - r13;;
    j5 = j5 + 4;  lc0 = j7;;
    j6 = j6 + 4;  r12 = (1 << 30);;
    r14 = -31;;
loop5:
    xr3:0 = q[k4 + 0]; yr3:0 = q[j4 + 0];;
    xr7:4 = q[k5 += 8]; yr7:4 = q[j5 += 8];;
    xr11:8 = q[k6 += 8]; yr11:8 = q[j6 += 8];;

    r17:16 = r0 * r4 (i);;
    lr19:18 = ashift r13:12 by r8;;
    r8 = r14 - r8;;
    lr17:16 = r17:16 + r19:18;;
    lr17:16 = ashift r17:16 by r8;;
    r0 = r16;;

    r17:16 = r1 * r5 (i);;
    lr19:18 = ashift r13:12 by r9;;
    r9 = r14 - r9;;
    lr17:16 = r17:16 + r19:18;;
    lr17:16 = ashift r17:16 by r9;;
    r1 = r16;;

    r17:16 = r2 * r6 (i);;
    lr19:18 = ashift r13:12 by r10;;
    r10 = r14 - r10;;
    lr17:16 = r17:16 + r19:18;;
    lr17:16 = ashift r17:16 by r10;;
    r2 = r16;;

    r17:16 = r3 * r7 (i);;
    lr19:18 = ashift r13:12 by r11;;
    r11 = r14 - r11;;
    lr17:16 = r17:16 + r19:18;;
    lr17:16 = ashift r17:16 by r11;;
    r3 = r16;;

    if nlc0e, jump loop5;  q[k4 += 8] = xr3:0; q[j4 += 8] = yr3:0;;

В принципе, тут ничего особенного нет, так что даже непонятно, что комментировать.

Снова мы в прологе настраиваем указатели и готовим константы, опять выбираем из памяти максимальные порции данных. Но обратите внимание, что процесс вычислений построен таким образом, что «пузырей» (по возможности) не возникает, а главное – результаты размещаются так, чтобы их можно было в конце цикла одним махом записать в память.

Сумеет ли компилятор найти такой код? Во всяком случае, это уже не совсем тривиальная задача…

Умножение с шагом

А теперь пересмотрим список, который приводили в начале. Пункты 1-3 раскрыты, остался пункт 4 – специфические инструкции. Давайте попробуем и их.

Опять же начнём, по возможности, с простого примера. Имеем (я не стал приводить внешние циклы, их тут три, и они дают xx, yy и ff):

        ((int32_t*)conv2d_nhwc_let)[(((yy * 320) + (xx * 64)) + ff)] = 0;
        for (int32_t rc = 0; rc < 64; ++rc) {
          int32_t cse_var_3 = ((yy * 320) + (xx * 64));
          int32_t cse_var_2 = (cse_var_3 + ff);
          ((int32_t*)conv2d_nhwc_let)[cse_var_2] = (((int32_t*)conv2d_nhwc_let)[cse_var_2] + (((int32_t)((int8_t*)pad_temp_let)[(cse_var_3 + rc)]) * ((int32_t)((int8_t*)fused_constant_2_let)[((rc * 64) + ff)])));
        }

Выглядит запутанно, но если присмотреться, то мы увидим, как некая 32-битная ячейка зануляется, а потом в ней суммируются некие произведения 8-битных чисел, причём один из сомножителей выбирается с постоянным шагом.

Для ассемблерного варианта проще сделать функцию, которая вернёт значение суммы, а записать её потом можно и на Си.

Итак:

_mul_i8_step_sum_i32_v8:  // int32_t mul_i8_step_add_i32_v8(int8_t *arg1, int8_t *arg2, unsigned step, unsigned qty);
    k7:4 = j7:4;  j4 = j4 + 4;;
    j0 = lshiftl j6 by 2;  lc0 = j7;;
    j5 = j5 + j0;  r1:0 = r1:0 - r1:0;  k0 = j0;;
loop2:
    xr2 = pb[k4 += 8]; yr2 = pb[j4 += 8];;

    xr4 = b[k5 += k6] (se);  yr4 = b[j5 += j6] (se);;
    xr5 = b[k5 += k6] (se);  yr5 = b[j5 += j6] (se);;
    xr6 = b[k5 += k6] (se);  yr6 = b[j5 += j6] (se);;
    xr7 = b[k5 += k6] (se);  yr7 = b[j5 += j6] (se);;

    sr3:2 = expand br2 (i);;

    sr4 = compact r5:4 (i);;
    sr5 = compact r7:6 (i);;

    r7:4 = r3:2 * r5:4 (i);;
    r1:0 = r1:0 + r5:4;;

    if nlc0e, jump loop2;  r1:0 = r1:0 + r7:6;  j5 = j5 + j0;  k5 = k5 + k0;;

    r0 = r0 + r1;;
    xr1 = yr0;;
    xr0 = r0 + r1;;

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

Для начала, нет необходимости читать большие блоки, да это и невозможно, так как во втором байты идут «вразбивку».

Вместо этого мы читаем в регистры xyr2 восемь байт, а восемь байт второго сомножителя отдельными инструкциями в xyr7:4, куда они попадают с расширением знака до 32 бит.

Теперь главный фокус – как мы будем умножать? Процессор умеет многое, но не всё. Для нашего случая применимо умножение 16-битных чисел. А ведь у нас ни те, ни те таковыми не являются! И если сделать из 32-битного числа в дополнительном коде 16-битное ещё просто, то расширение знака без аппаратной поддержки – уже заметно сложнее.

К счастью, всё предусмотрено: инструкция expand преобразует 4 8-битных числа в 4 16-битных на двух регистрах, а две инструкции compact дают те же 4 16-битных числа на двух регистрах из 32-битных данных.

И далее следует инструкция, которая совсем потерялась в коде, хотя она выполняет 8 (!) 16-битных умножений (не забываем, что у нас два модуля X и Y), с получением восьми 32-битных результатов. Чтобы их стало поменьше, накапливаем сумму в xyr1:0, а в эпилоге получаем итог из четырёх значений.

И опять же сакраментальный вопрос: сможет ли компилятор найти такое решение?

Усечение с насыщением

Ну и последний пример, который покажет, насколько велико открывающееся пространство возможностей.

Возьмём сразу два последовательных цикла:

      for (int32_t i3_2 = 0; i3_2 < 64; ++i3_2) {
        int32_t cse_var_8 = (((i1_2 * 320) + (i2_2 * 64)) + i3_2);
        int32_t v_ = ((int32_t*)conv2d_nhwc_let)[cse_var_8];
        int32_t v__1 = (v_) < (127) ? (v_) : (127);
        ((int32_t*)conv2d_nhwc_let)[cse_var_8] = ((v__1) > (-128) ? (v__1) : (-128));
      }
      for (int32_t ax3_3 = 0; ax3_3 < 64; ++ax3_3) {
        int32_t cse_var_9 = (((ax1_3 * 320) + (ax2_3 * 64)) + ax3_3);
        T_cast[cse_var_9] = ((int8_t)((int32_t*)conv2d_nhwc_let)[cse_var_9]);
      }

Что тут хотели сказать? Так очевидно: для 32-битных чисел вычисляется 8-битное насыщение, а потом результат записывается в массив 8-битных чисел.

Теперь я даже не стану приводить всю функцию, достаточно ключевого места:

    xr3:0 = q[k5 += 8]; yr3:0 = q[j5 += 8];;

    sr4 = compact r1:0 (si);;
    sr5 = compact r3:2 (si);;

    br6 = compact sr5:4 (si);;

Всего тремя инструкциями 8 слов превращаются в 8 байт, причём с насыщением, для которого даже не требуется проверять условия на значения. Вопрос про компилятор я задавать не стану…

Кто-то может сказать: вы так и будете переписывать на ассемблере весь Си?
Нет!

Ещё раз повторю основную идею: это не просто случайный код, это реализация нейросетевых операций, число которых невелико, причём полученная в результате автогенерации TVM, что обеспечивает абсолютно жёсткое форматирование. То есть рассмотренные конструкции встречаются снова и снова; меняются только размеры, возможно, типы, так что один раз написанный набор поддержки сможет применяться многократно.

Ну и не следует забывать о правиле «20/80», то есть большая часть времени тратится в малом объёме кода, так что достаточно оптимизировать самые тяжёлые операции.

Результаты

У читателя наверняка созрел вопрос: так стоила ли овчинка выделки? Давайте посмотрим…

Мы возьмём слой нейросети, из которого были взяты примеры, описанные в статье, и измерим время его выполнения (в тактах процессора). А потом будем добавлять ассемблерные вставки по одной, отмечая улучшения, возникающие на каждом шаге.

Результаты такие (эффект от вставки суть разность с предыдущим значением):

5813245 — исходный Си код, без вставок, то есть точка отсчёта.
5783619 — быстрое копирование памяти (copy_i32v8). Немного, так как размер блока очень маленький, но всё-таки.
1978783 — умножение с шагом (mul_i8_step_sum_i32_v8). Очень существенный выигрыш – тут ассемблер себя показал достойно.
1913388 — вычитание векторов (в статье не описано за тривиальностью). Немного, но курочка по зёрнышку…
1864005 — сложение векторов (аналогично).
1614949 — умножение со сдвигом и округлением (op5_v8). Заметное улучшение.
1557134 — прибавление к вектору константы (add_n_i32v8). Результат сравним со сложением/вычитанием векторов.
1444328 — усечение с насыщением (последний пример в статье, название пропущено). Заметная прибавка.

Вот как это выглядит:

Что тут можно сказать?

С одной стороны, не стоит так уж сильно кидаться камнями в компилятор. Мы видим, что хотя ассемблер победил везде (было бы удивительно, если бы нет), но только в одном случае (заметьте, реально сложном) выигрыш разгромный (почти втрое). Остальные выглядят во всяком случае терпимо.

С другой, суммарное ускорение получилось в четыре раза! Нечего и говорить, что за такой результат стоит побороться.

Планы на будущее

Что касается планов на будущее, они вполне понятны.

Во-первых, следует искать не отдельные циклы, а их последовательности (как в последнем примере), и объединять операции, чтобы избежать промежуточного копирования. Ну и присмотреться к внешним циклам – вдруг удастся получить выигрыш, включив в оптимизацию и их.

Во-вторых, в перспективе, перенести всю эту кухню непосредственно в TVM, чтобы результат его работы не требовал преобразований.

 

Источник

Читайте также