Как мне достичь теоретического максимума 4 FLOP за цикл?
Как достичь теоретической пиковой производительности 4 операций с плавающей запятой (двойной точности) за такт на современном процессоре Intel x86-64?
Насколько я понимаю, это займет три цикла дляSSE add
и пять циклов дляmul
завершить на большинстве современных процессоров Intel (см., например,«Таблицы инструкций» Агнера Фога ). Благодаря конвейерной обработке можно получить пропускную способность одного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 Gflops из 10,66 Gflops (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 (Нахелемская архитектура) не любит чередование add и mul и, похоже, не может выполнять обе операции параллельно. Тем не менее, если сгруппированы в 3-х, это внезапно работает как магия.
Другие архитектуры (возможноПесчаный мост и другие), по-видимому, могут выполнять 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
...