【问题标题】:Why is processing multiple streams of data slower than processing one?为什么处理多个数据流比处理一个数据流慢?
【发布时间】:2015-03-22 08:21:40
【问题描述】:

我正在测试读取多个数据流对 CPU 缓存性能的影响。我正在使用以下代码对此进行基准测试。基准测试读取顺序存储在内存中的整数并顺序写回部分和。读取的顺序块的数量是变化的。以循环方式读取块中的整数。

#include <iostream>
#include <vector>
#include <chrono>
using std::vector;
void test_with_split(int num_arrays) {
    int num_values = 100000000;
    // Fix up the number of values. The effect of this should be insignificant.
    num_values -= (num_values % num_arrays);
    int num_values_per_array = num_values / num_arrays;
    // Initialize data to process
    auto results = vector<int>(num_values);
    auto arrays = vector<vector<int>>(num_arrays);
    for (int i = 0; i < num_arrays; ++i) {
        arrays.emplace_back(num_values_per_array);
    }
    for (int i = 0; i < num_values; ++i) {
        arrays[i%num_arrays].emplace_back(i);
        results.emplace_back(0);
    }
    // Try to clear the cache
    const int size = 20*1024*1024; // Allocate 20M. Set much larger then L2
    char *c = (char *)malloc(size);
    for (int i = 0; i < 100; i++)
        for (int j = 0; j < size; j++)
            c[j] = i*j;
    free(c);
    auto start = std::chrono::high_resolution_clock::now();
    // Do the processing
    int sum = 0;
    for (int i = 0; i < num_values; ++i) {
        sum += arrays[i%num_arrays][i/num_arrays];
        results[i] = sum;
    }
    std::cout << "Time with " << num_arrays << " arrays: " << std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::high_resolution_clock::now() - start).count() << " ms\n";
}
int main() {
    int num_arrays = 1;
    while (true) {
        test_with_split(num_arrays++);
    }
}

以下是在 Intel Core 2 Quad CPU Q9550 @ 2.83GHz 上拆分 1-80 路的时序:

在 8 条流之后不久的速度提升对我来说是有意义的,因为处理器具有 8 路关联 L1 缓存。 24 路关联 L2 高速缓存反过来解释了 24 个流的颠簸。如果我得到与Why is one loop so much slower than two loops? 中相同的效果,这些尤其适用,其中多个大分配总是以相同的关联集结束。为了比较,我在最后一个大块中完成了分配的时间。

但是,我并不完全理解从一个流到两个流的颠簸。我自己的猜测是它与预取到 L1 缓存有关。阅读Intel 64 and IA-32 Architectures Optimization Reference Manual 似乎 L2 流式预取器支持跟踪多达 32 个数据流,但 L1 流式预取器没有提供此类信息。 L1 预取器是否无法跟踪多个流,或者这里还有其他原因?

背景

我正在对此进行调查,因为我想了解将游戏引擎中的实体组织为数组结构样式中的组件如何影响性能。目前看来,转换所需的数据在两个组件中而不是在 8-10 个组件中对于现代 CPU 来说并不重要。但是,上面的测试表明,有时避免将某些数据拆分为多个组件可能是有意义的,如果这将允许“瓶颈”转换仅使用一个组件,即使这意味着某些其他转换必须读取它是没兴趣。

在一个块中分配

如果改为分配多个数据块,则仅以跨步方式分配和访问一个数据块时的时间如下。这不会将凹凸从一个流更改为两个,但为了完整起见,我将其包含在内。

这是修改后的代码:

void test_with_split(int num_arrays) {
    int num_values = 100000000;
    num_values -= (num_values % num_arrays);
    int num_values_per_array = num_values / num_arrays;

    // Initialize data to process
    auto results = vector<int>(num_values);
    auto array = vector<int>(num_values);
    for (int i = 0; i < num_values; ++i) {
        array.emplace_back(i);
        results.emplace_back(0);
    }

    // Try to clear the cache
    const int size = 20*1024*1024; // Allocate 20M. Set much larger then L2
    char *c = (char *)malloc(size);
    for (int i = 0; i < 100; i++)
        for (int j = 0; j < size; j++)
            c[j] = i*j;
    free(c);

    auto start = std::chrono::high_resolution_clock::now();
    // Do the processing
    int sum = 0;
    for (int i = 0; i < num_values; ++i) {
        sum += array[(i%num_arrays)*num_values_per_array+i/num_arrays];
        results[i] = sum;
    }
    std::cout << "Time with " << num_arrays << " arrays: " << std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::high_resolution_clock::now() - start).count() << " ms\n";
}

编辑 1

我确保 1 对 2 拆分的差异不是由于编译器展开循环并以不同方式优化第一次迭代。使用__attribute__ ((noinline)) 我确保工作函数没有内联到主函数中。我通过查看生成的程序集验证了它没有发生。这些改变之后的时间是一样的。

【问题讨论】:

  • 如果num_arrays 不是编译时常量,我建议您将除法和模数拉出最内层循环。它可能会影响您的性能以及基准测试。
  • 同意。您的目标架构 (Core2) 上的模数和除法的组合可能需要 30 到 150 个周期,具体取决于许多因素。也就是说,我希望一旦你解决了这个问题,你会发现第二个版本与第一个版本相比会明显快(甚至比现在还要快)。

标签: c++ performance caching cpu data-oriented-design


【解决方案1】:

回答您问题的主要部分:L1 预取器是否能够跟踪多个流?

没有。这实际上是因为 L1 缓存根本没有预取器。 L1 缓存不够大,无法冒险推测性地获取可能未使用的数据。这将导致过多的驱逐并对任何未以适合该特定 L1 缓存预测方案的特定模式读取数据的软件产生不利影响。相反,L1 缓存已显式读取或写入的数据。L1 缓存仅在您写入数据和重新读取最近访问过的数据时才有用。

L1 缓存实现并不是您的配置文件从 1X 到 2X 阵列深度的原因。在像您设置的那样进行流式读取时,L1 缓存对性能的影响很小或根本没有影响。您的大部分读取都直接来自 L2 缓存。在您使用嵌套向量的第一个示例中,可能从 L1 中提取了一些读取(见下文)。

我的猜测是,您从 1X 到 2X 的提升与算法以及编译器如何优化它有很大关系。如果编译器知道num_arrays 是一个等于1 的常量,那么它会自动为您消除大量的每次迭代开销。

现在来说第二部分,至于为什么第二个版本更快?:

第二个版本更快的原因与其说是数据在物理内存中的排列方式,不如说是嵌套std::vector&lt;std::vector&lt;int&gt;&gt; 类型所暗示的底层逻辑变化。

在嵌套(第一种)情况下,编译后的代码执行以下步骤:

  1. 访问顶级std::vector 类。此类包含指向数据数组开头的指针。
  2. 必须从内存中加载该指针值。
  3. 将当前循环偏移量[i%num_arrays] 添加到该指针。
  4. 访问嵌套的std::vector 类数据。 (可能会命中 L1 缓存)
  5. 加载指向向量开始的数据数组的指针。 (可能会命中 L1 缓存)
  6. 添加循环偏移[i/num_arrays]
  7. 读取数据。终于!

(请注意,在 24 个左右的流之后,第 4 步和第 5 步获得 L1 缓存命中的机会会急剧下降,因为在通过循环的下一次迭代之前可能会被驱逐)

相比之下,第二个版本:

  1. 访问顶级std::vector 类。
  2. 加载指向数据数组向量开始的指针。
  3. 添加偏移量[(i%num_arrays)*num_values_per_array+i/num_arrays]
  4. 读取数据!

一整套底层步骤被删除。偏移量的计算稍长一些,因为它需要额外乘以num_values_per_array。但其他步骤远远弥补了这一点。

【讨论】:

  • 从我链接到的 x86/x64 优化手册看来,L1 数据缓存确实有某种预取器。第 2-23 页讨论了用于 L1 数据缓存的预取器,也称为“流式预取器”。
  • 我做了一些额外的测试并验证编译器优化第一次循环迭代的情况并非如此。感谢您的输入!我什至没有意识到会发生这种情况。
  • 是的,我熟悉您在优化手册中引用的具体段落。它指的是 Sandy Bridge 和更新的架构。您的 Core2 只有基于软件的预取指令,用于将内容提示到 L1。我最初的草稿答案开始包含对较新架构的解释,但是……我觉得它开始变得很长,并且对解释您的具体观察没有用处。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 2015-10-12
  • 2021-02-21
  • 1970-01-01
  • 1970-01-01
  • 2021-07-29
  • 2012-12-11
相关资源
最近更新 更多