Процессоры 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
загружает один дубль в верхнюю часть регистра 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 байт / цикл.