可能有些人会对为什么内联会降低指令高速缓存命中率或者导致抖动感到困惑,这可能是因为静态指令计数和动态指令计数之间的差异。内联(几乎总是)减少后者但通常增加前者。
让我们简要地讨论一下这些概念。
静态指令计数
某个执行跟踪的静态指令计数是二进制影像中出现的唯一指令数
0。基本上,您只需计算汇编转储文件中的指令行数。以下 x86 代码片段的静态指令计数为5(
.top:
行是一个标签,在二进制文件中没有任何变化):
mov eci, 10
mov eax, 0
.top:
add eax, eci
dec eci
jnz .top
静态指令计数在二进制大小和缓存考虑方面非常重要。静态指令计数也可以简称为“代码大小”,下文中有时会使用该术语。
动态指令计数取决于实际运行时行为,是执行的指令数量。同一静态指令可能由于循环和其他分支而被多次计算,静态计数中包括的某些指令可能根本不会执行,因此在动态情况下不计数。上述代码片段的动态指令计数为2 + 30 = 32:前两条指令执行一次,然后循环执行10次,每次迭代3条指令。
作为一个非常粗略的近似值,动态指令计数主要对运行时性能很重要。
许多优化技术,如循环展开、函数克隆、矢量化等,增加了代码大小(静态指令计数),以提高运行时性能(通常与动态指令计数强相关)。
内联也是这样一种优化,尽管有一个扭曲的地方,对于一些调用站点,内联减少了动态和静态指令计数。
引用块中提到了内联如何影响指令缓存命中率?文章提到了过度内联,基本思想是大量内联通过增加工作集的静态指令计数来增加代码占用空间,而通常会减少其动态指令计数。由于典型的指令缓存缓存静态指令,因此更大的静态占用空间意味着增加了缓存压力,并且通常导致较差的缓存命中率。
静态指令计数的增加是因为内联实质上在每个调用站点上复制函数体。因此,与调用函数N次并调用函数的几条指令相比,您最终将得到函数体的N个副本。
现在,这是内联如何工作的一个相当幼稚的模型,因为在内联之后,可能会在特定调用站点的上下文中进行进一步的优化,这可能会显著减小内联代码的大小。在内联非常小的函数或大量后续优化的情况下,结果代码在内联之后甚至可能更小,因为剩余的代码(如果有)可能比调用函数所涉及的开销还要小。
尽管如此,基本思想仍然存在:过度内联可能会使二进制图像中的代码膨胀。
i-cache的工作方式取决于某个执行的静态指令计数,更具体地说是二进制映像中涉及的指令高速缓存行数,这在很大程度上是静态指令计数的一个直接函数。也就是说,i-cache会缓存二进制映像的区域,因此区域越多、越大,缓存占用空间就越大,即使动态指令计数较低也是如此。
内联如何增加二进制可执行文件的大小?
与上述i-cache情况完全相同:更大的静态占用空间意味着需要换入更多不同的页面,可能会给虚拟内存系统带来更大压力。现在我们通常以MB为单位衡量代码大小,而服务器、台式机等内存通常以GB为单位衡量,因此过度的内联几乎不可能对这些系统的抖动产生实质性的影响。但它可能会成为更小或嵌入式系统(尽管后者通常根本没有MMU)的一个问题。
0 这里“unique”是指指令IP的唯一性,例如不是编码指令的实际值。你可能在二进制文件的多个位置找到"inc eax",但每个位置都是唯一的,因为它们出现在不同的位置。
1 有一些例外,例如某些类型的跟踪高速缓存。
2 在x86上,必要的开销基本上只是"call"指令。根据调用站点,可能还会有其他开销,例如将值移动到正确的寄存器中以符合ABI,并且溢出调用者保存的寄存器。更一般地说,对于函数调用可能存在很大的开销,因为编译器必须在函数调用之间重置其许多假设,例如内存状态。