Как мне достичь теоретического максимума 4 FLOP за цикл?

509

Как теоретическая пиковая производительность четырех операций с плавающей запятой (двойная точность) за цикл будет достигнута на современном процессоре Intel x86-64?

Насколько я понимаю, для большинства современных процессоров Intel требуется три цикла для SSE add и пять циклов для mul см., например, Таблицы инструкций Agner Fog '). Из-за конвейерной обработки можно получить пропускную способность одного add за цикл, если алгоритм имеет как минимум три независимых суммирования. Так как это верно для упакованных addpd, а также для скалярных версий addsd, а регистры SSE могут содержать два double, пропускная способность может достигать двух флопов за цикл.

Кроме того, кажется (хотя я не видел никакой соответствующей документации по этому поводу) add и mul могут выполняться параллельно, давая теоретическую максимальную пропускную способность четырех флопов за цикл.

Однако, я не смог воспроизвести эту производительность с помощью простой программы C/С++. Моя лучшая попытка привела к 2,7 флопам/циклу. Если кто-то может внести вклад в простую программу 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 GHz.

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 флопа за цикл. Есть ли простой пример, который достигает четырех флопов за цикл?

Хорошая небольшая программа Mystical; вот мои результаты (бегите всего на несколько секунд):

  • gcc -O2 -march=nocona: 5.6 Gflops из 10.66 Gflops (2.1 flops/cycle)
  • cl /O2, openmp удален: 10.1 Gflops из 10.66 Gflops (3.8 flops/cycle)

Все кажется немного сложным, но мои выводы до сих пор:

  • gcc -O2 изменяет порядок независимых операций с плавающей запятой с цель чередования addpd и mulpd, если это возможно. То же самое относится к gcc-4.6.2 -O2 -march=core2.

  • gcc -O2 -march=nocona, похоже, сохраняет порядок операций с плавающей запятой, как определено в источник С++.

  • cl /O2, 64-разрядный компилятор из SDK для Windows 7 делает цикл-разворачивание автоматически и, кажется, пытается организовать операции так что группы из трех addpd чередуются с тремя mulpd (ну, по крайней мере, в моей системе и для моей простой программы).

  • Мой Core i5 750 (архитектура Наэлема) не нравится чередующийся add и mul и кажется неспособным для параллельной работы обеих операций. Однако, если сгруппировано в 3, оно внезапно работает как магия.

  • Другие архитектуры (возможно, Sandy Bridge и другие) появляются иметь возможность выполнять добавление /mul параллельно без проблем если они чередуются в коде сборки.

  • Хотя трудно признать, но в моей системе cl /O2 намного лучше работает на низкоуровневых операциях оптимизации для моей системы и достигает почти максимальной производительности для небольшого примера С++ выше. Я измерял между 1.85-2.01 flops/cycle (использовали часы() в Windows, что не так точно. Думаю, вам нужно использовать лучший таймер - спасибо Mackie Messer).

  • Лучшее, что мне удалось с помощью gcc, - это ручное разворачивание и упорядочение дополнения и умножения в группах по три. С g++ -O2 -march=nocona addmul_unroll.cpp Я получаю в лучшем случае 0.207s, 4.825 Gflops, что соответствует 1.8 flops/cycle которым я доволен сейчас.

В коде С++ я заменил цикл 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
...
  • 163
    мммммм. Низкоуровневая оптимизация процессора в комплекте с метриками и родным языком ассемблера ... Меня волнуют самые странные вещи.
  • 12
    Полагаться на время настенных часов, вероятно, является частью причины. Предполагая, что вы работаете с такой операционной системой, как Linux, вы можете в любое время отменить процесс. Такого рода внешние события могут повлиять на ваши показатели производительности.
Показать ещё 7 комментариев
Теги:
optimization
architecture
assembly

4 ответа

395
Лучший ответ

Я делал эту точную задачу раньше. Но это было главным образом для измерения энергопотребления и температуры процессора. Следующий код (который довольно длинный) приближается к оптимальному на моем Core i7 2600K.

Ключевым моментом здесь является массивное количество ручных циклов, а также чередование умножений и добавление...

Полный проект можно найти на моем GitHub: https://github.com/Mysticial/Flops

Внимание:

Если вы решили скомпилировать и запустить это, обратите внимание на температуру процессора.
Убедитесь, что вы не перегреваете его. И убедитесь, что дросселирование ЦП не влияет на ваши результаты!

Кроме того, я не несу ответственности за любой ущерб, который может возникнуть в результате выполнения этого кода.

Примечания:

  • Этот код оптимизирован для x64. x86 не имеет достаточного количества регистров для компиляции.
  • Этот код был хорошо протестирован на Visual Studio 2010/2012 и GCC 4.6.
    ICC 11 (Intel Compiler 11) неожиданно имеет проблемы с его компиляцией.
  • Это для процессоров pre-FMA. Чтобы достичь пиковых FLOPS на процессорах Intel Haswell и AMD Bulldozer (и позже), потребуются инструкции FMA (Fused Multiply Add). Это выходит за рамки этого теста.

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

Вывод (1 поток, итерации 10000000) - Скомпилирован с Visual Studio 2010 SP1 - x64 Release:

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

Аппарат Core i7 2600K @4.4 ГГц. Теоретический пик SSE составляет 4 флопа * 4.4 ГГц = 17.6 GFlops. Этот код достигает 17.3 GFlops - неплохо.

Вывод (8 потоков, итераций 10000000) - Скомпилирован с Visual Studio 2010 SP1 - x64 Release:

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

Теоретический пик SSE составляет 4 флопа * 4 ядра * 4.4 ГГц = 70.4 GFlops. Фактический 65,5 GFlops.


Давайте сделаем еще один шаг. 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 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");
}

Вывод (1 поток, итерации 10000000) - Скомпилирован с Visual Studio 2010 SP1 - x64 Release:

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

Теоретический пик AVX - 8 флопов * 4.4 ГГц = 35.2 GFlops. Фактически 33.4 GFlops.

Вывод (8 потоков, итераций 10000000) - Скомпилирован с Visual Studio 2010 SP1 - x64 Release:

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

Теоретический пик AVX - 8 флопов * 4 ядра * 4.4 ГГц = 140.8 GFlops. Фактический 138.2 GFlops.


Теперь для некоторых объяснений:

Критическая часть производительности - это, по-видимому, 48 инструкций внутри внутреннего цикла. Вы заметите, что он разбит на 4 блока по 12 инструкций каждый. Каждый из этих 12 блоков инструкций полностью независим друг от друга - и принимает в среднем 6 циклов для выполнения.

Таким образом, существует 12 инструкций и 6 циклов между выпуском. Задержка умножения составляет 5 тактов, поэтому этого достаточно, чтобы избежать латентных ларьков.

Шаг нормализации необходим для того, чтобы данные перегружались/переполнялись. Это необходимо, поскольку код do-nothing будет медленно увеличивать/уменьшать величину данных.

Таким образом, на самом деле можно сделать лучше, чем это, если вы просто используете все нули и избавляетесь от шага нормализации. Однако, поскольку я написал контрольный показатель для измерения энергопотребления и температуры, , я должен был убедиться, что флопы были на "реальных" данных, а не нули, поскольку исполнительные устройства могут очень хорошо иметь специальный случай- обработки для нулей, которые потребляют меньше энергии и производят меньше тепла.


Дополнительные результаты:

  • Intel Core i7 920 @3.5 ГГц
  • Windows 7 Ultimate x64
  • Visual Studio 2010 SP1 - выпуск x64

Темы: 1

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

Теоретический пик SSE: 4 флопа * 3.5 ГГц = 14.0 GFlops. Фактически 13.3 GFlops.

Темы: 8

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

Теоретический пик SSE: 4 флопа * 4 ядра * 3.5 ГГц = 56.0 GFlops. Фактически 51.3 GFlops.

Мое процессорное время попало на 76C при многопоточном запуске! Если вы их используете, убедитесь, что на результаты не влияет дросселирование ЦП.


  • 2 x Intel Xeon X5482 Harpertown @3.2 ГГц
  • Ubuntu Linux 10 x64
  • GCC 4.5.2 x64 - (-O2 -msse3 -fopenmp)

Темы: 1

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

Теоретический пик SSE: 4 флопа * 3.2 ГГц = 12.8 GFlops. Фактически 12.3 GFlops.

Темы: 8

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

Теоретический пик SSE: 4 флопа * 8 ядер * 3.2 ГГц = 102.4 GFlops. Фактически 97.9 GFlops.

  • 9
    Ваши результаты очень впечатляют. Я скомпилировал ваш код с помощью g ++ на моей старой системе, но не получил столь же хороших результатов: 100 1.814s, 5.292 Gflops, sum=0.448883 итераций, 1.814s, 5.292 Gflops, sum=0.448883 из пиковых 10,68 Gflops или чуть меньше 2,0 флопов за цикл. Кажется, add / mul не выполняется параллельно. Когда я изменяю ваш код и всегда добавляю / rC один и тот же регистр, скажем, rC , он внезапно достигает почти пика: 0.953s, 10.068 Gflops, sum=0 или 3,8 флопс / цикл. Очень странно.
  • 9
    Да, поскольку я не использую встроенную сборку, производительность действительно очень чувствительна к компилятору. Код, который я здесь, был настроен для VC2010. И если я правильно помню, Intel Compiler дает такие же хорошие результаты. Как вы заметили, вам, возможно, придется немного его настроить, чтобы он хорошо компилировался.
Показать ещё 27 комментариев
26

В архитектуре Intel, которую люди часто забывают, есть точка, порты отправки разделяются между Int и FP/SIMD. Это означает, что вы получите только определенное количество пакетов FP/SIMD до того, как логика цикла создаст пузырьки в потоке с плавающей точкой. Мистик получил больше провалов из своего кода, потому что он использовал более длительные шаги в своей развернутой петле.

Если вы посмотрите на архитектуру Nehalem/Sandy Bridge здесь http://www.realworldtech.com/page.cfm?ArticleID=RWT091810191937&p=6 это совершенно ясно, что происходит.

В отличие от этого, должно быть проще достичь максимальной производительности на AMD (Bulldozer), поскольку каналы INT и FP/SIMD имеют отдельные порты проблем с собственным планировщиком.

Это теоретически, поскольку я не тестирую ни один из этих процессоров.

  • 2
    Существует только три инструкции для заголовка цикла: inc , cmp и jl . Все они могут идти в порт № 5 и не мешать векторизованному fadd или fmul . Я бы предпочел, чтобы декодер (иногда) мешал. Требуется выдержать от двух до трех инструкций за цикл. Я не помню точных ограничений, но в игру вступают длина инструкции, префиксы и выравнивание.
  • 0
    cmp и jl конечно, идут в порт 5, в том inc не так уверенно, так как он всегда в группе с двумя другими. Но вы правы, трудно сказать, где находится узкое место, и декодеры также могут быть частью этого.
Показать ещё 1 комментарий
14

Филиалы, безусловно, могут препятствовать поддержанию максимальной теоретической производительности. Вы видите разницу, если вы вручную выполняете цикл-разворот? Например, если вы поместили в 5 или 10 раз больше опций для каждой итерации цикла:

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
    Я могу ошибаться, но я верю, что g ++ с -O2 попытается автоматически размотать цикл (я думаю, что он использует устройство Даффа).
  • 6
    Да, спасибо, это действительно несколько улучшается. Теперь я получаю около 4,1-4,3 Гфлопс или 1,55 флопс за цикл. И нет, в этом примере -O2 не зациклился.
Показать ещё 5 комментариев
6

Используя Intels icc Version 11.1 на Intel Core 2 Duo с тактовой частотой 2,4 ГГц, я получаю

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 

Это очень близко к идеалу 9.6 Gflops.

EDIT:

Упс, глядя на код сборки, кажется, что icc не только векторизовал умножение, но и вытащил дополнения из цикла. При форсировании более строгой семантики fp код больше не будет векторизован:

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

EDIT2:

В соответствии с запросом:

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

Внутренний цикл кода clang выглядит следующим образом:

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

Наконец, два предложения: во-первых, если вам нравится этот тип бенчмаркинга, подумайте об использовании команды rdtsc istead gettimeofday(2). Он намного точнее и обеспечивает время в циклах, что обычно является тем, что вас интересует. Для gcc и друзей вы можете определить его следующим образом:

#include <stdint.h>

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

Во-вторых, вы должны запускать свою тестовую программу несколько раз и использовать только лучшую производительность. В современных операционных системах многое происходит параллельно, процессор может находиться в режиме энергосбережения с низкой частотой и т.д. Запуск программы несколько раз дает вам результат, который ближе к идеальному случаю.

  • 2
    и как выглядит разборка?
  • 1
    Интересно, что это меньше, чем 1 флоп / цикл. Компилятор смешивает addsd и mulsd или они в группах, как в моем выводе сборки? Я также получаю примерно 1 флоп / цикл, когда компилятор смешивает их (что я получаю без -march=native ). Как меняется производительность, если вы добавляете строку add=mul; в начале функции addmul(...) ?
Показать ещё 6 комментариев

Ещё вопросы

Сообщество Overcoder
Наверх
Меню