Странная внутренняя оптимизация VC++ SSE

Я выполняю разрозненное чтение 8-битных данных из файла (де-чередование 64-канального волнового файла). Затем я объединяю их в единый поток байтов. Проблема, с которой я сталкиваюсь, связана с моей реконструкцией данных для записи.

В основном я читаю 16 байтов, а затем создаю их в одну переменную __m128i, а затем использую _mm_stream_ps для записи значения обратно в память. Однако у меня есть некоторые странные результаты производительности.

В моей первой схеме я использую встроенную функцию _mm_set_epi8 для установки __m128i следующим образом:

    const __m128i packedSamples = _mm_set_epi8( sample15,   sample14,   sample13,   sample12,   sample11,   sample10,   sample9,    sample8,
                                                sample7,    sample6,    sample5,    sample4,    sample3,    sample2,    sample1,    sample0 );

По сути, я оставляю компилятору решать, как его оптимизировать для достижения наилучшей производительности. Это дает ХУДШУЮ производительность. МОЙ тест выполняется примерно за 0,195 секунды.

Во-вторых, я попытался объединиться, используя 4 инструкции _mm_set_epi32, а затем упаковав их:

    const __m128i samples0      = _mm_set_epi32( sample3, sample2, sample1, sample0 );
    const __m128i samples1      = _mm_set_epi32( sample7, sample6, sample5, sample4 );
    const __m128i samples2      = _mm_set_epi32( sample11, sample10, sample9, sample8 );
    const __m128i samples3      = _mm_set_epi32( sample15, sample14, sample13, sample12 );

    const __m128i packedSamples0    = _mm_packs_epi32( samples0, samples1 );
    const __m128i packedSamples1    = _mm_packs_epi32( samples2, samples3 );
    const __m128i packedSamples     = _mm_packus_epi16( packedSamples0, packedSamples1 );

Это несколько улучшает производительность. Мой тест теперь выполняется за ~ 0,15 секунды. Кажется нелогичным, что производительность улучшится, сделав это, поскольку я предполагаю, что это именно то, что делает _mm_set_epi8 в любом случае...

Моя последняя попытка состояла в том, чтобы использовать немного кода, который у меня есть, из создания четырех CC старомодным способом (со сдвигами и т. д.), а затем помещая их в __m128i с помощью одного _mm_set_epi32.

    const GCui32 samples0       = MakeFourCC( sample0, sample1, sample2, sample3 );
    const GCui32 samples1       = MakeFourCC( sample4, sample5, sample6, sample7 );
    const GCui32 samples2       = MakeFourCC( sample8, sample9, sample10, sample11 );
    const GCui32 samples3       = MakeFourCC( sample12, sample13, sample14, sample15 );
    const __m128i packedSamples = _mm_set_epi32( samples3, samples2, samples1, samples0 );

Это дает еще ЛУЧШУЮ производительность. Запуск моего теста занимает ~ 0,135 секунды. Я действительно начинаю путаться.

Поэтому я попробовал простую систему чтения байтов записи байтов, и это немного быстрее, чем даже последний метод.

Так, что происходит? Все это кажется мне контринтуитивным.

Я рассматривал идею о том, что задержки происходят на _mm_stream_ps, потому что я предоставляю данные слишком быстро, но тогда я бы получил точно такие же результаты, что бы я ни делал. Возможно ли, что первые 2 метода означают, что 16 нагрузок не могут быть распределены по циклу, чтобы скрыть задержку? Если да, то почему это? Конечно, встроенная функция позволяет компилятору выполнять оптимизацию по своему усмотрению ... я думал, что в этом все дело ... Также, безусловно, выполнение 16 операций чтения и 16 операций записи будет намного медленнее, чем 16 операций чтения и 1 запись с кучей жонглирования SSE инструкции ... В конце концов, чтение и запись - это медленный бит!

Любой, у кого есть какие-либо идеи, что происходит, будет высоко оценен! :D

Изменить: в дополнение к комментарию ниже я прекратил предварительную загрузку байтов как константы и изменил их на это:

    const __m128i samples0      = _mm_set_epi32( *(pSamples + channelStep3), *(pSamples + channelStep2), *(pSamples + channelStep1), *(pSamples + channelStep0) );
    pSamples    += channelStep4;
    const __m128i samples1      = _mm_set_epi32( *(pSamples + channelStep3), *(pSamples + channelStep2), *(pSamples + channelStep1), *(pSamples + channelStep0) );
    pSamples    += channelStep4;
    const __m128i samples2      = _mm_set_epi32( *(pSamples + channelStep3), *(pSamples + channelStep2), *(pSamples + channelStep1), *(pSamples + channelStep0) );
    pSamples    += channelStep4;
    const __m128i samples3      = _mm_set_epi32( *(pSamples + channelStep3), *(pSamples + channelStep2), *(pSamples + channelStep1), *(pSamples + channelStep0) );
    pSamples    += channelStep4;

    const __m128i packedSamples0    = _mm_packs_epi32( samples0, samples1 );
    const __m128i packedSamples1    = _mm_packs_epi32( samples2, samples3 );
    const __m128i packedSamples     = _mm_packus_epi16( packedSamples0, packedSamples1 );

и это улучшило производительность до ~ 0,143 секунды. Sitll не так хорош, как прямая реализация C...

Изменить снова: лучшая производительность, которую я получаю до сих пор,

    // Load the samples.
    const GCui8 sample0     = *(pSamples + channelStep0);
    const GCui8 sample1     = *(pSamples + channelStep1);
    const GCui8 sample2     = *(pSamples + channelStep2);
    const GCui8 sample3     = *(pSamples + channelStep3);

    const GCui32 samples0   = Build32( sample0, sample1, sample2, sample3 );
    pSamples += channelStep4;

    const GCui8 sample4     = *(pSamples + channelStep0);
    const GCui8 sample5     = *(pSamples + channelStep1);
    const GCui8 sample6     = *(pSamples + channelStep2);
    const GCui8 sample7     = *(pSamples + channelStep3);

    const GCui32 samples1   = Build32( sample4, sample5, sample6, sample7 );
    pSamples += channelStep4;

    // Load the samples.
    const GCui8 sample8     = *(pSamples + channelStep0);
    const GCui8 sample9     = *(pSamples + channelStep1);
    const GCui8 sample10    = *(pSamples + channelStep2);
    const GCui8 sample11    = *(pSamples + channelStep3);

    const GCui32 samples2       = Build32( sample8, sample9, sample10, sample11 );
    pSamples += channelStep4;

    const GCui8 sample12    = *(pSamples + channelStep0);
    const GCui8 sample13    = *(pSamples + channelStep1);
    const GCui8 sample14    = *(pSamples + channelStep2);
    const GCui8 sample15    = *(pSamples + channelStep3);

    const GCui32 samples3   = Build32( sample12, sample13, sample14, sample15 );
    pSamples += channelStep4;

    const __m128i packedSamples = _mm_set_epi32( samples3, samples2, samples1, samples0 );

    _mm_stream_ps( pWrite + 0,  *(__m128*)&packedSamples ); 

Это дает мне обработку примерно за 0,095 секунды, что значительно лучше. Я, кажется, не могу приблизиться к SSE, хотя ... Я все еще сбит с толку этим, но .. ho гул.


person Goz    schedule 05.01.2010    source источник
comment
Взгляните на ассемблер, созданный компилятором. Возможно, это дает некоторое представление о проблеме.   -  person Sebastian Tusk    schedule 05.01.2010
comment
Хм, честное замечание... Я должен был это проверить... компилятор генерирует какой-то ДЕЙСТВИТЕЛЬНО странный код... Если бы только была встроенная функция для разрозненных чтений :(   -  person Goz    schedule 05.01.2010
comment
Вы удвоили производительность по сравнению с наивной реализацией с одним внутренним элементом! Это неплохо! И вы используете SSE, чтобы собрать 4-байтовые фрагменты в 16 байтов и, наконец, сохранить их. После утечки проблема заключается в том, что у вас не так много работы для SSE, и вам нужно избегать слишком большого количества перемещений insns из общих регистров в регистры SSE. SSE действительно сиял бы, если бы вы читали непрерывный поток, но это не так.   -  person Potatoswatter    schedule 05.01.2010
comment
Также: обязательно протестируйте на реальных данных. Если вы не работаете из кеша при фактическом использовании, возможно, вы зря оптимизируете.   -  person Potatoswatter    schedule 05.01.2010
comment
Это справедливое замечание, но не волнуйтесь... я пробую это на 64-канальной волновой записи...   -  person Goz    schedule 05.01.2010
comment
Кроме того, да, я удвоил производительность, но я не понимаю, почему я получаю гораздо лучшую производительность со всеми сменами и т.д. Является ли переход на регистр XMM НАСТОЛЬКО медленным?   -  person Goz    schedule 05.01.2010
comment
Для каждого второго байта сдвиг+или может быть совершенно бесплатным, потому что вы можете обратиться к старшему байту 16-битного регистра, например, ah, bh (хотя я не знаю, как это работает аппаратно). В противном случае shift+or — это две очень быстрые инструкции, а move+move+pack — три, где каждое перемещение связывает два регистровых файла (опять же, не уверен в деталях HW, которые различаются). Новый способ не так уж плох, просто старый способ эффективен.   -  person Potatoswatter    schedule 06.01.2010


Ответы (3)


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

Вместо того, чтобы объявлять отдельный идентификатор для каждого образца, попробуйте поместить их в файл char[16]. Компилятор будет продвигать 16 значений в регистры по своему усмотрению, если вы не берете адрес чего-либо в массиве. Вы можете добавить тег __aligned__ (или что-то еще, что использует VC++) и, возможно, вообще избежать встроенного. В противном случае вызов встроенной функции с помощью ( sample[15], sample[14], sample[13] … sample[0] ) должен упростить работу компилятора или, по крайней мере, не причинить вреда.


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

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

person Potatoswatter    schedule 05.01.2010
comment
дело в том, что, делая это, я могу записать их все прямо в память. Хотя это дает мне идеи ... я начинаю думать, что мог бы улучшить производительность, написав какой-нибудь простой ассемблер. Я просто хотел избежать блока ассемблера по 64-битным причинам ... Я действительно надеялся, что компилятор позаботится об этом за меня ... моя ошибка;) - person Goz; 05.01.2010
comment
Вот почему я сделал редактирование… настоящий ключевой момент — убедиться, что байты собираются по мере их поступления. Тогда у вас есть не более трех 4-байтовых переменных и две 2-байтовых (поскольку x86 уже может адресовать старшие/младшие байты) максимум для пяти регистров, прежде чем вы вызовете _mm_set_epi32. - person Potatoswatter; 05.01.2010
comment
Я только что попробовал именно то, что вы говорите в своем редактировании. Внезапно время выполнения сократилось до ~ 0,095 секунды. Я думал, что компилятор выполнит такое переупорядочение, но, похоже, нет... ой. (Это для кода MakeFourCC, используя вторую попытку кода, я все еще возвращаюсь к ~ 0,143 секунды) - person Goz; 05.01.2010

VS, как известно, плохо оптимизирует встроенные функции. Особенно перемещение данных из и в регистры SSE. Однако сами внутренние функции используются довольно хорошо ... .

Вы видите, что он пытается заполнить регистр SSE этим монстром:

00AA100C  movzx       ecx,byte ptr [esp+0Fh]  
00AA1011  movzx       edx,byte ptr [esp+0Fh]  
00AA1016  movzx       eax,byte ptr [esp+0Fh]  
00AA101B  movd        xmm0,eax  
00AA101F  movzx       eax,byte ptr [esp+0Fh]  
00AA1024  movd        xmm2,edx  
00AA1028  movzx       edx,byte ptr [esp+0Fh]  
00AA102D  movd        xmm1,ecx  
00AA1031  movzx       ecx,byte ptr [esp+0Fh]  
00AA1036  movd        xmm4,ecx  
00AA103A  movzx       ecx,byte ptr [esp+0Fh]  
00AA103F  movd        xmm5,edx  
00AA1043  movzx       edx,byte ptr [esp+0Fh]  
00AA1048  movd        xmm3,eax  
00AA104C  movzx       eax,byte ptr [esp+0Fh]  
00AA1051  movdqa      xmmword ptr [esp+60h],xmm0  
00AA1057  movd        xmm0,edx  
00AA105B  movzx       edx,byte ptr [esp+0Fh]  
00AA1060  movd        xmm6,eax  
00AA1064  movzx       eax,byte ptr [esp+0Fh]  
00AA1069  movd        xmm7,ecx  
00AA106D  movzx       ecx,byte ptr [esp+0Fh]  
00AA1072  movdqa      xmmword ptr [esp+20h],xmm4  
00AA1078  movdqa      xmmword ptr [esp+80h],xmm0  
00AA1081  movd        xmm4,ecx  
00AA1085  movzx       ecx,byte ptr [esp+0Fh]  
00AA108A  movdqa      xmmword ptr [esp+70h],xmm2  
00AA1090  movd        xmm0,eax  
00AA1094  movzx       eax,byte ptr [esp+0Fh]  
00AA1099  movdqa      xmmword ptr [esp+10h],xmm4  
00AA109F  movdqa      xmmword ptr [esp+50h],xmm6  
00AA10A5  movd        xmm2,edx  
00AA10A9  movzx       edx,byte ptr [esp+0Fh]  
00AA10AE  movd        xmm4,eax  
00AA10B2  movzx       eax,byte ptr [esp+0Fh]  
00AA10B7  movd        xmm6,edx  
00AA10BB  punpcklbw   xmm0,xmm1  
00AA10BF  punpcklbw   xmm2,xmm3  
00AA10C3  movdqa      xmm3,xmmword ptr [esp+80h]  
00AA10CC  movdqa      xmmword ptr [esp+40h],xmm4  
00AA10D2  movd        xmm4,ecx  
00AA10D6  movdqa      xmmword ptr [esp+30h],xmm6  
00AA10DC  movdqa      xmm1,xmmword ptr [esp+30h]  
00AA10E2  movd        xmm6,eax  
00AA10E6  punpcklbw   xmm4,xmm5  
00AA10EA  punpcklbw   xmm4,xmm0  
00AA10EE  movdqa      xmm0,xmmword ptr [esp+50h]  
00AA10F4  punpcklbw   xmm1,xmm0  
00AA10F8  movdqa      xmm0,xmmword ptr [esp+70h]  
00AA10FE  punpcklbw   xmm6,xmm7  
00AA1102  punpcklbw   xmm6,xmm2  
00AA1106  movdqa      xmm2,xmmword ptr [esp+10h]  
00AA110C  punpcklbw   xmm2,xmm0  
00AA1110  movdqa      xmm0,xmmword ptr [esp+20h]  
00AA1116  punpcklbw   xmm1,xmm2  
00AA111A  movdqa      xmm2,xmmword ptr [esp+40h]  
00AA1120  punpcklbw   xmm2,xmm0  
00AA1124  movdqa      xmm0,xmmword ptr [esp+60h]  
00AA112A  punpcklbw   xmm3,xmm0  
00AA112E  punpcklbw   xmm2,xmm3  
00AA1132  punpcklbw   xmm6,xmm4  
00AA1136  punpcklbw   xmm1,xmm2  
00AA113A  punpcklbw   xmm6,xmm1  

Это работает намного лучше и (должно) быть быстрее:

__declspec(align(16)) BYTE arr[16] = { sample15, sample14, sample13, sample12, sample11, sample10, sample9, sample8, sample7, sample6, sample5, sample4, sample3, sample2, sample1, sample0 };

__m128i packedSamples = _mm_load_si128( (__m128i*)arr );

Соберу свой собственный тестовый стенд:

void    f()
{
    const int steps = 1000000;
    BYTE* pDest = new BYTE[steps*16+16];
    pDest += 16 - ((ULONG_PTR)pDest % 16);
    BYTE* pSrc = new BYTE[steps*16*16];

    const int channelStep0 = 0;
    const int channelStep1 = 1;
    const int channelStep2 = 2;
    const int channelStep3 = 3;
    const int channelStep4 = 16;

    __int64 freq;
    QueryPerformanceFrequency( (LARGE_INTEGER*)&freq );
    __int64 start = 0, end;
    QueryPerformanceCounter( (LARGE_INTEGER*)&start );

    for( int step = 0; step < steps; ++step )
    {
        __declspec(align(16)) BYTE arr[16];
        for( int j = 0; j < 4; ++j )
        {
            //for( int i = 0; i < 4; ++i )
            {
                arr[0+j*4] = *(pSrc + channelStep0);
                arr[1+j*4] = *(pSrc + channelStep1);
                arr[2+j*4] = *(pSrc + channelStep2);
                arr[3+j*4] = *(pSrc + channelStep3);
            }
            pSrc += channelStep4;
        }

#if test1
// test 1 with C
        for( int i = 0; i < 16; ++i )
        {
            *(pDest + step * 16 + i) = arr[i];
        }
#else
// test 2 with SSE load/store    
        __m128i packedSamples = _mm_load_si128( (__m128i*)arr );
        _mm_stream_si128( ((__m128i*)pDest) + step, packedSamples );
#endif
    }

    QueryPerformanceCounter( (LARGE_INTEGER*)&end );

    printf( "%I64d", (end - start) * 1000 / freq );

}

Для меня тест 2 быстрее, чем тест 1.

Я делаю что-то не так? Это не тот код, который вы используете? Что мне не хватает? Это только для меня?

person Christopher    schedule 05.01.2010
comment
Да, это определенно самая быстрая реализация на основе SSE на данный момент (~ 0,124 секунды). Но если вы проверите мое последнее редактирование, вы увидите, что отказ от SSE полностью дал мне прирост скорости, который превосходит даже это. Спасибо большое. Он по-прежнему очень полезен. Есть причина, по которой я предпочитаю просто писать чертовы вещи на ассемблере;) - person Goz; 05.01.2010
comment
На самом деле я лгу ... Я реализовал это немного неправильно (должен был быть модульный тест результата) ... странно, что это не дает никакого увеличения скорости ... он генерирует почти такой же код ... - person Goz; 05.01.2010
comment
И я пробую немного другую реализацию, в которой каждая загрузка равна *(pSamples += channelStep) (кроме первого, очевидно), и теперь я получаю 0,13 секунды... что хорошо, но все же не очень... - person Goz; 05.01.2010

Использование встроенных функций нарушает оптимизацию компилятора!

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

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

Возможно, если вы детализируете требуемый ввод и вывод, кто-то здесь может предложить оптимальный метод для его обработки.

person Skizz    schedule 05.01.2010
comment
Я почти уверен, что встроенные функции НЕ нарушают оптимизацию. В этом весь их смысл. Использование блока __asm ​​НАРУШАЕТ оптимизацию, поэтому Microsoft в первую очередь предложила встроенные функции. Эта ссылка, похоже, согласна со мной... blogs.msdn.com/vcblog/archive/2007/10/18/ - person Goz; 05.01.2010
comment
Skizz, вы когда-нибудь писали SIMD-код? Лично я предпочитаю избегать встроенных функций, но альтернативы еще менее переносимы и более рискованны. - person Potatoswatter; 05.01.2010
comment
@Goz: я отредактирую свой пост, но я пытался сказать, что если компилятор не знает, что делает встроенный, он будет таким же, как блок __asm. Внутренние функции в DevStudio могут быть хорошо известны компилятору, поэтому компилятор может оптимизировать их. Если встроенная функция является просто оболочкой вокруг блока __asm, то компилятор застрял и не может хорошо оптимизировать. Если это вызов библиотеки, то нет смысла использовать его для оптимизированного кода. - person Skizz; 05.01.2010
comment
@Potatoswatter: Да, я написал код SIMD (даже в некоторых моих ответах на вопросы SO). Некоторые говорят, что использование встроенных функций немного более переносимо, чем использование простого asm, но я думаю, что если вы используете встроенные функции, вы уже нацелены на подмножество доступных процессоров, поэтому просто используйте asm. Хорошо, asm использует краткие мнемоники, но вам нужно знать, как работают инструкции, чтобы воспользоваться ими, так что вы сделали сложную часть. - person Skizz; 05.01.2010
comment
Возможно, это верно для VS (я сомневаюсь в этом), но в целом компилятор так же осведомлен о семантике встроенной функции и IR функциональности внутри нее, как и для любой другой функции. Asm-блоки бывают разные. Обычно вы МОЖЕТЕ ожидать, что встроенные функции будут запланированы, и именно это делает их желательными. - person Potatoswatter; 06.01.2010
comment
На самом деле, одна из особенностей встроенных функций (помимо того, что они настолько независимы от платформы, насколько это возможно с SSE) заключается в том, чтобы разрешить оптимизацию компилятора. Поскольку они находятся не в непрозрачном ассемблерном блоке, а внутри вашего фактического кода, компилятор имеет полную свободу реорганизовать и преобразовывать инструкции SSE и назначать регистры по своему желанию. И на самом деле VS 2010 часто удивляет меня своими замечательными оптимизациями моих встроенных функций (например, превращение _mm_store_ss, за которым следует _mm_set1_ps гораздо позже, в одно перетасовку) именно по той причине, что он знает, что делают встроенные функции, и может оптимизировать их наилучшим образом. - person Christian Rau; 20.11.2012