Процессоры 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). punpckhqdq
перемещается с высокой скоростью 64 бит до 64 бит, но в целочисленном режиме, в отличие от более простого имени movhlpd
. 32-разрядные сдвиги выполняются в регистрах общего назначения. movhpd
загружает одну двойную в верхнюю часть регистра xmm без нарушения нижней части - это используется для загрузки элементов a
непосредственно в упакованные регистры.
Этот код заметно быстрее, чем код выше, который, в свою очередь, быстрее, чем простой код, и на каждом шаблоне доступа, но в простом случае B[i] = i
, где наивный цикл на самом деле самый быстрый. Я также пробовал несколько вещей, как функция около SUM(A(B(:)),C(:))
в Fortran, которая в итоге была эквивалентна простому циклу.
Я тестировал на Q6600 (65 нм Core 2 на 2,4 ГГц) с 4 ГБ памяти DDR2-667 в 4-х модулях. Тестирование пропускной способности памяти дает около 5333 МБ/с, поэтому кажется, что я вижу только один канал. Я компилирую с gcc 4.3.2-1.1, -O3 -Ffast-math -msse2 -Ftree-vectorize -std = gnu99 в Debian.
Для тестирования я позволяю 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 проб каждой функции. Я построил сглаженные распределения времени выполнения, используя некоторый код из кишок criterion (в частности, оценка плотности ядра в пакете statistics
).
Bandwidth
Теперь для реального важного замечания о пропускной способности. 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 байтов/цикл.
Каково распределение индексов в b? – MSN
Неизвестно, до времени исполнения. – Mike
Интересно, способствовали ли приведенные ниже рекомендации ускорить ваш код? – celion