Como alcanço o máximo teórico de 4 FLOPs por ciclo?

642

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 mulseja 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 addpor ciclo, se o algoritmo tiver pelo menos três somas independentes. Como isso é verdade tanto para addpdas addsdversõ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.cppo 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 -O2altera a ordem das operações independentes de ponto flutuante com o objetivo de alternar addpde mulpd, se possível. O mesmo se aplica a gcc-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 se addpdalternem 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 /O2faz 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 gccfoi fazer o loop desenrolar manualmente e organizar adições e multiplicações em grupos de três. Com g++ -O2 -march=nocona addmul_unroll.cpp eu chego na melhor das hipóteses, o 0.207s, 4.825 Gflopsque corresponde a 1,8 flops / ciclo com o qual estou muito feliz agora.

No código C ++, substituí o forloop 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
...
user1059432
fonte
15
Contar com o tempo do relógio de parede provavelmente faz parte da causa. Supondo que você esteja executando isso dentro de um sistema operacional como o Linux, é gratuito desmarcar seu processo a qualquer momento. Esse tipo de evento externo pode afetar suas medições de desempenho.
Tdenniston
Qual é a sua versão do GCC? Se você estiver usando um Mac usando o padrão, terá problemas (é um antigo 4.2).
semisight 5/12/11
2
Sim, rodar o Linux, mas não há carga no sistema e repeti-lo muitas vezes faz pequenas diferenças (por exemplo, varia de 4,0 a 4,2 Gflops para a versão escalar, mas agora com -funroll-loops). Tentei com a versão 4.4.1 e 4.6.2 do gcc, mas a saída asm parece ok?
user1059432
Você tentou o -O3gcc, o que habilita -ftree-vectorize? Talvez combinado com -funroll-loopsembora 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.
Grizzly
4
@ Grizzly -funroll-loopsé provavelmente algo para tentar. Mas acho que -ftree-vectorizeestá 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.
Mysticial

Respostas:

517

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:

  • Este código é otimizado para x64. O x86 não possui registros suficientes para compilar bem.
  • Este código foi testado para funcionar bem no Visual Studio 2010/2012 e no GCC 4.6.
    O ICC 11 (Intel Compiler 11) surpreendentemente tem problemas para compilá-lo bem.
  • Estes são para processadores pré-FMA. Para atingir o pico de FLOPS nos processadores Intel Haswell e AMD Bulldozer (e posterior), serão necessárias instruções FMA (Fused Multiply Add). Isso está além do escopo deste benchmark.

#include <emmintrin.h>
#include <omp.h>
#include <iostream>
using namespace std;

typedef unsigned long long uint64;

double test_dp_mac_SSE(double x,double y,uint64 iterations){
    register __m128d r0,r1,r2,r3,r4,r5,r6,r7,r8,r9,rA,rB,rC,rD,rE,rF;

    //  Generate starting data.
    r0 = _mm_set1_pd(x);
    r1 = _mm_set1_pd(y);

    r8 = _mm_set1_pd(-0.0);

    r2 = _mm_xor_pd(r0,r8);
    r3 = _mm_or_pd(r0,r8);
    r4 = _mm_andnot_pd(r8,r0);
    r5 = _mm_mul_pd(r1,_mm_set1_pd(0.37796447300922722721));
    r6 = _mm_mul_pd(r1,_mm_set1_pd(0.24253562503633297352));
    r7 = _mm_mul_pd(r1,_mm_set1_pd(4.1231056256176605498));
    r8 = _mm_add_pd(r0,_mm_set1_pd(0.37796447300922722721));
    r9 = _mm_add_pd(r1,_mm_set1_pd(0.24253562503633297352));
    rA = _mm_sub_pd(r0,_mm_set1_pd(4.1231056256176605498));
    rB = _mm_sub_pd(r1,_mm_set1_pd(4.1231056256176605498));

    rC = _mm_set1_pd(1.4142135623730950488);
    rD = _mm_set1_pd(1.7320508075688772935);
    rE = _mm_set1_pd(0.57735026918962576451);
    rF = _mm_set1_pd(0.70710678118654752440);

    uint64 iMASK = 0x800fffffffffffffull;
    __m128d MASK = _mm_set1_pd(*(double*)&iMASK);
    __m128d vONE = _mm_set1_pd(1.0);

    uint64 c = 0;
    while (c < iterations){
        size_t i = 0;
        while (i < 1000){
            //  Here's the meat - the part that really matters.

            r0 = _mm_mul_pd(r0,rC);
            r1 = _mm_add_pd(r1,rD);
            r2 = _mm_mul_pd(r2,rE);
            r3 = _mm_sub_pd(r3,rF);
            r4 = _mm_mul_pd(r4,rC);
            r5 = _mm_add_pd(r5,rD);
            r6 = _mm_mul_pd(r6,rE);
            r7 = _mm_sub_pd(r7,rF);
            r8 = _mm_mul_pd(r8,rC);
            r9 = _mm_add_pd(r9,rD);
            rA = _mm_mul_pd(rA,rE);
            rB = _mm_sub_pd(rB,rF);

            r0 = _mm_add_pd(r0,rF);
            r1 = _mm_mul_pd(r1,rE);
            r2 = _mm_sub_pd(r2,rD);
            r3 = _mm_mul_pd(r3,rC);
            r4 = _mm_add_pd(r4,rF);
            r5 = _mm_mul_pd(r5,rE);
            r6 = _mm_sub_pd(r6,rD);
            r7 = _mm_mul_pd(r7,rC);
            r8 = _mm_add_pd(r8,rF);
            r9 = _mm_mul_pd(r9,rE);
            rA = _mm_sub_pd(rA,rD);
            rB = _mm_mul_pd(rB,rC);

            r0 = _mm_mul_pd(r0,rC);
            r1 = _mm_add_pd(r1,rD);
            r2 = _mm_mul_pd(r2,rE);
            r3 = _mm_sub_pd(r3,rF);
            r4 = _mm_mul_pd(r4,rC);
            r5 = _mm_add_pd(r5,rD);
            r6 = _mm_mul_pd(r6,rE);
            r7 = _mm_sub_pd(r7,rF);
            r8 = _mm_mul_pd(r8,rC);
            r9 = _mm_add_pd(r9,rD);
            rA = _mm_mul_pd(rA,rE);
            rB = _mm_sub_pd(rB,rF);

            r0 = _mm_add_pd(r0,rF);
            r1 = _mm_mul_pd(r1,rE);
            r2 = _mm_sub_pd(r2,rD);
            r3 = _mm_mul_pd(r3,rC);
            r4 = _mm_add_pd(r4,rF);
            r5 = _mm_mul_pd(r5,rE);
            r6 = _mm_sub_pd(r6,rD);
            r7 = _mm_mul_pd(r7,rC);
            r8 = _mm_add_pd(r8,rF);
            r9 = _mm_mul_pd(r9,rE);
            rA = _mm_sub_pd(rA,rD);
            rB = _mm_mul_pd(rB,rC);

            i++;
        }

        //  Need to renormalize to prevent denormal/overflow.
        r0 = _mm_and_pd(r0,MASK);
        r1 = _mm_and_pd(r1,MASK);
        r2 = _mm_and_pd(r2,MASK);
        r3 = _mm_and_pd(r3,MASK);
        r4 = _mm_and_pd(r4,MASK);
        r5 = _mm_and_pd(r5,MASK);
        r6 = _mm_and_pd(r6,MASK);
        r7 = _mm_and_pd(r7,MASK);
        r8 = _mm_and_pd(r8,MASK);
        r9 = _mm_and_pd(r9,MASK);
        rA = _mm_and_pd(rA,MASK);
        rB = _mm_and_pd(rB,MASK);
        r0 = _mm_or_pd(r0,vONE);
        r1 = _mm_or_pd(r1,vONE);
        r2 = _mm_or_pd(r2,vONE);
        r3 = _mm_or_pd(r3,vONE);
        r4 = _mm_or_pd(r4,vONE);
        r5 = _mm_or_pd(r5,vONE);
        r6 = _mm_or_pd(r6,vONE);
        r7 = _mm_or_pd(r7,vONE);
        r8 = _mm_or_pd(r8,vONE);
        r9 = _mm_or_pd(r9,vONE);
        rA = _mm_or_pd(rA,vONE);
        rB = _mm_or_pd(rB,vONE);

        c++;
    }

    r0 = _mm_add_pd(r0,r1);
    r2 = _mm_add_pd(r2,r3);
    r4 = _mm_add_pd(r4,r5);
    r6 = _mm_add_pd(r6,r7);
    r8 = _mm_add_pd(r8,r9);
    rA = _mm_add_pd(rA,rB);

    r0 = _mm_add_pd(r0,r2);
    r4 = _mm_add_pd(r4,r6);
    r8 = _mm_add_pd(r8,rA);

    r0 = _mm_add_pd(r0,r4);
    r0 = _mm_add_pd(r0,r8);


    //  Prevent Dead Code Elimination
    double out = 0;
    __m128d temp = r0;
    out += ((double*)&temp)[0];
    out += ((double*)&temp)[1];

    return out;
}

void test_dp_mac_SSE(int tds,uint64 iterations){

    double *sum = (double*)malloc(tds * sizeof(double));
    double start = omp_get_wtime();

#pragma omp parallel num_threads(tds)
    {
        double ret = test_dp_mac_SSE(1.1,2.1,iterations);
        sum[omp_get_thread_num()] = ret;
    }

    double secs = omp_get_wtime() - start;
    uint64 ops = 48 * 1000 * iterations * tds * 2;
    cout << "Seconds = " << secs << endl;
    cout << "FP Ops  = " << ops << endl;
    cout << "FLOPs   = " << ops / secs << endl;

    double out = 0;
    int c = 0;
    while (c < tds){
        out += sum[c++];
    }

    cout << "sum = " << out << endl;
    cout << endl;

    free(sum);
}

int main(){
    //  (threads, iterations)
    test_dp_mac_SSE(8,10000000);

    system("pause");
}

Saída (1 thread, 10000000 iterações) - Compilado com o Visual Studio 2010 SP1 - Versão x64:

Seconds = 55.5104
FP Ops  = 960000000000
FLOPs   = 1.7294e+010
sum = 2.22652

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:

Seconds = 117.202
FP Ops  = 7680000000000
FLOPs   = 6.55279e+010
sum = 17.8122

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 ...

#include <immintrin.h>
#include <omp.h>
#include <iostream>
using namespace std;

typedef unsigned long long uint64;

double test_dp_mac_AVX(double x,double y,uint64 iterations){
    register __m256d r0,r1,r2,r3,r4,r5,r6,r7,r8,r9,rA,rB,rC,rD,rE,rF;

    //  Generate starting data.
    r0 = _mm256_set1_pd(x);
    r1 = _mm256_set1_pd(y);

    r8 = _mm256_set1_pd(-0.0);

    r2 = _mm256_xor_pd(r0,r8);
    r3 = _mm256_or_pd(r0,r8);
    r4 = _mm256_andnot_pd(r8,r0);
    r5 = _mm256_mul_pd(r1,_mm256_set1_pd(0.37796447300922722721));
    r6 = _mm256_mul_pd(r1,_mm256_set1_pd(0.24253562503633297352));
    r7 = _mm256_mul_pd(r1,_mm256_set1_pd(4.1231056256176605498));
    r8 = _mm256_add_pd(r0,_mm256_set1_pd(0.37796447300922722721));
    r9 = _mm256_add_pd(r1,_mm256_set1_pd(0.24253562503633297352));
    rA = _mm256_sub_pd(r0,_mm256_set1_pd(4.1231056256176605498));
    rB = _mm256_sub_pd(r1,_mm256_set1_pd(4.1231056256176605498));

    rC = _mm256_set1_pd(1.4142135623730950488);
    rD = _mm256_set1_pd(1.7320508075688772935);
    rE = _mm256_set1_pd(0.57735026918962576451);
    rF = _mm256_set1_pd(0.70710678118654752440);

    uint64 iMASK = 0x800fffffffffffffull;
    __m256d MASK = _mm256_set1_pd(*(double*)&iMASK);
    __m256d vONE = _mm256_set1_pd(1.0);

    uint64 c = 0;
    while (c < iterations){
        size_t i = 0;
        while (i < 1000){
            //  Here's the meat - the part that really matters.

            r0 = _mm256_mul_pd(r0,rC);
            r1 = _mm256_add_pd(r1,rD);
            r2 = _mm256_mul_pd(r2,rE);
            r3 = _mm256_sub_pd(r3,rF);
            r4 = _mm256_mul_pd(r4,rC);
            r5 = _mm256_add_pd(r5,rD);
            r6 = _mm256_mul_pd(r6,rE);
            r7 = _mm256_sub_pd(r7,rF);
            r8 = _mm256_mul_pd(r8,rC);
            r9 = _mm256_add_pd(r9,rD);
            rA = _mm256_mul_pd(rA,rE);
            rB = _mm256_sub_pd(rB,rF);

            r0 = _mm256_add_pd(r0,rF);
            r1 = _mm256_mul_pd(r1,rE);
            r2 = _mm256_sub_pd(r2,rD);
            r3 = _mm256_mul_pd(r3,rC);
            r4 = _mm256_add_pd(r4,rF);
            r5 = _mm256_mul_pd(r5,rE);
            r6 = _mm256_sub_pd(r6,rD);
            r7 = _mm256_mul_pd(r7,rC);
            r8 = _mm256_add_pd(r8,rF);
            r9 = _mm256_mul_pd(r9,rE);
            rA = _mm256_sub_pd(rA,rD);
            rB = _mm256_mul_pd(rB,rC);

            r0 = _mm256_mul_pd(r0,rC);
            r1 = _mm256_add_pd(r1,rD);
            r2 = _mm256_mul_pd(r2,rE);
            r3 = _mm256_sub_pd(r3,rF);
            r4 = _mm256_mul_pd(r4,rC);
            r5 = _mm256_add_pd(r5,rD);
            r6 = _mm256_mul_pd(r6,rE);
            r7 = _mm256_sub_pd(r7,rF);
            r8 = _mm256_mul_pd(r8,rC);
            r9 = _mm256_add_pd(r9,rD);
            rA = _mm256_mul_pd(rA,rE);
            rB = _mm256_sub_pd(rB,rF);

            r0 = _mm256_add_pd(r0,rF);
            r1 = _mm256_mul_pd(r1,rE);
            r2 = _mm256_sub_pd(r2,rD);
            r3 = _mm256_mul_pd(r3,rC);
            r4 = _mm256_add_pd(r4,rF);
            r5 = _mm256_mul_pd(r5,rE);
            r6 = _mm256_sub_pd(r6,rD);
            r7 = _mm256_mul_pd(r7,rC);
            r8 = _mm256_add_pd(r8,rF);
            r9 = _mm256_mul_pd(r9,rE);
            rA = _mm256_sub_pd(rA,rD);
            rB = _mm256_mul_pd(rB,rC);

            i++;
        }

        //  Need to renormalize to prevent denormal/overflow.
        r0 = _mm256_and_pd(r0,MASK);
        r1 = _mm256_and_pd(r1,MASK);
        r2 = _mm256_and_pd(r2,MASK);
        r3 = _mm256_and_pd(r3,MASK);
        r4 = _mm256_and_pd(r4,MASK);
        r5 = _mm256_and_pd(r5,MASK);
        r6 = _mm256_and_pd(r6,MASK);
        r7 = _mm256_and_pd(r7,MASK);
        r8 = _mm256_and_pd(r8,MASK);
        r9 = _mm256_and_pd(r9,MASK);
        rA = _mm256_and_pd(rA,MASK);
        rB = _mm256_and_pd(rB,MASK);
        r0 = _mm256_or_pd(r0,vONE);
        r1 = _mm256_or_pd(r1,vONE);
        r2 = _mm256_or_pd(r2,vONE);
        r3 = _mm256_or_pd(r3,vONE);
        r4 = _mm256_or_pd(r4,vONE);
        r5 = _mm256_or_pd(r5,vONE);
        r6 = _mm256_or_pd(r6,vONE);
        r7 = _mm256_or_pd(r7,vONE);
        r8 = _mm256_or_pd(r8,vONE);
        r9 = _mm256_or_pd(r9,vONE);
        rA = _mm256_or_pd(rA,vONE);
        rB = _mm256_or_pd(rB,vONE);

        c++;
    }

    r0 = _mm256_add_pd(r0,r1);
    r2 = _mm256_add_pd(r2,r3);
    r4 = _mm256_add_pd(r4,r5);
    r6 = _mm256_add_pd(r6,r7);
    r8 = _mm256_add_pd(r8,r9);
    rA = _mm256_add_pd(rA,rB);

    r0 = _mm256_add_pd(r0,r2);
    r4 = _mm256_add_pd(r4,r6);
    r8 = _mm256_add_pd(r8,rA);

    r0 = _mm256_add_pd(r0,r4);
    r0 = _mm256_add_pd(r0,r8);

    //  Prevent Dead Code Elimination
    double out = 0;
    __m256d temp = r0;
    out += ((double*)&temp)[0];
    out += ((double*)&temp)[1];
    out += ((double*)&temp)[2];
    out += ((double*)&temp)[3];

    return out;
}

void test_dp_mac_AVX(int tds,uint64 iterations){

    double *sum = (double*)malloc(tds * sizeof(double));
    double start = omp_get_wtime();

#pragma omp parallel num_threads(tds)
    {
        double ret = test_dp_mac_AVX(1.1,2.1,iterations);
        sum[omp_get_thread_num()] = ret;
    }

    double secs = omp_get_wtime() - start;
    uint64 ops = 48 * 1000 * iterations * tds * 4;
    cout << "Seconds = " << secs << endl;
    cout << "FP Ops  = " << ops << endl;
    cout << "FLOPs   = " << ops / secs << endl;

    double out = 0;
    int c = 0;
    while (c < tds){
        out += sum[c++];
    }

    cout << "sum = " << out << endl;
    cout << endl;

    free(sum);
}

int main(){
    //  (threads, iterations)
    test_dp_mac_AVX(8,10000000);

    system("pause");
}

Saída (1 thread, 10000000 iterações) - Compilado com o Visual Studio 2010 SP1 - Versão x64:

Seconds = 57.4679
FP Ops  = 1920000000000
FLOPs   = 3.34099e+010
sum = 4.45305

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:

Seconds = 111.119
FP Ops  = 15360000000000
FLOPs   = 1.3823e+011
sum = 35.6244

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:

  • Intel Core i7 920 a 3,5 GHz
  • Windows 7 Ultimate x64
  • Visual Studio 2010 SP1 - versão x64

Tópicos: 1

Seconds = 72.1116
FP Ops  = 960000000000
FLOPs   = 1.33127e+010
sum = 2.22652

Pico SSE teórico: 4 flops * 3,5 GHz = 14,0 GFlops . Real é 13,3 GFlops .

Tópicos: 8

Seconds = 149.576
FP Ops  = 7680000000000
FLOPs   = 5.13452e+010
sum = 17.8122

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.


  • 2 x Intel Xeon X5482 Harpertown a 3,2 GHz
  • Ubuntu Linux 10 x64
  • GCC 4.5.2 x64 - (-O2-msse3 -openmp)

Tópicos: 1

Seconds = 78.3357
FP Ops  = 960000000000
FLOPs   = 1.22549e+10
sum = 2.22652

Pico SSE teórico: 4 flops * 3,2 GHz = 12,8 GFlops . Real é 12,3 GFlops .

Tópicos: 8

Seconds = 78.4733
FP Ops  = 7680000000000
FLOPs   = 9.78676e+10
sum = 17.8122

Pico SSE teórico: 4 flops * 8 núcleos * 3,2 GHz = 102,4 GFlops . Real é 97,9 GFlops .

Mysticial
fonte
13
Seus resultados são muito impressionantes. Compilei seu código com o g ++ no meu sistema antigo, mas não 1.814s, 5.292 Gflops, sum=0.448883obtive 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. Parece add/ mulnão é executado em paralelo. Quando troco seu código e sempre adiciono / multiplico com o mesmo registro, digamos rC, de repente ele atinge quase o pico: 0.953s, 10.068 Gflops, sum=0ou 3,8 flops / ciclo. Muito estranho.
user1059432
11
Sim, como não estou usando assembly embutido, o desempenho é realmente muito sensível ao compilador. O código que tenho aqui foi ajustado para o VC2010. E, se bem me lembro, o Compilador Intel também oferece bons resultados. Como você notou, pode ser necessário ajustá-lo um pouco para compilar bem.
Mysticial
8
Posso confirmar seus resultados no Windows 7 usando 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. :)
user1059432
6
@Mysticial: Ele apareceu no subreddit r / coding hoje.
greyfade
2
@haylem Derrete ou decola. Nunca os dois. Se houver resfriamento suficiente, o tempo será de antena. Caso contrário, apenas derrete. :)
Mysticial 16/08
33

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.

Patrick Schlüter
fonte
2
Existem apenas três instruções de sobrecarga Loop: inc, cmp, e jl. Tudo isso pode ir para a porta 5 e não interfere com vetorizado faddou fmul. 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.
Mackie Messer
cmpe jlcertamente vá para a porta 5, incnã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.
Patrick Schlüter
3
Eu brinquei um pouco com o loop básico: a ordem das instruções é importante. Alguns arranjos levam 13 ciclos em vez dos mínimos 5. Tempo para olhar para os contadores de eventos de desempenho eu acho ...
Mackie Messer
16

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:

for(int i=0; i<loops/5; i++) {
      mul1*=mul; mul2*=mul; mul3*=mul; mul4*=mul; mul5*=mul;
      sum1+=add; sum2+=add; sum3+=add; sum4+=add; sum5+=add;
      mul1*=mul; mul2*=mul; mul3*=mul; mul4*=mul; mul5*=mul;
      sum1+=add; sum2+=add; sum3+=add; sum4+=add; sum5+=add;
      mul1*=mul; mul2*=mul; mul3*=mul; mul4*=mul; mul5*=mul;
      sum1+=add; sum2+=add; sum3+=add; sum4+=add; sum5+=add;
      mul1*=mul; mul2*=mul; mul3*=mul; mul4*=mul; mul5*=mul;
      sum1+=add; sum2+=add; sum3+=add; sum4+=add; sum5+=add;
      mul1*=mul; mul2*=mul; mul3*=mul; mul4*=mul; mul5*=mul;
      sum1+=add; sum2+=add; sum3+=add; sum4+=add; sum5+=add;
   }
TJD
fonte
4
Posso estar enganado, mas acredito que o g ++ com -O2 tentará desenrolar automaticamente o loop (acho que ele usa o dispositivo de Duff).
Weaver
6
Sim, obrigado, de fato, melhora um pouco. Agora recebo cerca de 4,1-4,3 Gflops, ou 1,55 flops por ciclo. E não, neste exemplo -O2 não desenrolou.
user1059432
1
Weaver está correto sobre o desenrolar do loop, acredito. Então manualmente desenrolando Provavelmente não é necessário
McNamara jim
5
Veja a saída da montagem acima, não há sinais de desenrolamento de loop.
user1059432
14
O desenrolamento automático também melhora a média de 4,2 Gflops, mas requer uma -funroll-loopsopção que nem sequer está incluída -O3. Veja g++ -c -Q -O2 --help=optimizers | grep unroll.
user1059432
7

Usando o Intels icc versão 11.1 em um Intel Core 2 Duo de 2,4 GHz, recebo

Macintosh:~ mackie$ icc -O3 -mssse3 -oaddmul addmul.cc && ./addmul 1000
addmul:  0.105 s, 9.525 Gflops, res=0.000000
Macintosh:~ mackie$ icc -v
Version 11.1 

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:

Macintosh:~ mackie$ icc -O3 -mssse3 -oaddmul addmul.cc -fp-model precise && ./addmul 1000
addmul:  0.516 s, 1.938 Gflops, res=1.326463

EDIT2:

Como pedido:

Macintosh:~ mackie$ clang -O3 -mssse3 -oaddmul addmul.cc && ./addmul 1000
addmul:  0.209 s, 4.786 Gflops, res=1.326463
Macintosh:~ mackie$ clang -v
Apple clang version 3.0 (tags/Apple/clang-211.10.1) (based on LLVM 3.0svn)
Target: x86_64-apple-darwin11.2.0
Thread model: posix

O loop interno do código do clang é assim:

        .align  4, 0x90
LBB2_4:                                 ## =>This Inner Loop Header: Depth=1
        addsd   %xmm2, %xmm3
        addsd   %xmm2, %xmm14
        addsd   %xmm2, %xmm5
        addsd   %xmm2, %xmm1
        addsd   %xmm2, %xmm4
        mulsd   %xmm2, %xmm0
        mulsd   %xmm2, %xmm6
        mulsd   %xmm2, %xmm7
        mulsd   %xmm2, %xmm11
        mulsd   %xmm2, %xmm13
        incl    %eax
        cmpl    %r14d, %eax
        jl      LBB2_4

EDIT3:

Por fim, duas sugestões: primeiro, se você gosta desse tipo de benchmarking, considere usar a rdtscinstrução istead of gettimeofday(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:

#include <stdint.h>

static __inline__ uint64_t rdtsc(void)
{
        uint64_t rval;
        __asm__ volatile ("rdtsc" : "=A" (rval));
        return rval;
}

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.

Mackie Messer
fonte
2
e como é a desmontagem?
Bahbar 5/12
1
Interessante, isso é menos de 1 flop / ciclo. O compilador combina os addsd's' e mulsd'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 linha add=mul;no início da função addmul(...)?
user1059432
1
@ user1059432: As instruções addsde subsdsã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.
precisa
1
@ user1059432: No final, trata-se de induzir o compilador a gerar código "significativo" para uma referência sintética. Isso é mais difícil do que parece à primeira vista. (isto é, o icc supera sua referência). Se tudo o que você deseja é executar algum código em 4 flops / ciclo, o mais fácil é escrever um pequeno loop de montagem. Muito menos headake. :-)
Mackie Messer
1
Ok, então você chega perto de 2 fracassos / ciclo com um código de montagem semelhante ao que eu citei acima? Quão perto de 2? Eu só recebo 1,4, então isso é significativo. Eu não acho que você consiga 3 flops / ciclo no seu laptop, a menos que o compilador faça otimizações como você já viu iccantes, você pode checar a montagem?
user1059432