如何解决为什么这个函数在额外读取内存时运行得如此之快?
我目前正在尝试了解 x86_64(特别是我的 Intel(R) Core(TM) i3-8145U cpu @ 2.10GHz 处理器)上某些循环的性能属性。具体来说,在循环体内部添加一个额外的读取内存指令几乎可以使性能翻倍,细节不是特别重要。
我一直在使用由两个主要部分组成的测试程序:一个测试循环和一个被测函数。测试循环将被测函数运行 232 次,每次将每个有符号的 32 位整数作为参数(按从 INT_MIN
到 INT_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 秒):
- 我们使用什么操作读取内存似乎并不重要:
- 或者我们读入的寄存器:
and 0x3e2e(%rip),%eax # 405040 <next_expected>
- 或(在大多数情况下)我们正在阅读的内容:
- 或者我们是否除了读取内存之外还要写入内存:
xor %edi,0x3e2a(%rip) # 40503c <junk>
- 此外,在
xor 0x11c7(%rip),%edi # 4023d9 <main>
命令之后而不是之前添加incl
也可以提高性能。
这些语句运行缓慢(约 11 秒):
- 使用 6 字节长但不读取内存的指令是不够的:
- 只写内存而不读它是不够的:
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
而不是incl
在next_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_cycles
和 idq.mite_cycles
少于 1000 万,在两个11-nop 和 12-nop 情况),消除了这里添加的大量 nop 导致指令缓存机制过载的可能性;并且 ld_blocks.store_forward
是一位数(我认为可能涉及存储转发,现在仍然可能涉及,但这是唯一与之相关的性能计数器,因此我们不会通过这种方式获得更多信息) .
上面使用的具体读写模式为:
这似乎是重现行为的最简单模式;我还没有发现任何导致行为重现的进一步简化。
我仍然不知道这里发生了什么,但希望这些信息对任何想弄清楚的人有用。
版权声明:本文内容由互联网用户自发贡献,该文观点与技术仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 dio@foxmail.com 举报,一经查实,本站将立刻删除。