Это возможный векторизовать myNum + = [b [я]] * c [я]; на x86_64?

Какой intrinsics я использовал бы для векторизации следующего (если даже возможно векторизовать) на x86_64?

double myNum = 0;
for(int i=0;i<n;i++){
    myNum += a[b[i]] * c[i]; //b[i] = int, a[b[i]] = double, c[i] = double
}
11
задан Mike 28 February 2010 в 04:52
поделиться

5 ответов

Вот моя попытка, полностью оптимизированная и протестированная:

#include <emmintrin.h>

__m128d sum = _mm_setzero_pd();
for(int i=0; i<n; i+=2) {
    sum = _mm_add_pd(sum, _mm_mul_pd(
        _mm_loadu_pd(c + i),
        _mm_setr_pd(a[b[i]], a[b[i+1]])
    ));
}

if(n & 1) {
    sum = _mm_add_pd(sum, _mm_set_sd(a[b[n-1]] * c[n-1]));
}

double finalSum = _mm_cvtsd_f64(_mm_add_pd(
    sum, _mm_shuffle_pd(sum, sum, _MM_SHUFFLE2(0, 1))
));

Это создает очень красивый ассемблерный код с использованием gcc -O2 -msse2 (4.4.1).

Как вы можете заметить, наличие четного n ускорит этот цикл, а также выровненного c . Если вы можете выровнять c , измените _mm_loadu_pd на _mm_load_pd для еще более быстрого выполнения.

8
ответ дан 3 December 2019 в 08:55
поделиться

Я бы начал с развертывания цикла. Что-то вроде

double myNum1 = 0, myNum2=0;
for(int i=0;i<n;i+=2)
{
    myNum1 += a[b[ i ]] * c[ i ];
    myNum2 += a[b[i+1]] * c[i+1];
}
// ...extra code to handle the remainder when n isn't a multiple of 2...
double myNum = myNum1 + myNum2;

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

Дальнейшее развертывание может позволить вам сделать следующее:

__m128d sum0, sum1;
// ...initialize to zero...
for(int i=0;i<n;i+=4)
{
    double temp0 = a[b[ i ]] * c[ i ];
    double temp1 = a[b[i+1]] * c[i+1];
    double temp2 = a[b[i+2]] * c[i+2];
    double temp3 = a[b[i+3]] * c[i+3];
    __m128d pair0 = _mm_set_pd(temp0, temp1);
    __m128d pair1 = _mm_set_pd(temp2, temp3);
    sum0 = _mm_add_pd(sum0, pair0);
    sum1 = _mm_add_pd(sum1, pair1);
}
// ...extra code to handle the remainder when n isn't a multiple of 4...
// ...add sum0 and sum1, then add the result's components...

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

Надеюсь, что это поможет.

4
ответ дан 3 December 2019 в 08:55
поделиться

короткий ответ нет. Длинный ответ - да, но не эффективно. Вы понесете штраф за выполнение невыровненных нагрузок, что сведет на нет любую выгоду. Если вы не можете гарантировать, что b [i] последовательных индексов выровнены, у вас, скорее всего, будет худшая производительность после векторизации

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

, чтобы ответить на ваш комментарий, вам в основном нужно сосредоточиться на массиве. Самый простой способ сразу же - это заблокировать цикл в два раза, загрузить низкий и высокий уровень отдельно, а затем использовать mm * _ pd, как обычно. Псевдокод:

__m128d a, result;
for(i = 0; i < n; i +=2) {
  ((double*)(&a))[0] = A[B[i]];
  ((double*)(&a))[1] = A[B[i+1]];
  // you may also load B using packed integer instruction
  result = _mm_add_pd(result, _mm_mul_pd(a, (__m128d)(C[i])));
}

Я точно не помню имена функций, возможно, стоит перепроверить. Кроме того, используйте ключевое слово restrict с указателями, если вы знаете, что проблем с псевдонимом быть не может. Это позволит компилятор должен быть более агрессивным.

0
ответ дан 3 December 2019 в 08:55
поделиться

Это не будет векторизоваться как есть из-за двойной косвенности индексов массива. Поскольку вы работаете с двойниками, от SSE ничего или мало что можно получить, тем более что большинство современных процессоров в любом случае имеют 2 FPU.

0
ответ дан 3 December 2019 в 08:55
поделиться

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

SSE

Для минимально возможных нагрузок без предположения об индексах в b требуется развернуть цикл четыре раза.Одна 128-битная загрузка получает четыре индекса из b , две 128-битные загрузки получают пару смежных удвоений из c , и сбор a требует независимых 64-битных загружает. Это минимальный предел в 7 циклов на четыре итерации для последовательного кода. (достаточно, чтобы заполнить пропускную способность моей памяти, если доступ к a плохо кэшируется). Я упустил некоторые раздражающие вещи, такие как обработка количества итераций, не кратных 4.

entry: ; (rdi,rsi,rdx,rcx) are (n,a,b,c)
  xorpd xmm0, xmm0
  xor r8, r8
loop:
  movdqa xmm1, [rdx+4*r8]
  movapd xmm2, [rcx+8*r8]
  movapd xmm3, [rcx+8*r8+8]
  movd   r9,   xmm1
  movq   r10,  xmm1
  movsd  xmm4, [rsi+8*r9]
  shr    r10,  32
  movhpd xmm4, [rsi+8*r10]
  punpckhqdq xmm1, xmm1
  movd   r9,   xmm1
  movq   r10,  xmm1
  movsd  xmm5, [rsi+8*r9]
  shr    r10,  32
  movhpd xmm5, [rsi+8*r10]
  add    r8,   4
  cmp    r8,   rdi
  mulpd  xmm2, xmm4
  mulpd  xmm3, xmm5
  addpd  xmm0, xmm2
  addpd  xmm0, xmm3
  jl loop

Получение индексов - самая сложная часть. movdqa загружает 128 бит целочисленных данных из 16-байтового выровненного адреса (у Nehalem есть штрафы за задержку за смешивание инструкций SSE "integer" и "float"). punpckhqdq перемещает старшие 64 бита в младшие 64 бита, но в целочисленном режиме, в отличие от более простого movhlpd . 32-битные сдвиги выполняются в регистрах общего назначения. movhpd загружает один double в верхнюю часть регистра xmm, не затрагивая нижнюю часть - это используется для загрузки элементов a непосредственно в упакованные регистры.

Этот код заметно быстрее, чем приведенный выше код, который, в свою очередь, быстрее, чем простой код, и для каждого шаблона доступа, кроме простого случая B [i] = i , где наивный цикл на самом деле самый быстрый. . Я также пробовал кое-что вроде функции около SUM (A (B (:)), C (:)) в Фортране, которая в итоге оказалась эквивалентной простому циклу.

Я тестировал Q6600 (65 нм Core 2 на частоте 2,4 ГГц) с 4 ГБ памяти DDR2-667 в 4 модулях. Тестирование пропускной способности памяти дает около 5333 МБ / с, поэтому мне кажется, что я вижу только один канал. Я компилирую с помощью Debian gcc 4.3.2-1.1, -O3 -Ffast-math -msse2 -Ftree-vectorize -std = gnu99.

Для тестирования я принимаю n равным одному миллиону, инициализируя массивы так, что a [b [i]] и c [i] оба равны 1.0 / (i + 1) , с несколькими различными шаблонами индексов. Один выделяет a с миллионом элементов и устанавливает b в случайную перестановку, другой выделяет a с 10M элементами и использует каждый 10-й, а последний выделяет ] a с 10M элементами и устанавливает b [i + 1] , добавляя случайное число от 1 до 9 в b [i] . Я рассчитываю, сколько времени занимает один вызов, с помощью gettimeofday , очищаю кеши, вызывая clflush над массивами, и измеряю 1000 попыток каждой функции. Я построил сглаженные распределения времени выполнения, используя некоторый код из внутренностей критерия (в частности, оценки плотности ядра в пакете statistics ).

Пропускная способность

А теперь самое важное замечание о пропускной способности. 5333 МБ / с при частоте 2,4 ГГц - это чуть больше двух байтов за цикл. Мои данные достаточно длинные, чтобы ничего не могло быть кешировано, и умножение времени выполнения моего цикла на (16 + 2 * 16 + 4 * 64) байтов, загружаемых на итерацию, если все пропадает, дает мне почти точно пропускную способность ~ 5333 МБ / с, которую имеет моя система . Довольно легко заполнить эту полосу пропускания без SSE.Даже если предположить, что a были полностью кэшированы, просто чтение b и c для одной итерации перемещает 12 байтов данных, и наивный может начать новую итерацию каждый третий цикл с конвейеризацией.

Если предположить, что что-то меньшее, чем полное кэширование на a , делает арифметику и подсчет команд еще менее узким местом. Я не удивлюсь, если большая часть ускорения в моем коде будет происходить из-за меньшего количества загрузок в b и c , поэтому остается больше места для отслеживания и предположений о прошлых промахах в кэше на ] a .

Более широкое оборудование может иметь большее значение. Системе Nehalem, использующей три канала DDR3-1333, потребуется перемещать 3 * 10667 / 2,66 = 12,6 байта за цикл для насыщения полосы пропускания памяти. Это было бы невозможно для одного потока, если a помещается в кеш - но при 64 байтах пропуски строчного кэша вектора быстро складываются - только одна из четырех загрузок в моем цикле, отсутствующих в кешах, вызывает средняя требуемая пропускная способность до 16 байт / цикл.

1
ответ дан 3 December 2019 в 08:55
поделиться
Другие вопросы по тегам:

Похожие вопросы: