我已经编写了一些用于分析小功能的代码。在较高级别上:
通过以下100次计算统计信息:
要估算功能的延迟时间,它:
std::chrono::high_resolution_clock
(似乎编译为system_clock
,但是)获取当前时间。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()>
...
Intel 990X上sqrt_sseinstr(float)
的计时结果为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
是here,其中包括其他几个变体,托管二进制文件here(x86-64 SSE2 +,Linux)。
问题再次出现:为什么某些配置文件函数会产生难以置信的小值?如果是高阶效应,请解释?
答案 0 :(得分:4)
问题在于基本方法,该方法减去空函数的“等待时间” 1 ,如下所述:
- 估计不执行任何操作的延迟。
- 估计测试功能的延迟时间。
- 从第二个减去第一个,以消除执行函数调用开销的成本,从而大致获得测试的成本 函数的内容。
内置的假设是,调用函数的成本为X,如果在函数中完成工作的延迟为Y,则总成本将类似于X + Y
。
这通常不适用于任何两个工作块,尤其是当其中一个工作块“调用函数”时,情况并非如此。更为复杂的观点是,总时间将在min(X, Y)
和X + Y
之间-但这甚至取决于细节通常也是错误的。不过,足以解释这里发生的事情是足够的:功能的成本并不能与功能中的工作相加:它们并行发生。
在现代的Intel上,空函数调用的成本大约为4到5个周期,这可能是两个已采取分支的前端吞吐量的瓶颈,而分支和返回预测变量的延迟是possibly。
但是,当您向空函数添加其他工作时,它通常不会竞争相同的资源,并且其执行指令也不会取决于调用的“输出”(即,工作将形成一个单独的依赖关系链),但在极少数情况下可能会操纵堆栈指针并且堆栈引擎不会删除依赖关系。
因此,本质上,该函数将花费函数调用机制所需的时间 ,该时间是该函数完成的实际工作的时间。这种近似是不正确的,因为某些类型的工作实际上可能会增加函数调用的开销(例如,如果在到达ret
之前,有足够的指令供前端通过,则总时间即使总功夫少于4-5个周期的空函数时间,它也会增加)-但这是一个很好的一阶近似值。
您的第一个函数花费的时间足以使实际工作占据执行时间。但是,第二个功能要快得多,可以使其“隐藏”在呼叫/回复机制所花费的现有时间之下。
解决方案很简单:在函数中重复工作N次,这样工作总是占主导地位。 N = 10或N = 50或类似的值。您必须决定是否要测试延迟,在这种情况下,一个工作副本的输出应馈入下一个工作,在吞吐量情况下则应避免吞吐量。
另一方面,如果您实际上想测试函数调用+工作的成本,例如,因为这是您在现实生活中的使用方式,则可能您获得的结果已经接近正确:当东西隐藏在函数调用后面时,它们实际上可以是“增量自由的”。
1 我在这里在引号中加上“等待时间”,因为尚不清楚我们是否应该讨论call/ret
的等待时间或吞吐量。 call
和ret
没有任何显式输出(并且ret
没有输入),因此它不参与经典的基于寄存器的依赖关系链-但这可能很有意义如果考虑其他隐藏的体系结构组件(如指令指针),则需要考虑延迟。无论哪种情况,吞吐量的延迟都指向同一件事,因为线程上的所有call
和ret
都在相同的状态下工作,因此说“独立”与“依赖”呼叫链。
答案 1 :(得分:1)
您的基准测试方法从根本上是错误的,并且您的“仔细的代码”是虚假的。
首先,清空缓存是虚假的。它不仅会很快用所需的数据重新填充,而且您发布的示例中的存储交互也很少[em]很少(仅call/ret
进行的缓存访问以及我们将要处理的负载。
第二,基准循环之前的屈服是虚假的。您迭代了1亿次,即使是在相当快的现代处理器上,它也要比常规操作系统上的典型调度时钟中断花费更长的时间。另一方面,如果您禁用调度时钟中断,则在基准测试之前不执行任何操作。
现在,关于现代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的一小部分揭示了为什么并行执行不适用于Nehalem上的sqrtss
:
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()
的固定点,否则您不会以此基准对相同的 input 进行基准测试。但是,您可以通过计算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()
的值。