19

I was experimenting with AVX -AVX2 instruction sets to see the performance of streaming on consecutive arrays. So I have below example, where I do basic memory read and store.

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

And after compiling with g++-4.9 -ggdb -march=core-avx2 -std=c++11 struct_of_arrays.cpp -O3 -o struct_of_arrays

I see quite good instruction per cycle performance and timings, for benchmark size 4000. However once I increase the benchmark size to 5000, I see instruction per cycle drops significantly and also latency jumps. Now my question is, although I can see that performance degradation seems to be related to L1 cache, I can not explain why this happens so suddenly.

To give more insight, if I run perf with Benchmark size 4000, and 5000

| 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 |

So my question is, why this impact is happening, considering haswell should be capable of delivering 2* 32 bytes to read, and 32 bytes store each cycle?

EDIT 1

I realized with this code gcc smartly eliminates accesses to the myData.a since it is set to 0. To avoid this I did another benchmark which is slightly different, where a is explicitly set.

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

Second example will have one array being read and other array being written. And this one produces following perf output for different sizes:

| 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 |

Again same pattern is seen as pointed out in the answer, with increasing data set size data does not fit in L1 anymore and L2 becomes bottleneck. What is also interesting is that prefetching does not seem to be helping and L1 misses increases considerably. Although, I would expect to see at least 50 percent hit rate considering each cache line brought into L1 for read will be a hit for the second access (64 byte cache line 32 byte is read with each iteration). However, once dataset is spilled over to L2 it seems L1 hit rate drops to 2%. Considering arrays are not really overlapping with L1 cache size this should not be because of cache conflicts. So this part still does not make sense to me.

4

2 回答 2

20

执行摘要:
不同的缓存级别可以为相同的基本工作负载维持不同的峰值带宽,因此拥有不同大小的数据集会极大地影响性能。

更长的解释:
考虑到Haswell,这并不奇怪,根据这篇文章例如可以

每个周期维持 2 个负载和 1 个存储

但那只是说申请L1。如果您继续阅读,您会看到 L2

可以为每个周期的数据或指令缓存提供完整的 64B 行

由于每次迭代需要一次加载和一次存储,因此将数据集驻留在 L1 将允许您享受 L1 带宽并可能达到每次迭代周期的吞吐量,而将数据集溢出到 L2 会强迫你等待更长的时间。这取决于你的系统中有多大的 double,但由于它最常见的是 8 字节,4000 * 2 数组 * 8 字节 = 64k,这超过了大多数当前系统的 L1 大小。但是,Peter Cords 在评论中建议原始代码可能已经优化了零数据数组(我不相信,但这是一种可能性)

现在,一旦您开始超出下一个缓存级别,就会发生两件事:

  1. L1-writebacks:请注意,文章没有提到 writebacks,这是您必须在带宽方面支付的额外损失(从您的 perf 输出中可以看出 - 尽管它看起来有点陡峭)。将数据保存在 L1 中意味着您不必进行任何驱逐,而在 L2 中保存一些数据意味着从 L2 读取的每一行都必须从 L1 中抛出一条现有行——其中一半被修改您的代码并需要显式写回。这些事务必须先读取您每次迭代使用的两个数据元素的值 - 请记住,存储还必须首先读取旧数据,因为部分行未使用并需要合并。

  2. 缓存替换策略- 请注意,由于缓存设置为关联的并且很可能使用 LRU 方案,并且由于您连续遍历数组,因此您的缓存使用模式可能会填充第一种关联方式,然后继续使用第二种方式,依此类推-当您填写最后一种方式时,如果 L2 中仍然需要数据(在较大的数据集情况下),您可能会从第一种方式中逐出所有行,因为它们是最近最少的-used,即使这也意味着它们是您接下来要使用的。这是 LRU 的缺点,它的数据集大于缓存。

这就解释了为什么由于这种访问模式,性能下降如此突然,一旦您超过缓存大小至少单路大小(L1 缓存的 1/8)。

关于性能结果的最后一条评论——你已经预料到,对于 5000 个元素的情况,L1 命中率会下降到一个不错的零,我相信确实如此。但是,硬件预取可以让您看起来仍然在 L1 中命中它,因为它在实际数据读取之前运行。您仍然必须等待这些预取来将数据带过来,更重要的是,因为您正在测量带宽 - 它们仍然占用与实际加载/存储相同的带宽,但它们不计入性能,让您相信你一直都有 L1 命中。这至少是我最好的猜测——你可以通过禁用预取并再次测量来检查(我似乎过于频繁地给出这个建议,抱歉这么拖累)。


编辑1(按照你的)

消除数组的绝妙之处,解决了双倍大小的谜团——它确实是 64 位的,所以要么一个 4000 个元素的数组,要么 2 个 2000 个元素的数组(在你修复之后)尽可能多地放入 L1 . 现在溢出发生在 3000 个元素处。L1 命中率现在很低,因为 L1 无法发出足够的预取来在您的 2 个不同的流之前运行。

至于每次加载会带来 2 次迭代的 64 字节行的期望——我看到了一些非常有趣的东西——如果你将内存单元发出的加载次数(L1 命中 + L1 未命中)相加,你会看到2000 个元素的情况几乎是 1000 个元素的 2 倍,但 3000 和 4000 的情况分别不是 3 倍和 4 倍,而是一半。具体来说,每个数组有 3000 个元素,您的访问量比使用 2000 个元素时要少!
这让我怀疑内存单元能够将每 2 个负载合并到一个内存访问中,但只有在进入 L2 及以上时才可以。当您想到这一点时,这是有道理的,如果您已经有一个等待该线路的 L2,则没有理由发出另一个访问权限来查找 L2,这是一种缓解该级别较低带宽的可行方法。我猜由于某种原因,第二次加载甚至没有被计为 L1 查找,并且无助于您想要查看的命中率(您可以检查指示有多少负载正在通过执行的计数器 - 这可能应该是真的)。这只是一种预感,我不确定计数器是如何定义的,但它确实符合我们看到的访问次数。

于 2013-10-27T19:00:53.587 回答
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 字节加载微指令和一个对齐的 32 字节存储微指令。外环行程计数为 100 万次。内部循环行程计数为BENCHMARK_SIZE/4(由于矢量化)。因此,对 L1 的加载请求总数应该在 100 万 * BENCHMARK_SIZE/4 左右,存储的总数也应该差不多。例如,如果BENCHMARK_SIZE是 4000,那么加载和存储请求的数量应该是 10 亿。循环分支是非常可预测的,因此我们不必担心非退休的推测加载和代码提取。

回想一下,Haswell 中的 L1D 有两个 32 字节的加载端口和一个 32 字节的存储端口。下图显示了我使用perf. 请注意,当我进行这些测量时,L1D 和 L2 预取器都已启用。禁用超线程以消除可能的干扰并利用其他 4 个可编程性能计数器。

在此处输入图像描述

可以观察到的第一件事是加载(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告诉我们需求加载或存储或软件预取请求错过 L1D 但无法从加载/存储缓冲区发出的周期数,因为没有可用的填充缓冲区。这似乎是一个重大问题。但是,此事件无法让我们确定加载、存储或两者是否被阻塞。我将很快讨论另一个事件。当为 2000 或更少时,此事件计数可以忽略不计,BENCHMARK_SIZE因为在内部循环的第一次迭代之后,所有以后的加载和存储都将在缓存中命中,从而消除了填充缓冲区的需要。

L2_TRANS.RFO计算访问 L2 的 RFO 请求的数量。如果您仔细查看图表,您会发现这似乎比存储微指令总数的一半还少。这是有道理的,因为每两个连续的存储微指令都指向同一缓存行。因此,如果一个错过了 L1D,另一个将错过并在同一个 LFB 条目中进行写入组合,并在对 L2 的同一个 RFO 请求中被压缩。我不知道为什么L2_TRANS.RFO不完全是一半(正如我对> 2000MEM_UOPS_RETIRED.ALL_STORES的情况所预期的那样)。BENCHMARK_SIZE

L2_RQSTS.ALL_DEMAND_DATA_RD,根据手册,应该是统计从L1加载的需求数据数量和L1预取请求到L2的数量。但它非常小。我认为它只计算需求数据加载的数量,或者 L1 流式预取器可以直接与 L3 通信。无论如何,这对于本次分析并不重要。

我们可以从该图中得出结论,加载请求不在关键路径上,但存储请求在。下一步显然是测量RESOURCE_STALLS.SB以确定商店真正受苦的严重程度。此事件计算由于存储缓冲区已满而导致的完全分配停顿周期的数量。

在此处输入图像描述

cycles图中指的是未停止的核心周期,基本上是执行时间。)

该图显示超过 60% 的执行时间浪费在分配器上,等待存储缓冲区条目空闲。为什么会这样?两个 L1D 预取器都只跟踪加载请求,并在 S 或 E 相干状态下获取行。如果加载和存储到相同的缓存行,并且没有其他内核共享这些行的副本,则 L1 流送器将预取 E 状态的行,有效地使加载和存储都受益。但是在我们的示例中,存储是针对不同的缓存行,并且这些不会被任何一个 L1D 预取器跟踪。写入组合 LFB 有很大帮助,但是紧密的循环压倒了 L1D 控制器并屈服于它的膝盖,请求加载/存储缓冲单元停止发出更多的存储请求。仍然可以发出加载请求,因为它们大多在缓存中命中并且不会 在这种情况下不需要LFB。所以存储将堆积在存储缓冲区中,直到它被填满,从而停止分配器。LFB 将主要被来自 L1 流媒体的组合存储未命中和请求所占据。因此,LFB 的数量和存储缓冲区条目位于关键路径上。L1D 写入端口的数量不是。当存储的数组大小超过 L1D 的容量时,就会出现该关键路径。

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

在此处输入图像描述

@PeterCordes 建议按问题大小对测量值进行标准化。下图绘制了不同 .Cycles 值的规范化指令周期计数,BENCHMARK_SIZE并且指令是不同的单位,所以我认为我应该给每个自己的轴。但是随后该图似乎给人一种错觉,即规范化的指令数变化很大,但事实并非如此,而且这没有任何意义。所以我决定将两者绘制在同一个轴上,如图所示。从这张图中可以很容易地观察到 IPC 和 CPI,这很好。

在此处输入图像描述

于 2018-09-06T23:58:44.657 回答