Оптимизация ARM neon — избавление от лишних нагрузок

Я пытаюсь построить оптимизированное правостороннее матричное умножение с использованием ручного неона. Этот

void transform ( glm::mat4 const & matrix, glm::vec4 const & input, glm::vec4 & output )
{
   float32x4_t &       result_local = reinterpret_cast < float32x4_t & > (*(&output[0]));
   float32x4_t const & input_local  = reinterpret_cast < float32x4_t const & > (*(&input[0] ));

   result_local = vmulq_f32 (               reinterpret_cast < float32x4_t const & > ( matrix[ 0 ] ), input_local );
   result_local = vmlaq_f32 ( result_local, reinterpret_cast < float32x4_t const & > ( matrix[ 1 ] ), input_local );
   result_local = vmlaq_f32 ( result_local, reinterpret_cast < float32x4_t const & > ( matrix[ 2 ] ), input_local );
   result_local = vmlaq_f32 ( result_local, reinterpret_cast < float32x4_t const & > ( matrix[ 3 ] ), input_local );
}

Компилятор (gcc) производит неоновые инструкции, однако кажется, что входной параметр (который предположительно находится в x1) перезагружается в q1 после каждого вызова fmla:

0x0000000000400a78 <+0>:    ldr q1, [x1]
0x0000000000400a7c <+4>:    ldr q0, [x0]
0x0000000000400a80 <+8>:    fmul    v0.4s, v0.4s, v1.4s
0x0000000000400a84 <+12>:   str q0, [x2]
0x0000000000400a88 <+16>:   ldr q2, [x0,#16]
0x0000000000400a8c <+20>:   ldr q1, [x1]
0x0000000000400a90 <+24>:   fmla    v0.4s, v2.4s, v1.4s
0x0000000000400a94 <+28>:   str q0, [x2]
0x0000000000400a98 <+32>:   ldr q2, [x0,#32]
0x0000000000400a9c <+36>:   ldr q1, [x1]
0x0000000000400aa0 <+40>:   fmla    v0.4s, v2.4s, v1.4s
0x0000000000400aa4 <+44>:   str q0, [x2]
0x0000000000400aa8 <+48>:   ldr q2, [x0,#48]
0x0000000000400aac <+52>:   ldr q1, [x1]
0x0000000000400ab0 <+56>:   fmla    v0.4s, v2.4s, v1.4s
0x0000000000400ab4 <+60>:   str q0, [x2]
0x0000000000400ab8 <+64>:   ret

Можно ли избежать и этого?

Компилятор gcc-linaro-6.3.1-2017.05-x86_64_aarch64-linux-gnu с опцией O2.

С уважением

Изменить: удаление ссылки на input_local помогло:

0x0000000000400af0 <+0>:    ldr q1, [x1]
0x0000000000400af4 <+4>:    ldr q0, [x0]
0x0000000000400af8 <+8>:    fmul    v0.4s, v1.4s, v0.4s
0x0000000000400afc <+12>:   str q0, [x2]
0x0000000000400b00 <+16>:   ldr q2, [x0,#16]
0x0000000000400b04 <+20>:   fmla    v0.4s, v1.4s, v2.4s
0x0000000000400b08 <+24>:   str q0, [x2]
0x0000000000400b0c <+28>:   ldr q2, [x0,#32]
0x0000000000400b10 <+32>:   fmla    v0.4s, v1.4s, v2.4s
0x0000000000400b14 <+36>:   str q0, [x2]
0x0000000000400b18 <+40>:   ldr q2, [x0,#48]
0x0000000000400b1c <+44>:   fmla    v0.4s, v1.4s, v2.4s
0x0000000000400b20 <+48>:   str q0, [x2]
0x0000000000400b24 <+52>:   ret

Редактировать 2: Это максимум, что я получил на данный момент.

0x0000000000400ea0 <+0>:    ldr q1, [x1]
0x0000000000400ea4 <+4>:    ldr q0, [x0,#16]
0x0000000000400ea8 <+8>:    ldr q4, [x0]
0x0000000000400eac <+12>:   ldr q3, [x0,#32]
0x0000000000400eb0 <+16>:   fmul    v0.4s, v0.4s, v1.4s
0x0000000000400eb4 <+20>:   ldr q2, [x0,#48] 
0x0000000000400eb8 <+24>:   fmla    v0.4s, v4.4s, v1.4s
0x0000000000400ebc <+28>:   fmla    v0.4s, v3.4s, v1.4s
0x0000000000400ec0 <+32>:   fmla    v0.4s, v2.4s, v1.4s
0x0000000000400ec4 <+36>:   str q0, [x2]
0x0000000000400ec8 <+40>:   ret

Согласно perf.


person Desperado17    schedule 24.01.2019    source источник


Ответы (2)


Вы работаете непосредственно с указателями (вызов по ссылке). Если вы работаете с указателями, вы должны знать, что вы полностью во власти компилятора. И компиляторы для ARM не совсем лучшие.

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

  • объявить локальные векторы (без &)
  • загрузить значения из указателя в соответствующие векторы (желательно всю матрицу плюс вектор)
  • посчитать с векторами
  • сохранить векторы в указатель

Вышеописанный процесс также действителен для ненеоновых вычислений. Компилятор почти всегда серьезно страдает от малейших намеков на (автоматические) операции с памятью.

Помните, локальные переменные — ваши лучшие друзья. И ВСЕГДА выполняйте загрузку/сохранение памяти вручную.


компилятор: Android clang 8.0.2 -o2

void transform(const float *matrix, const float *input, float *output)
{
    const float32x4_t input_local = vld1q_f32(input);
    const float32x4_t row0 = vld1q_f32(&matrix[0*4]);
    const float32x4_t row1 = vld1q_f32(&matrix[1*4]);
    const float32x4_t row2 = vld1q_f32(&matrix[2*4]);
    const float32x4_t row3 = vld1q_f32(&matrix[3*4]);

    float32x4_t rslt;
    rslt = vmulq_f32(row0, input_local);
    rslt = vmlaq_f32(rslt, row1, input_local);
    rslt = vmlaq_f32(rslt, row2, input_local);
    rslt = vmlaq_f32(rslt, row3, input_local);

    vst1q_f32(output, rslt);
}

; void __fastcall transform(const float *matrix, const float *input, float *output)
EXPORT _Z9transformPKfS0_Pf
_Z9transformPKfS0_Pf
matrix = X0             ; const float *
input = X1              ; const float *
output = X2             ; float *
; __unwind {
LDR             Q0, [input]
LDP             Q1, Q2, [matrix]
LDP             Q3, Q4, [matrix,#0x20]
FMUL            V1.4S, V0.4S, V1.4S
FMUL            V2.4S, V0.4S, V2.4S
FMUL            V3.4S, V0.4S, V3.4S
FADD            V1.4S, V1.4S, V2.4S
FADD            V1.4S, V3.4S, V1.4S
FMUL            V0.4S, V0.4S, V4.4S
FADD            V0.4S, V0.4S, V1.4S
STR             Q0, [output]
RET
; } // starts at 4

Как видите, Android clang 8.0.2 значительно лучше предыдущих версий, когда дело доходит до неоновых кодов. Наконец, компилятор генерирует коды, загружающие несколько регистров. Почему ему не нравится FMLA, я не понимаю.

person Jake 'Alquimista' LEE    schedule 24.01.2019
comment
объявить локальные векторы (без &) Удаление ссылки из input_local помогло. - person Desperado17; 24.01.2019
comment
Глядя на исходный прототип, вы видите, что компилятор не может знать наверняка, что input и output никогда не могут указывать на один и тот же объект. Вот почему он должен быть консервативным и предполагать, что каждое хранилище до output могло мутировать input. C имеет ключевое слово restrict именно для этого случая. - person Useless; 24.01.2019
comment
Ок, последняя версия почти готова. Разница лишь в том, что у него 5 загрузок вместо 3. Причина в том, что он пытается загружать матрицу чанками по 16 байт вместо 32. - person Desperado17; 24.01.2019
comment
Есть еще предложения? Согласно perf, он по-прежнему проводит много времени в инструкциях ldr. Должен ли я использовать встроенный_префетч? - person Desperado17; 24.01.2019
comment
@ Desperado17 Desperado17 Если можете, выровняйте указатель матрицы до 64 байт, а указатель вектора до 16 байт. В противном случае мне нужно больше информации о самой этой функции: сколько раз она вызывается?; Находятся ли матрицы и векторы в непрерывной памяти?; Они оба меняются каждую итерацию?; и т.п. - person Jake 'Alquimista' LEE; 25.01.2019
comment
Указатель матрицы загружается только один раз за вызов. Ассемблерный код показывает, что четыре столбца распределены по 4 q-регистрам. Записи ввода выровнены по 16 байтам (я распечатал адреса итераторов). Мой тест вызывает функцию только один раз со случайно сгенерированными 10000 входных записей. Компилятор, похоже, не выдает инструкцию предварительной выборки. Добавление __builtin_prefetch в тело цикла добавляет инструкцию предварительной выборки, но количество промахов кеша в соответствии с cachegrind остается прежним. - person Desperado17; 25.01.2019
comment
@ Desperado17 Desperado17 Итак, я понимаю, что вызов функции происходит один раз за итерацию (10000 раз), и матрица, и вектор меняются каждый раз, и каждый из них находится в непрерывной памяти, верно? Вы должны реализовать цикл внутри самой функции, повторяя столько раз, сколько указано параметром. И вы должны развернуть цикл, чтобы сделать два/четыре умножения внутри цикла, а также дополнительную остаточную часть обработки после цикла для случая, когда число данной итерации нечетно/не кратно четырем. Это в значительной степени устранит большинство задержек кэш-промаха/инструкций. - person Jake 'Alquimista' LEE; 25.01.2019
comment
Как и в случае с FYI, проблема в исходном случае заключается в том, что входные и выходные ссылки могут создавать псевдонимы друг друга. Компилятор должен предположить, что запись через выходную ссылку изменяет входную ссылку, следовательно, перезагружается. Ключевое слово restrict должно использоваться как форма обещания контракта API, что параметры не будут псевдонимами. - person solidpixel; 25.01.2019
comment
@solidpixel ahemmmmm, директива restrict технически устарела в C++, и, используя старый школьный способ, получение полного контроля — самый надежный способ по моему опыту. Я устал смотреть на дизассемблирование, чтобы проверить, правильно ли компилятор выполнил свою работу. - person Jake 'Alquimista' LEE; 25.01.2019
comment
Спасибо - не понял этого (я в основном программист на C). По моему опыту работы с SEE и NEON, вы либо пишете ассемблер, либо тратите свою жизнь на проверку того, что компилятор выполнил свою работу правильно... - person solidpixel; 25.01.2019

Ваш вывод glm::vec4 & output может быть ссылкой на ту же память, что и ваш input того же типа. Всякий раз, когда вы записываете вывод, компилятор предполагает, что вы могли изменить input, поэтому он снова загружает его из памяти.

Это из-за C правил псевдонима указателя.

Вы можете пообещать компилятору, что к памяти, на которую указывает output, никогда не будет доступа через какой-либо другой указатель (или ссылку, в данном случае) с ключевым словом restrict:

void transform (
   glm::mat4 const & matrix,
   glm::vec4 const & input,
   glm::vec4 & __restrict output)

Тогда лишние нагрузки исчезнут. Вот вывод компилятора (godbolt) (попробуйте удалить __restrict).

person maxy    schedule 06.07.2019