Измерение полосы пропускания памяти из точечного продукта двух массивов

Точечное произведение двух массивов

for(int i=0; i<n; i++) {
    sum += x[i]*y[i];
}

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

Использование кода в why-vectorizing-the-loop-does-not-have-performance-improvement Я получаю пропускную способность 9,3 ГБ/с для моей системы. Однако, когда я пытаюсь вычислить пропускную способность с использованием точечного продукта, я получаю более чем вдвое большую скорость для одного потока и более трех раз с частотой, использующей несколько потоков (у моей системы четыре ядра/восемь гиперпотоков). Это не имеет для меня никакого смысла, поскольку операция с привязкой к памяти не должна извлекаться из нескольких потоков. Вот результат из приведенного ниже кода:

Xeon E5-1620, GCC 4.9.0, Linux kernel 3.13
dot 1 thread:      1.0 GB, sum 191054.81, time 4.98 s, 21.56 GB/s, 5.39 GFLOPS
dot_avx 1 thread   1.0 GB, sum 191043.33, time 5.16 s, 20.79 GB/s, 5.20 GFLOPS
dot_avx 2 threads: 1.0 GB, sum 191045.34, time 3.44 s, 31.24 GB/s, 7.81 GFLOPS
dot_avx 8 threads: 1.0 GB, sum 191043.34, time 3.26 s, 32.91 GB/s, 8.23 GFLOPS

Может кто-нибудь объяснить мне, почему я получаю в два раза больше полосы пропускания для одного потока и в три раза больше полосы пропускания, используя более одного потока?

Вот код, который я использовал:

//g++ -O3 -fopenmp -mavx -ffast-math dot.cpp
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <stdint.h>
#include <x86intrin.h>
#include <omp.h>

extern "C" inline float horizontal_add(__m256 a) {
    __m256 t1 = _mm256_hadd_ps(a,a);
    __m256 t2 = _mm256_hadd_ps(t1,t1);
    __m128 t3 = _mm256_extractf128_ps(t2,1);
    __m128 t4 = _mm_add_ss(_mm256_castps256_ps128(t2),t3);
    return _mm_cvtss_f32(t4);
}

extern "C" float dot_avx(float * __restrict x, float * __restrict y, const int n) {
    x = (float*)__builtin_assume_aligned (x, 32);
    y = (float*)__builtin_assume_aligned (y, 32);
    float sum = 0;
    #pragma omp parallel reduction(+:sum)
    {
        __m256 sum1 = _mm256_setzero_ps();
        __m256 sum2 = _mm256_setzero_ps();
        __m256 sum3 = _mm256_setzero_ps();
        __m256 sum4 = _mm256_setzero_ps();
        __m256 x8, y8;
        #pragma omp for
        for(int i=0; i<n; i+=32) {
            x8 = _mm256_loadu_ps(&x[i]);
            y8 = _mm256_loadu_ps(&y[i]);
            sum1 = _mm256_add_ps(_mm256_mul_ps(x8,y8),sum1);
            x8 = _mm256_loadu_ps(&x[i+8]);
            y8 = _mm256_loadu_ps(&y[i+8]);
            sum2 = _mm256_add_ps(_mm256_mul_ps(x8,y8),sum2);
            x8 = _mm256_loadu_ps(&x[i+16]);
            y8 = _mm256_loadu_ps(&y[i+16]);
            sum3 = _mm256_add_ps(_mm256_mul_ps(x8,y8),sum3);
            x8 = _mm256_loadu_ps(&x[i+24]);
            y8 = _mm256_loadu_ps(&y[i+24]);
            sum4 = _mm256_add_ps(_mm256_mul_ps(x8,y8),sum4);
        }
        sum += horizontal_add(_mm256_add_ps(_mm256_add_ps(sum1,sum2),_mm256_add_ps(sum3,sum4)));
    }
    return sum; 
}

extern "C" float dot(float * __restrict x, float * __restrict y, const int n) {
    x = (float*)__builtin_assume_aligned (x, 32);
    y = (float*)__builtin_assume_aligned (y, 32);
    float sum = 0;
    for(int i=0; i<n; i++) {
        sum += x[i]*y[i];
    }
    return sum;
}

int main(){
    uint64_t LEN = 1 << 27;
    float *x = (float*)_mm_malloc(sizeof(float)*LEN,64);
    float *y = (float*)_mm_malloc(sizeof(float)*LEN,64);
    for(uint64_t i=0; i<LEN; i++) { x[i] = 1.0*rand()/RAND_MAX - 0.5; y[i] = 1.0*rand()/RAND_MAX - 0.5;}

    uint64_t size = 2*sizeof(float)*LEN;

    volatile float sum = 0;
    double dtime, rate, flops;  
    int repeat = 100;

    dtime = omp_get_wtime();
    for(int i=0; i<repeat; i++) sum += dot(x,y,LEN);
    dtime = omp_get_wtime() - dtime;
    rate = 1.0*repeat*size/dtime*1E-9;
    flops = 2.0*repeat*LEN/dtime*1E-9;
    printf("%f GB, sum %f, time %f s, %.2f GB/s, %.2f GFLOPS\n", 1.0*size/1024/1024/1024, sum, dtime, rate,flops);

    sum = 0;
    dtime = omp_get_wtime();
    for(int i=0; i<repeat; i++) sum += dot_avx(x,y,LEN);
    dtime = omp_get_wtime() - dtime;
    rate = 1.0*repeat*size/dtime*1E-9;
    flops = 2.0*repeat*LEN/dtime*1E-9;

    printf("%f GB, sum %f, time %f s, %.2f GB/s, %.2f GFLOPS\n", 1.0*size/1024/1024/1024, sum, dtime, rate,flops);
}

Я только что загрузил, выполнил и запустил STREAM, как предложил Джонатан Дурси, и вот результаты:

Один поток

Function      Rate (MB/s)   Avg time     Min time     Max time
Copy:       14292.1657       0.0023       0.0022       0.0023
Scale:      14286.0807       0.0023       0.0022       0.0023
Add:        14724.3906       0.0033       0.0033       0.0033
Triad:      15224.3339       0.0032       0.0032       0.0032

Восемь потоков

Function      Rate (MB/s)   Avg time     Min time     Max time
Copy:       24501.2282       0.0014       0.0013       0.0021
Scale:      23121.0556       0.0014       0.0014       0.0015
Add:        25263.7209       0.0024       0.0019       0.0056
Triad:      25817.7215       0.0020       0.0019       0.0027

Ответ 1

Здесь происходит несколько вещей, которые сводятся к следующему:

  • Вам нужно работать достаточно сложно, чтобы получить последний бит производительности из подсистемы памяти; и
  • Различные тесты измеряют разные вещи.

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

В этом случае аппаратное обеспечение помогает вам в одном потоке - поскольку доступ к памяти настолько предсказуем, аппаратное обеспечение может предварительно отбирать данные раньше, когда вам это нужно, что дает вам некоторое преимущество скрытия, скрывающегося даже с одним нить; но есть ограничения на то, что может делать предварительная выборка. Например, префицер не возьмет на себя перекрестные границы страниц. Каноническая ссылка для большей части этого Что каждый программист должен знать о памяти Ульриха Дреппера, который уже достаточно стар, что некоторые пробелы начинают показывать (обзор Intel Hot Chips вашего процессора Sandy Bridge здесь - обратите внимание, в частности, на более тесную интеграцию аппаратного обеспечения управления памятью с процессором).

Что касается вопроса о сравнении с memset, mbw или STREAM, сравнение тесты всегда будут вызывать головные боли, даже контрольные показатели, которые утверждают, что они измеряют одно и то же. В частности, "пропускная способность памяти" не является одним числом - производительность зависит от операций. Оба mbw и Stream выполняют некоторую версию операции копирования, при этом здесь выполняются операции STREAM (взятые прямо с веб-страницы, все операнды - плавающие точки с двойной точностью):

------------------------------------------------------------------
name        kernel                  bytes/iter      FLOPS/iter
------------------------------------------------------------------
COPY:       a(i) = b(i)                 16              0
SCALE:      a(i) = q*b(i)               16              1
SUM:        a(i) = b(i) + c(i)          24              1
TRIAD:      a(i) = b(i) + q*c(i)        24              2
------------------------------------------------------------------

так что примерно 1/2-1/3 операций с памятью в этих случаях записывает (и все записывает в случае memset). Хотя отдельные записи могут быть немного медленнее, чем чтение, большая проблема заключается в том, что гораздо сложнее насытить подсистему памяти записью, потому что вы не можете сделать эквивалент предварительной выборки записи. Чередование операций чтения и записи помогает, но пример вашего точечного продукта, который по существу является всем прочитанным, будет о наилучшем возможном случае для привязки иглы к пропускной способности памяти.

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

Ответ 2

Я сделал свой собственный контрольный код памяти https://github.com/zboson/bandwidth

Вот текущие результаты для восьми потоков:

write:    0.5 GB, time 2.96e-01 s, 18.11 GB/s
copy:       1 GB, time 4.50e-01 s, 23.85 GB/s
scale:      1 GB, time 4.50e-01 s, 23.85 GB/s
add:      1.5 GB, time 6.59e-01 s, 24.45 GB/s
mul:      1.5 GB, time 6.56e-01 s, 24.57 GB/s
triad:    1.5 GB, time 6.61e-01 s, 24.37 GB/s
vsum:     0.5 GB, time 1.49e-01 s, 36.09 GB/s, sum -8.986818e+03
vmul:     0.5 GB, time 9.00e-05 s, 59635.10 GB/s, sum 0.000000e+00
vmul_sum:   1 GB, time 3.25e-01 s, 33.06 GB/s, sum 1.910421e+04

Вот результаты токов для 1 потока:

write:    0.5 GB, time 4.65e-01 s, 11.54 GB/s
copy:       1 GB, time 7.51e-01 s, 14.30 GB/s
scale:      1 GB, time 7.45e-01 s, 14.41 GB/s
add:      1.5 GB, time 1.02e+00 s, 15.80 GB/s
mul:      1.5 GB, time 1.07e+00 s, 15.08 GB/s
triad:    1.5 GB, time 1.02e+00 s, 15.76 GB/s
vsum:     0.5 GB, time 2.78e-01 s, 19.29 GB/s, sum -8.990941e+03
vmul:     0.5 GB, time 1.15e-05 s, 468719.08 GB/s, sum 0.000000e+00
vmul_sum:   1 GB, time 5.72e-01 s, 18.78 GB/s, sum 1.910549e+04
  • write: записывает константу (3.14159) в массив. Это должно быть как memset.
  • копия, масштаб, добавление и триада определяются так же, как в STREAM
  • mul: a(i) = b(i) * c(i)
  • vsum: sum += a(i)
  • vmul: sum *= a(i)
  • vmul_sum: sum += a(i)*b(i)//точечный продукт

Мои результаты согласуются с STREAM. Я получаю максимальную пропускную способность для vsum. Метод vmul не работает в настоящее время (после того, как значение равно нулю, оно заканчивается раньше). Я могу получить несколько лучшие результаты (примерно на 10%), используя intrinsics и разворачивая цикл, который я добавлю позже.