Как достичь теоретической пиковой производительности 4 операций с плавающей запятой (двойной точности) за такт на современном процессоре Intel x86-64?
Насколько я понимаю, это займет три цикла для SSE add
и пять циклов для mul
, чтобы закончить на большинстве современных процессоров Intel (смотри, например, "Инструкция Таблицы" Agner FOG). Благодаря конвейерной обработке можно получить пропускную способность, равную одному 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
) mulpd
бы количество 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) не любит чередование add и mul и, по-видимому, не может выполнять обе операции параллельно. Однако, если сгруппировать в 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
...