【问题标题】:Measuring memory bandwidth from the dot product of two arrays从两个阵列的点积测量内存带宽
【发布时间】:2014-09-30 12:44:32
【问题描述】:

两个数组的点积

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

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

使用代码 why-vectorizing-the-loop-does-not-have-performance-improvement 我的系统带宽为 9.3 GB/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

【问题讨论】:

  • 您有多少物理 CPU?你的记忆通道是如何填充的?
  • 我希望你在某个时候写下整个项目。在这里,问题只是一个线程并没有完全使内存子系统饱和——这并不一定等于说单线程性能仍有提升空间。通过预取,并且一次有多个内存请求在运行,可能会有一些操作数准备好进行点积,但不是第一个线程所期望的。您可能已经看过 this ref - 它现在有点旧但很全面。
  • @JonathanDursi,我想我需要阅读“每个程序员应该了解的关于内存的知识”。我过去曾尝试过几次,但它有 114 页...
  • 我将尝试将其中的一些对话提炼成答案...
  • 我还发现内存带宽更难预测和测量。首先,您在读取和写入带宽之间有明显的区别。在某些系统上,您可以获得两者的全部带宽,因为它们使用不同的通道。那么你是否流式传输也很重要。如果您不流式写入,它们也会产生读取成本。与缓存和其他内部 CPU 瓶颈不同,扩大对带宽的需求不会导致性能图中的“悬崖”。相反,您会看到平稳的递减收益。

标签: c++ memory openmp bandwidth avx


【解决方案1】:

我制作了自己的内存基准代码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. 复制、缩放、添加和三元组的定义与 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%)。

【讨论】:

  • 通过绑定线程 (export OMP_PROC_BIND=true) 并将线程数设置为物理内核数(即不使用超线程),例如vsum 接近 39 GB/s(从 36 GB/s)。
【解决方案2】:

这里发生了一些事情,归结为:

  • 您必须付出相当大的努力才能从内存子系统中获得最后一点性能;和
  • 不同的基准衡量不同的事物。

第一个有助于解释为什么需要多个线程来使可用内存带宽饱和。内存系统中有很多并发性,利用这一点通常需要 CPU 代码中的一些并发性。多线程执行帮助的一个重要原因是latency hiding - 当一个线程停止等待数据到达时,另一个线程可能能够利用一些其他刚刚变得可用的数据。

在这种情况下,硬件可以在单个线程上为您提供很多帮助 - 因为内存访问是如此可预测,硬件可以在您需要时提前预取数据,从而为您提供一些隐藏延迟的优势,即使只有一个线;但是预取可以做的事情是有限制的。例如,预取器不会自行跨越页面边界。其中大部分内容的规范参考是What Every Programmer Should Know About Memory by Ulrich Drepper,它现在已经足够老了,一些差距开始显现(英特尔对您的 Sandy Bridge 处理器的热芯片概述是here - 特别注意内存管理硬件的更紧密集成与 CPU)。

关于与 memset、mbwSTREAM 进行比较的问题,跨基准比较总是会让人头疼,即使是声称测量相同事物的基准。特别是,“内存带宽”不是一个单一的数字 - 性能会因操作而异。 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 基准测试不一定是一个警告信号,尤其是当您正在做的是两次流式读取时.

【讨论】:

  • 我想我现在有了自己的基准:点积 :-) 我必须承认我很惊讶多线程在这种情况下的帮助。我过去曾多次观察到这一点,但不相信结果,因为它与我对 CPU 工作原理的幼稚看法相冲突。我假设 CPU 正在等待数据,而另一个 CPU 也无济于事。但是,如果一个 CPU 正在等待一组特定的数据(而不是任何一组),而另一个 CPU 正在等待另一组特定的数据,那么我可以理解多线程如何提供帮助。
  • 我自己编写了内存带宽基准测试代码github.com/zboson/bandwidth。我发布了一些结果来回答我的问题。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 2021-11-20
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2019-09-22
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多