从两个arrays的点积测量内存带宽

两个数组的点积

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

不重用数据,所以它应该是一个内存绑定操作。 因此,我应该能够从点积测量存储带宽。

使用为什么向量化循环没有提高性能 的代码,我的系统带宽为9.3 GB / s 。 但是,当我尝试使用点积来计算带宽时,我使用multithreading(我的系统有四个内核/八个超线程)来获得单线程速率的两倍以及超过三倍的速率。 这对我来说没有意义,因为内存绑定操作不应该从多个线程中受益。 以下是以下代码的输出:

 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 %fs, %.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 %fs, %.2f GB/s, %.2f GFLOPS\n", 1.0*size/1024/1024/1024, sum, dtime, rate,flops); } 

我只是按照Jonathan Dursi的build议下载,编译并运行了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 

这里有几件事情可以归结为:

  • 你必须努力工作才能使内存子系统的性能达到最后。 和
  • 不同的基准测量不同的东西。

第一个有助于解释为什么需要多个线程来使可用内存带宽饱和。 内存系统中存在很多并发性,利用这一点通常需要在你的CPU代码中进行一些并发处理。 multithreading执行帮助的一个主要原因是延迟隐藏 – 当一个线程停顿等待数据到达时,另一个线程可能会利用一些刚刚可用的其他数据。

在这种情况下,硬件在单线程上可以帮到你很多 – 因为内存访问是非常可预测的,硬件可以在需要的时候提前预取数据,即使只有一个线程,也可以提供一些延迟隐藏的优点。 但预取可以做什么是有限制的。 例如,预取器不会跨越页面边界。 大部分的规范参考是每个程序员都应该知道关于Ulrich Drepper的内存 ,现在已经足够老旧,一些差距已经开始显现(英特尔Sandy Bridge处理器的热芯片概述在这里 – 特别注意更紧密的集成与CPU的内存pipe理硬件)。

至于与memset, mbw或STREAM进行比较的问题,跨基准进行比较总是令人头痛,甚至是声称测量同一事物的基准。 特别是,“内存带宽”不是一个单一的数字 – 性能会因操作而有所不同。 mbw和Stream都执行复制操作的一些版本,其中STREAMs操作在这里拼写出来(直接从网页中获取,所有操作数都是双精度浮点):

 ------------------------------------------------------------------ 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的情况下,所有内容都是写操作)。 虽然单个写入可能比读取慢一点,但是更大的问题是,用写入来饱和内存子系统要困难得多,因为当然,你不能等同于预取写入。 交织读取和写入有助于实现,但是基本上所有读取的dot-product示例将是关于在内存带宽上钉住针的最佳可能情况。

另外,STREAM基准testing(有意)是完全可移植的,只有一些编译器的编译指示可以提供向量化,所以殴打STREAM基准testing不一定是一个警告信号,特别是当你在做两个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 

这里是一个线程的电stream结果:

 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 
  1. 写:写一个常量(3.14159)到一个数组。 这应该像memset
  2. 复制,缩放,添加和三元组的定义与STREAM中的相同
  3. mul: a(i) = b(i) * c(i)
  4. vsum: sum += a(i)
  5. vmul: sum *= a(i)
  6. vmul_sum: sum += a(i)*b(i) //点积

我的结果与STREAM一致。 我获得了vsum的最高带宽。 vmul方法当前不起作用(一旦值为零,它会提前结束)。 我可以使用内在函数和展开稍后将添加的循环来获得略好的结果(大约10%)。