如何在此循环中获得始终如一的高吞吐量?

时间:2018-02-26 05:21:05

标签: c++ c performance caching assembly

在优化内循环的过程中,我遇到了奇怪的表现行为,我无法理解和纠正。

代码的精简版本如下;粗略地说,有一个巨大的数组被分成16个字块,我简单地将每个字块中单词的前导零的数量加起来。 (实际上,我使用Dan Luu中的popcnt代码,但在这里我选择了一个更简单的指令,具有类似的性能特征,简洁"。Dan Luu' s代码基于对this SO question的回答,虽然它具有诱人的类似奇怪结果,但似乎没有回答我的问题。)

// -*- compile-command: "gcc -O3 -march=native -Wall -Wextra -std=c99 -o clz-timing clz-timing.c" -*-
#include <stdint.h>
#include <time.h>
#include <stdlib.h>
#include <stdio.h>

#define ARRAY_LEN 16

// Return the sum of the leading zeros of each element of the ARRAY_LEN
// words starting at u.
static inline uint64_t clz_array(const uint64_t u[ARRAY_LEN]) {
    uint64_t c0 = 0;
    for (int i = 0; i < ARRAY_LEN; ++i) {
        uint64_t t0;
        __asm__ ("lzcnt %1, %0" : "=r"(t0) : "r"(u[i]));
        c0 += t0;
    }
    return c0;
}

// For each of the narrays blocks of ARRAY_LEN words starting at
// arrays, put the result of clz_array(arrays + i*ARRAY_LEN) in
// counts[i]. Return the time taken in milliseconds.
double clz_arrays(uint32_t *counts, const uint64_t *arrays, int narrays) {
    clock_t t = clock();
    for (int i = 0; i < narrays; ++i, arrays += ARRAY_LEN)
        counts[i] = clz_array(arrays);
    t = clock() - t;
    // Convert clock time to milliseconds
    return t * 1e3 / (double)CLOCKS_PER_SEC;
}

void print_stats(double t_ms, long n, double total_MiB) {
    double t_s = t_ms / 1e3, thru = (n/1e6) / t_s, band = total_MiB / t_s;
    printf("Time: %7.2f ms, %7.2f x 1e6 clz/s, %8.1f MiB/s\n", t_ms, thru, band);
}

int main(int argc, char *argv[]) {
    long n = 1 << 20;
    if (argc > 1)
        n = atol(argv[1]);

    long total_bytes = n * ARRAY_LEN * sizeof(uint64_t);
    uint64_t *buf = malloc(total_bytes);
    uint32_t *counts = malloc(sizeof(uint32_t) * n);
    double t_ms, total_MiB = total_bytes / (double)(1 << 20);

    printf("Total size: %.1f MiB\n", total_MiB);

    // Warm up
    t_ms = clz_arrays(counts, buf, n);
    //print_stats(t_ms, n, total_MiB);    // (1)
    // Run it
    t_ms = clz_arrays(counts, buf, n);    // (2)
    print_stats(t_ms, n, total_MiB);

    // Write something into buf
    for (long i = 0; i < n*ARRAY_LEN; ++i)
        buf[i] = i;

    // And again...
    (void) clz_arrays(counts, buf, n);    // (3)
    t_ms = clz_arrays(counts, buf, n);    // (4)
    print_stats(t_ms, n, total_MiB);

    free(counts);
    free(buf);
    return 0;
}

上面代码的一个特别之处在于我第一次和第二次调用clz_arrays函数它是未初始化的内存。

以下是典型运行的结果(编译器命令位于源的开头):

$ ./clz-timing 10000000
Total size: 1220.7 MiB
Time:   47.78 ms,  209.30 x 1e6 clz/s,  25548.9 MiB/s
Time:   77.41 ms,  129.19 x 1e6 clz/s,  15769.7 MiB/s

运行它的CPU是&#34;英特尔(R)Core(TM)i7-6700HQ CPU @ 2.60GHz&#34;它具有3.5GHz的涡轮增压。 lzcnt指令的延迟为3个周期,但其吞吐量为每秒1次操作(参见Agner Fog's Skylake instruction tables),因此,在3.5GHz的峰值时,使用8字节字(使用uint64_t)带宽应为3.5e9 cycles/sec x 8 bytes/cycle = 28.0 GiB/s,这与我们在第一个数字中看到的非常接近。即使在2.6GHz,我们也应该接近20.8 GiB / s。

我的主要问题是,

  

为什么call(4)的带宽总是远低于call(2)中获得的最佳值,我该怎样做才能保证在大多数情况下的最佳性能呢?

关于我到目前为止所发现的一些观点:

  • 根据perf的广泛分析,在不会出现的慢速情况下, LLC缓存加载错失导致问题似乎在快速的情况下。我的猜测是,我们执行计算的内存还没有被初始化,这意味着编译器没有义务将任何特定的值加载到内存中,但输出objdump -d清楚地表明每次都运行相同的代码。好像硬件预取器第一次激活而不是第二次激活,但在每种情况下,这个数组应该是世界上最容易可靠地预取的东西。
  • &#34;热身&#34; (1)和(3)处的呼叫始终与对应于呼叫(4)的第二打印带宽一样慢。
  • 我在台式机上获得了相同的结果(&#34;英特尔(R)Xeon(R)CPU E5-2620 v3 @ 2.40GHz&#34;)。
  • GCC 4.9,7.0和Clang 4.0之间的结果基本相同。所有测试都在Debian测试中运行,内核4.14。
  • 所有这些结果和观察结果也可以通过Dan Luu帖子中的clz_array取代builtin_popcnt_unrolled_errata_manual获得,并经过必要的修改。

非常感谢任何帮助!

1 个答案:

答案 0 :(得分:6)

  

关于上面代码的一个有点奇怪的事情是我第一次和第二次调用clz_arrays函数它是未初始化的内存

malloc使用mmap从内核获取的未初始化内存最初都是写时复制映射到全零的同一物理页面。

所以你得到TLB未命中而不是缓存未命中。如果它使用4k页面,那么你会得到L1D命中。如果它使用2M巨页,那么你只能获得L3(LLC)命中,但这仍然比DRAM显着更好。

单核内存带宽通常受max_concurrency / latency限制,通常无法使DRAM带宽饱和。 (有关详细信息,请参阅Why is Skylake so much better than Broadwell-E for single-threaded memory throughput?this answer的“延迟限制平台”部分;在多核Xeon芯片上比在四核台式机/笔记本电脑上更糟糕。)

您的第一次热身运行将遭遇页面错误以及TLB未命中。此外,在启用了Meltdown缓冲的内核上,任何系统调用都将刷新整个TLB。如果您要添加额外的print_stats来显示预热运行性能,那么这会使运行速度变慢。

您可能希望在计时运行中在同一内存中多次循环,因此您不需要这么多的页面遍历来触及如此多的虚拟地址空间。

clock()不是衡量效果的好方法。它以秒为单位记录时间,而不是CPU核心时钟周期。如果您运行足够长的基准测试,则不需要非常高的精度,但是您需要控制CPU频率以获得准确的结果。调用clock()可能会导致系统调用(启用Meltdown和Spectre缓解)刷新TLB和分支预测。它可能足够慢,让Skylake从最大涡轮增压时钟回落。之后你不做任何热身工作,当然你不能因为第一个clock()之后的任何事情都在定时间隔内。

基于挂钟时间的东西可以使用RDTSC作为时间源而不是切换到内核模式(如gettimeofday())会降低开销,尽管那时你会测量挂钟时间而不是CPU时间。如果机器处于空闲状态,那么这基本上是等效的,因此您的流程不会被取消预定。

对于没有内存限制的内容,计算核心时钟周期的CPU性能计数器可以非常准确,并且不必为CPU频率控制带来不便。 (虽然现在您不必重新启动以暂时禁用turbo并将调控器设置为performance。)

但是对于内存限制的东西,改变核心频率会改变核心与内存的比率,使内存相对于CPU更快或更慢。