Jak osiągnąć teoretyczne maksimum 4 FLOP na cykl?


642

Jak teoretyczną szczytową wydajność 4 operacji zmiennoprzecinkowych (podwójna precyzja) na cykl można uzyskać na nowoczesnym procesorze Intel x86-64?

O ile rozumiem, potrzeba trzech cykli dla SSE add i pięciu cykli na mulukończenie większości współczesnych procesorów Intela (patrz na przykład „Tabele instrukcji” Agner Fog ). Ze względu na potokowanie można uzyskać przepustowość jednego addna cykl, jeśli algorytm ma co najmniej trzy niezależne sumy. Ponieważ dotyczy to zarówno wersji spakowanych, addpdjak i addsdwersji skalarnych, a rejestry SSE mogą zawierać dwa double, przepustowość może wynosić nawet dwa klapy na cykl.

Co więcej, wydaje się (chociaż nie widziałem żadnej właściwej dokumentacji na ten temat) addi mulmogą być wykonywane równolegle, dając teoretyczną maksymalną przepustowość czterech flopów na cykl.

Jednak nie byłem w stanie replikować tej wydajności za pomocą prostego programu C / C ++. Moja najlepsza próba przyniosła około 2,7 flopa / cykl. Jeśli ktoś może wnieść prosty program C / C ++ lub asembler, który wykazuje najwyższą wydajność, co byłoby bardzo mile widziane.

Moja próba:

#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;
}

Kompilowany z

g++ -O2 -march=native addmul.cpp ; ./a.out 1000

produkuje następujące dane wyjściowe na procesorze Intel Core i5-750, 2,66 GHz.

addmul:  0.270 s, 3.707 Gflops, res=1.326463

Oznacza to, że tylko około 1,4 klap na cykl. Patrzenie na kod asemblera z g++ -S -O2 -march=native -masm=intel addmul.cppgłówną pętlą wydaje mi się optymalne:

.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

Zmiana wersji skalarnej na wersję spakowaną ( addpdi mulpd) podwoiłaby liczbę flopów bez zmiany czasu wykonania, więc brakowało mi tylko 2,8 flopów na cykl. Czy istnieje prosty przykład, który pozwala uzyskać cztery klapy na cykl?

Miły mały program Mysticial; oto moje wyniki (uruchom tylko na kilka sekund):

  • gcc -O2 -march=nocona: 5,6 Gflops z 10,66 Gflops (2,1 flops / cykl)
  • cl /O2, usunięto openmp: 10,1 Gflops z 10,66 Gflops (3,8 flops / cykl)

Wszystko wydaje się nieco skomplikowane, ale moje dotychczasowe wnioski:

  • gcc -O2zmienia kolejność niezależnych operacji zmiennoprzecinkowych w celu naprzemiennego addpdi mulpd, jeśli to możliwe. To samo dotyczy gcc-4.6.2 -O2 -march=core2.

  • gcc -O2 -march=nocona wydaje się utrzymywać kolejność operacji zmiennoprzecinkowych, jak zdefiniowano w źródle C ++.

  • cl /O2, 64-bitowy kompilator z zestawu SDK dla systemu Windows 7 automatycznie rozwija pętlę i wydaje się, że próbuje zorganizować operacje tak, aby grupy trzech addpdzmieniały się z trzema mulpd(cóż, przynajmniej w moim systemie i dla mojego prostego programu) .

  • Mój Core i5 750 ( architektura Nehalem ) nie lubi na przemian dodawania i dodawania i wydaje się, że nie jest w stanie wykonywać obu operacji równolegle. Jednak po zgrupowaniu w 3 nagle działa jak magia.

  • Inne architektury (prawdopodobnie Sandy Bridge i inne) wydają się być w stanie wykonywać add / mul równolegle bez problemów, jeśli występują naprzemiennie w kodzie asemblera.

  • Chociaż trudno to przyznać, ale w moim systemie cl /O2wykonuje znacznie lepszą pracę przy operacjach optymalizacji niskiego poziomu w moim systemie i osiąga prawie najwyższą wydajność w przypadku małego przykładu C ++ powyżej. Zmierzyłem między 1,85-2,01 flop / cykl (użyłem clock () w Windowsie, co nie jest tak precyzyjne. Chyba muszę użyć lepszego timera - dzięki Mackie Messer).

  • Najlepsze, z czym mogłem zarządzać, gccto ręczne zapętlanie rozwijania i układanie dodatków i mnożenia w grupach po trzy. Ze g++ -O2 -march=nocona addmul_unroll.cpp mam w najlepszym wypadku 0.207s, 4.825 Gflopsco odpowiada 1,8 japonki / cykl którego jestem bardzo zadowolony z obecnie.

W kodzie C ++ zastąpiłem forpętlę

   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;
   }

A teraz zestaw wygląda

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

15
Poleganie na czasie ściennym jest prawdopodobnie częścią przyczyny. Zakładając, że używasz tego w systemie operacyjnym takim jak Linux, możesz w dowolnym momencie zaplanować swój proces. Tego rodzaju zdarzenie zewnętrzne może wpłynąć na twoje pomiary wydajności.
tdenniston,

Jaka jest twoja wersja GCC? Jeśli używasz komputera Mac z ustawieniem domyślnym, napotkasz problemy (jest to stary 4.2).
półrocza

2
Tak, działając na Linuksie, ale system nie jest obciążony, a powtarzanie go wiele razy robi małe różnice (np. Zakresy 4.0-4.2 Gflops dla wersji skalarnej, ale teraz z -funroll-loops). Próbowałem z gcc w wersji 4.4.1 i 4.6.2, ale wyjście asm wygląda dobrze?
user1059432

Czy próbowałeś -O3gcc, który umożliwia -ftree-vectorize? Może w połączeniu z -funroll-loopstym nie robię, jeśli jest to naprawdę konieczne. W końcu porównanie wydaje się niesprawiedliwe, jeśli jeden z kompilatorów wykonuje wektoryzację / rozwijanie, podczas gdy drugi nie robi tego, ponieważ nie może, ale dlatego, że nie jest mu powiedziane.
Grizzly,

4
@Grizzly -funroll-loopsto prawdopodobnie coś, czego można spróbować. Ale myślę, że -ftree-vectorizeto poza tym. OP stara się utrzymać 1 milion + 1 instrukcja dodawania / cykl. Instrukcje mogą być skalarne lub wektorowe - nie ma to znaczenia, ponieważ opóźnienia i przepustowość są takie same. Jeśli więc możesz utrzymać 2 / cykl za pomocą skalarnego SSE, możesz zastąpić je wektorowym SSE i uzyskasz 4 flopy / cykl. W mojej odpowiedzi właśnie to zrobiłem wychodząc z SSE -> AVX. Wszystkie SSE zastąpiłem AVX - te same opóźnienia, te same przepustowości, 2x flop.
Mysticial

Odpowiedzi:


517

Zrobiłem już dokładnie to zadanie. Ale miał głównie na celu pomiar zużycia energii i temperatur procesora. Poniższy kod (który jest dość długi) osiąga wartość zbliżoną do optymalnej na moim Core i7 2600K.

Najważniejszą rzeczą do odnotowania tutaj jest ogromna ilość ręcznego rozwijania pętli, a także przeplatanie mnożników i dodaje ...

Pełny projekt można znaleźć na moim GitHubie: https://github.com/Mysticial/Flops

Ostrzeżenie:

Jeśli zdecydujesz się go skompilować i uruchomić, zwróć uwagę na temperaturę procesora !!!
Upewnij się, że go nie przegrzejesz. I upewnij się, że dławienie procesora nie wpływa na twoje wyniki!

Ponadto nie biorę odpowiedzialności za jakiekolwiek szkody, które mogą wyniknąć z uruchomienia tego kodu.

Uwagi:

  • Ten kod jest zoptymalizowany dla x64. x86 nie ma wystarczającej liczby rejestrów, aby można było to dobrze skompilować.
  • Ten kod został przetestowany pod kątem poprawnego działania w Visual Studio 2010/2012 i GCC 4.6.
    ICC 11 (Intel Compiler 11) nieoczekiwanie ma problemy z jego kompilacją.
  • Są to dla procesorów sprzed FMA. Aby osiągnąć szczytowe FLOPS na procesorach Intel Haswell i AMD Bulldozer (i późniejszych), potrzebne będą instrukcje FMA (Fused Multiply Add). Są one poza zakresem tego testu porównawczego.

#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");
}

Dane wyjściowe (1 wątek, 10000000 iteracji) - Kompilacja z Visual Studio 2010 SP1 - wydanie x64:

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

Maszyna to Core i7 2600K @ 4,4 GHz. Teoretyczny szczyt SSE to 4 klapy * 4,4 GHz = 17,6 GFlops . Ten kod osiąga 17,3 GFlops - niezły.

Dane wyjściowe (8 wątków, 10000000 iteracji) - Kompilacja z Visual Studio 2010 SP1 - wydanie x64:

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

Teoretyczny szczyt SSE to 4 klapy * 4 rdzenie * 4,4 GHz = 70,4 GFlops. Rzeczywista jest 65,5 GFlops .


Zróbmy krok dalej. 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");
}

Dane wyjściowe (1 wątek, 10000000 iteracji) - Kompilacja z Visual Studio 2010 SP1 - wydanie x64:

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

Teoretyczny szczyt AVX to 8 klap * 4,4 GHz = 35,2 GFlops . Rzeczywista jest 33,4 GFlops .

Dane wyjściowe (8 wątków, 10000000 iteracji) - Kompilacja z Visual Studio 2010 SP1 - wydanie x64:

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

Teoretyczny szczyt AVX to 8 klap * 4 rdzenie * 4,4 GHz = 140,8 GFlops. Rzeczywista wartość to 138,2 GFlops .


Teraz kilka wyjaśnień:

Najważniejszą częścią wydajności jest oczywiście 48 instrukcji wewnątrz wewnętrznej pętli. Zauważysz, że jest on podzielony na 4 bloki po 12 instrukcji. Każdy z tych 12 bloków instrukcji jest całkowicie od siebie niezależny - jego wykonanie zajmuje średnio 6 cykli.

Jest więc 12 instrukcji i 6 cykli między wydaniami. Opóźnienie mnożenia wynosi 5 cykli, więc wystarczy, aby uniknąć opóźnień.

Krok normalizacji jest potrzebny, aby zapobiec przepełnieniu / niedopełnieniu danych. Jest to konieczne, ponieważ kod „nic nie rób” powoli zwiększy / zmniejszy wielkość danych.

Tak więc rzeczywiście można to zrobić lepiej, jeśli użyjesz wszystkich zer i pozbędziesz się etapu normalizacji. Ponieważ jednak napisałem test porównawczy do pomiaru zużycia energii i temperatury, musiałem się upewnić, że na flopach są „rzeczywiste” dane, a nie zera - ponieważ jednostki wykonawcze mogą bardzo dobrze obsługiwać przypadki dla zer, które zużywają mniej energii i wytwarzają mniej ciepła.


Więcej wyników:

  • Intel Core i7 920 @ 3,5 GHz
  • Windows 7 Ultimate x64
  • Visual Studio 2010 SP1 - wydanie x64

Wątki: 1

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

Teoretyczny szczyt SSE: 4 klapy * 3,5 GHz = 14,0 GFlops . Rzeczywista jest 13,3 GFlops .

Wątki: 8

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

Teoretyczny szczyt SSE: 4 klapy * 4 rdzenie * 3,5 GHz = 56,0 GFlops . Rzeczywista wartość to 51,3 GFlops .

Mój procesor temps uderzył 76C na wielowątkowy przebieg! Jeśli je uruchomisz, upewnij się, że dławienie procesora nie wpływa na wyniki.


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

Wątki: 1

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

Teoretyczny szczyt SSE: 4 klapy * 3,2 GHz = 12,8 GFlops . Rzeczywista jest 12,3 GFlops .

Wątki: 8

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

Teoretyczny szczyt SSE: 4 klapy * 8 rdzeni * 3,2 GHz = 102,4 GFlops . Rzeczywista wartość to 97,9 GFlops .


13
Twoje wyniki są bardzo imponujące. Skompilowałem Twój kod za pomocą g ++ na moim starszym systemie, ale nie 1.814s, 5.292 Gflops, sum=0.448883osiągam prawie tak dobrych wyników: 100 000 iteracji, poza szczytowym 10,68 Gflops lub po prostu 2,0 2,0 na cykl. Wydaje się add/ mulnie są wykonywane równolegle. Kiedy zmieniam kod i zawsze dodam / pomnożę z tym samym rejestrem, powiedzmy rC, nagle osiąga prawie szczyt: 0.953s, 10.068 Gflops, sum=0lub 3,8 flops / cykl. Bardzo dziwny.
user1059432,

11
Tak, ponieważ nie używam zestawu wbudowanego, wydajność jest rzeczywiście bardzo wrażliwa na kompilator. Kod, który tu mam, został dostrojony do VC2010. A jeśli dobrze pamiętam, kompilator Intel daje równie dobre wyniki. Jak zauważyłeś, być może będziesz musiał trochę go ulepszyć, aby dobrze się skompilował.
Mysticial

8
Mogę potwierdzić twoje wyniki w systemie Windows 7 przy użyciu cl /O2(64-bit z Windows SDK), a nawet mój przykład działa tam blisko szczytu dla operacji skalarnych (1,9 flops / cykl). Pętla kompilatora rozwija się i zmienia kolejność, ale to może nie być powód, aby przyjrzeć się temu trochę bardziej. Ograniczanie nie stanowi problemu Jestem miły dla mojego procesora i utrzymuję iteracje na 100k. :)
user1059432,


2
@haylem Albo się topi, albo startuje. Nigdy jedno i drugie. Jeśli będzie wystarczająco dużo chłodzenia, dostanie się czas antenowy. W przeciwnym razie po prostu się topi. :)
Mysticial

33

W architekturze Intel jest punkt, o którym ludzie często zapominają, że porty wysyłania są wspólne dla Int i FP / SIMD. Oznacza to, że otrzymasz tylko pewną liczbę serii FP / SIMD, zanim logika pętli utworzy bąbelki w strumieniu zmiennoprzecinkowym. Mystical uzyskał więcej klap ze swojego kodu, ponieważ używał dłuższych kroków w rozwiniętej pętli.

Jeśli spojrzysz na architekturę Nehalem / Sandy Bridge tutaj http://www.realworldtech.com/page.cfm?ArticleID=RWT091810191937&p=6 , jest całkiem jasne, co się dzieje.

Z drugiej strony powinno być łatwiej osiągnąć maksymalną wydajność na AMD (Bulldozer), ponieważ rury INT i FP / SIMD mają osobne porty danych z własnym harmonogramem.

Jest to tylko teoretyczne, ponieważ nie mam żadnego z tych procesorów do przetestowania.


2
Istnieją tylko trzy instrukcje pętli: napowietrznych inc, cmporaz jl. Wszystkie z nich mogą przejść do portu nr 5 i nie zakłócać ani wektoryzacji, faddani fmul. Wolałbym raczej podejrzewać, że dekoder (czasami) przeszkadza. Musi utrzymać od dwóch do trzech instrukcji na cykl. Nie pamiętam dokładnych ograniczeń, ale długość instrukcji, prefiksy i wyrównanie wchodzą w grę.
Mackie Messer,

cmpi na jlpewno udaj się do portu 5, incnie tak pewny, ponieważ zawsze jest w grupie z 2 innymi. Ale masz rację, trudno powiedzieć, gdzie jest wąskie gardło, a dekodery również mogą być jego częścią.
Patrick Schlüter,

3
Grałem trochę z podstawową pętlą: kolejność instrukcji ma znaczenie. Niektóre aranżacje zajmują 13 cykli zamiast minimum 5 cykli. Czas spojrzeć na liczniki zdarzeń wydajności, jak sądzę ...
Mackie Messer,

16

Oddziały zdecydowanie mogą powstrzymać Cię od utrzymania maksymalnej wydajności teoretycznej. Czy widzisz różnicę, jeśli ręcznie rozwijasz pętlę? Na przykład, jeśli dodasz 5 lub 10 razy więcej operacji na iterację w pętli:

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;
   }

4
Mogę się mylić, ale wierzę, że g ++ z -O2 spróbuje automatycznie rozwinąć pętlę (myślę, że używa urządzenia Duffa).
Weaver,

6
Tak, dzięki, rzeczywiście, poprawia się nieco. Teraz dostaję około 4,1-4,3 Gflops lub 1,55 flops na cykl. I nie, w tym przykładzie -O2 nie rozwinął się w pętli.
user1059432

1
Wierzę, że Weaver ma rację co do rozwijania pętli. Więc ręczne rozwijanie prawdopodobnie nie jest konieczne
Jim Mcnamara,

5
Patrz dane wyjściowe zestawu powyżej, nie ma żadnych oznak rozwijania pętli.
user1059432,

14
Automatyczne rozwijanie również poprawia się do średnio 4,2 Gflops, ale wymaga -funroll-loopsopcji, która nawet nie jest uwzględniona -O3. Zobaczyć g++ -c -Q -O2 --help=optimizers | grep unroll.
user1059432

7

Używam Intels icc wersja 11.1 na 2,4 GHz Intel Core 2 Duo

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 

To bardzo blisko idealnych 9,6 Gflops.

EDYTOWAĆ:

Ups, patrząc na kod asemblera, wydaje się, że icc nie tylko wektoryzuje mnożenie, ale także wyciąga dodatki z pętli. Wymuszając surowszą semantykę fp, kod nie jest już wektoryzowany:

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

EDYCJA 2:

Zgodnie z prośbą:

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

Wewnętrzna pętla kodu clanga wygląda następująco:

        .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

EDYCJA 3:

Na koniec dwie sugestie: Po pierwsze, jeśli podoba ci się ten typ testu porównawczego, rozważ użycie rdtscinstrukcji zamiast gettimeofday(2). Jest o wiele bardziej dokładny i zapewnia czas w cyklach, co zwykle jest tym, czym jesteś zainteresowany. W przypadku gcc i znajomych możesz to zdefiniować w następujący sposób:

#include <stdint.h>

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

Po drugie, powinieneś uruchomić program testowy kilka razy i korzystać wyłącznie z najlepszej wydajności . We współczesnych systemach operacyjnych wiele rzeczy dzieje się równolegle, procesor może być w trybie oszczędzania energii niskiej częstotliwości itp. Wielokrotne uruchamianie programu daje wynik zbliżony do idealnego przypadku.


2
i jak wygląda demontaż?
Bahbar

1
Interesujące, to mniej niż 1 flop / cykl. Czy kompilator miesza je z nimi addsd, mulsdczy też są w grupach jak w danych wyjściowych mojego zestawu? Dostaję również około 1 flopa / cykl, gdy kompilator je miesza (bez czego otrzymuję -march=native). Jak zmienia się wydajność, jeśli dodasz wiersz add=mul;na początku funkcji addmul(...)?
user1059432,

1
@ user1059432: Instrukcje addsdi subsdsą rzeczywiście mieszane w dokładnej wersji. Próbowałem też clang 3.0, nie miesza instrukcji i zbliża się do 2 flopów / cyklu w duecie Core 2. Kiedy uruchamiam ten sam kod na rdzeniu mojego laptopa i5, mieszanie kodu nie ma znaczenia. W obu przypadkach otrzymuję około 3 flopów / cykl.
Mackie Messer,

1
@ user1059432: Ostatecznie chodzi o to, aby oszukać kompilator w celu wygenerowania „znaczącego” kodu dla syntetycznego testu porównawczego. To trudniejsze niż się wydaje na pierwszy rzut oka. (tj. icc przechytrza twój benchmark) Jeśli wszystko, czego chcesz, to uruchomić kod przy 4 flopach / cyklu, najłatwiej jest napisać małą pętlę asemblera. Znacznie mniej kłopotów. :-)
Mackie Messer,

1
Ok, więc zbliżasz się do 2 flopów / cyklu z kodem asemblera podobnym do tego, co cytowałem powyżej? Jak blisko 2? Dostaję tylko 1,4, więc to znaczące. Nie sądzę, żebyś dostał 3 klapy / cykl na swoim laptopie, chyba że kompilator wykona optymalizacje tak jak iccwcześniej, czy możesz dwukrotnie sprawdzić zespół?
user1059432,
Korzystając z naszej strony potwierdzasz, że przeczytałeś(-aś) i rozumiesz nasze zasady używania plików cookie i zasady ochrony prywatności.
Licensed under cc by-sa 3.0 with attribution required.