Порядок инструкций микрооптимизации SSE - PullRequest
10 голосов
/ 01 сентября 2011

Я заметил, что иногда 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);

Ответы [ 4 ]

9 голосов
/ 01 сентября 2011

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

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

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

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

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

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

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

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

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

6 голосов
/ 01 сентября 2011

Вы можете найти главы 5-7 Справочного руководства по оптимизации архитектур Intel очень интересными, в них подробно рассказывается, как Intel думает, что вам следует написать оптимальный код SSE, и подробно излагаются многие вопросыспрашивать о.

6 голосов
/ 01 сентября 2011

Я только что построил это, используя 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 может предварительно выбирать до четырех потоков одновременно.

1 голос
/ 17 октября 2016

Я также хочу порекомендовать Intel® Architecture Code Analyzer:

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

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

...