Какой 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
}
Вот моя попытка, полностью оптимизированная и протестированная:
#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
для еще более быстрого выполнения.
Я бы начал с развертывания цикла. Что-то вроде
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...
(извиняюсь за псевдокод в начале и в конце, я считаю, что важной частью был цикл). Я не знаю наверняка, будет ли это быстрее; это зависит от различных задержек и от того, насколько хорошо компилятор может все переставить. Убедитесь, что вы профилировали до и после, чтобы увидеть, было ли улучшение.
Надеюсь, что это поможет.
короткий ответ нет. Длинный ответ - да, но не эффективно. Вы понесете штраф за выполнение невыровненных нагрузок, что сведет на нет любую выгоду. Если вы не можете гарантировать, что 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 с указателями, если вы знаете, что проблем с псевдонимом быть не может. Это позволит компилятор должен быть более агрессивным.
Это не будет векторизоваться как есть из-за двойной косвенности индексов массива. Поскольку вы работаете с двойниками, от SSE ничего или мало что можно получить, тем более что большинство современных процессоров в любом случае имеют 2 FPU.
Процессоры Intel могут выполнять две операции с плавающей запятой, но одну загрузку за цикл, поэтому доступ к памяти является самым жестким ограничением. Имея это в виду, я сначала стремился использовать упакованные загрузки, чтобы уменьшить количество инструкций загрузки, и использовал упакованную арифметику просто потому, что это было удобно. С тех пор я понял, что перегрузка пропускной способности памяти может быть самой большой проблемой, и вся возня с инструкциями 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 байт / цикл.