Як можна досягти теоретичної пікової продуктивності 4 операцій з плаваючою комою (подвійна точність) за цикл на сучасному процесорі Intel x86-64 Intel?
Наскільки я розумію, для більшості сучасних процесорів Intel потрібні три цикли для SSE add
і п'ять циклів mul
(див., Наприклад , «Таблиці інструкцій» 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
) дозволило б удвічі збільшити кількість флопів, не змінюючи час виконання, і тому я отримав би лише 2,8 флопа за цикл. Чи є простий приклад, який досягає чотирьох флопів за цикл?
Мила маленька програма Mysticial; ось мої результати (запустити всього кілька секунд):
gcc -O2 -march=nocona
: 5,6 Gflops з 10,66 Gflops (2,1 флопа / цикл)cl /O2
, openmp видалено: 10,1 Gflops з 10,66 Gflops (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-х, це раптом працює як магія.
Інші архітектури (можливо, Сенді Брідж та інші), схоже, можуть без проблем виконувати додавання / муль паралельно, якщо вони чергуються в асемблерному коді.
Хоча це важко визнати, але в моїй системі
cl /O2
робиться набагато краща робота при оптимізації низьких рівнів для моєї системи та досягає максимальної продуктивності для маленького прикладу С ++ вище. Я виміряв між 1,85-2,01 флопами / циклом (використовував годинник () у 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 - ті ж затримки, однакові пропускні здатності, 2х флопи.