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

问题描述

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

我一直在使用由两个主要部分组成的测试程序:一个测试循环和一个被测函数。测试循环将被测函数运行 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    $0x80000000,%ebx
loop:
  40130b:       89 df                   mov    %ebx,%edi
  40130d:       ff d5                   callq  *%rbp
  40130f:       83 c3 01                add    $0x1,%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 设置为非零值,否则保持不变。然后递增 next_expected。)

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

  401200:       33 3d 3a 3e 00 00       xor    0x3e3a(%rip),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 秒)

  • 我们使用什么操作读取内存似乎并不重要:
    • xor 0x3e2e(%rip),%edi # 405040 <next_expected>
    • and 0x3e2e(%rip),%edi # 405040 <next_expected>
    • mov 0x3e2e(%rip),%edi # 405040 <next_expected>
  • 或者我们读入的寄存器:
    • and 0x3e2e(%rip),%eax # 405040 <next_expected>
  • 或(在大多数情况下)我们正在阅读的内容
    • xor 0x11c7(%rip),%edi # 4023d9 <main>
    • or -0x12(%rip),%edi # 401200 <body>
  • 或者我们是否除了读取内存之外还要写入内存:
    • xor %edi,0x3e2a(%rip) # 40503c <junk>
  • 此外,在 xor 0x11c7(%rip),%edi # 4023d9 <main> 命令之后而不是之前添加 incl 也可以提高性能

这些语句运行缓慢(约 11 秒)

  • 使用 6 字节长但不读取内存的指令是不够的:
    • nopw %cs:(%rax,%rax,1)
    • lea 0x3e2e(%rip),%edi # 405040 <next_expected>
  • 只写内存而不读它是不够的:
    • mov %edi,0x3e2a(%rip) # 40503c <junk>

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

  401200:       33 3d 3a 3e 00 00       xor    0x3e3a(%rip),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;写入并立即读取相同的内存地址是处理器可能会遇到的问题),因此我们获得了快速路径性能 + 使用慢语句的速度减慢。如果我在 next_expected 语句之后而不是之前添加读取 main(而不是 incl)(同样,我们正在读取刚刚写入的内存地址,因此类似的行为并不奇怪)。

一个实验是在函数中更早地添加 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 $2,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 $0x80000000,%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 函数的测试结果:

  • 两者的 any_errors/next_expected 变量相同,无需额外读取:~11 秒
  • 两者的 any_errors/next_expected 变量相同,两者都需要额外读取:6-7 秒
  • 两者都使用相同的 any_errors/next_expected 变量,在一个而不是另一个中额外读取:6-7 秒
  • 相同的 next_expected 变量但不同的 any_errors 变量,没有额外的读取:~11 秒
  • 相同的 any_errors 变量但不同的 next_expected 变量(因此报告错误),没有额外的读取:5-5½ 秒(明显比目前任何情况都快)
  • 相同的 any_errors 变量但不同的 next_expected 变量,addl $2 而不是 inclnext_expected 上(因此不会报告错误),没有额外的读取: 5-5½ 秒
  • 与之前的情况相同,但有额外的读取时间:5-5½ 秒(以及几乎相同的循环计数:与数十亿次迭代相比仅相差数千万,因此每次迭代的循环数必须为一样)

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

进一步的实验#2

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

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

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

    mov $60,%rax
    mov $0,%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 是一位数(我认为可能涉及存储转发,现在仍然可能涉及,但这是唯一与之相关的性能计数器,因此我们不会通过这种方式获得更多信息) .

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

  • 将内存读入寄存器;
  • 读取-修改-写入不同的地址,使用相同的寄存器;
  • 读取-修改-写入原始地址;
  • 读取另一个地址(在原始示例中,指令指针的弹出作为无关读取)。

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

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

解决方法

暂无找到可以解决该程序问题的有效方法,小编努力寻找整理中!

如果你已经找到好的解决方法,欢迎将解决方案带上本链接一起发送给小编。

小编邮箱:dio#foxmail.com (将#修改为@)