SSE Intrinsics и развертывание цикла

Я пытаюсь оптимизировать некоторые циклы, и мне это удалось, но мне интересно, правильно ли я сделал это только частично. Скажем, например, что у меня есть этот цикл:

for(i=0;i<n;i++){
b[i] = a[i]*2;
}

если развернуть это в 3 раза, получаем следующее:

int unroll = (n/4)*4;
for(i=0;i<unroll;i+=4)
{
b[i] = a[i]*2;
b[i+1] = a[i+1]*2;
b[i+2] = a[i+2]*2;
b[i+3] = a[i+3]*2;
}
for(;i<n;i++)
{
b[i] = a[i]*2;
} 

Теперь это эквивалент перевода SSE:

__m128 ai_v = _mm_loadu_ps(&a[i]);
__m128 two_v = _mm_set1_ps(2);
__m128 ai2_v = _mm_mul_ps(ai_v, two_v);
_mm_storeu_ps(&b[i], ai2_v);

or is it:

__m128 ai_v = _mm_loadu_ps(&a[i]);
__m128 two_v = _mm_set1_ps(2);
__m128 ai2_v = _mm_mul_ps(ai_v, two_v);
_mm_storeu_ps(&b[i], ai2_v);

__m128 ai1_v = _mm_loadu_ps(&a[i+1]);
__m128 two1_v = _mm_set1_ps(2);
__m128 ai_1_2_v = _mm_mul_ps(ai1_v, two1_v);
_mm_storeu_ps(&b[i+1], ai_1_2_v);

__m128 ai2_v = _mm_loadu_ps(&a[i+2]);
__m128 two2_v = _mm_set1_ps(2);
__m128 ai_2_2_v = _mm_mul_ps(ai2_v, two2_v);
_mm_storeu_ps(&b[i+2], ai_2_2_v);

__m128 ai3_v = _mm_loadu_ps(&a[i+3]);
__m128 two3_v = _mm_set1_ps(2);
__m128 ai_3_2_v = _mm_mul_ps(ai3_v, two3_v);
_mm_storeu_ps(&b[i+3], ai_3_2_v);

Меня немного смущает раздел кода:

for(;i<n;i++)
{
b[i] = a[i]*2;
}

что это делает? Это просто для дополнительных частей, например, если цикл не делится на коэффициент, который вы выбираете для его развертывания? Спасибо.


person Kieran Lavelle    schedule 10.03.2016    source источник
comment
Последний раздел, о котором вы спрашиваете, действительно касается остального. Если n - целое число, n/4 отбрасывает остаток. Итак, for(;i<n;i++) делает последнюю часть.   -  person wally    schedule 10.03.2016
comment
Хорошо, спасибо, что прояснили для меня эту часть. Я очень признателен, я так и думал.   -  person Kieran Lavelle    schedule 10.03.2016
comment
Я предполагаю, что вы уже проверили полученный объектный код и убедились, что ваш компилятор еще не делает этого за вас, даже с соответствующими флагами? Нет смысла перехитрить оптимизатор.   -  person Cody Gray    schedule 10.03.2016
comment
Возможно, вам понравится Boost.SIMD, он выглядит очень аккуратный.   -  person Maxim Egorushkin    schedule 10.03.2016
comment
почему вы используете _mm_mul_ps для умножения на 2? Почему бы не использовать _mm_sll_epi32 или только один _mm_add_ps(ai_v, ai_v)? Отдельного two2_v не требуется   -  person phuclv    schedule 10.03.2016
comment
@ LưuVĩnhPhúc: Хорошее замечание, но я думаю, вы имеете в виду _mm_add_ps, а не ss. Кроме того, целочисленный сдвиг не даст желаемого результата для данных FP.   -  person Peter Cordes    schedule 10.03.2016
comment
Я ограничен sse3, эти инструкции доступны в sse3? Также при запуске раздела пролога кода я получаю tets.c: 69: 12: error: ‘i’ undeclared (первое использование в этой функции)} for (; i ‹N; i ++) {   -  person Kieran Lavelle    schedule 10.03.2016
comment
Как обычно, опрометчивая попытка ручной оптимизации. Только что попробовал ваш тест с gcc в режиме оптимизации. Конечно, были развернуты циклы и использовались инструкции, специфичные для SSE.   -  person SergeyA    schedule 10.03.2016
comment
@SergeyA: Да, на MSVC это тоже оказывается опрометчивым в данном случае, но я был удивлен, увидев, что инструкции SSE не использовались и работали намного быстрее без них.   -  person wally    schedule 10.03.2016


Ответы (2)


Ответ - первый блок:

    __m128 ai_v = _mm_loadu_ps(&a[i]);
    __m128 two_v = _mm_set1_ps(2);
    __m128 ai2_v = _mm_mul_ps(ai_v,two_v);
    _mm_storeu_ps(&b[i],ai2_v);

Он уже принимает четыре переменных одновременно.

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

#include <iostream>

int main()
{
    int i{0};
    float a[10] ={1,2,3,4,5,6,7,8,9,10};
    float b[10] ={0,0,0,0,0,0,0,0,0,0};

    int n = 10;
    int unroll = (n/4)*4;
    for (i=0; i<unroll; i+=4) {
        //b[i] = a[i]*2;
        //b[i+1] = a[i+1]*2;
        //b[i+2] = a[i+2]*2;
        //b[i+3] = a[i+3]*2;
        __m128 ai_v = _mm_loadu_ps(&a[i]);
        __m128 two_v = _mm_set1_ps(2);
        __m128 ai2_v = _mm_mul_ps(ai_v,two_v);
        _mm_storeu_ps(&b[i],ai2_v);
    }

    for (; i<n; i++) {
        b[i] = a[i]*2;
    }

    for (auto i : a) { std::cout << i << "\t"; }
    std::cout << "\n";
    for (auto i : b) { std::cout << i << "\t"; }
    std::cout << "\n";

    return 0;
}

Что касается эффективности; похоже, что сборка в моей системе генерирует movups инструкции, тогда как код, скрученный вручную, можно заставить использовать movaps, что должно быть быстрее.

Я использовал следующую программу, чтобы провести несколько тестов:

#include <iostream>
//#define NO_UNROLL
//#define UNROLL
//#define SSE_UNROLL
#define SSE_UNROLL_ALIGNED

int main()
{
    const size_t array_size = 100003;
#ifdef SSE_UNROLL_ALIGNED
    __declspec(align(16)) int i{0};
    __declspec(align(16)) float a[array_size] ={1,2,3,4,5,6,7,8,9,10};
    __declspec(align(16)) float b[array_size] ={0,0,0,0,0,0,0,0,0,0};
#endif
#ifndef SSE_UNROLL_ALIGNED
    int i{0};
    float a[array_size] ={1,2,3,4,5,6,7,8,9,10};
    float b[array_size] ={0,0,0,0,0,0,0,0,0,0};
#endif

    int n = array_size;
    int unroll = (n/4)*4;


    for (size_t j{0}; j < 100000; ++j) {
#ifdef NO_UNROLL
        for (i=0; i<n; i++) {
            b[i] = a[i]*2;
        }
#endif
#ifdef UNROLL
        for (i=0; i<unroll; i+=4) {
            b[i] = a[i]*2;
            b[i+1] = a[i+1]*2;
            b[i+2] = a[i+2]*2;
            b[i+3] = a[i+3]*2;
        }
#endif
#ifdef SSE_UNROLL
        for (i=0; i<unroll; i+=4) {
            __m128 ai_v = _mm_loadu_ps(&a[i]);
            __m128 two_v = _mm_set1_ps(2);
            __m128 ai2_v = _mm_mul_ps(ai_v,two_v);
            _mm_storeu_ps(&b[i],ai2_v);
        }
#endif
#ifdef SSE_UNROLL_ALIGNED
        for (i=0; i<unroll; i+=4) {
            __m128 ai_v = _mm_load_ps(&a[i]);
            __m128 two_v = _mm_set1_ps(2);
            __m128 ai2_v = _mm_mul_ps(ai_v,two_v);
            _mm_store_ps(&b[i],ai2_v);
        }
#endif
#ifndef NO_UNROLL
        for (; i<n; i++) {
            b[i] = a[i]*2;
        }
#endif
    }

    //for (auto i : a) { std::cout << i << "\t"; }
    //std::cout << "\n";
    //for (auto i : b) { std::cout << i << "\t"; }
    //std::cout << "\n";

    return 0;
}

Получил следующие результаты (x86):

  • NO_UNROLL: 0.994 секунд, SSE не выбран компилятором
  • UNROLL: 3,511 секунд, используется movups
  • SSE_UNROLL: 3,315 секунды, используется movups
  • SSE_UNROLL_ALIGNED: 3,276 секунды, используется movaps

Итак, очевидно, что разворачивание цикла в данном случае не помогло. Даже то, что мы используем более эффективный movaps, мало помогает.

Но при компиляции в 64 бит (x64) я получил еще более странный результат:

  • NO_UNROLL: 1,138 секунды, SSE не выбран компилятором
  • UNROLL: 1,409 секунд, SSE не выбран компилятором
  • SSE_UNROLL: 1,420 секунды, компилятор по-прежнему не выбрал SSE!
  • SSE_UNROLL_ALIGNED: 1,476 секунды, компилятор по-прежнему не выбрал SSE!

Кажется, MSVC видит предложение и генерирует лучшую сборку, хотя и медленнее, чем если бы мы вообще не пробовали какую-либо ручную оптимизацию.

person wally    schedule 10.03.2016
comment
Недавно Питер Кордес объяснил мне, что при использовании MOVUPS для согласованных данных вы не платите никаких штрафов. Вероятно, поэтому компилятор использует его, потому что он более общий. Нет никаких веских причин для выдачи MOVAPS, кроме, возможно, настройки для некоторых очень старых архитектур. Чтобы было ясно, доступ к невыровненным данным по-прежнему наказывается, поэтому убедитесь, что вы выровняли данные, если хотите, чтобы они были как можно быстрее. Это можно сделать либо с помощью встроенных функций, либо с помощью подсказок / атрибутов компилятора. - person Cody Gray; 11.03.2016
comment
В любом случае, +1 вам тоже за тестирование. Это настоящий урок, который Киран должен извлечь из этих ответов. - person Cody Gray; 11.03.2016

Как обычно, неэффективно разворачивать циклы и пытаться сопоставить инструкции SSE вручную. Компиляторы могут сделать это лучше, чем вы. Например, предоставленный образец автоматически компилируется в ASM с поддержкой SSE:

foo:
.LFB0:
    .cfi_startproc
    testl   %edi, %edi
    jle .L7
    movl    %edi, %esi
    shrl    $2, %esi
    cmpl    $3, %edi
    leal    0(,%rsi,4), %eax
    jbe .L8
    testl   %eax, %eax
    je  .L8
    vmovdqa .LC0(%rip), %xmm1
    xorl    %edx, %edx
    xorl    %ecx, %ecx
    .p2align 4,,10
    .p2align 3
.L6:
    addl    $1, %ecx
    vpmulld a(%rdx), %xmm1, %xmm0
    vmovdqa %xmm0, b(%rdx)
    addq    $16, %rdx
    cmpl    %esi, %ecx
    jb  .L6
    cmpl    %eax, %edi
    je  .L7
    .p2align 4,,10
    .p2align 3
.L9:
    movslq  %eax, %rdx
    addl    $1, %eax
    movl    a(,%rdx,4), %ecx
    addl    %ecx, %ecx
    cmpl    %eax, %edi
    movl    %ecx, b(,%rdx,4)
    jg  .L9
.L7:
    rep
    ret
.L8:
    xorl    %eax, %eax
    jmp .L9
    .cfi_endproc

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

Вывод

Ручное развертывание не принесет вам пользы.

person SergeyA    schedule 10.03.2016
comment
Это, мягко говоря, преувеличение. Компиляторы, безусловно, могут и делают циклы развертывания, и они также могут генерировать векторный код, это правда. Однако верно и то, что в некоторых случаях циклы, развернутые вручную, превосходят существующие компиляторы (иногда с большим отрывом). Вот один пример. - person Jerry Coffin; 10.03.2016
comment
@JerryCoffin, вы попали в угол. Кстати, в 2014 году. 2 поколения компилятора gcc назад. По моему опыту, любое время, потраченное на ручное развертывание цикла, должно быть потрачено иначе. Рентабельность всегда будет лучше. - person SergeyA; 10.03.2016
comment
Это конкретное испытание было пару лет назад. На прошлой неделе я сделал нечто похожее с предварительным выпуском gcc 6 и получил очень похожие результаты. Стоимость / выгода ... ну, без моего развертывания код, над которым я работал на прошлой неделе, просто не мог поддерживать требуемую скорость передачи данных, поэтому стоимость была бы заключалась в лицензировании более быстрого ядра ARM и разработке нового оборудования. Мне трудно поверить, что это будет дешевле, чем 15 минут (или около того), которые я потратил на развертывание цикла. - person Jerry Coffin; 10.03.2016
comment
Как и Джерри, меня раздражает общий совет: доверяйте своему компилятору, а не оптимизируйте его вручную. Я предпочитаю надеяться, что компилятор все исправит, а затем проверять, чтобы убедиться. В коде, не критичном к скорости, он работает достаточно хорошо. В критическом коде вы можете быть приятно удивлены или разочарованы, и вам придется потратить от 15 минут до часа, закатывая рукава и оптимизируя его вручную. Убеждать людей никогда не делать этого совершенно неправильно. Но в то же время преждевременная оптимизация, не глядя, что делает компилятор, также неверна. - person Cody Gray; 11.03.2016
comment
Подводя итог: этот ответ хорош в данном конкретном случае, потому что вы действительно проверили, что делает компилятор. Но не преувеличивайте свой совет. Дело не в том, что ручное развертывание никогда не приносит никакой пользы. - person Cody Gray; 11.03.2016