Como o desempenho teórico máximo de 4 operações de ponto flutuante (precisão dupla) por ciclo pode ser alcançado em uma moderna CPU Intel x86-64?
Até onde eu entendo, são necessários três ciclos para um SSE add
e cinco ciclos para que um mul
seja concluído na maioria das CPUs modernas da Intel (veja, por exemplo , 'Tabelas de Instruções' de Agner Fog ). Devido ao pipelining, é possível obter uma taxa de transferência de um add
por ciclo, se o algoritmo tiver pelo menos três somas independentes. Como isso é verdade tanto para addpd
as addsd
versões compactadas quanto para as escalares e os registros SSE podem conter dois double
, a taxa de transferência pode chegar a dois flops por ciclo.
Além disso, parece (embora eu não tenha visto nenhuma documentação adequada sobre isso) add
's mul
' e 's podem ser executados em paralelo, fornecendo uma taxa de transferência máxima teórica de quatro fracassos por ciclo.
No entanto, não consegui replicar esse desempenho com um simples programa C / C ++. Minha melhor tentativa resultou em cerca de 2,7 flops / ciclo. Se alguém puder contribuir com um programa C / C ++ ou assembler simples que demonstre desempenho máximo que seria muito apreciado.
Minha tentativa:
#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;
}
Compilado com
g++ -O2 -march=native addmul.cpp ; ./a.out 1000
produz a seguinte saída em um Intel Core i5-750, 2,66 GHz.
addmul: 0.270 s, 3.707 Gflops, res=1.326463
Ou seja, apenas cerca de 1,4 falhas por ciclo. Observar o código do assembler com
g++ -S -O2 -march=native -masm=intel addmul.cpp
o loop principal parece ótimo para mim:
.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
Alterando as versões escalares com versões compactadas (addpd
e mulpd
) dobraria a contagem de flops sem alterar o tempo de execução e, portanto, ficaria apenas com 2,8 flops por ciclo. Existe um exemplo simples que atinge quatro fracassos por ciclo?
Bom pequeno programa de Mysticial; Aqui estão meus resultados (executados apenas por alguns segundos):
gcc -O2 -march=nocona
: 5.6 Gflops em 10.66 Gflops (2.1 flops / ciclo)cl /O2
, openmp removido: 10,1 Gflops em 10,66 Gflops (3,8 flops / ciclo)
Tudo parece um pouco complexo, mas minhas conclusões até agora:
gcc -O2
altera a ordem das operações independentes de ponto flutuante com o objetivo de alternaraddpd
emulpd
, se possível. O mesmo se aplica agcc-4.6.2 -O2 -march=core2
.gcc -O2 -march=nocona
parece manter a ordem das operações de ponto flutuante, conforme definido na fonte C ++.cl /O2
, o compilador de 64 bits do SDK para Windows 7 desenrola automaticamente o loop e parece tentar organizar operações para que grupos de três seaddpd
alternem com trêsmulpd
(bom, pelo menos no meu sistema e no meu programa simples) .Meu Core i5 750 ( arquitetura Nehalem ) não gosta de adicionar e mul alternar e parece incapaz de executar as duas operações em paralelo. No entanto, se agrupado em 3, de repente funciona como mágica.
Outras arquiteturas (possivelmente Sandy Bridge e outras) parecem capazes de executar add / mul em paralelo sem problemas, se alternarem no código de montagem.
Embora seja difícil admitir, mas no meu sistema
cl /O2
faz um trabalho muito melhor em operações de otimização de baixo nível para o meu sistema e alcança desempenho próximo ao pico no pequeno exemplo de C ++ acima. Eu medi entre 1,85-2,01 flops / cycle (usei clock () no Windows, o que não é tão preciso. Acho que preciso usar um cronômetro melhor - obrigado Mackie Messer).O melhor que consegui
gcc
foi fazer o loop desenrolar manualmente e organizar adições e multiplicações em grupos de três. Comg++ -O2 -march=nocona addmul_unroll.cpp
eu chego na melhor das hipóteses, o0.207s, 4.825 Gflops
que corresponde a 1,8 flops / ciclo com o qual estou muito feliz agora.
No código C ++, substituí o for
loop por
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;
}
E a montagem agora parece
.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
). Tentei com a versão 4.4.1 e 4.6.2 do gcc, mas a saída asm parece ok?
-O3
gcc, o que habilita -ftree-vectorize
? Talvez combinado com -funroll-loops
embora eu não, se isso é realmente necessário. Afinal, a comparação parece meio injusta se um dos compiladores fizer vetorização / desenrolar, enquanto o outro não, porque não pode, mas porque também não é dito.
-funroll-loops
é provavelmente algo para tentar. Mas acho que -ftree-vectorize
está além do ponto. O OP está tentando apenas sustentar 1 mul + 1 add instrução / ciclo. As instruções podem ser escalares ou vetoriais - não importa, pois a latência e a taxa de transferência são as mesmas. Portanto, se você puder sustentar 2 / ciclo com SSE escalar, poderá substituí-los pelo vetor SSE e obterá 4 flops / ciclo. Na minha resposta, fiz exatamente isso no SSE -> AVX. Troquei todo o SSE pelo AVX - mesmas latências, mesmas taxas de transferência, 2x os fracassos.