Haswell内存访问

19

我正在尝试使用AVX-AVX2指令集来测试连续数组上的流式处理性能。因此,我有以下示例,在其中进行基本的内存读取和存储。

#include <iostream>
#include <string.h>
#include <immintrin.h>
#include <chrono>
const uint64_t BENCHMARK_SIZE = 5000;

typedef struct alignas(32) data_t {
  double a[BENCHMARK_SIZE];
  double c[BENCHMARK_SIZE];
  alignas(32) double b[BENCHMARK_SIZE];
}
data;

int main() {
  data myData;
  memset(&myData, 0, sizeof(data_t));

  auto start = std::chrono::high_resolution_clock::now();

  for (auto i = 0; i < std::micro::den; i++) {
    for (uint64_t i = 0; i < BENCHMARK_SIZE; i += 1) {
      myData.b[i] = myData.a[i] + 1;
    }
  }
  auto end = std::chrono::high_resolution_clock::now();
  std::cout << (end - start).count() / std::micro::den << " " << myData.b[1]
            << std::endl;
}

使用g++-4.9 -ggdb -march=core-avx2 -std=c++11 struct_of_arrays.cpp -O3 -o struct_of_arrays编译后,对于规模为4000的基准测试来说,我看到了相当不错的每周期指令性能和时间。然而,一旦我将基准测试规模增加到5000,我发现每周期指令性能显著下降,延迟也大幅跳升。我的问题是,尽管我可以看到性能下降似乎与L1缓存有关,但我无法解释为什么会这样突然。

更具体地说,如果我以4000和5000的基准测试规模运行perf,

| Event                               | Size=4000 | Size=5000 |
|-------------------------------------+-----------+-----------|
| Time                                |    245 ns |    950 ns |
| L1 load hit                         |    525881 |    527210 |
| L1 Load miss                        |     16689 |     21331 |
| L1D writebacks that access L2 cache |   1172328 | 623710387 |
| L1D Data line replacements          |   1423213 | 624753092 |

我的问题是,鉴于Haswell应该能够在每个周期内提供2 * 32字节的读取和32字节的存储,为什么会发生这种影响?

编辑1

我意识到gcc通过巧妙地消除对myData.a的访问,因为它被设置为0。为了避免这种情况,我进行了另一个略有不同的基准测试,其中a被明确设置。

#include <iostream>
#include <string.h>
#include <immintrin.h>
#include <chrono>
const uint64_t BENCHMARK_SIZE = 4000;

typedef struct alignas(64) data_t {
  double a[BENCHMARK_SIZE];
  alignas(32) double c[BENCHMARK_SIZE];

  alignas(32) double b[BENCHMARK_SIZE];

}
data;

int main() {
  data myData;
  memset(&myData, 0, sizeof(data_t));
  std::cout << sizeof(data) << std::endl;
  std::cout << sizeof(myData.a) << " cache lines " << sizeof(myData.a) / 64
            << std::endl;
  for (uint64_t i = 0; i < BENCHMARK_SIZE; i += 1) {
    myData.b[i] = 0;
    myData.a[i] = 1;
    myData.c[i] = 2;
  }

  auto start = std::chrono::high_resolution_clock::now();
  for (auto i = 0; i < std::micro::den; i++) {
    for (uint64_t i = 0; i < BENCHMARK_SIZE; i += 1) {
      myData.b[i] = myData.a[i] + 1;  
    }
  }
  auto end = std::chrono::high_resolution_clock::now();
  std::cout << (end - start).count() / std::micro::den << " " << myData.b[1]
            << std::endl;
}

第二个例子将读取一个数组并写入另一个数组。对于不同的大小,它会产生以下性能输出:
| Event          | Size=1000   | Size=2000   | Size=3000   | Size=4000     |
|----------------+-------------+-------------+-------------+---------------|
| Time           | 86  ns      | 166 ns      | 734 ns      | 931    ns     |
| L1 load hit    | 252,807,410 | 494,765,803 | 9,335,692   | 9,878,121     |
| L1 load miss   | 24,931      | 585,891     | 370,834,983 | 495,678,895   |
| L2 load hit    | 16,274      | 361,196     | 371,128,643 | 495,554,002   |
| L2 load miss   | 9,589       | 11,586      | 18,240      | 40,147        |
| L1D wb acc. L2 | 9,121       | 771,073     | 374,957,848 | 500,066,160   |
| L1D repl.      | 19,335      | 1,834,100   | 751,189,826 | 1,000,053,544 |

在答案中指出了一个相同的模式,随着数据集大小的增加,数据不再适合于L1缓存,而L2成为瓶颈。有趣的是,预取似乎没有起到作用,L1缺失显著增加。尽管我希望至少能看到50%的命中率,因为每个读取L1中缓存行的缓存行都将是第二次访问的命中(64字节缓存行32字节与每个迭代一起读取)。然而,一旦数据集溢出到L2,似乎L1命中率就会降至2%。考虑到数组实际上与L1缓存大小不重叠,这不应该是由于缓存冲突引起的。所以这部分对我来说仍然没有意义。

2个回答

20
执行摘要:
对于相同的基本工作负载,不同缓存级别可以维持不同的峰值带宽,因此具有不同大小的数据集可能会极大地影响性能。 详细说明:
根据这篇文章,例如Haswell为每个周期提供2个负载和1个存储器,这并不令人惊讶。但是这仅适用于L1。如果您继续阅读,您将发现L2可以每个周期向数据或指令缓存提供完整的64B行。由于每次迭代需要一个负载和一个存储,使数据集驻留在L1中将允许您享受L1带宽,并可能达到每次迭代的吞吐量,而使数据集溢出到L2将迫使您等待更长时间。这取决于您系统中double的大小,但由于它通常为8字节,4000 * 2个数组* 8字节= 64k,超过了大多数当前系统的L1大小。然而,Peter Cords在评论中建议原始代码可能已经优化了零数据数组(我不确定,但这是可能的)。
现在有两件事情发生在您开始超出下一个缓存级别时:
  • L1写回:请注意,文章没有提到写回,这是你在带宽方面需要额外付出的代价(就像从perf输出中可以看到的那样 - 虽然看起来有点陡峭)。将数据保留在L1中意味着您不必进行任何逐出操作,而将一些数据放在L2中则意味着每次从L2读取的行都必须从L1中抛弃一行 - 其中一半被您的代码修改并需要显式写回。这些事务将不得不加上为每次迭代使用的两个数据元素读取其值的任务 - 请记住,存储器还必须首先读取旧数据,因为该行的一部分未使用且需要合并。

  • 高速缓存替换策略 - 请注意,由于高速缓存是组相联的并且最可能采用LRU(最近最少使用)策略,并且由于您按顺序遍历数组,因此您的高速缓存使用模式很可能会填充第一个组相联方式,然后转移到第二个方式,以此类推 - 当您填满最后一个方式时,如果仍需要L2中的数据(在较大的数据集情况下),您可能会驱逐所有来自第一个组相联方式的行,因为它们是最不经常使用的,尽管这也意味着它们是您下次要使用的行。这是在数据集大于高速缓存时LRU的缺点。

  • 这就解释了为什么一旦您至少超出一个组相联方式的大小(L1高速缓存的1/8)而性能下降如此突然了。

    关于性能结果的最后一点评论-你本以为在5000个元素的情况下L1命中率会降到0, 我相信它确实是这样。但是,硬件预取可以让它看起来仍然在L1中发生了命中,因为它在实际数据读取之前运行。您仍然需要等待这些预取将数据带过来,更重要的是,由于您正在测量带宽,它们仍然占用与实际负载/存储相同的带宽,但是它们没有被性能考虑在内,从而让您认为一直有L1 命中。至少这是我最好的猜测-您可以通过禁用预取并再次进行测量来检查它(我似乎经常这么建议,很抱歉让你感到困扰)。
    < p >< strong>编辑1 (按照您的要求)< / p > < p >关于消除数组的发现真是太棒了,这解决了双倍大小的谜团-它确实是64位的,所以一个4000个元素的数组或两个2000个元素的数组(在您的修复后)是您可以放入L1中的所有内容。现在溢出发生在3000个元素处。由于L1无法发出足够的预取以在两个不同的流之前运行,因此L1命中率现在很低。 < p >至于每个加载都会为2次迭代带来64字节线路的期望-我看到了非常有趣的东西-如果您将从内存单元(L1命中+ L1未命中)发出的负载数量相加,您会发现2000个元素的情况几乎是从1000个元素中的2倍,但是3000和4000个元素的情况并不是分别是3倍和4倍,而是一半。具体而言,每个数组3000个元素时的访问量少于2000个元素时!这使我怀疑内存单元能够将每2个加载合并为单个内存访问,但仅限于L2及以上的级别。当您考虑到它时,这是有意义的,没有理由发出另一个访问以查找L2,如果您已经有一个待处理该行的挂起访问,并且这是缓解该级别较低带宽的可行方法。我猜想由于某种原因,第二次加载甚至没有被计算为L1查找,也没有帮助您想要看到的命中率(您可以检查指示有多少负载正在执行的计数器 - 那应该是真实的)。不过这只是一种猜测,我不确定计数器如何定义,但它符合我们所看到的访问次数。

    3
    +1. 我唯一想要补充的是,在我所见过的所有x86平台上,double类型都是8个字节。 - Jason R
    确实,如果缓存不在 L1 中,写回会消耗带宽。如果数据不在 L1 中(对于任何大于 L1 的流式使用情况几乎都是如此),无法利用处理单元的强大性能有些令人失望。 - edorado
    2
    这就是为什么性能关键算法通常将其工作集拆分为可以适应较小缓存的子集(例如,参见缓存平铺技术)。根据文章所述,与旧CPU相比,L2带宽也有所增加,我想要追赶L1的改进可能会很困难。 - Leeor
    可能确实是预取器无法跟上两个流的速度,尽管这仍然令人失望 :). - edorado
    @edorado,我认为它旨在处理内存延迟而非内存带宽。在任何受压的带宽场景中,任何预取都只会用其他请求替换负载,但不会改变内存子系统或任何缓存级别的基本峰值带宽。 - Leeor
    显示剩余3条评论

    4

    我也使用Haswell,但无法复现相同的结果。你确定使用了正确的性能事件吗?我很好奇并且自己调试了代码。但首先,让我们通过静态分析来确定预期的加载和存储次数,然后将其与我们得到的数字进行比较,看看它们是否合理。你正在使用gcc 4.9。这是使用-march=core-avx2 -O3编译的循环嵌套的汇编代码:

      4007a8:   48 8d 85 d0 2a fe ff    lea    -0x1d530(%rbp),%rax
      4007af:   90                      nop
      4007b0:   c5 f5 58 00             vaddpd (%rax),%ymm1,%ymm0
      4007b4:   48 83 c0 20             add    $0x20,%rax
      4007b8:   c5 fd 29 80 60 38 01    vmovapd %ymm0,0x13860(%rax)
      4007bf:   00 
      4007c0:   48 39 c2                cmp    %rax,%rdx
      4007c3:   75 eb                   jne    4007b0 <main+0x50>
      4007c5:   83 e9 01                sub    $0x1,%ecx
      4007c8:   75 de                   jne    4007a8 <main+0x48>
    

    每个内部循环迭代中恰好有一个对齐的32字节加载uop和一个对齐的32字节存储uop。外部循环trip计数为100万。由于向量化,内部循环trip计数为BENCHMARK_SIZE/4。因此,对L1的总加载请求次数应该约为100万* BENCHMARK_SIZE/4,存储的总数也应该大致相同。例如,如果BENCHMARK_SIZE为4000,则加载和存储请求的数量应该各为10亿。循环分支非常可预测,因此我们不必担心未退休的推测加载和代码获取。
    请记住,Haswell中的L1D具有两个32字节加载端口和一个32字节存储端口。下面的图显示了我使用perf得到的结果。请注意,在进行这些测量时,启用了两个L1D和两个L2预取器。禁用超线程以消除可能的扰动并利用其他4个可编程性能计数器。

    enter image description here

    首先可以观察到的是负载数量(MEM_UOPS_RETIRED.ALL_LOADS)和存储数量(MEM_UOPS_RETIRED.ALL_STORES)与我们的静态分析相匹配。这很棒。但是第一个关键的观察结果是L1D负载命中数(MEM_LOAD_UOPS_RETIRED.L1_HIT)非常接近于L1D负载数。这意味着L1D流式预取器能够及时地预取大多数myData.a[i]访问。显然,L1D负载未命中数(MEM_LOAD_UOPS_RETIRED.L1_MISS)必须非常小。这对所有BENCHMARK_SIZE值都成立。

    "L1D_PEND_MISS.REQUEST_FB_FULL"告诉我们有多少周期需要进行需求加载、存储或软件预取请求,但是由于没有可用的填充缓冲区,它们无法从加载/存储缓冲区中发出。这似乎是一个重大问题。然而,此事件并不能确定是加载、存储还是两者都被阻塞。接下来我会讨论另一个事件来解决这个问题。当“BENCHMARK_SIZE”小于或等于2000时,此事件计数可以忽略不计,因为在内部循环的第一次迭代之后,所有后续的加载和存储都将命中缓存,消除了对填充缓冲区的需求。"

    L2_TRANS.RFO用于计算访问L2的RFO请求数量。仔细观察图表,你会发现这似乎比总存储uop数量的一半要少一些。这是有道理的,因为每两个连续的存储uop针对同一缓存行。因此,如果一个错过了L1D,另一个也将错过,并在相同的LFB条目中进行写合并,并在同一RFO请求中被抛弃到L2中。我不知道为什么L2_TRANS.RFO不完全是MEM_UOPS_RETIRED.ALL_STORES的一半(对于BENCHMARK_SIZE>2000的情况,我原本以为应该是这样)。

    根据说明书,L2_RQSTS.ALL_DEMAND_DATA_RD应该计算L1的需求数据加载次数和向L2的L1预取请求次数。但它非常小。我认为它只计算了需求数据加载次数,或者L1流式预取器可以直接与L3通信。无论如何,这对分析不重要。

    我们可以从那张图中得出结论,加载请求不在关键路径上,但存储请求在关键路径上。下一步显然是测量RESOURCE_STALLS.SB,以确定存储器的状况有多严重。该事件计数由于完整的存储缓冲区而导致的完整分配停顿周期数。

    enter image description here

    图中的cycles指未停顿的核心周期,基本上就是执行时间。)
    图表显示,超过60%的执行时间浪费在分配器等待存储缓冲区条目变为可用上。这是为什么?两个L1D预取器仅跟踪加载请求并获取处于S或E一致性状态的行。如果负载和存储是到相同的高速缓存行,并且没有其他核心共享该行,则L1数据流处理器将预取处于E状态的行,从而有效地使负载和存储受益。但在我们的示例中,存储是到不同的高速缓存行的,这些高速缓存行不会被任何一个L1D预取器跟踪。写组合LFBs非常有帮助,但是紧密循环压倒了L1D控制器,并将其拖入深渊,请求负载/存储缓冲区单元停止发出更多存储请求。虽然可以继续发出负载请求,因为它们大多数都命中缓存,因此在这种情况下不需要LFB。因此,存储将在存储缓冲区中堆积,直到它变满,从而使分配器停顿。LFB主要由组合存储未命中和来自L1数据流处理器的请求竞争占用。因此,LFB的数量和存储缓冲区条目位于关键路径上。L1D写入端口的数量不在关键路径上。当要存储的数组大小超过L1D容量时,该关键路径出现。

    为了完整起见,这里是一张图表,显示了退休指令的数量和执行时间(以秒为单位)。

    enter image description here

    @PeterCordes建议按问题大小对测量结果进行归一化处理。下图绘制了不同BENCHMARK_SIZE值的归一化指令周期计数。由于周期和指令是不同的单位,因此我认为应该给每个单位分别设置轴线。但是,那么图表似乎会产生归一化指令计数显著变化的错觉,而实际上并非如此,这没有任何意义。因此,我决定像图中所示一样将两者绘制在同一轴线上。从这张图中可以轻松观察到IPC和CPI,这很好。

    enter image description here


    1
    如果您的图表能够针对问题规模进行归一化,那将会很酷,这样它们看起来就像带宽图(例如,像这个SiSoft Sandra结果中HSW和SKL在几乎相等的时钟速度下的情况:https://techreport.com/review/28751/intel-core-i7-6700k-skylake-processor-reviewed/4)。顺便说一句,Haswell每个时钟周期只有1个`vaddpd`,不像Skylake,所以负载将超过ALU。但是,存储吞吐量中的任何停顿都会使事情变得更糟,因此可以说存储是真正的关键路径。gcc缺乏展开也使得前端几乎成为瓶颈(循环中的4个融合域uop)。 - Peter Cordes
    @PeterCordes 你是指像那样的东西吗? - Hadi Brais
    是的。我认为您可以略去那个指令轴非常缩放的图表;那只会分散注意力。在最终的图表中添加第二个字节/时钟轴将非常有用。我还想,对于性能计数器,制作一个标准化的条形图也会很有趣。在您的图表中,“L1D_PEND_MISS.REQUEST_FB_FULL”达到高峰,因此它实际上在最大问题规模下变得不太频繁。在斜坡上更难以发现。 - Peter Cordes

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