【问题标题】:Inconsistent `perf annotate` memory load/store time reporting不一致的`perf annotate`内存加载/存储时间报告
【发布时间】:2021-04-30 12:43:19
【问题描述】:

我很难解释英特尔性能事件报告。

考虑以下主要读取/写入内存的简单程序:

#include <stdint.h>
#include <stdio.h>

volatile uint32_t a;
volatile uint32_t b;

int main() {
  printf("&a=%p\n&b=%p\n", &a, &b);
  for(size_t i = 0; i < 1000000000LL; i++) {
    a ^= (uint32_t) i;
    b += (uint32_t) i;
    b ^= a;
  }
  return 0;
}

我用gcc -O2编译它并在perf下运行:

# gcc -g -O2 a.c
# perf stat -a ./a.out
&a=0x55a4bcf5f038
&b=0x55a4bcf5f034

 Performance counter stats for 'system wide':

         32,646.97 msec cpu-clock                 #   15.974 CPUs utilized
               374      context-switches          #    0.011 K/sec
                 1      cpu-migrations            #    0.000 K/sec
                 1      page-faults               #    0.000 K/sec
    10,176,974,023      cycles                    #    0.312 GHz
    13,010,322,410      instructions              #    1.28  insn per cycle
     1,002,214,919      branches                  #   30.699 M/sec
           123,960      branch-misses             #    0.01% of all branches

       2.043727462 seconds time elapsed
# perf record -a ./a.out
&a=0x5589cc1fd038
&b=0x5589cc1fd034
[ perf record: Woken up 3 times to write data ]
[ perf record: Captured and wrote 0.997 MB perf.data (9269 samples) ]
# perf annotate

perf annotate 的结果(我为内存加载/存储注释):

Percent│      for(size_t i = 0; i < 1000000000LL; i ++) {
       │      xor    %eax,%eax
       │      nop
       │            a ^= (uint32_t) i;
       │28:   mov    a,%edx                             // 32-bit load
       │      xor    %eax,%edx
  9.74 │      mov    %edx,a                             // 32-bit store
       │            b += (uint32_t) i;
 12.12 │      mov    b,%edx                             // 32-bit load
  8.79 │      add    %eax,%edx
       │      for(size_t i = 0; i < 1000000000LL; i ++) {
       │      add    $0x1,%rax
       │            b += (uint32_t) i;
 18.69 │      mov    %edx,b                             // 32-bit store
       │            b ^= a;
  0.04 │      mov    a,%ecx                             // 32-bit load
 22.39 │      mov    b,%edx                             // 32-bit load
  8.92 │      xor    %ecx,%edx
 19.31 │      mov    %edx,b                             // 32-bit store
       │      for(size_t i = 0; i < 1000000000LL; i ++) {
       │      cmp    $0x3b9aca00,%rax
       │    ↑ jne    28
       │      }
       │      return 0;
       │    }
       │      xor    %eax,%eax
       │      add    $0x8,%rsp
       │    ← retq

我的观察:

  • 从 1.28 insn per cycle 我得出结论,程序主要是内存绑定的。
  • ab 似乎位于同一缓存行中,彼此相邻。

我的问题:

  • 对于各种内存加载和存储,CPU 时间不应该更加一致吗?
  • 为什么第一次内存加载 (mov a,%edx) 的 CPU 时间为零?
  • 为什么第三次加载的时间是mov a,%ecx 0.04%,而旁边的时间是mov b,%edx 22.39%?
  • 为什么有些指令需要 0 时间?循环由 14 条指令组成,因此每条指令都必须贡献一些可观察的时间。

注意事项:

操作系统:Linux 4.19.0-amd64,CPU:Intel Core i9-9900K,100% 空闲系统(也在 i7-7700 上测试,结果相同)。

【问题讨论】:

  • 奇怪的代码不高亮

标签: performance assembly x86-64 perf micro-optimization


【解决方案1】:

不完全是“内存”限制,而是存储转发延迟的限制。 i9-9900K 和 i7-7700 的每个内核都有完全相同的微架构,所以这并不奇怪:P https://en.wikichip.org/wiki/intel/microarchitectures/coffee_lake#Key_changes_from_Kaby_Lake。 (可能是为了改善 Meltdown 的硬件缓解,并可能修复循环缓冲区 (LSD)。)

请记住,当 perf 事件计数器溢出并触发样本时,无序超标量 CPU 必须恰好选择其中一条执行中的指令来“责备”此 cycles 事件。这通常是 ROB 中最古老的未退休指令,或之后的指令。非常怀疑cycles 非常小规模的事件样本。

Perf 从不归咎于产生结果缓慢的负载,通常是等待它的指令。 (在这种情况下为xoradd)。在这里,有时商店会消耗该异或的结果。这些不是缓存未命中负载;在 Skylake 上,存储转发延迟只有大约 3 到 5 个周期(可变,如果您不早点尝试,则更短:Loop with function call faster than an empty loop),因此您确实有大约每 3 到 5 个周期完成 2 个负载。

你有两个通过内存的依赖链

  • 最长的一个涉及b 的两个RMW。这是两倍长,将成为循环的整体瓶颈。
  • 另一个涉及a 的一个 RMW(每次迭代都会进行额外读取,这可能与下一次 a ^= i; 的读取并行发生)。

i 的 dep 链只涉及寄存器,可以跑得远远领先于其他; add $0x1,%rax 没有计数也就不足为奇了。它的执行成本完全隐藏在等待加载的阴影中。

我有点惊讶mov %edx,a 的数量很多。也许有时它必须等待涉及b 的旧存储微指令在 CPU 的单个存储数据端口上运行。 (Uops 按照最旧的优先分配到端口。How are x86 uops scheduled, exactly?

在所有先前的 uops 都执行之前,Uops 不能退出,因此它可能只是从循环底部的存储中得到一些偏差。 Uops 以 4 个一组的形式退出,因此如果 mov %edx,b 确实退出,则已经执行的 cmp/jcc、a 的 mov 负载和 xor %eax,%edx 可以随之退出。这些不是等待b 的dep 链的一部分,因此只要b 商店准备退休,他们总是会坐在ROB 中等待退休。 (这是关于 mov %edx,a 如何获得计数的猜测,尽管不是真正的瓶颈的一部分。

存储地址 uop 都应该在循环之前运行,因为它们不必等待以前的迭代:RIP 相对寻址1 立即准备就绪。它们可以在端口 7 上运行,或者与端口 2 或 3 的负载竞争。负载也是如此:它们可以立即执行并检测他们正在等待的存储,负载缓冲区监视它并准备报告何时在 store-data uop 最终运行后数据就准备好了。

据推测,前端最终会成为分配负载缓冲区条目的瓶颈,这将限制后端可以有多少微指令,而不是 ROB 或 RS 大小。

脚注 1:您的注释输出仅显示 a 而不是 a(%rip),这很奇怪;如果您确实以某种方式让它使用 32 位绝对,或者它只是一个反汇编怪癖未能显示 RIP-relative,这并不重要。

【讨论】:

  • 谢谢。仍然没有完全理解时间安排。根据统计数据,它应该是 10 个周期/迭代,分支或数据依赖性几乎没有变化,所以我希望时间“步长”是 10% 的倍数。那么如何解释 12% 和 18% 之类的数字呢?
  • @rustyx:退休是突发性的。当最旧的 uop 最终执行并退出时,如果它们已经执行并且只是坐在 ROB 中等待退出,则允许更多的 4 组在连续的时钟周期中退出。请注意,涉及b(关键路径)的 dep 链主要位于循环的底部,并且大部分计数都在属于循环的一部分的指令中。相关:What considerations go into predicting latency for operations on modern superscalar processors and how can I calculate them by hand?
  • @rustyx 百分比单位是样本,而不是周期。 12.12% 是指令指针指向相应指令时捕获的所有样本的一部分。这些百分比通常与每条指令对挂钟执行时间的影响程度无关,这在该程序中很明显(Peter 已经解释过,关键路径主要由涉及变量 @987654345 的两个顺序存储到加载转发组成@,这基本上就是你得到 10c/iter 的原因)。即使这些百分比是周期,我也不知道您为什么只期望看到 10%s。
  • @HadiBrais:公平地说,像div 这样的慢指令,或者消耗缓存未命中加载结果的指令,通常会获得很多循环事件的计数。但无论如何,在第二次查看 Rusty 的评论时,我认为预期 perf 将在其组件成本中削减一次迭代(需要 10 个周期),因此整数如 20% 和 10%。它不是这样的原因是它是在同一个循环(100 亿 c)的多次迭代中平均的,而不仅仅是 10 个循环,而且@rusty 它采样的机制是事件计数器溢出,触发采样的记录。
  • @PeterCordes 好的,这是否意味着报告的百分比不准确(例如,由于“突发性”)或者迭代之间是否存在一些差异?注意:无论# 次迭代,我都会得到这些百分比。还尝试了 IACA 和 llvm-mca 无济于事,因为它们似乎不支持存储转发时间。
猜你喜欢
  • 2017-11-11
  • 1970-01-01
  • 2013-01-18
  • 1970-01-01
  • 1970-01-01
  • 2021-02-18
  • 1970-01-01
  • 1970-01-01
  • 2012-08-21
相关资源
最近更新 更多