我写了一些代码来分析小函数。在高层次上:
- 将线程亲和性设置为仅一个核心,并将线程优先级设置为最大。
通过执行以下 100 次计算统计信息:
- 估计什么都不做的函数的延迟。
- 估计测试功能的延迟。
- 从第二个中减去第一个以消除执行函数调用开销的成本,从而粗略地得到测试函数内容的成本。
要估计函数的延迟,它:
- 使缓存无效(这在用户模式下实际上很难做到,但我分配了一个 L3 大小的缓冲区并将其写入内存,这可能会有所帮助)。
- 产生线程,以便配置文件循环具有尽可能少的上下文切换。
- 从 a 获取当前时间
std::chrono::high_resolution_clock
(似乎编译为system_clock
, 但是)。 - 运行配置文件循环 100,000,000 次,在其中调用测试函数。
- 从 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)。)
问题又来了:为什么某些异形函数会产生难以置信的小值?如果是高阶效应,解释一下?