Тайны оптимизации С++

Возьмите два следующих фрагмента:

int main()
{
    unsigned long int start = utime();

    __int128_t n = 128;

    for(__int128_t i=1; i<1000000000; i++)
        n = (n * i);

    unsigned long int end = utime();

    cout<<(unsigned long int) n<<endl;

    cout<<end - start<<endl;
}

и

int main()
{
    unsigned long int start = utime();

    __int128_t n = 128;

    for(__int128_t i=1; i<1000000000; i++)
        n = (n * i) >> 2;

    unsigned long int end = utime();

    cout<<(unsigned long int) n<<endl;

    cout<<end - start<<endl;
}

Я сравниваю 128-битные целые числа в С++. При выполнении первого (просто умножение) все работает в ок. 0,95 секунды. Когда я также добавляю операцию сдвига бит (второй фрагмент), время выполнения увеличивается до поразительного 2,49 секунды.

Как это возможно? Я думал, что смещение бит было одной из самых легких операций для процессора. Почему из-за такой простой операции возникает так много накладных расходов? Я компилирую с активированным флагом O3.

Любая идея?

Ответ 1

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

Хотя есть инструкции, которые четко документированы для работы таким образом (например, целочисленное деление), есть очень сильные признаки того, что целочисленное умножение выполняется в постоянном числе циклов в современных процессорах, независимо от ввода. Заметка в документации Intel, которая изначально заставляла меня думать, что количество циклов для целочисленного умножения может зависеть от входных данных, похоже, не относится к этим инструкциям. Кроме того, я сделал несколько более строгих тестов производительности с той же последовательностью инструкций как для нулевых, так и для ненулевых операндов, и результаты не дали существенных различий. Насколько я могу судить, harold комментарий по этому вопросу верен. Виноват; извините.

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

Информация структурирована в следующие разделы:

  • Что делает код
  • Что делает компилятор
  • Что делает процессор
  • Что вы можете с этим сделать
  • Неотвеченные вопросы

Что делает код

Он переполняется, в основном.

В первой версии n начинает переполняться на 33 r-й итерации. Во второй версии со сдвигом n начинает переполняться на 52 n-й итерации.

В версии без сдвига, начиная с 128 -й итерации, n равен нулю (он переполняет "чисто", оставляя только нули в наименее значимых 128 бит результата).

Во второй версии правый сдвиг (деление на 4) выдает больше коэффициентов два от значения n на каждой итерации, чем при вводе новых операндов, поэтому сдвиг приводит к округлению на некоторых итерациях, Быстрый расчет: общее количество факторов из двух во всех числах от 1 до 128 равно

128/2 + 128/4 +... + 2 + 1 = 2 6 + 2 5 +... + 2 + 1 = 2 7 - 1

в то время как число факторов двух, выведенных правым сдвигом (если бы было достаточно, чтобы взять), было 128 * 2, более чем вдвое.

Вооружившись этим знанием, мы можем дать первый ответ: с точки зрения стандарта С++ этот код проводит большую часть своего времени в режиме w90 > , поэтому все ставки отключены. Задача решена; перестаньте читать.

Что делает компилятор

Если вы все еще читаете, с этого момента мы будем игнорировать переполнения и посмотреть некоторые детали реализации. "Компилятор" в этом случае означает GCC 4.9.2 или Clang 3.5.1. Я только сделал измерения производительности кода, созданного GCC. Для Clang я просмотрел сгенерированный код для нескольких тестовых примеров и отметил некоторые отличия, о которых я расскажу ниже, но я на самом деле не запускаю код; Возможно, я пропустил некоторые вещи.

Операции умножения и сдвига доступны для 64-битных операндов, поэтому 128-битные операции должны быть реализованы в терминах этих. Во-первых, умножение: n может быть записано как 2 64nh + nl, где nh и nl - это верхние и нижние 64-разрядные половинки соответственно. То же самое касается i. Итак, умножение можно записать:

(2 64nh + nl) (2 64ih + il) = 2 128nh ih + 2 64 (nh il + nl ih) + nl il

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

В скобках только добавляются биты в верхнюю 64-битную половину 128-битного результата; любое переполнение, вызванное двумя умножениями или добавлением, также игнорируется (результат усечен до 64 бит).

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

Таким образом реализовано 128-битное целочисленное умножение: три 64-битных умножения и два 64-битных дополнения.

Теперь сдвиг: используя те же обозначения, что и выше, два младших значащих бита nh должны стать двумя самыми значимыми битами nl, после того как содержимое последнего сдвигается вправо на два бита. Используя синтаксис С++, он будет выглядеть так:

nl = nh << 62 | nl >> 2 //Doesn't change nh, only uses its bits.

Кроме того, nh также нужно сдвинуть, используя что-то вроде

nh >>= 2;

Таким образом, компилятор реализует 128-битный сдвиг. Для первой части есть инструкция x86-64, которая имеет точную семантику этого выражения; он называется SHRD. Использование этого может быть хорошим или плохим, как мы увидим ниже, и два компилятора делают несколько разные варианты в этом отношении.

Что делает процессор

... сильно зависит от процессора. (Нет... действительно?!)

Подробная информация о том, что происходит для процессоров Haswell, находится в отличном ответе. Здесь я попытаюсь охватить больше земли на более высоком уровне. Для получения более подробных данных приведены некоторые источники:

Я буду ссылаться на следующие архитектуры:

У меня есть данные измерений, взятые в системе IntelSB; Я думаю, что он достаточно точен, пока компилятор не действует. К сожалению, при работе с такими жесткими петлями это может произойти очень легко. В разных точках во время тестирования мне приходилось использовать всевозможные глупые трюки, чтобы избежать особенностей GCC, обычно связанных с использованием регистра. Например, кажется, что имеет тенденцию к случайному перетасовке регистров при компиляции более простого кода, чем в других случаях, когда он генерирует оптимальную сборку. По иронии судьбы, в моей тестовой настройке он имел тенденцию генерировать оптимальный код для второго образца, используя сдвиг и худший код для первого, делая влияние сдвига менее заметным. Кажется, что у Clang/LLVM меньше таких плохих привычек, но опять же, я смотрел на меньшее количество примеров, использующих его, и я не измерял ни одного из них, так что это мало значит. В интересах сравнения яблок с яблоками все данные измерений ниже относятся к лучшему коду, созданному для каждого случая.

Сначала переставьте выражение для 128-битного умножения из предыдущего раздела в (ужасную) диаграмму:

nh * il
        \
         +  -> tmp
        /          \
nl * ih             + -> next nh
                   /
             high 64 bits
                 /
nl * il --------
         \
      low 64 bits 
           \
             -> next nl

(извините, я надеюсь, что это будет иметь смысл)

Некоторые важные моменты:

  • Два добавления не могут выполняться до тех пор, пока их соответствующие входы не будут готовы; окончательное дополнение не может выполнить, пока все остальное не будет готово.
  • Три умножения могут, теоретически, выполняться параллельно (вход не зависит от другого выхода умножения).
  • В приведенном выше идеальном сценарии общее количество циклов для завершения всего вычисления для одной итерации представляет собой сумму числа циклов для одного умножения и двух дополнений.
  • next nl может быть готов к началу. Это, наряду с тем, что следующие il и ih очень дешевы для расчета, означает, что вычисления nl * il и nl * ih для следующей итерации могут начаться рано, возможно, до того, как был вычислен next nh.

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

Все вышеизложенное означает, что если тело цикла не содержит ничего другого, что мешает, процессор может изменить порядок этих умножений для достижения чего-то как можно ближе к идеальному сценарию выше. Это относится к первому фрагменту кода. На IntelH, как измеряется гарольдом, это точно идеальный сценарий: 5 циклов на итерацию состоят из 3 циклов для одного умножения и по одному циклу для двух дополнений (впечатляюще, если честно). На IntelSB я измерил 6 циклов на итерацию (ближе к 5.5, фактически).

Проблема в том, что во втором фрагменте кода что-то мешает:

nh * il
        \                              normal shift -> next nh
         +  -> tmp                   /
        /          \                /
nl * ih             + ----> temp nh
                   /                \
             high 64 bits            \
                 /                     "composite" shift -> next nl
nl * il --------                     /
         \                          /
      low 64 bits                  /
           \                      /
             -> temp nl ---------

next nl уже не готов раньше. temp nl должен ждать, пока temp nh будет готов, так что оба могут быть отправлены в composite shift, и только тогда у нас будет next nl. Даже если обе смены очень быстрые и выполняются параллельно, они не просто добавляют стоимость выполнения одной смены к итерации; они также изменяют динамику петлевого "конвейера", действуя как своего рода синхронизирующий барьер.

Если две смены закончатся одновременно, то все три умножения для следующей итерации будут готовы к выполнению в одно и то же время, и они не могут начать все параллельно, как объяснялось выше; им придется ждать друг друга, тратя впустую циклы. Это относится к IntelSB, где две смены одинаково быстрые (см. Ниже); Я измерил 8 циклов на итерацию для этого случая.

Если две смены не заканчиваются одновременно, обычно это будет нормальный сдвиг, который заканчивается первым (сложный сдвиг на большинстве архитектур медленнее). Это означает, что next nh будет готов раньше, поэтому верхнее умножение может начаться раньше для следующей итерации. Тем не менее, два других умножения по-прежнему должны ждать больше (потраченных впустую) циклов для завершения композитного сдвига, и тогда они будут готовы в одно и то же время, и каждый должен будет ждать, пока другой начнет, теряя еще больше времени. Это имеет место на IntelH, измеряется гарольдом в 9 циклов на итерацию.

Я ожидаю, что AMD также подпадет под эту последнюю категорию. Несмотря на еще большую разницу в производительности между композитным сдвигом и нормальным сдвигом на этой платформе, умножение на множители также меньше на AMD, чем на Intel (более чем в два раза медленнее), что делает первый пример медленнее. Как очень приблизительная оценка, я думаю, что первая версия может занимать около 12 циклов на AMD, а вторая - около 16. Было бы неплохо провести некоторые конкретные измерения.

Еще несколько данных о сложном сводном сдвиге, SHRD:

  • В IntelSB это точно так же дешево, как простой сдвиг (отлично!); простые сдвиги примерно так же дешевы, как и они: они выполняются за один цикл, а две смены могут запускать каждый цикл.
  • В IntelH, SHRD выполняется 3 цикла (да, это ухудшилось в новом поколении), и две смены любого типа (простые или составные) могут запускать каждый цикл;
  • На AMD это еще хуже. Если я правильно читаю данные, выполнение SHRD удерживает оба исполнительных блока сдвига занятыми до тех пор, пока выполнение не завершится - no parallelism и невозможна конвейерная обработка; он принимает 3 цикла, в течение которых никакой другой сдвиг не может начать выполнение.

Что вы можете с этим сделать

Я могу думать о трех возможных улучшениях:

  • замените SHRD чем-то более быстрым на платформах, где это имеет смысл;
  • оптимизировать умножение, чтобы использовать используемые здесь типы данных;
  • реструктурировать цикл.

1. SHRD можно заменить двумя смещениями и поразрядным ИЛИ, как описано в разделе компилятора. С++ реализация 128-битного сдвига вправо на два бита может выглядеть так:

__int128_t shr2(__int128_t n)
{
   using std::int64_t;
   using std::uint64_t;

   //Unpack the two halves.
   int64_t nh = n >> 64;
   uint64_t nl = static_cast<uint64_t>(n);

   //Do the actual work.
   uint64_t rl = nl >> 2 | nh << 62;
   int64_t rh = nh >> 2;

   //Pack the result.
   return static_cast<__int128_t>(rh) << 64 | rl;
}

Хотя это выглядит как много кода, только средняя часть, выполняющая фактическую работу, генерирует сдвиги и OR. Остальные части просто указывают компилятору, какие 64-битные части мы хотим работать; поскольку 64-разрядные части уже находятся в отдельных регистрах, они фактически не работают в сгенерированном ассемблерном коде.

Однако имейте в виду, что это означает "попытка написать сборку с использованием синтаксиса С++", и это вообще не очень яркая идея. Я использую его только потому, что проверял, что он работает для GCC, и я пытаюсь свести к минимуму количество кода сборки в этом ответе. Тем не менее, есть один сюрприз: оптимизатор LLVM обнаруживает, что мы пытаемся сделать с этими двумя сменами, и одним OR и... в некоторых случаях заменяет их SHRD (подробнее об этом ниже).

Функции одной и той же формы могут использоваться для сдвигов другими числами бит, менее 64. От 64 до 127 это становится проще, но форма изменяется. Следует иметь в виду, что было бы ошибкой передавать количество бит для переключения в качестве параметра времени выполнения на функцию shr. Команды переключения с переменным числом бит медленнее, чем те, которые используют постоянное число на большинстве архитектур. Вы можете использовать параметр шаблона непигового типа для генерации разных функций во время компиляции - это С++, в конце концов...

Я думаю, что использование такой функции имеет смысл на всех архитектурах, кроме IntelSB, где SHRD уже так быстро, как может быть. На AMD это определенно будет улучшение. Меньше, чем на IntelH: для нашего случая я не думаю, что это будет иметь значение, но, как правило, он может бриться после цикла некоторых вычислений; теоретически могут быть случаи, когда это может немного ухудшить ситуацию, но я думаю, что это очень необычно (как обычно, нет никакой замены для измерения). Я не думаю, что это изменит наш цикл, потому что он изменит вещи из [nh, которые будут готовы после цикла и nl после трех], чтобы [оба были готовы после двух]; это означает, что все три умножения для следующей итерации будут готовы в одно и то же время, и им придется ждать друг друга, по существу тратя впустую цикл, который был получен сдвигом.

GCC, похоже, использует SHRD для всех архитектур, а приведенный выше код "сборка в С++" может использоваться в качестве оптимизации, где это имеет смысл. Оптимизатор LLVM использует более тонкий подход: он оптимизирует (заменяет SHRD) автоматически для AMD, но не для Intel, где он даже отменяет его, как упоминалось выше. Это может измениться в будущих выпусках, о чем свидетельствует обсуждение патча для LLVM, который реализовал эту оптимизацию. Пока же, если вы хотите использовать альтернативу LLVM на Intel, вам придется прибегнуть к ассемблеру.

2. Оптимизация умножения: тестовый код использует 128-битное целое для i, но это не нужно в этом случае, так как его значение легко вписывается в 64 бита (32, фактически, но это нам не помогает). Это означает, что ih всегда будет равен нулю; это уменьшает диаграмму для 128-битного умножения на следующее:

nh * il
        \
         \
          \
           + -> next nh
          /
    high 64 bits
        /
nl * il 
        \
     low 64 bits 
          \
            -> next nl

Обычно я просто говорю "объявляю i как long long и позволяю компилятору оптимизировать вещи", но, к сожалению, это не работает здесь; оба компилятора идут за стандартным поведением преобразования двух операндов в их общий тип перед выполнением вычисления, поэтому i заканчивается на 128 бит, даже если он начинается с 64. Нам придется делать что-то трудное:

__int128_t mul(__int128_t n, long long i)
{
   using std::int64_t;
   using std::uint64_t;

   //Unpack the two halves.
   int64_t nh = n >> 64;
   uint64_t nl = static_cast<uint64_t>(n);

   //Do the actual work.
   __asm__(R"(
    movq    %0, %%r10
    imulq   %2, %%r10
    mulq    %2
    addq    %%r10, %0
   )" : "+d"(nh), "+a"(nl) : "r"(i) : "%r10");

   //Pack the result.
   return static_cast<__int128_t>(nh) << 64 | nl;
}

Я сказал, что пытался избежать кода сборки в этом ответе, но это не всегда возможно. Мне удалось уговорить GCC создать код с "сборкой в ​​С++" для функции выше, но после того, как функция встроена, все разваливается - оптимизатор видит, что происходит в полном цикле и преобразует все в 128 бит. LLVM, похоже, ведет себя в этом случае, но, поскольку я тестировал GCC, мне пришлось использовать надежный способ получить правильный код.

Объявляя i как long long и используя эту функцию вместо обычного оператора умножения, я измерил 5 циклов на итерацию для первого образца и 7 циклов для второго в IntelSB, коэффициент усиления одного цикла в каждом случае, Я ожидаю, что он побьет один цикл итераций для обоих примеров на IntelH.

3.Цикл иногда может быть реструктурирован, чтобы стимулировать конвейерное выполнение, когда (по крайней мере некоторые) итерации не зависят от предыдущих результатов, хотя они могут выглядеть так, как они. Например, мы могли бы заменить цикл for для второго образца примерно так:

__int128_t n2 = 1;
long long j = 1000000000 / 2;
for(long long i = 1; i < 1000000000 / 2; ++i, ++j)
{
   n *= i;
   n2 *= j;
   n >>= 2;
   n2 >>= 2; 
}
n *= (n2 * j) >> 2;

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

Приведенный выше код является не более чем наивным доказательством концепции. Реальный код должен будет обрабатывать общее количество итераций более надежным способом. Большая проблема заключается в том, что этот код не будет генерировать те же результаты, что и оригинал, из-за различного поведения при наличии переполнения и округления. Даже если мы остановим цикл на 51-й итерации, чтобы избежать переполнения, результат будет по-прежнему отличаться примерно на 10% из-за округления, происходящего по-разному при смещении вправо. В реальном коде это, скорее всего, будет проблемой; но опять же, у вас не было бы такого реального кода, не так ли?

Предполагая, что этот метод применяется к случаю, когда вышеприведенные проблемы не встречаются, я оценил производительность такого кода в нескольких случаях, опять же на IntelSB. Результаты приведены в "циклах на итерацию", как и раньше, где "итерация" означает одно из исходного кода (я разделил общее количество циклов для выполнения всего цикла на общее число итераций, выполняемых исходным кодом, не для реструктурированного, чтобы иметь значимое сравнение):

  • Приведенный выше код выполняется в 7 циклах на итерацию, коэффициент усиления одного цикла над оригиналом.
  • Приведенный выше код с оператором умножения, замененным нашей функцией mul(), требует 6 циклов на итерацию.

Измененный код действительно страдает от большего перетасовки реестров, чего, к сожалению, нельзя избежать (больше переменных). Более поздние процессоры, такие как IntelH, имеют усовершенствования архитектуры, которые во многих случаях делают перемещение регистра существенно свободным; это может привести к тому, что код даст еще больший выигрыш. Использование новых инструкций, таких как MULX для IntelH, может вообще избежать некоторых движений в регистре; GCC использует такие команды при компиляции с помощью -march=haswell.

Неотвеченные вопросы

Ни одно из измерений, которое мы до сих пор не объясняли большими различиями в производительности, сообщаемыми OP, и наблюдалось мной в другой системе.

Мои начальные тайминги были взяты на удаленной системе (семейный процессор Westmere), где, конечно, могло случиться много вещей; Тем не менее, результаты были странно стабильными.

В этой системе я также экспериментировал с выполнением второго образца с правым сдвигом и сдвигом влево; код с правым сдвигом был последовательно на 50% медленнее, чем другой вариант. Я не мог воспроизвести это на моей контролируемой тестовой системе IntelSB, и у меня также нет объяснения этих результатов.

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

Верно, что на самом деле нет смысла тестировать такие жесткие петли без контролируемой системы, точные инструменты (циклы подсчета) и просмотр сгенерированного кода сборки для каждого случая. Компилятор особенностей может легко привести к коду, который искусственно вводит различия в 50% и более в производительности.

Другим фактором, который может объяснить большие различия, является наличие Intel Hyper-Threading. Различные части ядра ведут себя по-разному, когда это разрешено, и поведение также изменилось между семействами процессоров. Это может иметь странные последствия для плотных петель.

В довершение ко всему, вот сумасшедшая гипотеза: Flipping bits потребляет больше энергии, чем сохранение их постоянной. В нашем случае первый образец, работающий с нулевыми значениями большую часть времени, будет переворачивать гораздо меньше бит, чем второй, поэтому последний будет потреблять больше энергии. Многие современные процессоры имеют функции, которые динамически регулируют частоту ядра в зависимости от электрических и тепловых ограничений (Intel Turbo Boost/AMD Turbo Core). Это означает, что, теоретически, при правильных (или неправильных?) Условиях второй образец может инициировать уменьшение частоты ядра, тем самым делая такое же количество циклов более длительным, и делая зависимость от данных.

Ответ 2

После бенчмаркинга (используя сборку, сгенерированную GCC 4.7.3 на -O2) на моем 4770K, я обнаружил, что первый из них занимает 5 циклов на итерацию, а второй - 9 циклов на итерацию. Почему так много разницы?

Оказывается, это взаимодействие между пропускной способностью и задержкой. Главный убийца shrd, который занимает 3 цикла и находится на критическом пути. Вот его картина (я игнорирую цепочку для i, потому что она быстрее и есть много запасной пропускной способности, чтобы она просто бежала вперед, она не будет мешать):

dependency chain

Ребра здесь - это зависимости, а не поток данных.

Основываясь исключительно на задержках в этой цепочке, ожидаемое время будет равно 8 циклам на итерацию. Но это не так. Проблема здесь в том, что для выполнения 8 циклов mul2 и imul3 должны выполняться параллельно, а целочисленное умножение имеет пропускную способность 1/цикла. Таким образом, он (один) должен ждать цикл и удерживает цепь по циклу. Я проверил это, изменив значение imul на add, что сократило время до 8 циклов на итерацию. Изменение другого imul на add не имело эффекта, как и было предсказано на основе этого объяснения (оно не зависит от shrd и поэтому может быть запланировано ранее, не мешая другим умножениям).

Эти точные данные предназначены только для Хасуэлла.

Используемый мной код:

section .text

global cmp1
proc_frame cmp1
[endprolog]
    mov r8, rsi
    mov r9, rdi
    mov esi, 1
    xor edi, edi
    mov eax, 128
    xor edx, edx
.L2:
    mov rcx, rdx
    mov rdx, rdi
    imul    rdx, rax
    imul    rcx, rsi
    add rcx, rdx
    mul rsi
    add rdx, rcx
    add rsi, 1
    mov rcx, rsi
    adc rdi, 0
    xor rcx, 10000000
    or  rcx, rdi
    jne .L2
    mov rdi, r9
    mov rsi, r8
    ret
endproc_frame

global cmp2
proc_frame cmp2
[endprolog]
    mov r8, rsi
    mov r9, rdi
    mov esi, 1
    xor edi, edi
    mov eax, 128
    xor edx, edx
.L3:
    mov rcx, rdi
    imul    rcx, rax
    imul    rdx, rsi
    add rcx, rdx
    mul rsi
    add rdx, rcx
    shrd    rax, rdx, 2
    sar rdx, 2
    add rsi, 1
    mov rcx, rsi
    adc rdi, 0
    xor rcx, 10000000
    or  rcx, rdi
    jne .L3
    mov rdi, r9
    mov rsi, r8
    ret
endproc_frame

Ответ 3

Если ваш процессор не поддерживает собственные 128-битные операции, операции должны быть закодированы в программном обеспечении, чтобы использовать следующий лучший вариант.

В ваших 128-битных операциях используется та же схема, что и 8-разрядные процессоры при использовании 16-разрядных операций, и это требует времени.

Например, 128-битный сдвиг вправо на один бит с использованием 64-битных регистров требует:
Сдвиньте самый значительный регистр прямо в перенос. Флаг Carry будет содержать бит, который был сдвинут.
Сдвиньте наименее значимый регистр справа, с переносом. Биты будут сдвинуты вправо, при этом флаг переноса сдвинут в положение наиболее значимого бита.

Без поддержки собственных 128-битных операций код будет занимать в два раза больше операций, чем те же 64-битные операции; иногда больше (например, умножение). Вот почему вы видите такую ​​плохую производительность.

Я настоятельно рекомендую использовать только 128 бит в местах, где это крайне необходимо.