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

21
两个数组的点积
for(int i=0; i<n; i++) {
    sum += x[i]*y[i];
}

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

使用why-vectorizing-the-loop-does-not-have-performance-improvement上的代码,我在我的系统上获得了9.3GB/s的带宽。然而,当我尝试使用点积计算带宽时,使用单个线程的速率超过两倍,使用多个线程的速率超过三倍(我的系统有四个核心/八个超线程)。这对我来说毫无意义,因为内存绑定操作不应受益于多个线程。以下是下面代码的输出:

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

我刚刚按照Jonathan Dursi的建议下载、编译和运行了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
你有多少个物理CPU?你的内存通道是如何填充的? - David Schwartz
2
希望你能在某个时候将整个项目写出来。这里,问题只是一个线程没有完全饱和内存子系统——这并不一定意味着单线程性能仍有提升空间。通过预取和同时有多个内存请求,可能会有操作数已经准备好进行点积运算,但这些操作数不是第一个线程所期望的。你可能已经看过了这个参考文献——它现在有点老了,但很全面。 - Jonathan Dursi
2
@JonathanDursi,我想我需要阅读《每个程序员都应该了解的内存知识》。我曾经试图多次阅读它,但它有114页... - Z boson
2
我将尝试将这些对话概括成一个答案... - Jonathan Dursi
3
我发现内存带宽更难以预测和测量。首先,读写带宽之间存在明显差异。在某些系统上,由于使用了不同的通道,你可以同时获得完整的读写带宽。其次,是否进行流式传输也很重要。如果你不进行流式写入,则它们也会产生读取成本。与缓存和其他内部CPU瓶颈不同,增加对带宽的需求并不会导致性能图表中的“悬崖”。相反,你会看到平稳的递减回报。 - Mysticial
显示剩余10条评论
2个回答

13

这里有几个问题,可以从以下几个方面进行解释:

  • 你必须努力工作才能从内存子系统中获得最后一点性能; 和
  • 不同的基准测试衡量不同的东西。

第一个问题有助于解释为什么需要多个线程来饱和可用内存带宽。 内存系统中有很多并发性,并且利用它通常需要在CPU代码中进行一些并发性。 多个执行线程有帮助的一个重要原因是隐藏延迟 - 当一个线程等待数据到达时,另一个线程可能能够利用刚刚变得可用的其他数据。

硬件单线程帮助你很多 - 因为内存访问是如此可预测,硬件可以提前获取数据,使你在单线程情况下获得一些延迟隐藏的优势;但是预取有其限制。 预取器不会自行跨越页面边界,例如。 其中大部分的规范参考是由Ulrich Drepper撰写的《每个程序员都应该知道内存》,现在已经足够老,一些漏洞正在显露出来(Intel Sandy Bridge处理器的Hot Chips概述在这里 - 特别注意内存管理硬件与CPU的更紧密集成)。

至于与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的情况下,所有操作都是写入)。虽然单个写操作可能比读取慢一点,但更大的问题是,由于预取写操作的等效操作不可行,因此很难使内存子系统饱和。交错读写操作可以帮助,但您的点积示例实际上只涉及读取操作,这将是使内存带宽达到最佳水平的最好情况。

此外,STREAM基准测试是(有意)完全可移植编写的,仅使用一些编译器指示向量化,因此击败STREAM基准测试并不一定意味着出现了警告标志,特别是在进行两个流式读取操作时。


1
我想现在我有自己的基准了:点积 :-) 我必须承认,我很惊讶多线程在这种情况下有所帮助。我过去观察到过这种情况,但由于与我对CPU工作方式的天真看法相冲突,我并不相信结果。我假设CPU正在等待数据,另一个CPU不会有所帮助。但是,如果一个CPU正在等待特定的一组数据(而不是任何一组),而另一个CPU正在等待另一组特定的数据,那么我可以理解多线程如何有所帮助。 - Z boson
我编写了自己的内存带宽基准测试代码 https://github.com/zboson/bandwidth。我将一些结果发布到了我的问题答案中。 - Z boson

4

我编写了自己的内存基准测试代码 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
  1. write: 向数组中写入一个常量(3.14159),此操作类似于memset
  2. copyscaleaddtriad的定义与STREAM相同。
  3. mula(i) = b(i) * c(i)
  4. vsumsum += a(i)
  5. vmulsum *= a(i)
  6. vmul_sumsum += a(i)*b(i) // 点积操作

我的结果与STREAM一致。对于vsum,我获得了最高的带宽。目前vmul方法不能正常工作(一旦值为零,便会提前结束)。使用内部函数和展开循环可以稍微提高一点效果(大约10%),我稍后将添加这些内容。


通过绑定线程(export OMP_PROC_BIND=true)并将线程数设置为物理核心数(即不使用超线程),我得到了更好的结果。例如,vsum可达近39 GB/s(从36 GB/s)。 - Z boson

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接