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
...
fonte
-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.Respostas:
Eu já fiz essa tarefa exata antes. Mas era principalmente para medir o consumo de energia e as temperaturas da CPU. O código a seguir (que é bastante longo) atinge quase o ideal no meu Core i7 2600K.
A principal coisa a observar aqui é a enorme quantidade de desenrolamento manual de loop, bem como a intercalação de multiplicações e acréscimos ...
O projeto completo pode ser encontrado no meu GitHub: https://github.com/Mysticial/Flops
Atenção:
Se você decidir compilar e executar isso, preste atenção às temperaturas da CPU !!!
Certifique-se de não superaquecer. E verifique se a otimização da CPU não afeta seus resultados!
Além disso, não me responsabilizo por qualquer dano que possa resultar da execução deste código.
Notas:
O ICC 11 (Intel Compiler 11) surpreendentemente tem problemas para compilá-lo bem.
Saída (1 thread, 10000000 iterações) - Compilado com o Visual Studio 2010 SP1 - Versão x64:
A máquina é um Core i7 2600K a 4,4 GHz. O pico teórico do SSE é de 4 flops * 4,4 GHz = 17,6 GFlops . Este código atinge 17,3 GFlops - nada mal.
Saída (8 threads, 10000000 iterações) - Compilado com o Visual Studio 2010 SP1 - Versão x64:
O pico teórico do SSE é de 4 flops * 4 núcleos * 4,4 GHz = 70,4 GFlops. Real é 65,5 GFlops .
Vamos dar um passo adiante. AVX ...
Saída (1 thread, 10000000 iterações) - Compilado com o Visual Studio 2010 SP1 - Versão x64:
O pico teórico do AVX é de 8 flops * 4,4 GHz = 35,2 GFlops . Real é 33,4 GFlops .
Saída (8 threads, 10000000 iterações) - Compilado com o Visual Studio 2010 SP1 - Versão x64:
O pico teórico do AVX é de 8 flops * 4 núcleos * 4,4 GHz = 140,8 GFlops. Real é 138,2 GFlops .
Agora, para algumas explicações:
A parte crítica do desempenho é obviamente as 48 instruções dentro do loop interno. Você notará que está dividido em 4 blocos de 12 instruções cada. Cada um desses 12 blocos de instruções é completamente independente um do outro - e leva em média 6 ciclos para executar.
Portanto, há 12 instruções e 6 ciclos entre o problema e o uso. A latência da multiplicação é de 5 ciclos, portanto, é apenas o suficiente para evitar paradas de latência.
A etapa de normalização é necessária para evitar que os dados sejam excedidos / insuficientes. Isso é necessário, pois o código de não fazer nada aumenta / diminui lentamente a magnitude dos dados.
Portanto, é realmente possível fazer melhor do que isso se você apenas usar todos os zeros e se livrar da etapa de normalização. No entanto, desde que escrevi o benchmark para medir o consumo de energia e a temperatura, tive que garantir que os flops estivessem com dados "reais", em vez de zeros - já que as unidades de execução podem muito bem ter tratamento especial de caso para zeros que usam menos energia e produz menos calor.
Mais resultados:
Tópicos: 1
Pico SSE teórico: 4 flops * 3,5 GHz = 14,0 GFlops . Real é 13,3 GFlops .
Tópicos: 8
Pico SSE teórico: 4 flops * 4 núcleos * 3,5 GHz = 56,0 GFlops . Real é 51,3 GFlops .
A temperatura do meu processador atingiu 76C na execução multiencadeada! Se você executá-las, verifique se os resultados não são afetados pela otimização da CPU.
Tópicos: 1
Pico SSE teórico: 4 flops * 3,2 GHz = 12,8 GFlops . Real é 12,3 GFlops .
Tópicos: 8
Pico SSE teórico: 4 flops * 8 núcleos * 3,2 GHz = 102,4 GFlops . Real é 97,9 GFlops .
fonte
1.814s, 5.292 Gflops, sum=0.448883
obtive resultados tão bons: iterações de 100 mil, fora de um pico de 10,68 Gflops ou apenas de 2,0 flops por ciclo. Pareceadd
/mul
não é executado em paralelo. Quando troco seu código e sempre adiciono / multiplico com o mesmo registro, digamosrC
, de repente ele atinge quase o pico:0.953s, 10.068 Gflops, sum=0
ou 3,8 flops / ciclo. Muito estranho.cl /O2
(64 bits do windows sdk) e até mesmo meu exemplo é quase o pico para operações escalares (1,9 flops / ciclo) lá. O compilador desenrola e reordena, mas esse pode não ser o motivo para analisar um pouco mais isso. Regulando não é um problema, eu sou legal com minha CPU e mantenho as iterações em 100k. :)Há um ponto na arquitetura Intel que as pessoas costumam esquecer: as portas de despacho são compartilhadas entre Int e FP / SIMD. Isso significa que você receberá apenas uma certa quantidade de rajadas de FP / SIMD antes que a lógica do loop crie bolhas no seu fluxo de ponto flutuante. Mystical conseguiu mais falhas em seu código, porque ele usou avanços mais longos em seu loop desenrolado.
Se você observar a arquitetura Nehalem / Sandy Bridge aqui http://www.realworldtech.com/page.cfm?ArticleID=RWT091810191937&p=6 , é bastante claro o que acontece.
Por outro lado, deve ser mais fácil atingir o desempenho máximo no AMD (Bulldozer), pois os tubos INT e FP / SIMD têm portas de problemas separadas com seu próprio agendador.
Isso é apenas teórico, pois não tenho nenhum desses processadores para testar.
fonte
inc
,cmp
, ejl
. Tudo isso pode ir para a porta 5 e não interfere com vetorizadofadd
oufmul
. Prefiro suspeitar que o decodificador (às vezes) atrapalhe. Precisa manter entre duas e três instruções por ciclo. Não me lembro das limitações exatas, mas o comprimento, o prefixo e o alinhamento das instruções entram em jogo.cmp
ejl
certamente vá para a porta 5,inc
não tão certa, pois sempre vem em grupo com as outras 2. Mas você está certo, é difícil dizer onde está o gargalo e os decodificadores também podem fazer parte dele.As filiais podem definitivamente impedir você de manter o desempenho teórico máximo. Você vê alguma diferença se você desenrola manualmente algum loop? Por exemplo, se você colocar 5 ou 10 vezes mais operações por iteração de loop:
fonte
-funroll-loops
opção que nem sequer está incluída-O3
. Vejag++ -c -Q -O2 --help=optimizers | grep unroll
.Usando o Intels icc versão 11.1 em um Intel Core 2 Duo de 2,4 GHz, recebo
Isso é muito próximo dos 9,6 Gflops ideais.
EDITAR:
Ops, olhando o código do assembly, parece que o icc não apenas vetorizou a multiplicação, mas também retirou as adições do loop. Forçando uma semântica fp mais rigorosa, o código não é mais vetorizado:
EDIT2:
Como pedido:
O loop interno do código do clang é assim:
EDIT3:
Por fim, duas sugestões: primeiro, se você gosta desse tipo de benchmarking, considere usar a
rdtsc
instrução istead ofgettimeofday(2)
. É muito mais preciso e fornece o tempo em ciclos, que geralmente é o que você está interessado. Para gcc e amigos, você pode defini-lo assim:Segundo, você deve executar seu programa de benchmark várias vezes e usar apenas o melhor desempenho . Nos sistemas operacionais modernos, muitas coisas acontecem em paralelo, a cpu pode estar no modo de economia de energia de baixa frequência etc. A execução repetida do programa fornece um resultado mais próximo do caso ideal.
fonte
addsd
's' emulsd
's ou eles estão em grupos como na minha saída de montagem? Eu também recebo apenas 1 flop / ciclo quando o compilador os mistura (o que eu fico sem-march=native
). Como o desempenho muda se você adicionar uma linhaadd=mul;
no início da funçãoaddmul(...)
?addsd
esubsd
são realmente misturadas na versão precisa. Também tentei o clang 3.0, ele não combina instruções e chega muito perto de 2 flops / cycle no core 2 duo. Quando executo o mesmo código nos meus laptops core i5, misturar o código não faz diferença. Eu recebo cerca de 3 flops / ciclo em ambos os casos.icc
antes, você pode checar a montagem?