Порядок инструкций по микрооптимизации SSE

Я заметил, что иногда MSVC 2010 не изменяет порядок инструкций SSE. Я думал, что мне не нужно заботиться о порядке инструкций внутри моего цикла, поскольку компилятор справляется с этим лучше всего, что, похоже, не так.

Как я должен думать об этом? Что определяет лучший порядок инструкций? Я знаю, что некоторые инструкции имеют более высокую задержку, чем другие, и что некоторые инструкции могут выполняться параллельно/асинхронно на уровне процессора. Какие показатели важны в контексте? Где я могу их найти?

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

Также мне следует заботиться о предварительной выборке программного обеспечения (_mm_prefetch) или я могу предположить, что процессор будет работать лучше, чем я?

Допустим, у меня есть следующая функция. Должен ли я чередовать некоторые инструкции? Должен ли я делать магазины перед потоками, делать все нагрузки в порядке, а затем выполнять вычисления и т.д.?? Нужно ли мне рассматривать USWC против не-USWC, а также временные и невременные?

            auto cur128     = reinterpret_cast<__m128i*>(cur);
            auto prev128    = reinterpret_cast<const __m128i*>(prev);
            auto dest128    = reinterpret_cast<__m128i*>(dest;
            auto end        = cur128 + count/16;

            while(cur128 != end)            
            {
                auto xmm0 = _mm_add_epi8(_mm_load_si128(cur128+0), _mm_load_si128(prev128+0));
                auto xmm1 = _mm_add_epi8(_mm_load_si128(cur128+1), _mm_load_si128(prev128+1));
                auto xmm2 = _mm_add_epi8(_mm_load_si128(cur128+2), _mm_load_si128(prev128+2));
                auto xmm3 = _mm_add_epi8(_mm_load_si128(cur128+3), _mm_load_si128(prev128+3));

                                    // dest128 is USWC memory
                _mm_stream_si128(dest128+0, xmm0);  
                _mm_stream_si128(dest128+1, xmm1);
                _mm_stream_si128(dest128+2, xmm2);;
                _mm_stream_si128(dest128+3, xmm3);

                                    // cur128 is temporal, and will be used next time, which is why I choose store over stream
                _mm_store_si128 (cur128+0, xmm0);               
                _mm_store_si128 (cur128+1, xmm1);                   
                _mm_store_si128 (cur128+2, xmm2);                   
                _mm_store_si128 (cur128+3, xmm3);

                cur128  += 4;
                dest128 += 4;
                prev128 += 4;
            }

           std::swap(cur, prev);

Ответ 1

Я согласен со всеми, что тестирование и настройка - лучший подход. Но есть некоторые трюки, которые помогут ему.

Прежде всего, MSVC выполняет переупорядочение инструкции SSE. Ваш пример, вероятно, слишком простой или уже оптимальный.

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

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

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

Например, предположим, что у меня есть цикл с 100 добавлением и 50 умножений. На Intel и AMD (pre-Bulldozer) каждое ядро ​​может поддерживать одно добавление SSE/AVX и один SSE/AVX умножить на каждый цикл. Поскольку у моего цикла есть 100 добавлений, я знаю, что не могу сделать ничего лучше, чем 100 циклов. Да, множитель будет бездействовать в половине случаев, но сумматор является узким местом.

Теперь я иду и время, когда моя петля, и я получаю 105 циклов за итерацию. Это означает, что я довольно близок к оптимальному, и выиграть нечего. Но если я получаю 250 циклов, то это означает что-то не так с циклом, и это стоит больше поработать с ним.

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

Agner Fog имеет отличную ссылку для внутренних деталей текущих процессоров: http://www.agner.org/optimize/microarchitecture.pdf

Ответ 2

Я только что создал это с помощью 32-битного компилятора VS2010, и я получаю следующее:

void F (void *cur, const void *prev, void *dest, int count)
{
00901000  push        ebp  
00901001  mov         ebp,esp  
00901003  and         esp,0FFFFFFF8h  
  __m128i *cur128     = reinterpret_cast<__m128i*>(cur);
00901006  mov         eax,220h  
0090100B  jmp         F+10h (901010h)  
0090100D  lea         ecx,[ecx]  
  const __m128i *prev128    = reinterpret_cast<const __m128i*>(prev);
  __m128i *dest128    = reinterpret_cast<__m128i*>(dest);
  __m128i *end        = cur128 + count/16;

  while(cur128 != end)            
  {
    auto xmm0 = _mm_add_epi8(_mm_load_si128(cur128+0), _mm_load_si128(prev128+0));
00901010  movdqa      xmm0,xmmword ptr [eax-220h]  
    auto xmm1 = _mm_add_epi8(_mm_load_si128(cur128+1), _mm_load_si128(prev128+1));
00901018  movdqa      xmm1,xmmword ptr [eax-210h]  
    auto xmm2 = _mm_add_epi8(_mm_load_si128(cur128+2), _mm_load_si128(prev128+2));
00901020  movdqa      xmm2,xmmword ptr [eax-200h]  
    auto xmm3 = _mm_add_epi8(_mm_load_si128(cur128+3), _mm_load_si128(prev128+3));
00901028  movdqa      xmm3,xmmword ptr [eax-1F0h]  
00901030  paddb       xmm0,xmmword ptr [eax-120h]  
00901038  paddb       xmm1,xmmword ptr [eax-110h]  
00901040  paddb       xmm2,xmmword ptr [eax-100h]  
00901048  paddb       xmm3,xmmword ptr [eax-0F0h]  

    // dest128 is USWC memory
    _mm_stream_si128(dest128+0, xmm0);  
00901050  movntdq     xmmword ptr [eax-20h],xmm0  
    _mm_stream_si128(dest128+1, xmm1);
00901055  movntdq     xmmword ptr [eax-10h],xmm1  
    _mm_stream_si128(dest128+2, xmm2);;
0090105A  movntdq     xmmword ptr [eax],xmm2  
    _mm_stream_si128(dest128+3, xmm3);
0090105E  movntdq     xmmword ptr [eax+10h],xmm3  

    // cur128 is temporal, and will be used next time, which is why I choose store over stream
    _mm_store_si128 (cur128+0, xmm0);               
00901063  movdqa      xmmword ptr [eax-220h],xmm0  
    _mm_store_si128 (cur128+1, xmm1);                   
0090106B  movdqa      xmmword ptr [eax-210h],xmm1  
    _mm_store_si128 (cur128+2, xmm2);                   
00901073  movdqa      xmmword ptr [eax-200h],xmm2  
    _mm_store_si128 (cur128+3, xmm3);
0090107B  movdqa      xmmword ptr [eax-1F0h],xmm3  

    cur128  += 4;
00901083  add         eax,40h  
00901086  lea         ecx,[eax-220h]  
0090108C  cmp         ecx,10h  
0090108F  jne         F+10h (901010h)  
    dest128 += 4;
    prev128 += 4;
  }
}

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

Кроме того, предварительная выборка, особенно в коде, считывает память последовательно, часто не требуется. MMU может предварительно выбирать до четырех потоков за раз.

Ответ 4

Я также хочу рекомендовать анализатор кода архитектуры Intel®:

https://software.intel.com/en-us/articles/intel-architecture-code-analyzer

Это статический анализатор кода, который помогает определять/оптимизировать критические пути, задержки и пропускную способность. Он работает для Windows, Linux и MacOs (я только пробовал это в Linux). В документации есть средний простой пример того, как ее использовать (например, как избежать задержек с помощью инструкций по переупорядочению).