Измерение пропускной способности памяти по точечному произведению двух массивов
Точечное произведение двух массивов
for(int i=0; i<n; i++) {
sum += x[i]*y[i];
}
не использует данные повторно, поэтому это должна быть операция, связанная с памятью. Поэтому я должен быть в состоянии измерить пропускную способность памяти от точечного продукта.
Используя код, описывающий причину повышения эффективности векторизации цикла,я получаю пропускную способность 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
2 ответа
Здесь происходит несколько вещей, которые сводятся к:
- Вы должны работать довольно усердно, чтобы получить все до последней части производительности подсистемы памяти; а также
- Различные тесты измеряют разные вещи.
Первый помогает объяснить, почему вам нужно несколько потоков для насыщения доступной пропускной способности памяти. В системе памяти много параллелизма, и использование этого преимущества часто требует некоторого параллелизма в коде вашего процессора. Одной из основных причин того, что несколько потоков выполнения помогают, является скрытие задержек - в то время как один поток останавливается в ожидании поступления данных, другой поток может использовать некоторые другие данные, которые только что стали доступны.
Аппаратное обеспечение очень помогает вам в одном потоке в этом случае - поскольку доступ к памяти настолько предсказуем, аппаратное обеспечение может предварительно выбирать данные заранее, когда вам это нужно, что дает вам некоторое преимущество скрытия задержки даже в одном потоке; но есть пределы тому, что может делать предварительная выборка. Например, средство предварительной выборки не возьмет на себя обязательство пересекать границы страницы. Каноническим справочным материалом для большинства из этого является то, что каждый программист должен знать о памяти Ульриха Дреппера, который уже достаточно стар, чтобы начать показывать некоторые пробелы (краткий обзор Intel о процессорах Sandy Bridge от Intel приведен здесь - обратите внимание, в частности, на более тесную интеграцию). аппаратного обеспечения управления памятью с процессором).
Что касается вопроса о сравнении с 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 не обязательно является предупреждающим знаком, особенно когда вы выполняете два потоковых чтения.
Я сделал свой собственный код теста памяти 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
, - copy, scale, add и triad определены так же, как в STREAM
- мул:
a(i) = b(i) * c(i)
- ВСУМ:
sum += a(i)
- vmul:
sum *= a(i)
- vmul_sum:
sum += a(i)*b(i)
// скалярное произведение
Мои результаты соответствуют STREAM. Я получаю самую высокую пропускную способность для vsum
, vmul
метод не работает в настоящее время (если значение равно нулю, оно заканчивается рано). Я могу получить немного лучшие результаты (примерно на 10%), используя встроенные функции и развернув цикл, который я добавлю позже.