Performance 仔细分析(超标量效应?)

Performance 仔细分析(超标量效应?),performance,x86,floating-point,profiling,superscalar,Performance,X86,Floating Point,Profiling,Superscalar,我已经编写了一些用于分析小函数的代码。在高级别,它: 将线程关联仅设置为一个核心,并将线程优先级设置为最大。 通过执行以下100次来计算统计信息: 估计不执行任何操作的函数的延迟。 估计测试函数的延迟。 将第一个从第二个中减去,以除去执行函数调用开销的成本,从而大致得到测试函数内容的成本。 要估计函数的延迟,请执行以下操作: 使缓存无效在用户模式下很难做到这一点,但我分配了一个三级缓存大小的缓冲区并将其写入内存,这可能会有所帮助。 生成线程,以便概要文件循环具有尽可能少的上下文切换。 从std:

我已经编写了一些用于分析小函数的代码。在高级别,它:

将线程关联仅设置为一个核心,并将线程优先级设置为最大。 通过执行以下100次来计算统计信息:

估计不执行任何操作的函数的延迟。 估计测试函数的延迟。 将第一个从第二个中减去,以除去执行函数调用开销的成本,从而大致得到测试函数内容的成本。 要估计函数的延迟,请执行以下操作:

使缓存无效在用户模式下很难做到这一点,但我分配了一个三级缓存大小的缓冲区并将其写入内存,这可能会有所帮助。 生成线程,以便概要文件循环具有尽可能少的上下文切换。 从std::chrono::high_resolution_时钟获取当前时间,该时钟似乎编译为system_时钟,但是。 运行配置文件循环100000000次,调用中的测试函数。 从std::chrono::high_resolution_时钟获取当前时间,然后减去以获得延迟。 因为在这个级别上,单个指令很重要,在任何时候我们都必须编写非常仔细的代码,以确保编译器不会省略、内联、缓存或以不同的方式处理函数。我已经在各种测试用例中手动验证了生成的程序集,包括我下面介绍的那个

在某些情况下,我得到了极低的亚纳秒延迟。我想尽一切办法来解释这一点,但找不到错误

我正在寻找一种解释来解释这种行为。为什么我的分析函数占用的时间这么少

让我们以计算浮点的平方根为例

函数签名是float*float,空函数是平凡的:

empty_function(float):
    ret
让我们使用sqrtss指令计算平方根,并通过平方根倒数的乘法计算平方根。即,被测试的功能是:

sqrt_sseinstr(float):
    sqrtss  xmm0, xmm0
    ret
sqrt_rcpsseinstr(float):
    movaps  xmm1, xmm0
    rsqrtss xmm1, xmm0
    mulss   xmm0, xmm1
    ret
这是配置文件循环。同样,使用空函数和测试函数调用相同的代码:

double profile(float):
    ...

    mov    rbp,rdi

    push   rbx
    mov    ebx, 0x5f5e100

    call   1c20 <invalidate_caches()>
    call   1110 <sched_yield()>

    call   1050 <std::chrono::high_resolution_clock::now()>

    mov    r12, rax
    xchg   ax,  ax

    15b0:
    movss  xmm0,DWORD PTR [rip+0xba4]
    call   rbp
    sub    rbx, 0x1
    jne    15b0 <double profile(float)+0x20>

    call   1050 <std::chrono::high_resolution_clock::now()>

    ...
my上sqrt_sseinstrfloat的计时结果为3.60±0.13纳秒。在这个处理器的额定频率为3.46GHz的情况下,计算结果是12.45±0.44个周期。这似乎非常准确,因为文档中说SQRTS的延迟大约为13个周期,它没有在该处理器的Nehalem架构中列出,但它似乎也可能在13个周期左右

sqrt_rcpsseinstrfloat的计时结果为:0.01±0.07纳秒或0.02±0.24个周期。除非发生另一种影响,否则这是完全不可信的

我想也许处理器能够在某种程度上或完美地隐藏测试函数的延迟,因为测试函数使用不同的指令端口,即超标量隐藏了什么?我试着用手来分析,但没走多远,因为我真的不知道自己在做什么

注意:为了方便起见,我清理了一些汇编符号。整个程序的一个未经编辑的objdump(包括其他几个变体)是,我暂时托管二进制x86-64 SSE2+,Linux


问题是:为什么一些分析函数会产生难以置信的小值?如果是高阶效应,请解释?

您的基准测试方法从根本上是错误的,而您谨慎的代码是假的

首先,清空缓存是假的。它不仅可以快速地重新填充所需的数据,而且您发布的示例也只有很少的内存交互,只有通过call/ret进行缓存访问,我们将得到一个负载

其次,在基准测试循环之前屈服是虚假的。迭代次数为100000000次,即使在速度相当快的现代处理器上,也要比股票操作系统上的典型调度时钟中断花费更长的时间。另一方面,如果您禁用了调度时钟中断,那么在基准测试没有做任何事情之前就让步

现在,无用的附带复杂性已经过时了,关于现代CPU的基本误解:

您希望loop\u time\u gross/loop\u count是每个循环迭代中花费的时间。这是错误的。现代CPU不按顺序一个接一个地执行指令。现代CPU流水线、预测分支、并行执行多条指令,以及合理快速的CPU无序运行

因此,在基准测试循环的前几次迭代之后,所有分支都可以完美地预测到接下来的近100000000次迭代。这使CPU能够进行推测。实际上,基准测试循环中的条件分支消失了,间接调用的大部分成本也消失了。CPU可以有效地展开循环:

movss  xmm0, number
movaps  xmm1, xmm0
rsqrtss xmm1, xmm0
mulss   xmm0, xmm1
movss  xmm0, number
movaps  xmm1, xmm0
rsqrtss xmm1, xmm0
mulss   xmm0, xmm1
movss  xmm0, number
movaps  xmm1, xmm0
rsqrtss xmm1, xmm0
mulss   xmm0, xmm1
...
或者,对于另一个循环

movss  xmm0, number
sqrtss  xmm0, xmm0
movss  xmm0, number
sqrtss  xmm0, xmm0
movss  xmm0, number
sqrtss  xmm0, xmm0
...
值得注意的是,数字的负载总是相同的,因此可以快速缓存,并且它会覆盖刚刚计算的值,从而破坏dep 不连续链

说句公道话

call   rbp
sub    rbx, 0x1
jne    15b0 <double profile(float)+0x20>
i、 例如,指令不能流水线,只能在一个执行端口上运行。 相反,对于MOVAP、rsqrtss、mulss:

依赖链的最大交互吞吐量为2,因此您可以期望代码在稳定状态下每2个周期执行一个依赖链。此时,基准测试循环的浮点部分的执行时间小于或等于循环开销,并且与循环开销重叠,因此您减少循环开销的天真方法会导致无意义的结果

如果您想正确地做到这一点,您应该确保单独的循环迭代相互依赖,例如通过将基准测试循环更改为

float x = INITIAL_VALUE;
for (i = 0; i < 100000000; i++)
    x = benchmarked_function(x);

虽然开销相对较小,但您应该确保浮点错误不会累积,从而显著更改传递给基准函数的值。

您的基准方法从根本上是错误的,您谨慎的代码是伪造的

首先,清空缓存是假的。它不仅可以快速地重新填充所需的数据,而且您发布的示例也只有很少的内存交互,只有通过call/ret进行缓存访问,我们将得到一个负载

其次,在基准测试循环之前屈服是虚假的。迭代次数为100000000次,即使在速度相当快的现代处理器上,也要比股票操作系统上的典型调度时钟中断花费更长的时间。另一方面,如果您禁用了调度时钟中断,那么在基准测试没有做任何事情之前就让步

现在,无用的附带复杂性已经过时了,关于现代CPU的基本误解:

您希望loop\u time\u gross/loop\u count是每个循环迭代中花费的时间。这是错误的。现代CPU不按顺序一个接一个地执行指令。现代CPU流水线、预测分支、并行执行多条指令,以及合理快速的CPU无序运行

因此,在基准测试循环的前几次迭代之后,所有分支都可以完美地预测到接下来的近100000000次迭代。这使CPU能够进行推测。实际上,基准测试循环中的条件分支消失了,间接调用的大部分成本也消失了。CPU可以有效地展开循环:

movss  xmm0, number
movaps  xmm1, xmm0
rsqrtss xmm1, xmm0
mulss   xmm0, xmm1
movss  xmm0, number
movaps  xmm1, xmm0
rsqrtss xmm1, xmm0
mulss   xmm0, xmm1
movss  xmm0, number
movaps  xmm1, xmm0
rsqrtss xmm1, xmm0
mulss   xmm0, xmm1
...
或者,对于另一个循环

movss  xmm0, number
sqrtss  xmm0, xmm0
movss  xmm0, number
sqrtss  xmm0, xmm0
movss  xmm0, number
sqrtss  xmm0, xmm0
...
值得注意的是,数字的负载总是相同的,因此会快速缓存,并且会覆盖刚刚计算的值,从而打破依赖链

说句公道话

call   rbp
sub    rbx, 0x1
jne    15b0 <double profile(float)+0x20>
i、 例如,指令不能流水线,只能在一个执行端口上运行。 相反,对于MOVAP、rsqrtss、mulss:

依赖链的最大交互吞吐量为2,因此您可以期望代码在稳定状态下每2个周期执行一个依赖链。此时,基准测试循环的浮点部分的执行时间小于或等于循环开销,并且与循环开销重叠,因此您减少循环开销的天真方法会导致无意义的结果

如果您想正确地做到这一点,您应该确保单独的循环迭代相互依赖,例如通过将基准测试循环更改为

float x = INITIAL_VALUE;
for (i = 0; i < 100000000; i++)
    x = benchmarked_function(x);

开销相对较小,但应确保浮点错误不会累积,从而显著改变传递给基准函数的值。

问题在于减去空函数的延迟1的基本方法,如下所述:

估计不执行任何操作的函数的延迟。 估计测试函数的延迟。 从第二个中减去第一个,以除去函数调用开销的成本,从而大致得到测试的成本 函数的内容。 内置的假设是调用函数的成本是X,如果在函数中完成的工作的延迟是Y,那么总成本将是X+Y

这通常不适用于任何两个工作块,尤其是当其中一个正在调用函数时。一个更复杂的观点是,总时间将介于minX、Y和X+Y之间,但根据细节,即使这样也往往是错误的。尽管如此,对这里所发生的事情的解释已经足够了:函数的成本与函数中所做的工作并不是相加的:它们是并行发生的

在现代Intel上,一个空函数调用的成本大约为4到5个周期,这可能限制了两个执行的分支的前端吞吐量,以及按分支和返回预测器的延迟

但是,当您向空函数添加额外的工作时,它通常不会竞争相同的资源,其执行指令也不会依赖于调用的输出,也就是说,工作将形成一个单独的依赖链,除非堆栈指针为ma 未填充,堆栈引擎不会删除依赖项

因此,从本质上讲,函数将占用函数调用机制所需的更多时间,或者函数所完成的实际工作。这种近似值并不精确,因为某些类型的工作实际上可能会增加函数调用的开销,例如,如果在到达ret之前有足够的指令供前端通过,则总时间可能会在4-5个周期的空函数时间之外增加,即使总功小于这个,但这是一个很好的一阶近似

第一个函数需要足够的时间,以使实际工作支配执行时间。但是,第二个函数的速度要快得多,它可以隐藏在调用/重试机制占用的现有时间之下

解决方案很简单:将函数中的功复制N次,使功始终占主导地位。N=10或N=50或类似的情况都可以。您必须决定是要测试延迟,在这种情况下,工作的一个副本的输出应该反馈到下一个副本,还是吞吐量,在这种情况下,不应该

另一方面,如果你真的想测试函数调用+工作的成本,例如,因为这就是你在现实生活中使用它的方式,那么你得到的结果很可能已经接近正确:当它隐藏在函数调用后面时,东西真的可以逐渐免费


1我在这里引用延迟,因为我们不清楚是应该讨论call/ret的延迟还是吞吐量。调用和RET没有任何明确的输出,RET没有输入,因此它不参与一个基于登记的传统依赖链——但是如果考虑像指令指针这样的其他隐藏的架构组件,则可能会考虑延迟。在这两种情况下,吞吐量的延迟大多指向同一件事,因为线程上的所有调用和ret都在相同的状态下运行,因此,让say独立与依赖调用链是没有意义的。

问题在于减去空函数的延迟1的基本方法,如下所述:

估计不执行任何操作的函数的延迟。 估计测试函数的延迟。 从第二个中减去第一个,以除去函数调用开销的成本,从而大致得到测试的成本 函数的内容。 内置的假设是调用函数的成本是X,如果在函数中完成的工作的延迟是Y,那么总成本将是X+Y

这通常不适用于任何两个工作块,尤其是当其中一个正在调用函数时。一个更复杂的观点是,总时间将介于minX、Y和X+Y之间,但根据细节,即使这样也往往是错误的。尽管如此,对这里所发生的事情的解释已经足够了:函数的成本与函数中所做的工作并不是相加的:它们是并行发生的

在现代Intel上,一个空函数调用的成本大约为4到5个周期,这可能限制了两个执行的分支的前端吞吐量,以及按分支和返回预测器的延迟

但是,当您向空函数添加额外的工作时,它通常不会竞争相同的资源,其执行指令也不会依赖于调用的输出,即工作将形成一个单独的依赖链,除非在极少数情况下,堆栈指针被操纵,堆栈引擎不会删除依赖项

因此,从本质上讲,函数将占用函数调用机制所需的更多时间,或者函数所完成的实际工作。这种近似值并不精确,因为某些类型的工作实际上可能会增加函数调用的开销,例如,如果在到达ret之前有足够的指令供前端通过,则总时间可能会在4-5个周期的空函数时间之外增加,即使总功小于这个,但这是一个很好的一阶近似

第一个函数需要足够的时间,以使实际工作支配执行时间。但是,第二个函数的速度要快得多,它可以隐藏在调用/重试机制占用的现有时间之下

解决方案很简单:将函数中的功复制N次,使功始终占主导地位。N=10或N=50或类似的情况都可以。您必须决定是要测试延迟,在这种情况下,工作的一个副本的输出应该反馈到下一个副本,还是吞吐量,在这种情况下,不应该

另一方面,如果你真的想测试函数调用+工作的成本,例如,因为这就是你在现实生活中使用它的方式,那么你得到的结果很可能已经接近正确:当它隐藏时,东西真的可以增量免费 指定一个函数调用


1我在这里引用延迟,因为我们不清楚是应该讨论call/ret的延迟还是吞吐量。调用和RET没有任何明确的输出,RET没有输入,因此它不参与一个基于登记的传统依赖链——但是如果考虑像指令指针这样的其他隐藏的架构组件,则可能会考虑延迟。在这两种情况下,吞吐量的延迟大多指向同一件事,因为线程上的所有调用和ret都在相同的状态下运行,因此,有独立的调用链和依赖的调用链是没有意义的。

周围循环的成本以及从空函数和实函数返回的成本都将根据分支是否被正确预测而变化很多个周期。一般来说,这种微观基准测试往往是徒劳的。您是否尝试过使用RDTSC指令来测量时间?别忘了使用CPUID之类的工具来防止CPU的无序执行把你搞得一团糟。需要考虑的其他事项:禁用中断、禁用turbo(即固定时钟频率)和监视CPU计数器,以查看实际执行的预期指令数,例如perf utility。您没有使用函数的结果,因此,处理器可以几乎任意地按顺序重叠指令+无依赖项+您的基准代码不使用向量/浮点寄存器,而基准代码只使用向量/浮点寄存器,因此只会在管道化较差的指令/端口SQRTS上显示长指令延迟。实际上,您测量的是指令吞吐量而不是延迟。从中,SQRTS和rsqrtss的吞吐量分别为7-18和2个周期。此外,mulss的吞吐量为1个周期,这取决于rsqrtss。第一个函数的吞吐量12.45±0.44实际上可能比实际值小。我认为空函数时间过高估计了测量开销。您应该内联代码并改用RDTSC。请参阅:。我不知道movaps xmm1,xmm0的意义是什么。周围循环的成本以及null函数和实函数返回的成本都将根据分支是否正确预测而变化许多周期。一般来说,这种微观基准测试往往是徒劳的。您是否尝试过使用RDTSC指令来测量时间?别忘了使用CPUID之类的工具来防止CPU的无序执行把你搞得一团糟。需要考虑的其他事项:禁用中断、禁用turbo(即固定时钟频率)和监视CPU计数器,以查看实际执行的预期指令数,例如perf utility。您没有使用函数的结果,因此,处理器可以几乎任意地按顺序重叠指令+无依赖项+您的基准代码不使用向量/浮点寄存器,而基准代码只使用向量/浮点寄存器,因此只会在管道化较差的指令/端口SQRTS上显示长指令延迟。实际上,您测量的是指令吞吐量而不是延迟。从中,SQRTS和rsqrtss的吞吐量分别为7-18和2个周期。此外,mulss的吞吐量为1个周期,这取决于rsqrtss。第一个函数的吞吐量12.45±0.44实际上可能比实际值小。我认为空函数时间过高估计了测量开销。您应该内联代码并改用RDTSC。请看:。我不明白movaps xmm1,xmm0的意义是什么。@PeterCordes老实说,我已经很久没有使用过Nehalem了,而且我对uArch不是特别熟悉,所以在这个答案中有几点我可能有点偏离了。特别是rsqrtss和mulss对执行端口的使用可能会发生冲突,并导致吞吐量比我所写的低很多。我不介意您是否编写了答案或编辑了此答案。1 OP仅提供了一段代码示例,其执行时间需要测量。因此,一般来说,其他代码可能需要刷新缓存。2类似地,生成线程的时间片通常可能很有用,例如,当定时区域的执行时间足够小时。3我敢肯定,能写这样一个问题的人对现代CPU的工作原理有一定的了解。4调用指令并不是完全免费的,它会被解码,并且会消耗执行资源……5因此,如果该负载破坏了dep链,该怎么办?这显然是OP想要的。6我认为环路吞吐量可能超过2个周期,因为rsqrtss和mulss在端口0和1上竞争,但我们必须测量它。7我认为减去呼叫开销并不会导致无意义的结果,而是导致比实际吞吐量小几个周期的结果
呃这个问题并没有荒谬的错误。使用RDTSC允许OP使用较少的迭代次数,大约为1亿次,而不是1亿次。在这种情况下,可能不需要RDTSCP,但可以肯定的是,RDTSCP是可以使用的。一般来说,简单地将基准测试方法从延迟更改为吞吐量并不能解决这个问题。这可能会在这里起作用,因为与inv吞吐量相比,延迟足够慢,因此会超过呼叫成本,但情况肯定并非总是如此。@PeterCordes老实说,我很久没有使用过Nehalem,而且我对uArch也不是特别熟悉,所以在这个答案中有几点我可能会有点偏离。特别是rsqrtss和mulss对执行端口的使用可能会发生冲突,并导致吞吐量比我所写的低很多。我不介意您是否编写了答案或编辑了此答案。1 OP仅提供了一段代码示例,其执行时间需要测量。因此,一般来说,其他代码可能需要刷新缓存。2类似地,生成线程的时间片通常可能很有用,例如,当定时区域的执行时间足够小时。3我敢肯定,能写这样一个问题的人对现代CPU的工作原理有一定的了解。4调用指令并不是完全免费的,它会被解码,并且会消耗执行资源……5因此,如果该负载破坏了dep链,该怎么办?这显然是OP想要的。6我认为环路吞吐量可能超过2个周期,因为rsqrtss和mulss在端口0和1上竞争,但我们必须测量它。7我认为减去呼叫开销并不会导致无意义的结果,而是导致比实际吞吐量小几个周期的结果。我在这个问题下的评论并没有荒谬的错误。使用RDTSC允许OP使用较少的迭代次数,大约为1亿次,而不是1亿次。在这种情况下,可能不需要RDTSCP,但可以肯定的是,RDTSCP是可以使用的。一般来说,简单地将基准测试方法从延迟更改为吞吐量并不能解决这个问题。这可能会在这里起作用,因为与inv吞吐量相比,延迟足够慢,因此会超过呼叫成本,但情况肯定并非总是如此。