为什么这个函数 运行 在额外读取内存时速度如此之快?

Why does this function run so much faster when it makes an extra read of memory?

我目前正在尝试了解 x86_64 上某些循环的性能属性(特别是我的 Intel(R) Core(TM) i3-8145U CPU @ 2.10GHz 处理器)。具体来说,在循环体内多加一条读取内存的指令,性能几乎翻倍,细节不是特别重要。

我一直在使用一个由两个主要部分组成的测试程序:一个测试循环和一个被测函数。测试循环 运行s 被测函数 232 次,一次以每个带符号的 32 位整数作为参数(从 INT_MININT_MAX).被测函数(名为 body)是一个小函数,它检查是否使用预期参数调用它,否则将错误记录在全局变量中。测试程序接触的内存量足够小,所有内容都可能适合 L1 缓存。

为了消除可能由编译器行为引起的任何速度差异,我用汇编语言编写了两个有问题的函数(我使用 clang 作为汇编程序),并且被迫从固定地址开始(这种测试循环的性能通常受到与对齐或缓存相关的影响的影响,因此使用固定地址将消除与更改无关的任何对齐影响或缓存影响)。

这是反汇编的测试循环(它需要在 %rdi 中循环的函数地址):

  401300:       53                      push   %rbx
  401301:       55                      push   %rbp
  401302:       51                      push   %rcx
  401303:       48 89 fd                mov    %rdi,%rbp
  401306:       bb 00 00 00 80          mov    [=11=]x80000000,%ebx
loop:
  40130b:       89 df                   mov    %ebx,%edi
  40130d:       ff d5                   callq  *%rbp
  40130f:       83 c3 01                add    [=11=]x1,%ebx
  401312:       71 f7                   jno    40130b <loop>
  401314:       59                      pop    %rcx
  401315:       5d                      pop    %rbp
  401316:       5b                      pop    %rbx
  401317:       c3                      retq   

下面是最简单的body,被测函数:

  401200:       33 3d 3a 3e 00 00       xor    0x3e3a(%rip),%edi        # 405040 <next_expected>
  401206:       09 3d 30 3e 00 00       or     %edi,0x3e30(%rip)        # 40503c <any_errors>
  40120c:       ff 05 2e 3e 00 00       incl   0x3e2e(%rip)        # 405040 <next_expected>
  401212:       c3                      retq

(基本思想是 body 检查其参数 %edi 是否等于 next_expected,如果不等于则将 any_errors 设置为非零值't,否则保持不变。然后递增 next_expected。)

这个版本的 body%rdi 的测试循环在我的处理器上需要大约 11 秒到 运行。但是,添加额外的内存读取会导致它在 6 秒内达到 运行(差异太大,无法用随机变化来解释):

  401200:       33 3d 3a 3e 00 00       xor    0x3e3a(%rip),%edi        # 405040 <next_expected>
  401206:       09 3d 30 3e 00 00       or     %edi,0x3e30(%rip)        # 40503c <any_errors>
  40120c:       33 3d 2e 3e 00 00       xor    0x3e2e(%rip),%edi        # 405040 <next_expected>
  401212:       ff 05 28 3e 00 00       incl   0x3e28(%rip)        # 405040 <next_expected>
  401218:       c3                      retq

我已经尝试了此代码的许多不同变体,以查看附加语句(上面标记为 401212)的哪些特定属性提供了“快速”行为。常见的模式似乎是语句需要从内存中读取。我在那里尝试过的各种语句(注意:其中每一个都是一个恰好 6 个字节长的语句,因此无需担心对齐问题):

这些语句 运行 很快(~6 秒):

这些语句运行慢(~11秒):

此外,我尝试将读取的值写回 next_expected 而不是就地递增:

  401200:       33 3d 3a 3e 00 00       xor    0x3e3a(%rip),%edi        # 405040 <next_expected>
  401206:       09 3d 30 3e 00 00       or     %edi,0x3e30(%rip)        # 40503c <any_errors>
  40120c:       8b 3d 2e 3e 00 00       mov    0x3e2e(%rip),%edi        # 405040 <next_expected>
  401212:       ff c7                   inc    %edi
  401214:       89 3d 26 3e 00 00       mov    %edi,0x3e26(%rip)        # 405040 <next_expected>
  40121a:       c3                      retq

这与最初的 11 秒程序的性能非常接近。

一个异常是语句xor 0x3e2a(%rip),%edi # 40503c <any_errors>;补充说,401212 语句始终给出 7.3 秒的性能,与其他两个性能中的任何一个都不匹配。我怀疑这里发生的事情是内存的读取足以将函数发送到“快速路径”,但语句本身很慢(因为我们只是在前一行写了 any_errors ;写入并立即读取相同的内存地址是处理器可能会遇到的问题),因此我们获得了快速路径性能+使用慢速语句的速度减慢。如果我在 incl 语句之后而不是之前添加对 next_expected 的读取(而不是 main (同样,我们正在读取刚刚写入的内存地址,所以类似的行为并不奇怪)。

另一个实验是在函数的前面添加 xor next_expected(%rip), %eax(在写入 %edi 之前或刚开始时,在读取 next_expected 之前)。这些给出了大约 8.5 秒的性能。

无论如何,在这一点上似乎很清楚什么导致了快速行为(添加额外的内存读取使函数运行更快,至少当它与此处显示的特定测试循环结合时;如果测试循环的细节相关,我不会感到惊讶)。不过,我不明白的是 为什么 处理器会这样。特别是,是否有一个通用规则可用于计算向程序添加额外读取会使它 运行(这么多)更快?

如果您想自己试验一下

这是可以编译和 运行 的最小版本的程序,并显示了这个问题(这是具有 gcc/clang 扩展名的 C,特定于 x86_64 个处理器):

#include <limits.h>

unsigned any_errors = 0;
unsigned next_expected = INT_MIN;

extern void body(signed);
extern void loop_over_all_ints(void (*f)(signed));

asm (
    ".p2align 8\n"
    "body:\n"
    "   xor next_expected(%rip), %edi\n"
    "   or %edi, any_errors(%rip)\n"
//    " xor next_expected(%rip), %edi\n"
    "   addl , next_expected(%rip)\n"
    "   retq\n"

    ".p2align 8\n"
    "loop_over_all_ints:\n"
    "   push %rbx\n"
    "   push %rbp\n"
    "   push %rcx\n"
    "   mov %rdi, %rbp\n"
    "   mov [=15=]x80000000, %ebx\n"
    "loop:\n"
    "   mov %ebx, %edi\n"
    "   call *%rbp\n"
    "   inc %ebx\n"
    "   jno loop\n"
    "   pop %rcx\n"
    "   pop %rbp\n"
    "   pop %rbx\n"
    "   retq\n"
    );

int
main(void)
{
    loop_over_all_ints(&body);
    return 0;
}

(注释掉的行是额外内存读取的示例,它使程序 运行 更快。)

进一步实验

发布问题后,我尝试了一些进一步的实验,其中测试循环展开到深度 2,并进行了修改,以便对被测函数的两次调用现在可以转到两个不同的函数。当使用 body 作为两个函数调用循环时,在有和没有额外内存读取的代码版本之间仍然存在明显的性能差异(有 6-7 秒,没有 >11 秒),给出了更清晰的平台看看差异。

以下是使用两个独立 body 函数的测试结果:

看起来很像这里发生的任何事情都与 next_expected 上的依赖链有某种关联,因为打破该链提供的性能比任何可能存在的链都快。

进一步的实验#2

我一直在尝试该程序的更多变体以消除可能性。我现在已经设法将重现此行为的测试用例缩小到以下 asm 文件(通过使用 as test.s -o test.o; ld test.o 组装使用 gas 构建;这不是针对 libc 的链接,因此是Linux-具体):

    .bss
    .align 256
a:
    .zero   4
b:
    .zero   4
c:
    .zero   4

        .text
    .p2align 8, 0
    .globl _start
_start:
    mov [=16=]x80000000, %ebx
1:
//  .byte 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90
//  .byte 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x66, 0x90
    mov a(%rip), %esi
    or %esi, b(%rip)
    or , a(%rip)
    mov c(%rip), %eax
    add , %ebx
    jno 1b

    mov , %rax
    mov [=16=], %rdi
    syscall

有三个版本的程序可以比较:编写的版本,有 12 个单字节 NOP 指令的版本,和有 11 个 NOP 指令的版本(我把其中一个变成了两个字节来得到与 12-NOP 情况下的对齐方式相同,但这并不重要)。

当运行使用没有 NOP 或 11 个 NOP 的程序时,运行在 11 秒内完成。使用12个单字节NOP时,7秒运行秒

在这一点上,我认为当有问题的循环运行“太快”时,很明显出了问题,并在循环被人为减慢时自行修复。该程序的原始版本可能在从 L1 缓存读取内存的带宽上存在瓶颈;所以当我们添加额外的阅读时,问题就自行解决了。这个版本的程序在前端(人为)遇到瓶颈时会加速; “12 个单字节 NOP”与“10 个单字节 NOP 和一个 2 字节 NOP”之间的唯一区别是 NOP 指令通过处理器前端的速度。因此,如果人为减慢循环 运行,它似乎会更快,而且使用什么机制来减慢它似乎并不重要。

一些排除可能性的性能计数器信息:循环正在运行宁出循环流解码器(lsd.cycles_active超过250亿,idq.dsb_cyclesidq.mite_cycles更少超过 1000 万,在 11-NOP 和 12-NOP 的情况下),这消除了这里添加的大量 NOP 使指令缓存机制过载的可能性;并且 ld_blocks.store_forward 是一个数字(我认为可能涉及存储转发,它仍然可能是,但这是唯一与之相关的性能计数器所以我们不会通过这种方式获得更多关于它的信息) .

上面使用的读写的具体模式是:

这似乎是重现该行为的最简单模式;我还没有发现任何导致行为重现的进一步简化。

我仍然不知道这里发生了什么,但希望这些信息对任何想弄清楚的人都有用。

什么

所以@Peter Cordes 在评论中是正确的:这是由 .

引起的

这是一个产生相同效果的小程序,基本上是上述程序的一个最小示例:

    .bss
    .align 256
a:
    .zero   4

    .text
    .p2align 8, 0
    .globl _start
_start:
    mov [=10=]x80000000, %ecx
1:
    mov a(%rip), %edx
    mov %edx, a(%rip)
    .byte 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90
    add , %ecx
    jno 1b

    mov , %rax
    mov [=10=], %rdi
    syscall

循环体中的 8 条无操作指令将循环减慢到 运行 以每三个周期恰好一条指令的速度,导致循环 运行大约 3.5 秒(具体数量取决于频率缩放)。如果你删除它们,循环需要五秒钟才能到达 运行.

为什么

看上面的小程序

这回答了触发器是什么的问题,但它仍然没有真正解释导致问题的实际原因,或者为什么它会产生如此大的性能偏差。关键线索是查看每个端口上发送的 µop 数量。以下是上面最小化程序的数字,以及一些其他有趣的统计数据:

          slow             fast
 1,156,849,106    1,426,795,253      uops_dispatched_port.port_0:u
 1,386,792,998    1,432,967,110      uops_dispatched_port.port_1:u
 3,635,449,011    3,582,057,598      uops_dispatched_port.port_2:u
 4,169,272,248    5,003,425,117      uops_dispatched_port.port_3:u
18,538,282,061    6,427,027,115      uops_dispatched_port.port_4:u
 1,763,689,624    1,435,813,046      uops_dispatched_port.port_5:u
 4,299,495,754    4,286,287,760      uops_dispatched_port.port_6:u
   784,337,695       12,429,585      uops_dispatched_port.port_7:u
19,393,649,837   12,969,389,151      cpu-cycles:u
17,197,960,860   51,654,810,808      uops_issued.any:u
21,677,912,988   21,641,546,006      uops_executed.core:u
   5.245720659      3.491356466      seconds time elapsed
   5.220800000      3.483496000      seconds user

这两个程序之间的唯一区别是“快速”程序每次迭代 运行 额外八 nop 秒;对于 232 次迭代,这是额外的 34359738368 nops。在 µop 级别,nop 是发出后消失的 µop,因此为了在程序之间进行公平比较,我们可以从 51,654,810,808 中减去 34,359,738,368 以获得发出的“调整后”计数 17,295,072,440 µop “快速”程序。

这揭示了有关 µop 生命周期的一些非常奇怪的结果。删除 nops 后,两个程序发出的微操作数量几乎相同——这并不奇怪,因为在这种情况下它们具有相同的代码。执行的微操作数也几乎相同,这也不足为奇。 (问题和执行计数彼此不匹配,因为微操作在执行过程中有被拆分、融合、重新排列等的趋势,但这应该以相同的方式影响两个程序。)

另一方面,μop 调度计数似乎根本不匹配,这最初看起来应该是不可能的。诀窍在于,尽管命名如此,但用于调度的性能计数器并不直接计算微操作——而是计算端口忙于处理微操作的周期数。因此,端口 4 上的疯狂不匹配表明微操作“卡在”端口 4 中并需要一段时间才能执行(根据统计数据,大约 3 个周期)。

在我使用的处理器上,端口 4 是唯一能够存储到内存的执行端口(MOV 指令本身占用两个端口;2、3 或 7 计算地址,4 执行实际操作存储)。我认为我对所讨论的处理器怪异的心理模型是,在尝试从存储后不到 3 个周期的内存位置读取时,读取时间将比正常时间长。然而,实际效果似乎比这更糟:处理器的行为似乎是,如果您尝试从存储到内存位置后不到 3 个周期的时间读取,所有写入处理器线程 被阻塞了 3 个周期。

看题中的程序

现在让我们看看上面“进一步实验#2”中的程序,就端口使用而言(旁注:出于某种原因,我不得不添加额外的三个 nops 以获得“快速”行为,所以也许在微码更新中有一些额外的优化):

          slow             fast
 8,881,288,565    3,278,012,510      uops_dispatched_port.port_0:u
 8,852,984,797    4,992,142,029      uops_dispatched_port.port_1:u
10,385,944,986   10,726,029,245      uops_dispatched_port.port_2:u
11,048,867,603   10,721,449,693      uops_dispatched_port.port_3:u
25,015,457,606   11,608,396,673      uops_dispatched_port.port_4:u
 9,255,886,026    5,102,583,196      uops_dispatched_port.port_5:u
11,265,935,146    7,295,609,724      uops_dispatched_port.port_6:u
 4,333,803,057    4,304,364,919      uops_dispatched_port.port_7:u
42,554,124,800   26,024,822,032      cpu-cycles:u
38,686,833,876  103,170,056,257      uops_issued.any:u
52,058,581,194   51,940,941,322      uops_executed.core:u
  11.504080112      7.024786187      seconds time elapsed
  11.488620000      7.018980000      seconds user

在“快速”版本中,每次迭代发出 15 nops,发出的非 nop 微操作数为 38,745,546,817。所以我们看到了几乎相同的结果:不计算 nops,这两个程序的微操作问题和执行率是相同的,但是一些微操作在慢速版本中执行时间要长得多并且会被占用端口。

有一个小区别:写入内存后过早读取内存的指令现在是读-修改-写指令 or , a(%rip)。写入的一半需要同时使用端口 4 进行写入,以及一个能够执行 or(0、1、5 或 6)的端口来计算要写入的值。所以我们现在看到端口 0、1、5 和 6 都显示为卡住 µops——指令由于提前读取而卡住,并且在三个周期中除了端口 4 外还占用了 ALU 端口“我不会写”时期。这样做的主要后果似乎是程序的其余部分也慢了一点;我对此的猜测(与上面的数字一致,但无论如何都可能是错误的)是当端口 6 最终卡住时,循环逻辑必须等待,因为端口 6 是唯一可以处理 add , %ebx; jno 1b return 到循环开始所需的宏融合指令。所以程序在每次循环迭代中被减慢了 3 个时钟周期,有时更多,因为循环逻辑也被卡住了。

不过,有趣的是性能偏差有多大。对于上面的小程序,端口 4 在每个循环迭代中停留 3 个周期,但这并没有使整个循环减慢那么多,因为端口 4 吞吐量不是限制因素。但是,对于这个更复杂的程序,端口 4 在每个循环迭代中被卡住 3 个周期似乎确实使每个循环迭代减慢了 3 个周期(并且更多一点,可能是由于端口 6 也被卡住了),因此端口 4 堵塞对整个程序的影响接近最大。仍然不完全清楚为什么端口 4 被阻塞 3 个周期会导致整个程序停止 3 个周期(这个程序也没有明显地限制端口 4 的吞吐量),但至少这样的效果是合理的可能正在发生。

无论如何,要回答 OP 中的主要实际问题,“特别是,是否有一般规则可用于计算向程序添加额外读取时将使程序 运行(这么多)快?”:规则是“在这个处理器上,在写入后读取少于三个周期的内存地址将阻塞写指令 3 个周期,这反过来会使你的程序减慢多达 3 个周期,甚至可能再多一点;您可以通过防止读取后很快发生的写入来避免它”。不幸的是,对于无序处理器,很难知道处理器何时会尝试 运行 指令,因此很难保证两条指令不会 运行 过快彼此;对于我最初开发的程序,最主要的实用建议很可能是避免在紧密循环中重复读取和写入相同的内存地址(而不是将值存储在寄存器中并从中重复读取和写入)在那里——原始的汇编代码最终只是看起来像它所做的那样,因为我通过函数指针调用函数,这意味着编译器无法安全地进行这种优化。