1

我写了一些代码来分析小函数。在高层次上:

  1. 将线程亲和性设置为仅一个核心,并将线程优先级设置为最大。
  2. 通过执行以下 100 次计算统计信息:

    1. 估计什么都不做的函数的延迟。
    2. 估计测试功能的延迟。
    3. 从第二个中减去第一个以消除执行函数调用开销的成本,从而粗略地得到测试函数内容的成本。

要估计函数的延迟,它:

  1. 使缓存无效(这在用户模式下实际上很难做到,但我分配了一个 L3 大小的缓冲区并将其写入内存,这可能会有所帮助)。
  2. 产生线程,以便配置文件循环具有尽可能少的上下文切换。
  3. 从 a 获取当前时间std::chrono::high_resolution_clock(似乎编译为system_clock, 但是)。
  4. 运行配置文件循环 100,000,000 次,在其中调用测试函数。
  5. 从 a 中获取当前时间std::chrono::high_resolution_clock并减去以获得延迟。

因为在这个级别上,单独的指令很重要,所以我们必须在任何时候编写非常仔细的代码,以确保编译器不会删除、内联、缓存或区别对待函数。我已经在各种测试用例中手动验证了生成的程序集,包括我在下面介绍的那个。


在某些情况下,我报告的延迟极低(亚纳秒)。我已经尝试了我能想到的一切来解决这个问题,但找不到错误。

我正在寻找解释这种行为的原因。为什么我的配置文件花费的时间如此之少?


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

函数签名是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()>

    ...

sqrt_sseinstr(float)我的Intel 990X的计时结果是 3.60±0.13 纳秒。在此处理器的额定 3.46 GHz 下,计算结果为 12.45±0.44 个周期。这似乎很准确,因为文档说延迟sqrtss约为 13 个周期(此处理器的 Nehalem 架构没有列出它,但它似乎也可能在 13 个周期左右)。

更奇怪的计时结果sqrt_rcpsseinstr(float):0.01±0.07 纳秒(或 0.02±0.24 个周期)。除非发生其他影响,否则这是完全不可信的。

我想也许处理器能够在某种程度上或完美地隐藏被测函数的延迟,因为被测函数使用不同的指令端口(即超标量隐藏了一些东西)?我试图手动分析这个,但没有走得很远,因为我真的不知道我在做什么。

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


问题又来了:为什么某些异形函数会产生难以置信的小值?如果是高阶效应,解释一下?

4

2 回答 2

4

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

  1. 估计什么都不做的函数的延迟。
  2. 估计测试功能的延迟。
  3. 从第二个中减去第一个以消除执行函数调用开销的成本,从而粗略地得到测试函数内容的成本。

内置假设是调用函数的成本是 X,如果函数中完成的工作的延迟是 Y,那么总成本将类似于X + Y.

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

在现代 Intel 上,空函数调用的成本大约是 4 到 5 个周期,可能是两个采用分支的前端吞吐量的瓶颈,也可能是分支和返回预测器延迟。

但是,当你给一个空函数添加额外的工作时,它一般不会竞争相同的资源,它的执行指令也不会依赖于调用的“输出”(即工作会形成一个单独的依赖链),除非在极少数情况下操作堆栈指针并且堆栈引擎不会删除依赖项。

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

您的第一个函数需要足够的时间,以致实际工作支配了执行时间。然而,第二个函数要快得多,使其能够“隐藏”调用/调用机制所花费的现有时间。

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

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


1我在这里将“延迟”放在引号中,因为不清楚我们是否应该谈论延迟call/ret或吞吐量。call并且ret没有任何显式输出(并且ret没有输入),因此它不参与经典的基于寄存器的依赖链 - 但如果您考虑其他隐藏的架构组件(如指令指针),考虑延迟可能是有意义的. 在任何一种情况下,吞吐量的延迟大多都指向同一件事,因为所有线程callret线程都在相同的状态下运行,因此说“独立”与“依赖”调用链是没有意义的。

于 2019-03-02T23:58:48.323 回答
1

您的基准测试方法从根本上是错误的,并且您的“谨慎代码”是虚假的。

首先,清空缓存是虚假的。它不仅会很快重新填充所需的数据,而且您发布的示例几乎没有内存交互(只有缓存访问call/ret和我们将要进行的负载。

其次,在基准测试循环之前让步是虚假的。您迭代 100000000 次,即使在相当快的现代处理器上,这也将比普通操作系统上的典型调度时钟中断花费更长的时间。另一方面,如果您禁用调度时钟中断,那么在基准测试之前让步不会做任何事情。

既然无用的附带复杂性已经排除,关于现代 CPU 的基本误解:

您期望loop_time_gross/loop_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
...

值得注意的是,负载number总是相同的(因此被快速缓存),它会覆盖刚刚计算的值,从而破坏依赖链

公平地说,

call   rbp
sub    rbx, 0x1
jne    15b0 <double profile(float)+0x20>

仍然执行,但它们从浮点代码中获取的唯一资源是解码/微操作缓存和执行端口。值得注意的是,虽然整数循环代码有一个依赖链(确保最短执行时间),但浮点代码不携带对它的依赖。此外,浮点代码由许多相互完全独立的短依赖链组成。

在您希望 CPU 顺序执行指令的地方,CPU 可以改为并行执行它们。

稍微看一下https://agner.org/optimize/instruction_tables.pdf就会发现为什么这种并行执行不适用于sqrtssNehalem:

instruction: SQRTSS/PS
latency: 7-18
reciprocal throughput: 7-18

即,该指令不能流水线化,只能在一个执行端口上运行。相反,对于movaps, rsqrtss, mulss:

instruction: MOVAPS/D
latency: 1
reciprocal throughput: 1

instruction: RSQRTSS
latency: 3
reciprocal throughput: 2

instruction: MULSS
latency: 4
reciprocal throughput: 1

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

如果您想正确执行此操作,您将确保单独的循环迭代相互依赖,例如通过将基准测试循环更改为

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

显然,您不会以这种方式对相同的输入INITIAL_VALUE进行基准测试,除非benchmarked_function(). 但是,您可以通过计算然后制作循环将其安排为扩展函数的不动点float diff = INITIAL_VALUE - benchmarked_function(INITIAL_VALUE);

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

开销相对较小,但您应该确保浮点错误不会累积以显着更改传递给benchmarked_function().

于 2019-03-02T18:30:29.050 回答