Как достичь теоретической пиковой производительности 4 операций с плавающей запятой (двойной точности) за такт на современном процессоре Intel x86-64?
Насколько я понимаю, для большинства современных процессоров Intel требуется три цикла для SSE add
и пять циклов для a mul
(см., Например , «Таблицы инструкций» Агнера Фога ). Благодаря конвейерной обработке можно получить пропускную способность по одному add
за цикл, если алгоритм имеет как минимум три независимых суммирования. Поскольку это верно как для упакованных, addpd
так и для скалярных addsd
версий и регистров SSE может содержать два double
, пропускная способность может достигать двух флопов за цикл.
Кроме того, кажется (хотя я не видел никакой надлежащей документации по этому вопросу) add
, и mul
могут выполняться параллельно, давая теоретическую максимальную пропускную способность четыре флопс за цикл.
Однако я не смог воспроизвести эту производительность с помощью простой программы на C / C ++. Моя лучшая попытка привела к примерно 2,7 флопс / цикл. Если кто-то может предложить простую C / C ++ или ассемблерную программу, которая демонстрирует пиковую производительность, это было бы очень признательно.
Моя попытка:
#include <stdio.h>
#include <stdlib.h>
#include <math.h>
#include <sys/time.h>
double stoptime(void) {
struct timeval t;
gettimeofday(&t,NULL);
return (double) t.tv_sec + t.tv_usec/1000000.0;
}
double addmul(double add, double mul, int ops){
// Need to initialise differently otherwise compiler might optimise away
double sum1=0.1, sum2=-0.1, sum3=0.2, sum4=-0.2, sum5=0.0;
double mul1=1.0, mul2= 1.1, mul3=1.2, mul4= 1.3, mul5=1.4;
int loops=ops/10; // We have 10 floating point operations inside the loop
double expected = 5.0*add*loops + (sum1+sum2+sum3+sum4+sum5)
+ pow(mul,loops)*(mul1+mul2+mul3+mul4+mul5);
for (int i=0; i<loops; i++) {
mul1*=mul; mul2*=mul; mul3*=mul; mul4*=mul; mul5*=mul;
sum1+=add; sum2+=add; sum3+=add; sum4+=add; sum5+=add;
}
return sum1+sum2+sum3+sum4+sum5+mul1+mul2+mul3+mul4+mul5 - expected;
}
int main(int argc, char** argv) {
if (argc != 2) {
printf("usage: %s <num>\n", argv[0]);
printf("number of operations: <num> millions\n");
exit(EXIT_FAILURE);
}
int n = atoi(argv[1]) * 1000000;
if (n<=0)
n=1000;
double x = M_PI;
double y = 1.0 + 1e-8;
double t = stoptime();
x = addmul(x, y, n);
t = stoptime() - t;
printf("addmul:\t %.3f s, %.3f Gflops, res=%f\n", t, (double)n/t/1e9, x);
return EXIT_SUCCESS;
}
Составлено с
g++ -O2 -march=native addmul.cpp ; ./a.out 1000
выдает следующий вывод на Intel Core i5-750, 2,66 ГГц.
addmul: 0.270 s, 3.707 Gflops, res=1.326463
То есть примерно 1,4 флопа за цикл. Глядя на ассемблерный код с
g++ -S -O2 -march=native -masm=intel addmul.cpp
основным циклом мне кажется оптимальным:
.L4:
inc eax
mulsd xmm8, xmm3
mulsd xmm7, xmm3
mulsd xmm6, xmm3
mulsd xmm5, xmm3
mulsd xmm1, xmm3
addsd xmm13, xmm2
addsd xmm12, xmm2
addsd xmm11, xmm2
addsd xmm10, xmm2
addsd xmm9, xmm2
cmp eax, ebx
jne .L4
Изменение скалярных версий с упакованными версиями ( addpd
и mulpd
) удвоило бы количество флопов без изменения времени выполнения, и поэтому мне хватило бы лишь 2,8 флопов за цикл. Есть ли простой пример, который достигает четырех флопов за цикл?
Хорошая маленькая программа от Mysticial; Вот мои результаты (хотя бы на несколько секунд):
gcc -O2 -march=nocona
: 5,6 Гфлоп из 10,66 Гфлоп (2,1 Флоп / цикл)cl /O2
openmp удалено: 10,1 Гфлоп из 10,66 Гфлоп (3,8 Флоп / цикл)
Все это кажется немного сложным, но мои выводы пока:
gcc -O2
изменяет порядок независимых операций с плавающей запятой с целью чередованияaddpd
иmulpd
по возможности. То же самое относится и кgcc-4.6.2 -O2 -march=core2
.gcc -O2 -march=nocona
похоже, сохраняет порядок операций с плавающей запятой, как определено в источнике C ++.cl /O2
64-разрядный компилятор из SDK для Windows 7 выполняет автоматическое развертывание циклов и, по-видимому, пытается упорядочить операции так, чтобы группы из трехaddpd
чередовались с тремяmulpd
(ну, по крайней мере, в моей системе и для моей простой программы) ,Мой Core i5 750 ( архитектура Nehalem ) не любит чередование надстроек и мул и, по-видимому, не может выполнять обе операции параллельно. Тем не менее, если сгруппированы в 3-х, это внезапно работает как магия.
Другие архитектуры (возможно, Sandy Bridge и другие), по-видимому, могут выполнять add / mul параллельно без проблем, если они чередуются в коде сборки.
Хотя это трудно признать, но в моей системе
cl /O2
гораздо лучше справляется с низкоуровневыми операциями оптимизации для моей системы и достигает почти максимальной производительности для небольшого примера C ++, описанного выше. Я измерял между 1,85-2,01 флопс / цикл (использовал clock () в Windows, что не так точно. Я думаю, нужно использовать лучший таймер - спасибо Mackie Messer).Лучшее, с чем мне удалось
gcc
справиться, - это вручную развернуть цикл и расставить сложения и умножения в группы по три. Сg++ -O2 -march=nocona addmul_unroll.cpp
я получаю в лучшем случае,0.207s, 4.825 Gflops
что соответствует 1,8 плюхается / цикл , который я очень доволен компанией.
В коде C ++ я заменил for
цикл
for (int i=0; i<loops/3; i++) {
mul1*=mul; mul2*=mul; mul3*=mul;
sum1+=add; sum2+=add; sum3+=add;
mul4*=mul; mul5*=mul; mul1*=mul;
sum4+=add; sum5+=add; sum1+=add;
mul2*=mul; mul3*=mul; mul4*=mul;
sum2+=add; sum3+=add; sum4+=add;
mul5*=mul; mul1*=mul; mul2*=mul;
sum5+=add; sum1+=add; sum2+=add;
mul3*=mul; mul4*=mul; mul5*=mul;
sum3+=add; sum4+=add; sum5+=add;
}
И сборка теперь выглядит так
.L4:
mulsd xmm8, xmm3
mulsd xmm7, xmm3
mulsd xmm6, xmm3
addsd xmm13, xmm2
addsd xmm12, xmm2
addsd xmm11, xmm2
mulsd xmm5, xmm3
mulsd xmm1, xmm3
mulsd xmm8, xmm3
addsd xmm10, xmm2
addsd xmm9, xmm2
addsd xmm13, xmm2
...
-funroll-loops
). Пробовал с gcc версии 4.4.1 и 4.6.2, но вывод asm выглядит нормально?
-O3
gcc, который позволяет -ftree-vectorize
? Может быть, в сочетании с тем, -funroll-loops
хотя я этого не делаю, если это действительно необходимо. В конце концов, сравнение кажется несправедливым, если один из компиляторов выполняет векторизацию / развёртывание, а другой - не потому, что не может, а потому, что об этом сказано не слишком.
-funroll-loops
, наверное, что-то попробовать. Но я думаю, -ftree-vectorize
что дело не в этом. ОП пытается просто выдержать 1 муль + 1 инструкцию добавления / цикл. Инструкции могут быть скалярными или векторными - это не имеет значения, поскольку задержка и пропускная способность одинаковы. Так что если вы можете выдержать 2 / цикл со скалярным SSE, то вы можете заменить их векторным SSE, и вы получите 4 флопа / цикл. В своем ответе я поступил именно так из SSE -> AVX. Я заменил все SSE на AVX - те же задержки, те же пропускные способности, 2x флопс.