Рассчитать среднее 4d векторов с SSE
Я пытаюсь ускорить вычисление среднего из 4d векторов, помещенных в массив. Вот мой код:
#include <sys/time.h>
#include <sys/param.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <xmmintrin.h>
typedef float dot[4];
#define N 1000000
double gettime ()
{
struct timeval tv;
gettimeofday (&tv, 0);
return (double)tv.tv_sec + (0.000001 * (double)tv.tv_usec);
}
void calc_avg1 (dot res, const dot array[], int n)
{
int i,j;
memset (res, 0, sizeof (dot));
for (i = 0; i < n; i++)
{
for (j = 0; j<4; j++) res[j] += array[i][j];
}
for (j = 0; j<4; j++) res[j] /= n;
}
void calc_avg2 (dot res, const dot array[], int n)
{
int i;
__v4sf r = _mm_set1_ps (0.0);
for (i=0; i<n; i++) r += _mm_load_ps (array[i]);
r /= _mm_set1_ps ((float)n);
_mm_store_ps (res, r);
}
int main ()
{
void *space = malloc (N*sizeof(dot)+15);
dot *array = (dot*)(((unsigned long)space+15) & ~(unsigned long)15);
dot avg __attribute__((aligned(16)));
int i;
double time;
for (i = 0; i < N; i++)
{
array[i][0] = 1.0*random();
array[i][1] = 1.0*random();
array[i][2] = 1.0*random();
}
time = gettime();
calc_avg1 (avg, array, N);
time = gettime() - time;
printf ("%f\n%f %f %f\n", time, avg[0], avg[1], avg[2]);
time = gettime();
calc_avg2 (avg, array, N);
time = gettime() - time;
printf ("%f\n%f %f %f\n", time, avg[0], avg[1], avg[2]);
return 0;
}
Итак, как вы можете видеть calc_avg1
использует наивные циклы от 0 до 4 и calc_avg2
заменяет их инструкциями SSE. Я компилирую этот код с помощью Clang 3.4:
cc -O2 -o test test.c
Вот разборка функций calc_avgX:
0000000000400860 <calc_avg1>:
400860: 55 push %rbp
400861: 48 89 e5 mov %rsp,%rbp
400864: 85 d2 test %edx,%edx
400866: 0f 57 c0 xorps %xmm0,%xmm0
400869: 0f 11 07 movups %xmm0,(%rdi)
40086c: 7e 42 jle 4008b0 <calc_avg1+0x50>
40086e: 48 83 c6 0c add $0xc,%rsi
400872: 0f 57 c0 xorps %xmm0,%xmm0
400875: 89 d0 mov %edx,%eax
400877: 0f 57 c9 xorps %xmm1,%xmm1
40087a: 0f 57 d2 xorps %xmm2,%xmm2
40087d: 0f 57 db xorps %xmm3,%xmm3
400880: f3 0f 58 5e f4 addss -0xc(%rsi),%xmm3
400885: f3 0f 11 1f movss %xmm3,(%rdi)
400889: f3 0f 58 56 f8 addss -0x8(%rsi),%xmm2
40088e: f3 0f 11 57 04 movss %xmm2,0x4(%rdi)
400893: f3 0f 58 4e fc addss -0x4(%rsi),%xmm1
400898: f3 0f 11 4f 08 movss %xmm1,0x8(%rdi)
40089d: f3 0f 58 06 addss (%rsi),%xmm0
4008a1: f3 0f 11 47 0c movss %xmm0,0xc(%rdi)
4008a6: 48 83 c6 10 add $0x10,%rsi
4008aa: ff c8 dec %eax
4008ac: 75 d2 jne 400880 <calc_avg1+0x20>
4008ae: eb 0c jmp 4008bc <calc_avg1+0x5c>
4008b0: 0f 57 c0 xorps %xmm0,%xmm0
4008b3: 0f 57 c9 xorps %xmm1,%xmm1
4008b6: 0f 57 d2 xorps %xmm2,%xmm2
4008b9: 0f 57 db xorps %xmm3,%xmm3
4008bc: f3 0f 2a e2 cvtsi2ss %edx,%xmm4
4008c0: f3 0f 5e dc divss %xmm4,%xmm3
4008c4: f3 0f 11 1f movss %xmm3,(%rdi)
4008c8: f3 0f 5e d4 divss %xmm4,%xmm2
4008cc: f3 0f 11 57 04 movss %xmm2,0x4(%rdi)
4008d1: f3 0f 5e cc divss %xmm4,%xmm1
4008d5: f3 0f 11 4f 08 movss %xmm1,0x8(%rdi)
4008da: f3 0f 5e c4 divss %xmm4,%xmm0
4008de: f3 0f 11 47 0c movss %xmm0,0xc(%rdi)
4008e3: 5d pop %rbp
4008e4: c3 retq
4008e5: 66 66 2e 0f 1f 84 00 nopw %cs:0x0(%rax,%rax,1)
4008ec: 00 00 00 00
00000000004008f0 <calc_avg2>:
4008f0: 55 push %rbp
4008f1: 48 89 e5 mov %rsp,%rbp
4008f4: 85 d2 test %edx,%edx
4008f6: 0f 57 c0 xorps %xmm0,%xmm0
4008f9: 7e 10 jle 40090b <calc_avg2+0x1b>
4008fb: 89 d0 mov %edx,%eax
4008fd: 0f 1f 00 nopl (%rax)
400900: 0f 58 06 addps (%rsi),%xmm0
400903: 48 83 c6 10 add $0x10,%rsi
400907: ff c8 dec %eax
400909: 75 f5 jne 400900 <calc_avg2+0x10>
40090b: 66 0f 6e ca movd %edx,%xmm1
40090f: 66 0f 70 c9 00 pshufd $0x0,%xmm1,%xmm1
400914: 0f 5b c9 cvtdq2ps %xmm1,%xmm1
400917: 0f 5e c1 divps %xmm1,%xmm0
40091a: 0f 29 07 movaps %xmm0,(%rdi)
40091d: 5d pop %rbp
40091e: c3 retq
40091f: 90 nop
И вот результат:
> ./test
0.004287
1073864320.000000 1074018048.000000 1073044224.000000
0.003661
1073864320.000000 1074018048.000000 1073044224.000000
Так что версия SSE в 1,17 раза быстрее. Но когда я пытаюсь выполнить, казалось бы, ту же самую работу, которая заключается в вычислении среднего значения скаляров одинарной точности в массиве (как описано здесь, например, уменьшение вектора с плавающей запятой в SSE), версия SSE работает в 3,32 раза быстрее. Вот код функций calc_avgX:
float calc_avg1 (const float array[], int n)
{
int i;
float avg = 0;
for (i = 0; i < n; i++) avg += array[i];
return avg / n;
}
float calc_avg3 (const float array[], int n)
{
int i;
__v4sf r = _mm_set1_ps (0.0);
for (i=0; i<n; i+=4) r += _mm_load_ps (&(array[i]));
r = _mm_hadd_ps (r, r);
r = _mm_hadd_ps (r, r);
return r[0] / n;
}
Поэтому мой вопрос таков: почему я так выигрываю от SSE в последнем примере (вычисление среднего для скаляров с плавающей запятой), а не в первом (вычисление среднего для 4d векторов)? Для меня эти работы практически одинаковы. Как правильно ускорить вычисления в первом примере, если я делаю это неправильно?
UPD: Если вы считаете это уместным, я также приведу разборку второго примера, где рассчитывается среднее значение скаляров (также скомпилировано с помощью clang3.4 -O2).
0000000000400860 <calc_avg1>:
400860: 55 push %rbp
400861: 48 89 e5 mov %rsp,%rbp
400864: 85 d2 test %edx,%edx
400866: 0f 57 c0 xorps %xmm0,%xmm0
400869: 0f 11 07 movups %xmm0,(%rdi)
40086c: 7e 42 jle 4008b0 <calc_avg1+0x50>
40086e: 48 83 c6 0c add $0xc,%rsi
400872: 0f 57 c0 xorps %xmm0,%xmm0
400875: 89 d0 mov %edx,%eax
400877: 0f 57 c9 xorps %xmm1,%xmm1
40087a: 0f 57 d2 xorps %xmm2,%xmm2
40087d: 0f 57 db xorps %xmm3,%xmm3
400880: f3 0f 58 5e f4 addss -0xc(%rsi),%xmm3
400885: f3 0f 11 1f movss %xmm3,(%rdi)
400889: f3 0f 58 56 f8 addss -0x8(%rsi),%xmm2
40088e: f3 0f 11 57 04 movss %xmm2,0x4(%rdi)
400893: f3 0f 58 4e fc addss -0x4(%rsi),%xmm1
400898: f3 0f 11 4f 08 movss %xmm1,0x8(%rdi)
40089d: f3 0f 58 06 addss (%rsi),%xmm0
4008a1: f3 0f 11 47 0c movss %xmm0,0xc(%rdi)
4008a6: 48 83 c6 10 add $0x10,%rsi
4008aa: ff c8 dec %eax
4008ac: 75 d2 jne 400880 <calc_avg1+0x20>
4008ae: eb 0c jmp 4008bc <calc_avg1+0x5c>
4008b0: 0f 57 c0 xorps %xmm0,%xmm0
4008b3: 0f 57 c9 xorps %xmm1,%xmm1
4008b6: 0f 57 d2 xorps %xmm2,%xmm2
4008b9: 0f 57 db xorps %xmm3,%xmm3
4008bc: f3 0f 2a e2 cvtsi2ss %edx,%xmm4
4008c0: f3 0f 5e dc divss %xmm4,%xmm3
4008c4: f3 0f 11 1f movss %xmm3,(%rdi)
4008c8: f3 0f 5e d4 divss %xmm4,%xmm2
4008cc: f3 0f 11 57 04 movss %xmm2,0x4(%rdi)
4008d1: f3 0f 5e cc divss %xmm4,%xmm1
4008d5: f3 0f 11 4f 08 movss %xmm1,0x8(%rdi)
4008da: f3 0f 5e c4 divss %xmm4,%xmm0
4008de: f3 0f 11 47 0c movss %xmm0,0xc(%rdi)
4008e3: 5d pop %rbp
4008e4: c3 retq
4008e5: 66 66 2e 0f 1f 84 00 nopw %cs:0x0(%rax,%rax,1)
4008ec: 00 00 00 00
00000000004008d0 <calc_avg3>:
4008d0: 55 push %rbp
4008d1: 48 89 e5 mov %rsp,%rbp
4008d4: 31 c0 xor %eax,%eax
4008d6: 85 f6 test %esi,%esi
4008d8: 0f 57 c0 xorps %xmm0,%xmm0
4008db: 7e 0f jle 4008ec <calc_avg3+0x1c>
4008dd: 0f 1f 00 nopl (%rax)
4008e0: 0f 58 04 87 addps (%rdi,%rax,4),%xmm0
4008e4: 48 83 c0 04 add $0x4,%rax
4008e8: 39 f0 cmp %esi,%eax
4008ea: 7c f4 jl 4008e0 <calc_avg3+0x10>
4008ec: 66 0f 70 c8 01 pshufd $0x1,%xmm0,%xmm1
4008f1: f3 0f 58 c8 addss %xmm0,%xmm1
4008f5: 66 0f 70 d0 03 pshufd $0x3,%xmm0,%xmm2
4008fa: 0f 12 c0 movhlps %xmm0,%xmm0
4008fd: f3 0f 58 c1 addss %xmm1,%xmm0
400901: f3 0f 58 c2 addss %xmm2,%xmm0
400905: 0f 57 c9 xorps %xmm1,%xmm1
400908: f3 0f 2a ce cvtsi2ss %esi,%xmm1
40090c: f3 0f 5e c1 divss %xmm1,%xmm0
400910: 5d pop %rbp
400911: c3 retq
400912: 66 66 66 66 66 2e 0f nopw %cs:0x0(%rax,%rax,1)
400919: 1f 84 00 00 00 00 00
1 ответ
Извините, этот ответ получился немного длинным и бессвязным. Я провел несколько тестов, но я не стал долго редактировать предыдущие вещи, подумав о чем-то другом, чтобы попробовать.
Ваш рабочий набор составляет 15,25 МБ (16 МБ). Обычно для тестирования такой процедуры вы должны усреднять меньший буфер несколько раз, чтобы он помещался в кэш. Вы не видите большой разницы между медленной и быстрой версиями, потому что разница скрыта узким местом в памяти.
calc_avg1
не векторизация вообще (обратите внимание на addss
, ss
означает скаляр, с одинарной точностью, в отличие от addps
(упакован с одинарной точностью)). Я думаю, что он не может автоматически векторизоваться, даже если встроен в main, потому что не может быть уверен, что нет NaN
s в положении 4-го вектора, что вызовет исключение FP, которого не будет скалярный код. Я попытался скомпилировать его для Sandybridge с помощью gcc 4.9.2 -O3 -march=native -ffast-math
и с clang-3.5, но не повезло ни с одним.
Несмотря на это, версия встроена в main
только работает немного медленнее, потому что память является узким местом. 32-разрядные нагрузки могут почти не отставать от 128-разрядных при загрузке основной памяти. (Не встроенная версия будет плохой, хотя: каждый +=
результат сохраняется в res
массив, потому что цикл накапливается непосредственно в памяти, которая может иметь другие ссылки на него. Таким образом, он должен сделать каждую операцию видимой в магазине. Это версия, для которой вы разместили разборку, кстати. Разбираемся, какая часть main была получена при помощи -S -fverbose-asm
.)
К сожалению, Clang и GCC не могут автоматически векторизовать __v4sf
от 4 до 8 AVX.
Поцарапайте это, после упаковки for (int i=0; i<4000 ; i++)
вокруг звонков calc_avgX
и сокращение N
до 10 Кб, гкц -O3
превращает внутренний внутренний цикл avg1 в:
400690: c5 f8 10 08 vmovups (%rax),%xmm1
400694: 48 83 c0 20 add $0x20,%rax
400698: c4 e3 75 18 48 f0 01 vinsertf128 $0x1,-0x10(%rax),%ymm1,%ymm1
40069f: c5 fc 58 c1 vaddps %ymm1,%ymm0,%ymm0
4006a3: 48 39 d8 cmp %rbx,%rax
4006a6: 75 e8 jne 400690 <main+0xe0>
$ (get CPU to max-turbo frequency) && time ./a.out
0.016515
1071570752.000000 1066917696.000000 1073897344.000000
0.032875
1071570944.000000 1066916416.000000 1073895680.000000
Это странно; Я понятия не имею, почему он не просто использует 32B нагрузки. Это действительно использует 32B vaddps
, что является узким местом при работе с набором данных, который помещается в кэш L2.
IDK, почему ему удалось автоматически векторизовать внутренний цикл, когда он был внутри другого цикла. Обратите внимание, что это относится только к версии, встроенной в main
, Вызываемая версия все еще только для скаляров. Также обратите внимание, что только gcc справился с этим. лязг 3.5 не сделал. Может быть, GCC знал, что он будет использовать malloc
таким образом, чтобы вернуть обнуленный буфер (так что не нужно беспокоиться о NaN
с 4-го элемента)?
Я также удивлен, что Clang не векторизован avg1
не медленнее, когда все помещается в кеш. N=10000
, счетчик повторений = 40к.
3.3GHz SNB i5 2500k, max turbo = 3.8GHz.
avg1: 0.350422s: clang -O3 -march=native (not vectorized. loop of 6 scalar addss with memory operands)
avg2: 0.320173s: clang -O3 -march=native
avg1: 0.497040s: clang -O3 -march=native -ffast-math (haven't looked at asm to see what happened)
avg1: 0.160374s: gcc -O3 -march=native (256b addps, with 2 128b loads)
avg2: 0.321028s: gcc -O3 -march=native (128b addps with a memory operand)
avg2: ~0.16: clang, unrolled with 2 dependency chains to hide latency (see below).
avg2: ~0.08: unrolled with 4 dep chains
avg2: ~0.04: in theory unrolled-by-4 with 256b AVX. I didn't try unrolling the one gcc auto-vectorized with 256b addps
Так что большой сюрприз в том, что скалярный лязг avg1
код идет в ногу с avg2
, Возможно, цепочка зависимостей, переносимая циклами, является более узким местом?
perf
показывает 1,47 insns за цикл для не векторизованного Clang avg1
, что, вероятно, насыщает модуль добавления FP на порту 1. (Большинство инструкций цикла - это добавления).
Тем не мение, avg2
с использованием 128b addps
с операндом памяти, получает только 0,58 insns за цикл. Уменьшение размера массива еще в 10 раз до N=1000
, получает 0,60 insns за цикл, вероятно, из-за большего количества времени, проводимого в прологе / эпилоге. Так что я думаю, что есть серьезная проблема с цепочкой зависимостей, переносимых циклами. clang развертывает цикл на 4, но использует только один аккумулятор. Цикл имеет 7 инструкций, которые декодируют до 10 моп. (Каждый vaddps
2, так как он используется с операндом памяти с режимом адресации с 2-мя регистрами, что предотвращает микроплавление. cmp
а также jne
макро-предохранитель). http://www.brendangregg.com/perf.html говорит, что perf
событие для UOPS_DISPATCHED.CORE
является r2b1
, так:
$ perf stat -d -e cycles,instructions,r2b1 ./a.out
0.031793
1053298112.000000 1052673664.000000 1116960256.000000
Performance counter stats for './a.out':
118,453,541 cycles
71,181,299 instructions # 0.60 insns per cycle
102,025,443 r2b1 # this is uops, but perf doesn't have a nice name for it
40,256,019 L1-dcache-loads
21,254 L1-dcache-load-misses # 0.05% of all L1-dcache hits
9,588 LLC-loads
0 LLC-load-misses:HG # 0.00% of all LL-cache hits
0.032276233 seconds time elapsed
Это подтверждает мои 7:10 инструкции к анализу мопов. Это на самом деле не относится к проблеме производительности: цикл работает намного медленнее, чем верхний предел 4 моп / цикл. Изменение внутреннего цикла для получения двух отдельных цепочек развертывания удваивает пропускную способность (60M циклов вместо 117M, но 81M insns вместо 71M):
for (i=0; i<n-1; i+=2) { // TODO: make sure the loop end condition is correct
r0 += _mm_load_ps (array[i]);
r1 += _mm_load_ps (array[i+1]);
}
r0 += r1;
Развертывание на 4 (с 4 аккумуляторами, которые вы объединяете в конце цикла) снова удваивает производительность. (до 42M циклов, 81M Insns, 112M Uops.) Внутренний цикл имеет 4x vaddps -0x30(%rcx),%xmm4,%xmm4
(и аналогичные), 2x add
, cmp
, jl
, Эта форма vaddps
должен микро-предохранитель, но я все еще вижу гораздо больше мопов, чем инструкции, поэтому я думаю, r2b1
считает неиспользованные домены мопов (Linux perf
не имеет хороших документов для платформных событий HW). Сгибать N
Опять же, чтобы убедиться, что это самый внутренний цикл, который полностью доминирует во всех подсчетах, я вижу соотношение uop:insn, равное 1,39, которое хорошо соответствует 8 insns, 11 uops (1,375) (считая vaddps
как 2, но считая cmp
+ jl
как один). Я нашел http://www.bnikolic.co.uk/blog/hpc-prof-events.html, в котором есть полный список поддерживаемых событий perf, включая их коды для Sandybridge. (И инструкции о том, как сбросить таблицу для любого другого процессора). (Ищите Code:
строка в каждом блоке. Вам нужен байт umask, а затем код, как аргумент perf
.)
# a.out does only avg2, as an unrolled-by-4 version.
$ perf stat -d -e cycles,instructions,r14a1,r2b1,r10e,r2c2,r1c2 ./a.out
0.011331
1053298752.000000 1052674496.000000 1116959488.000000
Performance counter stats for './a.out':
42,250,312 cycles [34.11%]
56,103,429 instructions # 1.33 insns per cycle
20,864,416 r14a1 # UOPS_DISPATCHED_PORT: 0x14=port2&3 loads
111,943,380 r2b1 # UOPS_DISPATCHED: (2->umask 00 -> this core, any thread).
72,208,772 r10e # UOPS_ISSUED: fused-domain
71,422,907 r2c2 # UOPS_RETIRED: retirement slots used (fused-domain)
111,597,049 r1c2 # UOPS_RETIRED: ALL (unfused-domain)
0 L1-dcache-loads
18,470 L1-dcache-load-misses # 0.00% of all L1-dcache hits
5,717 LLC-loads [66.05%]
0 LLC-load-misses:HG # 0.00% of all LL-cache hits
0.011920301 seconds time elapsed
Так что да, похоже, что это может подсчитать число мопов в fused-domain и unused-domain!
Развертывание на 8 не помогает совсем: все еще 42M циклов. (но до 61 млн. insns и 97 млн. мопов благодаря меньшим накладным расходам петли). Аккуратно, лязг использует sub $-128, %rsi
вместо добавления, потому что -128 вписывается в imm8
+128 нет. Так что я думаю, что разворачивания на 4 достаточно, чтобы насытить порт добавления FP.
Что касается ваших 1avg-функций, которые возвращают один float, а не вектор, хорошо, clang не автоматически векторизовал первую, а gcc делает. Он выдает гигантский пролог и эпилог для скалярных сумм, пока не доберется до выровненного адреса, а затем делает 32B AVX vaddps
в маленькой петле. Вы говорите, что нашли с ними гораздо больший разность скоростей, но вы, возможно, тестировали с меньшим буфером? Это объясняло бы ускорение векторного кода по сравнению с не векторным.