面向现代C++ CPU的高效C++编程,第4章/第2部分

Hacker News Top 新闻

摘要

本书草稿章节提供了一个信息图以及对现代C++ CPU的CPU时钟周期中操作成本的详细分析,涵盖乘法、除法和RTTI,并附有各种架构的延迟表。

暂无内容
查看原文
查看缓存全文

缓存时间: 2026/06/22 04:30

# 信息图:CPU时钟周期操作成本,第二版 - 6IT 来源:https://6it.dev/blog/infographics-operation-costs-in-cpu-clock-cycles-take-2-80736 *本文是雪莉·伊格纳琴科(Sherry Ignatchenko)与德米特罗·伊万奇金(Dmytro Ivanchykhin)即将出版的书籍《面向现代64位CPU的高效C++编程》第一卷第四章的第二部分草稿。欢迎提出意见——特别是发现任何事实不一致之处,我们将乐于修正。* *第四章的第一部分请见*https://6it.dev/blog/on-cpu-physics-and-cpu-cycles-80730。 ## ## 冷冰冰的数字 现在,结合以上所有信息(并补充来自[Ignatchenko16a](https://6it.dev/blog/infographics-operation-costs-in-cpu-clock-cycles-take-2-80736#ref-Ignatchenko16a)、[Fog](https://6it.dev/blog/infographics-operation-costs-in-cpu-clock-cycles-take-2-80736#ref-Fog)以及下文列出的其他来源的内容),我们可以绘制一张至关重要的图表,以替代许多实际场景下的微基准测试,并展示开发者常用操作的成本。 *免责声明:以下所有数字仅在同一数量级内准确!* 注意,其中一些项目并未在上文讨论,可能需要额外解释: ### 乘法 / 除法 在所有寄存器到寄存器操作中,乘法和尤其是除法在性能方面相当特殊。除了乘法和除法之外的所有整数操作都极其便宜(Darn Cheap™)——它们的延迟通常仅为1个CPU周期,或者如果该特定操作被CPU设计师认为不太重要,则可能为2个CPU周期;此外,对于非乘除操作,通常不会因操作数的位宽而产生差异:也就是说,除了乘法和除法之外的任何操作,在处理8位、16位、32位和64位操作数时所用时间大致相同。 相比之下,乘法往往需要3-5个CPU周期,而且对于32位和64位操作数,所需CPU周期数可能不同。至于除法,情况更糟:即便是现代CPU,64位除法也可能需要多达15个CPU周期(我们应该感谢这个数字,因为[Fog](https://6it.dev/blog/infographics-operation-costs-in-cpu-clock-cycles-take-2-80736#ref-Fog)列出的数字是100+ CPU周期,哎呀!)。对于更新的CPU,[uops](https://6it.dev/blog/infographics-operation-costs-in-cpu-clock-cycles-take-2-80736#ref-uops)和[Cortex-A78](https://6it.dev/blog/infographics-operation-costs-in-cpu-clock-cycles-take-2-80736#ref-Cortex-A78)给出了以下操作延迟(以CPU周期为单位): Skylake-X (2017) Zen 2 (2019) Cortex-A78 (2020) Alder Lake-P (2021) Alder Lake-E (2021) Zen 4 (2022) 架构 x64/Intel x64/AMD AArch64 x64/Intel x64/Intel x64/AMD IMUL R32, R32 3 3 2 (MUL, W格式) 3 3 3 IMUL R64, R64 3 3 2 (MUL, X格式) 3 5 3 IDIV R32 23-28 8-25 5-12 (DIV, W格式) 10-15 11-28 9-14 IDIV R64 37-96 8-41 5-20 (DIV, X格式) 14-18 11-24 9-19 总结一下(针对真正现代64位CPU,大致从2019-2021年开始): - 尽管进行了所有改进,除法仍然相当昂贵(Darn Expensive™),尽管不像过去那样骇人听闻。 - 大约从2020年起,32位和64位除法之间的差距已不那么显著,但仍然存在。 - 至于乘法——32位和64位版本几乎相同(除某些E核外)。 TODO:添加浮点乘除 ### RTTI RTTI可能相当昂贵,其中`dynamic_cast<>`比简单的虚函数调用要贵多达5倍(确实如此!)[TR](https://6it.dev/blog/infographics-operation-costs-in-cpu-clock-cycles-take-2-80736#ref-TR)[Didriksen](https://6it.dev/blog/infographics-operation-costs-in-cpu-clock-cycles-take-2-80736#ref-Didriksen)。 TODO:基准测试虚调用 vs typeid vs dynamic_cast的性能(各来源数据矛盾且可能过时,需自行实验) 不过请注意,与某些说法(例如[Fog04, 第7.23节](https://6it.dev/blog/infographics-operation-costs-in-cpu-clock-cycles-take-2-80736#ref-Fog04))相反,RTTI并不会给类对象实例增加任何开销(对于多态类,RTTI使用已存在的vfptr;对于非多态类,则完全没有RTTI)。尽管如此,RTTI确实增加了代码体积。 ### C++异常 vs 返回值并检查 C++长期以来主张其异常比C风格的返回错误并检查(return-error-and-check)更高效。事实上,对于错误极其罕见的代码,这一说法似乎成立。例如,[Nayar](https://6it.dev/blog/infographics-operation-costs-in-cpu-clock-cycles-take-2-80736#ref-Nayar)的微基准测试表明,带有错误码的返回比不带错误码的返回多花费2个CPU周期。另一方面,当C++异常实际发生时,其代价极其高昂(Damned Lot™)——根据[Ongaro](https://6it.dev/blog/infographics-operation-costs-in-cpu-clock-cycles-take-2-80736#ref-Ongaro),异常大约花费5,000个CPU周期(确实如此!),而根据[Nayar](https://6it.dev/blog/infographics-operation-costs-in-cpu-clock-cycles-take-2-80736#ref-Nayar)则为大约2,700个周期。这意味着如果每100次函数调用发生1次异常,那么使用返回错误码更好;但如果每发生一次异常有10,000次或更多成功调用,则C++异常胜出。 是的,我们知道这与[Fog04](https://6it.dev/blog/infographics-operation-costs-in-cpu-clock-cycles-take-2-80736#ref-Fog04)的声明——“所有函数都必须为异常处理程序保存一些信息,即使从未发生过异常”——相矛盾,但截至2026年,这一声明已完全过时。这一观察可能指的是在特殊调整的栈帧中存储展开信息(参见[TR, 第5.4节](https://6it.dev/blog/infographics-operation-costs-in-cpu-clock-cycles-take-2-80736#ref-TR)中的“The Code Approach”),但长期以来,所有主流编译器都实现了[TR](https://6it.dev/blog/infographics-operation-costs-in-cpu-clock-cycles-take-2-80736#ref-TR)同一节中的“Table Approach”,现在通常被称为“零成本异常”。 ### 原子操作、CAS 和 LL/SC > ### FSB 前端总线 历史上,原子操作(特别是CAS=比较并交换)在基于FSB的架构中通过系统级“LOCK”在FSB上实现。然而,自从21世纪初FSB消失后(感谢AMD64和Opteron在x86/x64世界中开创了转向NUMA的先河),现在这一切都依赖于某种缓存一致性协议,通常是MESI的变种。关于涉及的CPU周期,[AlBahra](https://6it.dev/blog/infographics-operation-costs-in-cpu-clock-cycles-take-2-80736#ref-AlBahra)估计CAS操作大约需要15个CPU周期;但在多插槽NUMA场景下,很容易达到300-600个周期。 此外,原子操作往往产生一些令人不快的副作用,超出直接的延迟增加;在FSB时代,原子操作曾导致全局总线锁定,这意味着在多核/多插槽系统上性能显著下降;如今,随着FSB被NUMA和类MESI协议取代,情况有所好转,但仍引起相当显著的影响,例如“阻碍指令级并行性,显著限制带宽(与简单写入相比高达30倍),即使连续操作之间没有依赖关系”[SchweizerEtAl](https://6it.dev/blog/infographics-operation-costs-in-cpu-clock-cycles-take-2-80736#ref-SchweizerEtAl);另见[Josuttis](https://6it.dev/blog/infographics-operation-costs-in-cpu-clock-cycles-take-2-80736#ref-Josuttis)关于写入停顿等相关影响的讨论。 > ### LL/SC 链接加载 / 条件存储。条件存储仅在自上次链接加载以来该位置未被更新时才存储数据。 另请注意,RISC-V下的原子操作支持并非面向CAS,而是面向LL/SC,有其自身的一系列优势和特性;近期的ARM同时支持LL/SC和CAS,而x64传统上则面向CAS。 ### 函数调用与内联 历史上,[BulkaMayhew, p.115](https://6it.dev/blog/infographics-operation-costs-in-cpu-clock-cycles-take-2-80736#ref-BulkaMayhew)估计函数调用的CPU成本约为25-250个周期,具体取决于参数数量。不过,这本2000年的书现在感觉相当过时,我们未能找到足够权威的现代参考😕。尽管如此,根据我们自身的经验,参数数量适中的函数通常成本更接近于15-30个周期——即直接成本。这一观察对于非Intel CPU似乎也一致,如[Ruskin](https://6it.dev/blog/infographics-operation-costs-in-cpu-clock-cycles-take-2-80736#ref-Ruskin)所指出的。 对于间接调用(通过函数指针调用),[Ignatchenko16a](https://6it.dev/blog/infographics-operation-costs-in-cpu-clock-cycles-take-2-80736#ref-Ignatchenko16a)给出了20-50个周期的估计,而C++虚函数成本为30-60个周期,这似乎也与[Ruskin](https://6it.dev/blog/infographics-operation-costs-in-cpu-clock-cycles-take-2-80736#ref-Ruskin)一致。最后但同样重要的是,需要考虑当编译器内联一个函数时会发生什么。在这种情况下,我们不仅不再承担函数调用的直接成本(无论是直接调用、间接调用还是虚调用)——而且编译器在内联后,会将调用函数加上内联的被调用函数作为一个整体进行优化。仅举一例:如果代码形如: `` int square(int x) { return x*x; } int cube(int x) { return x*x*x; } int g(int x) { return square(x) + cube(x); } `` 那么当`g()`与内联的`square()`和`cube()`一起编译时,x64 Clang将其简化为简单的: `` mov eax, edi imul eax, eax imul edi, eax add eax, edi `` 如我们所见,只有2次乘法和1次加法。然而,当禁止内联时,有效生成的代码(除了可归因于直接函数调用成本的额外`mov`、`push`/`pop`以及对栈指针的操作)会产生3次`imul`和1次`add`,即由于缺乏内联,至少有一个`imul`没有被优化掉。换句话说,在非内联代码中,至少有一个`imul`代表了由于缺乏内联而导致的机会成本损失。顺便说一句,这完全合理——稍微思考一下就会发现,没有内联,这种通过识别出`square()`的计算可用于`cube()`的计算来节省一次乘法的优化(编译器将`x*x + x*x*x`重写为`(x*x)*(1+x)`)根本不可能实现(确实如此!)。在实践中,这种机会成本损失很容易达到数十个CPU周期;再举一个例子,如果有两次对相同参数的函数`f()`的调用,且`f()`可能任意昂贵,编译器在内联之前无法优化掉第二次对`f()`的调用(嘿,如果`f()`有副作用怎么办?),但一旦它能看到`f()`的主体,它就有足够的信息意识到:“嘿,它没有副作用,所以我们可以优化掉第二次调用!”。另见[TODL: hint # about non-mutable semantics]中内联救场的例子,以及第12章中关于[Carruth19](https://6it.dev/blog/infographics-operation-costs-in-cpu-clock-cycles-take-2-80736#ref-Carruth19)的讨论。 让我们注意(正如[Godbolt19](https://6it.dev/blog/infographics-operation-costs-in-cpu-clock-cycles-take-2-80736#ref-Godbolt19)所指出的),有些(尽管不是全部)因内联而启用的优化实际上并非内联本身所致,而是由于函数体对编译器可见(例如上面关于第二次调用函数`f()`的例子);另一方面,在不内联的情况下确保可见性虽然有可能(例如通过在头文件中声明函数并强制不内联),但通常不太实用,因此“内联有助于编译器优化”这句老话仍然成立。 ### 线程本地存储 那些`thread_local`变量(与直接访问数据相比)并非免费;此外,不同编译器的实现在同一个平台上也可能差异显著;上次我们检查时,对于x64下的GCC和Clang,TLS的指针实际存储在FS寄存器中,访问TLS需要一次额外的间接寻址;而在MSVC下,可能涉及多达3次额外间接寻址——尽管这些被访问的位置通常被良好缓存,但它们仍然是间接寻址,这增加了访问`thread_local`变量的成本😔。 ### 线程上下文切换 关于线程上下文切换,普遍共识是它们“极其昂贵”(毕竟,这很可能是*nginx*胜过*Apache*的原因)。但“极其昂贵”到底有多贵?根据我们自身的经验,上下文切换的成本大约在10,000到100,000个CPU周期之间。这与一些相关的真实世界观察吻合;例如,对于Windows/x64下的CRITICAL_SECTION,默认自旋计数为4000次迭代。这意味着通常值得消耗4000次自旋——至少消耗15-20K CPU周期,有时更像40-50K——仅仅是为了等待一个锁,唯一目的是避免那个该死的线程上下文切换(并且有可能所有4000次自旋都被浪费,最终仍进入上下文切换,同时承受自旋和上下文切换的成本!)。当然,这强烈表明自旋锁试图避免的上下文切换明显比浪费的15-50K CPU周期更昂贵。尽管如此,许多来源报告的数字低得多。这种差异源于你具体测量的是什么。正如[LiEtAl](https://6it.dev/blog/infographics-operation-costs-in-cpu-clock-cycles-take-2-80736#ref-LiEtAl)所解释的,上下文切换的成本有两种不同类型。 首先是切换线程的直接成本。这是相对轻量级的部分——通常约为2,000个CPU周期。但间接成本——主要是由于缓存失效——往往更为显著。根据[LiEtAl](https://6it.dev/blog/infographics-operation-costs-in-cpu-clock-cycles-take-2-80736#ref-LiEtAl),这可能高达300万个CPU周期。理论上,在完全随机的内存访问模式下,具有12MB L3缓存的现代CPU(假设每次缓存未命中约150个周期——即L3访问与主存访问的比较)每次上下文切换可能遭受高达3000万个周期的惩罚。实际上,通常没那么严重,但即便是100万个周期也显然称得上“极其昂贵”。 --- ### 参考文献 [Ignatchenko16a] S. Ignatchenko,Operation Costs in CPU Clock Cycles (http://ithare.com/infographics-operation-costs-in-cpu-clock-cycles/) 1 ↩ (https://6it.dev/blog/infographics-operation-costs-in-cpu-clock-cycles-take-2-80736#cite-Ignatchenko16a-1) 2 ↩ (https://6it.dev/blog/infographics-operation-costs-in-cpu-clock-cycles-take-2-80736#cite-Ignatchenko16a-2) [Fog] Agner Fog,Instruction tables. Lists of instruction latencies, throughputs and micro-operation breakdowns for Intel, AMD, and VIA CPUs (https://www.agner.org/optimize/instruction_tables.pdf) 1 ↩ (https://6it.dev/blog/infographics-operation-costs-in-cpu-clock-cyc

相似文章

中间浮点精度

Lobsters Hottest

本文探讨了C++代码中的中间浮点精度如何依赖于编译器设置、CPU标志和架构,尤其是在x87 FPU上,以及这如何影响性能和计算结果。

当浮点数除法胜过整数除法

Lobsters Hottest

一篇博客文章,解释了一个反直觉的优化现象:在现代CPU上,使用浮点数除法(DIVSD)比整数除法(IDIVQ)性能更佳,并附有基准测试和汇编分析。

每个字节都很重要

Lobsters Hottest

本文通过Java和C语言的示例,阐述了理解CPU缓存行与数据结构布局对编程性能优化的重要性,讨论了多余字节的开销以及结构体数组与数组结构体之间的权衡。

旋转算法再探:clang的libcxx中的循环分解

The Old New Thing (Raymond Chen)

本文深入探讨了clang的libcxx中用于旋转操作的循环分解算法,解释了该算法如何通过计算最大公约数(gcd)来确定循环数量,从而实现最少的交换次数。