Оптимизация SIMD кода

Остапа понесло.

“12 стульев”

Почувствовав прилив сил и некоторую уверенность после оптимизации программ на Си, я решил погрузиться ещё глубже. И поводом для этого стало странное поведение функции volk_8i_s32f_convert_32f под RaspberryPI.

В предыдущей статье я смог с помощью этой функции существенно ускорить работу своей программы sdr-server. На скриншоте ниже видно, что конкретная реализация этой функции под MacOS была volk_8i_s32f_convert_32f_u_sse4_1.

Это значит, что все метод реализован с помощью инструкций и регистров SSE4. Однако, запуская этот же код на RaspberryPI, я не заметил существенной разницы по сравнению с обычным циклом.

#: volk_profile -R volk_8i_s32f_convert_32f -n

RUN_VOLK_TESTS: volk_8i_s32f_convert_32f(131071,1987)
generic completed in 446.774 ms
neon completed in 434.753 ms
a_generic completed in 416.605 ms
Best aligned arch: a_generic
Best unaligned arch: neon
Warning: this was a dry-run. Config not generated

Разница между generic реализаций и специальной реализацией под ARM процессор (neon) несущественна. Это навело меня на мысли. Почему расширенные регистры в Intel работают быстрее, чем похожие регистры в ARM? Я не смог заглянуть внутрь реализации обоих процессоров, чтобы сравнить. Зато я решил открыть исходный код Volk и почитать.

Интристики

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

На самом деле, всё достаточно просто. Интристики - это некоторый промежуточный слой между ассемблером и языком Си. Они оборачивают инструкции ассемблера в функции на Си, чтобы можно было удобнее их читать. Иногда они могут подготавливать регистры ассемблера для вызова операции. Можно написать код на ассемблере и подключить его в программу на Си, но удобнее написать с помощью интристиков. А компилятор уже преобразует их в ассемблерные инструкции.

Для примера можно взять функцию vld1q_s8.

Эта функция загружает в регистр данные из памяти:

int8_t *inputVectorPtr;
int8x16_t inputVal = vld1q_s8(inputVectorPtr);

После этого можно выполнять необходимые SIMD инструкции над этими данными. При этом компилятор сам выбирает в какой именно регистр будут загружаться данные. Ассемблерный код выглядел бы так:

VLD1.16 {d0,d1}, [r0]

Оптимизация

Итак, теперь более-менее понятно, что такое интристики. Пришло время расчехлить теоретические знания, полученные более 15 лет назад, и попытаться разобраться, что происходит в методе.

А там почему-то используются структуры размерности 2 (int8x8x2_t) вместо обычных векторов. Мне показалось это странным.

int8x8x2_t inputVal;
float32x4x2_t outputFloat;
int16x8_t tmp;

unsigned int number = 0;
const unsigned int sixteenthPoints = num_points / 16;
for (; number < sixteenthPoints; number++) {
    __VOLK_PREFETCH(inputVectorPtr + 16);

    inputVal = vld2_s8(inputVectorPtr);
    inputVal = vzip_s8(inputVal.val[0], inputVal.val[1]);
    inputVectorPtr += 16;
...

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

int8x16_t inputVal;
int16x8_t lower;
int16x8_t higher;
float32x4_t outputFloat;

unsigned int number = 0;
const unsigned int sixteenthPoints = num_points / 16;
for (; number < sixteenthPoints; number++) {
    inputVal = vld1q_s8(inputVectorPtr);
    inputVectorPtr += 16;
...

Получилось значительно прямее и проще. Однако, не факт, что быстрее. Единственным способом проверить - это запустить код.

#: volk_profile -R volk_8i_s32f_convert_32f -n

RUN_VOLK_TESTS: volk_8i_s32f_convert_32f(131071,1987)
generic completed in 401.395 ms
neon completed in 324.097 ms
a_generic completed in 377.772 ms
Best aligned arch: neon
Best unaligned arch: neon
Warning: this was a dry-run. Config not generated

Какого же было моё удивление, когда я увидел 20% прирост производительности! Я перепроверил код ещё несколько раз, и всякий раз моя реализация оказывалась быстрее.

У меня нет достаточно знаний, чтобы точно сказать, почему мой код оказался быстрее. Тем не менее я выдвинул несколько теорий:

  • gcc не смог оптимизировать слишком сложный код в оригинальном методе
  • оригинальный код работал с N-мерными структурами, которые не подходят для таких простых операций
  • оригинальный код был написан и оттестирован на armv8 (arm64), а не armv7 (RaspberryPI 3+)

В любом случае я создал pull request в оригинальный репозиторий и отправил его на ревью.

Результаты

Пожинать плоды моих усилий придётся нескоро. Для начала мой pull request должен быть проверен. Потом он должен попасть в master, и только потом, через какое-то время, он попадёт в релизную версию. Но вместо того, чтобы сидеть и ждать, я готов исследовать новые горизонты:

  • попробовать сравнить libvolk и openmax. Возможно, в openmax уже реализован этот метод.
  • попробовать собрать свою версию libvolk и подключить её в проект с помощью conan.io. Тогда не придётся ждать официального релиза.

В любом случае sdr-server есть куда улучшать.