内联、指令缓存命中率和抖动

9
在这篇文章https://www.geeksforgeeks.org/inline-functions-cpp/中,它指出内联的缺点有:

3) 过多的内联也会降低指令缓存命中率,从而将指令获取速度从缓存内存降低到主内存的速度。

内联如何影响指令缓存命中率?

6) 内联函数可能会导致抖动,因为内联可能会增加二进制可执行文件的大小。内存抖动会导致计算机性能下降。

内联如何增加二进制可执行文件的大小?它只是增加了代码库的长度吗?此外,我不清楚为什么拥有更大的二进制可执行文件会导致抖动,因为这两者似乎没有关联。

1
为什么要踩?这是一个完全合理的问题。 - Trajan
不是我给你的负评。但是SO主要解决特定问题并提供解决方案,通常不会提供“我不理解这个,请解释一下”的答案。你得到了一些来自友好人士的帮助,但也因为有人认为你在发布问题之前应该更加努力地研究这个话题而受到了负评。 - Patrick Artner
现代台式机/笔记本电脑不太可能因为代码过大而导致交换空间/虚拟内存频繁抖动,除非进行了极其不合理的内联处理,生成数百或数千兆字节的可执行文件,或者整个系统都使用了一个疯狂的编译器编译,所有程序都变得非常庞大,并且同时运行多个程序,而你“只有”几个 GiB 的物理内存。 - Peter Cordes
3
可能是因为geeksforgeeks网站被认为包含很多垃圾信息,不是一个好的学习来源。在这种情况下,一些信息看起来像是25年前Turbo-C++文档的引用。正确使用内联可以使代码更小更快。 - Bo Persson
1
@Jurassic - 我不是DV(我UVed),但通常情况下,你会遇到一群人对基于“标准没有说”、“你不需要知道那个”、“只需编写简单的代码,编译器会解决它”、“这是实现特定的”、“编译器比你聪明”、“它需要快吗”或其他与实现或性能相关的问题持有厌恶态度。许多这样的人都在C/C++标签上闲逛,如果你在那里问这种类型的问题,你会立即被DV。一个提示是只需将这些标签留空,而是包括其他标签。 - BeeOnRope
显示剩余4条评论
4个回答

9
可能有些人会对为什么内联会降低指令高速缓存命中率或者导致抖动感到困惑,这可能是因为静态指令计数和动态指令计数之间的差异。内联(几乎总是)减少后者但通常增加前者。
让我们简要地讨论一下这些概念。
静态指令计数
某个执行跟踪的静态指令计数是二进制影像中出现的唯一指令数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,并且溢出调用者保存的寄存器。更一般地说,对于函数调用可能存在很大的开销,因为编译器必须在函数调用之间重置其许多假设,例如内存状态。

6
假设您有一个函数,它有100条指令,并且每次调用它时需要10条指令。
这意味着对于10次调用,它将在二进制中使用100 + 10 * 10 = 200条指令。
现在假设它被内联到每个使用它的地方。这将在您的二进制文件中使用100*10 = 1000条指令。
因此,在第3点中,这意味着它将在指令缓存中占用更多的空间(内联函数的不同调用在i-cache中不是“共享”的)。
而在第6点中,您的总二进制文件大小现在更大了,更大的二进制文件大小可能会导致抖动。

1
这些计算不正确。当一个函数被内联时,它的代码会与调用它的函数的代码一起进行优化。因此,得到的指令总数可能与各个函数的指令数量之和非常不同。 - Hadi Brais
我不明白为什么更大的二进制文件大小会导致抖动。似乎并不相关,因为我不明白二进制文件大小和内存管理之间的联系。 - Trajan
2
这里的二进制大小也与i-cache工作集或静态指令占用空间有关。基本上,您的i-cache具有有限的大小,因此执行更多的总代码(按使用的缓存行计算)意味着您在各种缓存级别上会有更大的压力。这是因为内联基本上是关于_复制_代码的。抖动是相同的事情,只是“i-cache”被“页面缓存”或“虚拟内存/交换”之类的东西所取代。不过,根据我们所讨论的相对较小的尺寸,我认为很少会出现向磁盘抖动的情况。 - BeeOnRope

4
如果编译器尽可能地内联所有内容,大多数函数都会变得巨大。(虽然您可能只有一个巨大的main函数调用库函数,但在最极端的情况下,程序中的所有函数都将内联到main中)。想象一下,如果每个东西都是宏而不是函数,那么它就会在您使用它的任何地方完全展开。这是源代码级别的内联版本。
大多数函数有多个调用点。调用函数的代码大小随参数数量略微增加,但通常与中等到大型函数相比非常小。因此,在所有调用点内联大型函数将增加总代码大小,降低I-cache命中率。
但是这些天在编写许多小包装/辅助函数方面是常见做法,特别是在C++中。独立版本的小函数的代码通常不比调用它所需的代码大多少,尤其是当您包括函数调用的副作用(如损坏寄存器)时。内联小函数通常可以节省代码大小,特别是在内联后进一步优化变得可能的情况下。(例如,函数计算某些与函数外部代码也计算的内容相同,因此可以进行CSE)。
因此,对于编译器来说,是否将特定的调用点内联的决策应基于被调用函数的大小,以及它是否在循环内被调用。 (如果调用点运行得更频繁,则优化掉调用/返回开销更有价值。)通过"花费"更多的代码大小在热函数上,并在冷函数中节省代码大小(例如,许多函数仅在程序生命周期内运行一次,而少数热点函数占据了大部分时间),可以帮助编译器做出更好的决策。
如果编译器没有好的内联启发式方法,或者您将其覆盖为过于激进的方法,则会导致I-cache缺失。
但是,现代编译器确实具有良好的内联启发式方法,通常这使程序显着加快,但只增加了一点点的大小。 您所读的文章谈到了为什么需要限制。
上述代码大小的推理应该很明显,可执行文件的大小会增加,因为它没有缩小数据。许多函数仍然在可执行文件中有一个独立的副本,并且在各种调用站点中进行了内联(和优化)拷贝。
有一些因素可以缓解I-cache命中率问题。更好的局部性(不跳来跳去)让代码预取做得更好。许多程序在其总代码的一小部分中花费大部分时间,通常在内联后仍适合I-cache。
但是,较大的程序(如Firefox或GCC)有很多代码,并从大型“热”循环中的许多调用站点调用相同的函数。过多的内联膨胀每个热循环的总代码大小会损害它们的I-cache命中率。

内存抖动会导致计算机性能下降。

https://en.wikipedia.org/wiki/Thrashing_(computer_science)

在拥有多个GiB RAM的现代计算机上,除非系统中的每个程序都使用极度激进的内联编译,否则虚拟内存(分页)的抖动是不可信的。如今,大多数内存都被数据占用,而不是代码(特别是在运行GUI的计算机中的像素图),因此代码需要爆炸几个数量级才能开始对总体内存压力产生真正的影响。
抖动I-cache基本上就是有很多I-cache未命中。但是可能会超越这一点,进入缓存代码+数据的更大统一缓存(L2和L3)的抖动。

内联也可以增加重用距离并减少使用频率,从而降低代码在共享缓存级别中的可能性。这在一定程度上独立于容量效应;正如所述,数据使用可能会主导容量。 (在多个线程甚至具有共享库的进程之间共享时,倾向于增加此效果。) - user2467198

1
通常来说,由于调用点被替换成更大的代码块,内联倾向于增加发出的代码大小。因此,可能需要更多的内存空间来存储代码,这可能会导致抖动。我将稍微详细讨论一下这个问题。
“内联”如何影响指令高速缓存命中率?
通常情况下,内联对性能的影响很难在不实际运行代码并测量其性能的情况下进行静态特征化。
是的,内联可能会影响代码大小,并且通常会使发出的本机代码变大。让我们考虑以下情况:
  • 代码在特定时间内执行,无论是否进行内联,都适合于特定级别的内存层次结构(例如L1I)。因此,相对于该特定级别的性能不会改变。
  • 在未进行内联的情况下,特定时间内执行的代码适合于特定级别的内存层次结构,但是在进行内联时不适合。这可能会对性能产生影响,取决于执行的局部性。基本上,如果代码中最热门的部分首先位于该内存级别中,则该级别的缺失率可能会略微增加。现代处理器的特性,如预测执行、乱序执行、预取等,可以隐藏或减少附加缺失的惩罚。需要注意的是,内联确实提高了代码的局部性,这可能会导致性能的净正面影响,尽管代码大小增加。当在调用站点内联的代码经常执行时,这一点尤其正确。已经开发了部分内联技术,仅内联被视为热点的函数部分。
  • 在特定时间内执行的代码在两种情况下都不适合于特定级别的内存层次结构。因此,相对于该特定级别的性能不会改变。
此外,我不清楚为什么具有较大的二进制可执行文件会导致抖动,因为这两者似乎没有关联。考虑在资源受限制的系统上的主存储器级别。即使代码大小仅增加了5%,也可能在主存储器上引起抖动,从而导致显着的性能下降。在其他资源丰富的系统(桌面,工作站,服务器)上,通常只在缓存时发生抖动,当热指令的总大小太大无法适应一个或多个缓存时发生。

2
是的,应该附带下降原因。 - Trajan
@BeeOnRope 我确实想知道为什么增加代码大小会减少i-cache命中率。 - Trajan

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接