04.10.2020

SSE

Ну вот, наконец-то добрался до SSE. И предварительные результаты несколько обескураживающие. Я, конечно не ожидал, что использование векторных команд увеличит производительность пропорционально размеру вектора, все же даже решение СЛАУ, которое прямо просятся решаться векторными операциями, на 100% не векторизуется.

Но все же я надеялся хотя бы на двукратный рост на типе single (float), которому на SSE соответствует вектор из 4 элементов. На практике же максимальное ускорение относительно решения на Scalar SSE составило всего 1.66 раза, что на AMD FX-4350 соответствует решению 158 уравнений в секунду.
Очевидно, что на маленьких СЛАУ вышло даже небольшое замедление. На СЛАУ из 3 уравнений ускорение получилось 0.93.

Но самая печаль на типе double. В SSE ему соотвествует вектор всего из двух элементов. Максимальное ускорение достигнуто на СЛАУ из 192 переменных и составляет 1.18 раза. На маленьких размерностях (3 и 6 уравнений) ускорение меньше единицы, далее растет, после максимума на 192 уравнениях плавно снижается, приближаясь к единице 1536 уравнениях.

Одна из возможных причин состоит в том, что SSE не умеет выполнять арифметические операции с невыровненными на границу 16 байт данными. А при решении СЛАУ они гарантированно не выровнены.
AMD рекомендует в таких случая выполнять сохранение в две команды, записывая за раз по половине векторного регистра. Попробовал, ощутимой разницы не увидел: на каких-то размерностях стало быстрее, на других же наоборот.
Вроде бы в AVX эту "недоработку" пофиксили и арифметические команды выполняют и над невыровненными данными.
Тут кстати обнаружил, что моя версия Delphi не умеет в AVX. Надо подумать, как красивее обойти это органичение.

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

procedure MakeDoubleNextRowsP_SSE;
asm
// R8 - P, R9w - n, R10w - index
  lea EBX, [R10d+1]; // BX - i
  cmp BX, R9w;
  jae @exit;
@Loop1:
  mov RCX, [R8 + RBX*8]; // RCX - S
  lea EDX, [R10d+1];
  lea EAX, [R9d-7];
  movsd XMM0, [RCX+R10*8];
  unpcklpd XMM0, XMM0;
  cmp EDX, EAX;
  jg @Loop2_4;
@Loop2:
  movapd XMM1, XMM0;
  movupd XMM2, [R11+RDX*8];
  mulpd XMM1, XMM2;
  movupd XMM2, [RCX+RDX*8];
  subpd XMM2, XMM1;
  movupd [RCX+RDX*8], XMM2;
  movapd XMM1, XMM0;
  movupd XMM2, [R11+RDX*8+16];
  mulpd XMM1, XMM2;
  movupd XMM2, [RCX+RDX*8+16];
  subpd XMM2, XMM1;
  movupd [RCX+RDX*8+16], XMM2;
  movapd XMM1, XMM0;
  movupd XMM2, [R11+RDX*8+32];
  mulpd XMM1, XMM2;
  movupd XMM2, [RCX+RDX*8+32];
  subpd XMM2, XMM1;
  movupd [RCX+RDX*8+32], XMM2;
  movapd XMM1, XMM0;
  movupd XMM2, [R11+RDX*8+48];
  mulpd XMM1, XMM2;
  movupd XMM2, [RCX+RDX*8+48];
  subpd XMM2, XMM1;
  movupd [RCX+RDX*8+48], XMM2;
  add DX, 8;
  cmp EDX, EAX;
  jle @Loop2;
@Loop2_4:
  add EAX, 4;
  cmp EDX, EAX;
  jg @Loop2_2;
  movapd XMM1, XMM0;
  movupd XMM2, [R11+RDX*8];
  mulpd XMM1, XMM2;
  movupd XMM2, [RCX+RDX*8];
  subpd XMM2, XMM1;
  movupd [RCX+RDX*8], XMM2;
  movapd XMM1, XMM0;
  movupd XMM2, [R11+RDX*8+16];
  mulpd XMM1, XMM2;
  movupd XMM2, [RCX+RDX*8+16];
  subpd XMM2, XMM1;
  movupd [RCX+RDX*8+16], XMM2;
  add DX, 4;
@Loop2_2:
  add EAX, 2;
  cmp EDX, EAX;
  jg @Loop2_1;
  movapd XMM1, XMM0;
  movupd XMM2, [R11+RDX*8];
  mulpd XMM1, XMM2;
  movupd XMM2, [RCX+RDX*8];
  subpd XMM2, XMM1;
  movupd [RCX+RDX*8], XMM2;
  add DX, 2;
@loop2_1:
  cmp DX, R9w;
  ja @Loop2_Fin;
  movapd XMM1, XMM0;
  mulsd XMM1, [R11+RDX*8];
  movsd XMM2, [RCX+RDX*8];
  subsd XMM2, XMM1;
  movsd [RCX+RDX*8], XMM2;
@Loop2_Fin:
  inc BX;
  cmp BX, R9w;
  jb @Loop1;
@exit:
end;

 

7 комментариев:

  1. У меня тоже прирост далеко не в 4 раз на double и AVX:
    - 192 уравнений: 1.8
    - 1536: 1.7
    - 3072: 1.5
    - 6144: 1.3

    Но у моего процессора, судя по описанию, еще есть 64 MB L4 кэш (видео память) возможно это объясняет не единичное ускорение.

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

    А не пробовали посмотреть куда тратится время?

    Я, чтобы примерно оценить где узкое место, измерял следующее:
    - T_base - время решения одной СЛАУ
    - T_no_loop3 - время "решения" СЛАУ с закоментированном телом внутреннего цикла.
    - T_extra_loop3 - время "решения" с продублированном 3-м циклом (не телом, а именно циклом целиком).

    Далее считал:
    - T_loop3 = T_base - T_no_loop3 - время затраченное целиком на 3-й цикл, включая загрузку данных если необходимо.
    - T_loop3_cached = T_extra_loop3 - T_base - время достаточное для 3-го цикла если все данные уже в L1. 1-й цикл - прогревает кэш, а 2-й дополнительный цикл проходит уже по горячим данным.
    - T_loop3_mem = T_loop3 - T_loop3_cached - очень примерное время затраченное на ожидание загрузки данных в L1 кэш

    В моем случае для 192 уравнений (Scalar AVX, Vector AVX) выше описанные времена:
    - T_base: 1.395, 0.770 ms (x1.8)
    - T_no_loop: 0.076, 0.076 ms - время одинаковое, так как только 3-й цикл векторизирован (я не добиваюсь единицы на главной диагонали)
    - T_loop3: 1.319, 0.694 ms (x1.9, уже лучше так как исключили невекторизированный код)
    - T_loop3_cached: 1.101, 0.365 ms (x3, не x4 как ожидалось, но все же лучше чем x1.8)
    - T_loop3_mem: 0.218 ms, 0.328 ms (почему векторизация увеличила это время, я не знаю, возможно погрешность измерений)

    Для 1536 все аналогично (Scalar AVX, Vector AVX):
    - T_base: 920, 541 ms (x1.7)
    - T_no_loop: 19, 19 ms
    - T_loop3: 902, 523 ms (x1.7)
    - T_loop3_cached: 563, 185 (x3)
    - T_loop3_mem: 339, 338 ms Время получилось одинаковым, вроде так и должно быть, но как то прям не верится.

    Судя по тем цифрам что я наблюдаю у себя, чем больше СЛАУ, тем увеличивается ожидание загрузки данных (T_loop3_mem), которое не уменьшается простым переходом на векторные инструкции, это объясняет падение ускорения от векторизации.

    > А при решении СЛАУ они гарантированно не выровнены.

    Вроде можно выровнять следующим образом: изначально выровнять строки по 128 байтам, перед векторизированным циклом выполнять короткий Scalar SSE цикл (от 0 до 3-х итераций для float и 0 до 1-й итерации для double в зависимости от текущей колонки), но на сколько это ускорит и ускорит ли вообще я без понятия :)))

    > возможно, мне опять какой-нибудь очевидный косяк замылился

    Вроде норм, я с ходу не вижу как улучшить :)

    ОтветитьУдалить
    Ответы
    1. > выровнять строки по 128 байтам
      опечатался, конечно по 128 битам, а не байтам

      Удалить
    2. Этот комментарий был удален автором.

      Удалить
    3. > У меня тоже прирост далеко не в 4 раз на double и AVX: - 192 уравнений: 1.8
      Ну, если уж на AVX такой прирост... Тогда для SSE вроде норм.

      > А не пробовали посмотреть куда тратится время?
      Нет, так глубоко не копал. А ты прямо очень серьезно к вопросу подошел.

      > Судя по тем цифрам что я наблюдаю у себя, чем больше СЛАУ, тем увеличивается ожидание загрузки данных (T_loop3_mem)
      Судя по всему, именно так. Все же процессор работает намного быстрее, чем память. Даже скалярные инструкции слишком быстрые в случае гаусса, при выходе за пределы L3 производительность сильно проседает. Ну а в случае векторных так вообще, особенно при использовании AVX.
      В связи с чем возникает резонный вопрос необходимости AVX-512. Не даром AMD его вообще не торопится реализовывать...

      Удалить
    4. > Ну, если уж на AVX такой прирост... Тогда для SSE вроде норм.
      Ага, на серверном варианте бы запустить с 8 канальной памятью, думаю разница была бы куда интереснее

      > А ты прямо очень серьезно к вопросу подошел.
      Это точно :))), меня почему-то очень заинтересовало как переписать алгоритм так, чтобы он более эффективно кэш использовал. Как я уже писал ранее, придумал пересчитывать строки блоками. И все конечно, как позже оказалось, было уже придумано до меня :( Даже статья есть в английской википедии: https://en.wikipedia.org/wiki/Loop_nest_optimization И именно по такому принципу реализовано LU-разложение в том же OpenBLAS.

      Текущий мой рекорд: 5.3 СЛАУ из 1536 уравнений за секунду, почти в 3 раза быстрее чем стандартный алгоритм. А еще интересно что оптимизированный алгоритм больше зависит от векторизации, что в принципе ожидаемо, на 384 уравнений Vector AVX в 2.5 раза быстрее чем его Scalar AVX версия для double.

      > В связи с чем возникает резонный вопрос необходимости AVX-512
      Это точно, Линус того же мнения: https://www.realworldtech.com/forum/?threadid=193189&curpostid=193190

      Удалить
    5. > Текущий мой рекорд: 5.3 СЛАУ из 1536 уравнений за секунду, почти в 3 раза быстрее чем стандартный алгоритм.
      Да, это прям супер, для больших размерностей. Впрочем, кажется, пока система вмешается в L2, а может даже и в L3, этот метод особых преимущество не дает? Или я не прав?

      Удалить
    6. На самых крохотных - медленнее, так как больше индексов инкрементируется и в целом код чуток посложнее, а дальше обработка блоками потихоньку начинает проявлять себя:
      - < 48 уравнений медленнее, нужно переходить на обычный алгоритм.
      - 48: 54107 (x1.14)
      - 96: 10666 (x1.60)
      - 192: 2162 (x1.66)
      - 384: 345 (x2.08)
      - 768: 38 (x1.92)
      - 1536: 5.288 (x2.86)
      - 3072: 0.617 (x3.43)
      - 6144: 0.051 (x3)

      Удалить