当前位置: 首页 > 知识库问答 >
问题:

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

边翔宇
2023-03-14

我目前正试图了解x86_64上某些环路的性能属性(具体来说,我的Intel(R)Core(TM)i3-8145U CPU@2.10GHz处理器)。具体来说,在循环体中添加一条额外的指令来读取内存,几乎可以将性能提高一倍,而细节并不特别重要。

我一直在使用一个由两个主要部分组成的测试程序:一个测试循环和一个正在测试的函数。测试循环运行测试2下的函数32次,每次一个有符号32位整数作为参数(按INT\u MIN到INT\u MAX的顺序)。被测函数(名为body)是一个小函数,它检查是否使用预期参数调用了该函数,否则会将错误记录在全局变量中。测试程序涉及的内存量足够小,所有内容都可能适合一级缓存。

为了消除编译器行为可能导致的任何速度差异,我用汇编语言编写了这两个有问题的函数(我使用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%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秒):

    null
    null
    null
    null

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

    null
    null

此外,我尝试将读取值写回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秒节目非常接近。

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

这是一个可以编译和运行的程序的最低版本,并展示了这个问题(这是一个具有gcc扩展名的C,特定于x86\u 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秒,

以下是使用两个单独的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½秒(和几乎相同的循环计数:与数十亿次迭代相比,它仅相差数千万次,因此每次迭代的循环数必须相同)

看起来这里发生的一切都与下一个上的依赖链有关,因为打破这条链比存在链的任何事情都能提供更快的性能。

我一直在尝试该程序的更多变体,以消除各种可能性。我现在已经设法将一个复制这种行为的测试用例缩减为以下asm文件(通过将组装为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 $0x80000000, %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 $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是一位数(我认为可能涉及存储转发,现在仍然可能,但这是唯一与之相关的性能计数器,因此我们不会以这种方式获得更多信息)。

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

  • 将存储器读入寄存器

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

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


共有1个答案

束新
2023-03-14
匿名用户

因此,@PeterCordes在评论中的观点是正确的:这是由于在写入内存位置后,试图读取不到三个周期的内存位置而导致的暂停。

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

    .bss
    .align 256
a:
    .zero   4

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

    mov $60, %rax
    mov $0, %rdi
    syscall

循环体中的八个no-op指令使循环体的运行速度减慢到每三个周期只有一条指令的速度,导致循环体在大约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

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

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

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

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

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

          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

在“快速”版本中,每次迭代发布十五个nop,发布的非nop数量为38745546817。因此,我们看到了几乎相同的结果:不计算nop,两个程序之间的µop问题和执行率是相同的,但一些µop在慢速版本中执行需要更长的时间,并占用了端口。

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

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

 类似资料:
  • (sharth的评论已经回答了这个问题。) 我已经用python编写了一个二进制搜索算法,它或多或少遵循与在bisect模块中找到的bisect_left函数相同的结构。事实上,它有几个较少的条件,因为我知道,高点是列表的长度,低点是0。然而由于某种原因,内置函数的运行速度是我的5倍。 我的代码如下: 内置函数的源代码是: 正如你所看到的,几乎完全相同。然而,my函数(在100000字的有序列表中

  • 问题内容: 当我使用固定内存进行CUDA数据传输时,我发现数据传输速度大大提高。在linux上,实现此目标的底层系统调用是mlock。从mlock的手册页中可以看出,锁定该页可防止将其换出: mlock()将页面锁定在地址范围内,该地址范围从addr开始并持续len个字节。当调用成功返回时,保证所有包含指定地址范围一部分的页面都驻留在RAM中; 在测试中,我的系统上有几千个可用内存,因此从不存在内

  • 问题内容: 我用 和做了一些测试 。他们似乎都慢于。是因为精度更高吗?计算斜边函数的方法是什么?令人惊讶的是,我在文档中找不到任何表明性能不佳的迹象。 问题答案: 这不是一个简单的sqrt函数。您应该检查此链接以实现算法:http : //www.koders.com/c/fid7D3C8841ADC384A5F8DE0D081C88331E3909BF3A.aspx 它具有while循环以检查收

  • 预计此函数将无法typeCheck。然而,没有解释发生这种情况的原因。在GHCI中试用时,我得到了以下输出: 为什么会出现这种情况?

  • 问题内容: 我已经对流进行了一些测试,特别是对nio- package的DirectoryStreams进行了流测试。我只是尝试获取目录中所有文件的列表,该列表按上次修改日期和大小排序。 旧File.listFiles()的JavaDoc对File中 的 方法进行了注释: 请注意,Files类定义了newDirectoryStream方法来打开目录并遍历目录中的文件名。当使用非常大的目录时,这可能