29.09.2020

Продолжаю оптимизировать

Точнее, пытаюсь закончить оптимизировать Scalar SSE в тех местах, которые до конца не проверил, или проверил не правильно. 

1. Замена вычитания сложением. В чем суть этой попытки оптимизации? У нас есть вот такое выражение:
  S[j] := S[j]-R[j]*S[index];
На ассемблере я его транслировал в такой набор команд
  movapd XMM1, XMM0;
  mulsd XMM1, [R11+RDX*8];
  movsd XMM2, [RCX+RDX*8];
  subsd XMM2, XMM1;
  movsd [RCX+RDX*8], XMM2;

в двух операторной форме команд, принятой в x86, я вынужден сначала загрузить S[j] в регистр, а уже потом выполнять вычитание.
Если же у S[index] перед внутренним циклом поменять знак, то операцию вычитания можно заменить сложением без предварительной загрузки из памяти в регистр XMM2. В результате получится вот такой код:
  movapd XMM1, XMM0;
  mulsd XMM1, [R11+RDX*8];
  addsd XMM1, [RCX+RDX*8];
  movsd [RCX+RDX*8], XMM1;
Как видим, он на одну команду короче, и, возможно, быстрее. Не стоит забывать, что нам пришлось во внешней цикле добавить команду изменения знака S[index].
Но будет ли этот код реально быстрее? Зависит от того, какая команда выполняется быстрее: умножение XMM1 на значение в память или загрузка в XMM2 значения из памяти. Дело в том, что команда addsd не может начаться, пока не закончится умножение XMM1.
Поэтому, если умножение медленное, то XMM2 успеет загрузиться из памяти (так как эти команды независимы) и затем можно будет вычесть из XMM2 XMM1 без потери производительности. То есть в этом случае замена вычитания сложением не нужна, так как ничего не даст.
В том случае, если умножение выполняется быстрее, чем загрузка XMM2, то ситуация будет обратная: выгоднее будет второй вариант со сложением.
На мой взгляд, разницы тут не должно быть много, но давайте посмотрим на результаты.
На типе single (float) ускорение от замены вычитания на сложение составило от сотых долей процента на системах из 3 уравнений, до 4-5% на больших системах до 48 переменных включительно. А вот на больших размерностях результат оказался парадоксальный: незначительное снижение производительности на системах из 96 уравнений и потери до 3.5% и выше на больших размерностях.
На типе double ускорение не превышает 3%, достигая максимума на 24 уравнениях, затем постепенно снижается и заменяется замедлением на системах в 1536 уравнений.
В общем, я не увидел особого смысла в такой оптимизации.

2. Развертывание цикла. Я прошлый раз ограничился четырьмя операциями, но Иван предложил попробовать увеличить их до восьми.
Результат моих размышлений на эту тему.
Ускорение от развертывания можно получить за счет снижения доли служебных операций на организацию цикла. Но это работает только в том случае, когда тело цикла (рабочая нагрузка) очень проста и по времени сравнима с работой по организации самого цикла.
В моем случае сам цикл организуется всего тремя командами: двумя короткими регистровыми операциями и условным переходом. А полезной нагрузкой является 5 команд, в том числе три с памятью. В принципе, доля служебных команд достаточна велика и такой цикл имеет смысл разложить на две итерации. Дальнейшее разложение приведет лишь к незначительному росту производительности, тем более, что хорошо работает блок предсказаний переходов, команды организации цикла и полезной нагрузки исполняются на разных блоках, так что частично могут выполняться параллельно.
Второй источник ускорения связан с отсутствием сбросов конвейера. Чем длиннее линейная часть программы, без условных переходов, тем полнее заполнен конвейер и выше производительность процессора. Наличие условных переходов сбрасывает конвейер,что приводит к сильному снижению производительности.
Вышеописанная страшилка более актуальная для очень старых процессоров. Современные версии процессоров имеют очень хороший блок предсказания переходов, который работает совместно с процессором и остановка конвейера происходит только в том случае, если блок предсказаний ошибся. В частности, для for-циклов блок предсказаний ошибается очень редко, в идеальном случае лишь один раз ‒ когда цикл заканчивается. Думаю, что именно этот источник ускорения при решении моей задачи имеет очень низкое значение.
Третий источник ускорения
‒ суперскалярная архитектура, причем именно она, на мой взгляд, дает наибольший эффект в моем случае. Посмотрим на полезную нагрузку цикла. В случае одной операции на итерацию у нас требуется выполнить одно умножение и одно сложение, причем сложение зависит от умножение. Фактически здесь нет операций, которые могли бы выполнятся параллельно.
Если выполнять две операции за итерацию, то нужно выполнить 2 умножения и 2 сложения, тут уже есть что распараллелить. 2 умножения можно выполнять параллельно, но со сложениям из-за зависимости от умножений ситуация сложнее, скорее всего параллельно умножениям удастся выполнить лишь одну команду сложения.
При увеличении количества операций на итерацию ситуация с распараллеливанием становится все лучше и лучше. Теоретически могут выполняться 4 инструкции за такт, но практически, есть подозрение, их количество лежит в пределах от 2 до 3, причем ближе к двум, по всей видимости. Это связано с тем, что умножение все же выполняется медленнее сложения, поэтому для сумматоров данные всегда поставляются с задержкой.
Тем не менее, попробуем оценить, насколько увеличивается производительность при переходе от 4 операций на итерацию к 8.
На типе single (float) ускорение составило от 0% (3 переменных) до 11% (192 переменных), затем уменьшилось до 4% на 384 переменных.
На типе double ускорение максимум составило около 7% на 192 переменных, на всех остальных размерах ускорение составляет около 1%, что ниже погрешности измерений.
В результате решил оставить развертывание на восемь операций для типа
single (float), а для типа double остановиться на четырех.

3. Решил побаловаться с командами PREFETCH. Эффект для моей задачи равен нулю, так как, видимо, механизм автоматической предзагрузки кэша в этом случае справляется на ура.

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

  1. > 1. Замена вычитания сложением.

    Протестировал у себя и заметной разницы тоже не увидел (ни ускорения, ни замедления), если она и есть то меньше 5%, моей погрешности измерений. Как я понимаю современный процессор заменяет макро-инструкции с памятью на 2 микро-инструкции связанные через теневой регистр, а дальше выполняет независимо по мере освобождения нужных блоков. Теоретическое преимущество может быть: а) меньше задействовано нормальных регистров, но нам вроде и так хватает, б) меньше нагрузка на декодер команд, вроде тоже не узкое место.

    > Третий источник ускорения ‒ суперскалярная архитектура, причем именно она, на мой взгляд, дает наибольший эффект в моем случае

    Согласен. Переход предсказывается правильно, а вот служебные команды временно прерывают поток новых полезных команд на конвейер. Это как светофор, сильно часто: машины едут медленно, реже: быстрее, но с какой-то дистанции становится все равно, так как находятся другие места для пробок.

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

    > Теоретически могут выполняться 4 инструкции за такт

    Начать выполняться вроде да, но вот находится в процессе выполнения, как я понимаю, может намного больше. Скажем у Piledriver каждый из 2-х FPU каждый такт может начать новое умножение или сложение, которое занимает аж 6 тактов и того где-то внутри FPU могут быть до 6 умножений/сложений на разных стадиях готовности. Или я не правильно трактую задержку, и она не внутри FPU?

    > умножение все же выполняется медленнее сложения

    На днях прочитал, что вроде не все так однозначно:
    - умножение: перемножили мантиссы, параллельно сложили порядки. Для целочисленного умножения есть эффективные алгоритмы реализуемые в железе.
    - сложение: нашли разницу в порядках, потом сдвинули одну из мантисс, далее сложили их. И того операции вроде проще, но они в отличии от умножения последовательные.

    > видимо, механизм автоматической предзагрузки кэша в этом случае справляется на ура.

    Ага похоже, я тоже в сгенерированном компилятором коде PREFETCH не увидел.

    ОтветитьУдалить
    Ответы
    1. > Или я не правильно трактую задержку, и она не внутри FPU?
      Видимо, я не совсем точно выразился. Конечно, 4 таких длинных инструкции за такт выполнить нельзя. Можно одновременно выполнять 4 таких инструкции, по 2 сложения и умножения.
      Честно говоря, а не знаю, где происходит задержка, но есть подозрение, что FPU может начать выполнять следующую команду, только когда освободится сумматор или умножитель. Но так-то готовая и полностью декодированная команда уже где-то лежит с готовыми операндами, наверное только в этом смысле можно говорить про начало исполнения инструкций каждый такт.

      > На днях прочитал, что вроде не все так однозначно:
      Вроде как вещественное сложение и умножение в Scalar SSE выполняются за одинаковые количество тактов. Но если посмотреть целочисленные команды, то там всегда сложение быстрее умножения. Если есть алгоритм, которые позволяет умножать так же быстро, как складывать, то почему он не используется в целочисленной арифметике? Не понятно...
      С такой же засадой я уже сталкивался несколько лет назад на целочисленных операциях: если сложение что байта, что 64-битного целого занимает одно количество времени, то чего экономить? Можно все тогда выполнять в 64 битной арифметике? Но нет, короткий тип и в арифметике оказывается почему-то быстрее. Но это было давно, я уже не помню точно. Вполне может быть, что проигрыш связан с тем, что 64-битные значения дольше сохраняется в памяти, но вроде в основном там были регистровые операции.

      Удалить
    2. > почему он не используется в целочисленной арифметике?

      Потому что целочисленное умножение медленнее сложения, но мы ведь про вещественные числа, а там мантисса и порядок:

      - вещественное умножение - это параллельно целочисленное умножение мантисс плюс сложение порядков. Сложение быстрее умножения, поэтому итоговое время сравнимое с целочисленным умножением. Где-то 3-4 такта.
      - вещественное сложение - это последовательно: вычитание порядков, сдвиг одной из мантисс, целочисленное сложение мантисс, в итоге 3 простых однотактовых операций одна за другой, а в итоге 1 + 1 + 1 = 3 такта.

      > Но нет, короткий тип и в арифметике оказывается почему-то быстрее.

      Вы это на Piledriver замечали или других старых AMD? Там действительно умножение 64-бит целых существенно медленнее, но Ryzen, и разные Intel выполняют 64-бит целое умножение также быстро как 32-битное.

      Удалить
    3. > Потому что целочисленное умножение медленнее сложения, но мы ведь про вещественные числа, а там мантисса и порядок:
      Да, все верно, при вещественном умножении нам нужно выполнить одно целочисленное умножение (мантисс) плюс сложение (порядков) плюс коррекцию порядков после умножения. Даже если это все выполняется параллельно, то умножение мантисс (24 бита) не может быть никак быстрее умножение 16-битных целых,точнее всегда медленнее. То есть умножение вещественных должно быть всегда медленнее целочисленных сравнимой длины в битах. И так и есть, кстати.
      Удивляет, что вещественное сложение происходит так же медленно, как и умножение. Ведь сложение + сдвиг двум мантисс - это быстрее одного умножения. А такты одинаковые, что на команду умножения, что сложения.

      > Вы это на Piledriver замечали...
      Да. Но дело не только в умножении, все медленнее. Такое ощущение, что выполнение длинных арифметических команд вносит какую-то задержку.

      Удалить
    4. Как я понимаю время одинаковое из-за того что FPU - это конвейер, если выполнять операцию без тактов, то будет очевидно быстрее, но так как операция разбивается на несколько под-операций, синхронизируемых таковым генератором, то получается одинаковое/близкое число тактов с умножением.

      Блоки процессора оптимизированы на пропускную способность, а не на выполнение отдельной операции. Скажем если, условно, вычитание порядков, сдвиг, сложение мантисс каждая занимает по 0.6 такта:

      - если просто соединить их в железе, то получим 0.6 * 3 = 1.8 тактов, то есть каждые 2 такта FPU выполняет 1 сложение. На время выполнения внутри FPU бардак, и он полностью блокирован для других команд. И того 1/2 = 0.5 новых операций за такт.

      - операции организованы в виде конвейера, полное время выполнения дольше 1 * 3 = 3 такта, но так как это конвейер, каждый такт FPU может принимать на выполнения очередную инструкцию, в итоге пропускная способность в 2 раза выше: 1 новая операция за такт.

      Плюс одинаковое время более удобно для планировщика.

      > умножение вещественных должно быть всегда медленнее целочисленных сравнимой длины в битах. И так и есть, кстати.

      У Ryzen вроде, что умножение целых 64-bit, что вещественных - 3 такта, у Skylake - вещественное умножение действительно чуть-чуть медленнее 4 такта vs 3 для целочисленного.

      Удалить