Порядок инструкций по микрооптимизации 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 может предварительно выбирать до четырех потоков за раз.
Ответ 3
вы можете найти главы 5-7 из Справочное руководство по оптимизации архитектуры Intel очень интересно, в нем подробно описывается, как Intel думает, что вам следует писать оптимальные SSE, и он детализирует много вопросов, о которых вы просите.
Ответ 4
Я также хочу рекомендовать анализатор кода архитектуры Intel®:
https://software.intel.com/en-us/articles/intel-architecture-code-analyzer
Это статический анализатор кода, который помогает определять/оптимизировать критические пути, задержки и пропускную способность. Он работает для Windows, Linux и MacOs (я только пробовал это в Linux). В документации есть средний простой пример того, как ее использовать (например, как избежать задержек с помощью инструкций по переупорядочению).