写点什么

令人沮丧的 C++ 调试性能

  • 2022-10-25
    北京
  • 本文字数:4413 字

    阅读完需:约 14 分钟

令人沮丧的C++调试性能

到目前为止,“‘零成本抽象’是一个谎言”应该(希望如此)已经成为一个常识了。公平地说,这更像是用词不当——“抽象在经过优化后可能提供零运行时开销”这样的说法可能会更恰当一些,但我知道为什么不是这么回事……


大多数 C++ 程序员倾向于接受这样一个事实——“零成本抽象”只在启用了优化的情况下才能提供零运行时开销,而且它们对编译速度有负面的影响。同样是这些人,他们倾向于相信这种抽象是如此的有价值,以至于认为让他们的程序在调试模式下执行得很差(即没有启用优化)和编译得更慢是值得的。


我曾经也是他们中的一员。


然而,在过去的几年里,我开始意识到,在某些领域拥有高性能调试和快速编译是多么的重要,比如游戏开发。从事游戏开发的人往往直言不讳地说 C++ 的抽象与他们的工作格格不入,而且他们有充分的理由——游戏是实时模拟的,即使在调试版本中也需要可玩性和响应性——想象一下在 20FPS 左右的帧率下调试虚拟现实游戏导致眩晕的情形。


在本文中,我们将探讨 C++ 的抽象模型如何严重依赖编译器优化,并揭示一些导致意外性能损失的例子。之后,我们将比较三种主要编译器(GCC、Clang 和 MSVC)在这方面的表现,并讨论一些潜在的改进或解决方案。


移动 int 很慢


我在今年的 ACCU 2022 大会上做了一场闪电演讲(“移动 int 很慢:调试性能很重要!”),演讲的题目具有挑衅意味——移动 int 怎么会很慢?


我们来看一下这段代码。


#include <utility>
int main(){ return std::move(0);}
复制代码


C++ 程序员应该知道 std::move(0) 在语义上与 static_cast<int&&>(0) 相同,而且大多数人都希望编译器不会为 move 生成代码,即使禁用了优化。结果是 GCC 12.2、Clang 14.0 和 MSVC v19.x 最终都会生成一个 call 指令。


你可能认为这没什么大不了的——毕竟,这里或那里多出一个额外的 call 指令又有什么关系呢?下面是一个高性能算法的例子,它的内部循环中包含了一个 move。


template <class InputIterator, class _Tp>inline constexprT accumulate(InputIterator first, InputIterator last, T init){    for (; first != last; ++first)#if _LIBCPP_STD_VER > 17        init = std::move(init) + *first;#else        init = init + *first;#endif    return init;}
复制代码


请注意 C++ 17 及以上版本中的 init 对象在每次循环时是如何移动的。具有讽刺意味的是,从 C++ 14 切换到 C++ 17,由于额外的 std::move 导致使用了 std::accumulate 的程序调试性能出现巨大的损失——想象一下在处理算术类型对象的循环中每次调用无用函数的开销!


情况比想象的更糟


std::move 不是一个孤立的例子——在禁用优化的情况下,任何语义上是强制转换的函数最终都会生成一个无用的 call 指令。这里还有一些例子——std::addressof、std::forward、std::forward_like、std::move_if_noexcept、std::as_const、std::to_fundamental。


假设你完全不关心调试性能……好吧,猜猜怎么着——所有上述的实用函数都会导致函数模板实例化,从而降低编译速度。此外,这些“强制转换”将在调试时作为调用堆栈的一部分出现,使逐步遍历代码的过程变得更加痛苦和嘈杂。


强制转换的实用函数并不是唯一一种没有优化就表现得很糟糕的抽象类别——对于概念上的轻量级类型,如 std::vector::iterator,没有人希望在调试时进入 iterator::operator* 和 iterator::operator++,也没有人希望在遍历 std::vector 时每次迭代都需要付出调用函数的开销。然而,在调试模式下,情况就是如此。


在 C++ 中,你可以在任何地方找到这样的例子。值得注意的是,下面是 Chris Green 关于 std::byte 的推文:


你真的不会想要使用 std::byte(https://t.co/esFxAngT2D)。


从链接的 Compiler Explorer 示例(https://godbolt.org/z/8sdvra6xb)中可以看到,为 std::byte 的位移操作符生成的汇编非常糟糕,导致了对 CPU 可执行的最简单、最快的操作的 call 指令。当然,使用 char 并不会生成如此糟糕的汇编,即使完全禁用了优化。


后果是什么


这些低效率的结果对于 C++ 在游戏开发领域的声誉和用途来说是毁灭性的,并且(在我看来)还会导致更低的生产效率和更长的调试周期。


  • 首先,到目前为止我们所展示的一切都意味着任何开发重要项目的游戏开发者都不会使用“零成本抽象”。std::move、std::forward 等都将被强制转换或宏替换。

  • 不提倡使用 std::vector,而提倡使用 T*,或者至少通过指针进行迭代(即通过 std::vector::data),而不是通过迭代器。

  • 来自和头文件的任何东西都可能不会被使用,因为有很大的开销风险(就像 std::accumulate 那样),或者因为这些头文件在编译方面是出了名的繁重。

  • 不使用诸如 std::byte 等更安全的 C 类型替代类型,从而降低了类型安全性和可表达性。


每次经验丰富的 C++ 程序员向游戏开发者建议使用更安全、更难以被误用的抽象时,他们都不会听——他们负担不起这样做的代价。因此,在其他领域工作的人会认为游戏开发者是尚未发现抽象概念的原始人,喜欢用指针和宏来玩火,完全意识不到导致他们使用这些技术的原因。


另一方面,游戏开发者会嘲笑和避开那些信奉高级抽象和类型安全的 C++ 程序员,因为他们没有意识到调试性能和编译速度可能没有更干净、更安全、更可维护的代码那么重要。


我也没有任何证据证明这一点,但我怀疑,怀着优化调试体验的愿望编写低级代码最终会增加调试的频率。


如果有人想要避免使用可以让他们的代码变得更安全的抽象,他们将不可避免地写出更多的 Bug,从而需要进行更频繁的调试。一旦 Bug 被修复,他们就会对调试器称赞有加,并更有动力通过编写低级代码来保持高调试性能。这是一个恶性循环!


在调试模式下启用优化


我知道你在想什么——你认为这些游戏开发者无能,因为他们可能一直在使用 -Og!


你错了。


首先,-Og 只在 GCC 上可用。Clang 接受了这个标志,但它与 -O1 完全相同——LLVM 维护者从未实现过恰当的调试优化级别。MSVC 没有与 -Og 相对应的东西,而大多数游戏开发者使用 MSVC 作为他们的主要编译器!


即使 -Og 无处不在,但它仍然不及 -O0——对于高效的调试会话来说,它可能仍然内联了太多代码。


任何高于 -Og 的优化级别都将导致非常糟糕的调试体验,因为编译器将执行激进的优化。


我们可以做些什么


有几个方面可以改进——语言本身、编译器、标准库。


我们可以说函数模板不是为强制转换和位操作创建轻量级抽象的正确模型,类模板和轻量级类型,如 std::vector::iterator,也是如此。

过去曾有人尝试为“卫生宏(Hygenic Macro)”引入一种语言特性来解决本文所描述的问题,特别是 Jason Rice 的 P1221(“参数表达式”)提议。可惜的是,这篇论文几年来都没有更新。

即使我们设法在语言中引入了“卫生宏”,也无助于现有的实用函数,这些实用函数在过去已经被标准化为函数和类模板——也就是说,它不会让 std::move 变得更好。也许我们可以发明一些类似 [[no_unique_address]] 结合 [[gnu::always_inline]] 的属性或向后兼容的关键字来强制编译器始终内联有标记的函数,不需要为它们生成代码。

我目前还没有具体的想法,不过这可能是一个值得探索的方向。


编译器可以在处理这些函数的方式上变得更聪明一些,它们确实正在朝着这个方向发展!


GCC 12.x 引入了一个新的 -ffold-simple-inlines 标志(这是因为我提交的 Bug 报告,https://gcc.gnu.org/bugzilla/show_bug.cgi?id=104719),它允许 C++ 前端折叠对 std::move、std::forward、std::addressof 和 std::as_const 的调用。文档提到它应该是默认启用的,但如果我不手动指定标志,就无法让编译器执行折叠——请参考 Compiler Explorer 上的示例(https://gcc.godbolt.org/z/KPGe3YYsG)。

Clang 15.x 也受到了我提交的 #53689 问题(https://github.com/llvm/llvm-project/issues/53689)的启发,也为相同的函数引入了类似的折叠调用(加上 std::move_if_noexcept,如果 GCC 维护人员忘记了的话)。这个似乎是默认启用的——请参考 Compiler Explorer 上关于 Clang 14.x 和 Clang 15.x 之间的比较(https://gcc.godbolt.org/z/7MjM53h7G)。

MSVC 还没有在这方面提供任何改进。

我必须说,看到 GCC 和 Clang 维护人员逐步改进调试性能,我感到非常高兴,也非常感谢他们。

无论如何,我不认为硬编码的函数是正确的解决方案。我支持编译器用一些非常规手段,但规则应该更通用一些。

例如,它们可以对由单个 return 语句(只包含一个强制转换)组成的函数执行折叠,然后也可以将规则放宽到任意包含单个“基本”操作的函数,也包括 std::byte 和 std::vector::iterator。如果能看到这样的东西,那就非常酷了!


最后,标准库实现本身也可以变得更加聪明和对用户友好。

例如,它们可以在 std::accumulate 中使用 static_cast<T&&>(x) 而不是 std::move(x)。此外,它们可以将简单的包装器函数标记为 [[gnu::always_inline]] 或一个等效的内置属性,强制编译器内联它们。

不幸的是,libc++ 的维护者并不喜欢这些想法。我认为他们的理由没有说服力,而且我在 GitHub 上非常明确地表达了我的观点,但他们没有让步。

我希望在这方面看到一些进展——也许用强制转换替换一些 std::move 和 std::forward 调用,并在合适的位置添加一些属性,让整个 C++ 社区受益。在一个已经完全不可读的代码库中加入非常小的可读性,这真的是不值得做这些变更的理由吗?我认为不是。


关于问答


问:人们应该写出包含更少 Bug 的代码,这样他们就不需要调试了!


答:或许……但是,调试器不仅用于找出 Bug 发生的原因,它还有其他用途。例如,有些人用调试器了解不熟悉的代码,或者找出无法找到的逻辑错误。


问:受这个问题影响的人不能有选择地只为某些文件进行无优化编译吗?


答:这在技术上是可能的,但在实践中很难实现。首先,如果你正在调试,你并不总能知道需要检查哪些地方——你可能会做出一个有根据的猜测,只禁用一些相关模块中的优化,但你可能是错误的,而且这样会浪费你的时间。


此外,许多构建系统可能不容易支持这种基于单个文件的优化标志。我可以想象,在较老的代码库或专有 / 遗留构建系统中实现这个想法可能会非常困难。


最后,不要忘了,直接解决这个问题,而不是绕过它,我们还可以从中获得其他好处,比如更快的编译。


原文链接:


https://vittorioromeo.info/index/blog/debug_performance_cpp.html


声明:本文为 InfoQ 翻译,未经许可禁止转载。


今日好文推荐


让小型企业提高 20 倍效率的统一技术栈


60 岁周星驰招聘 Web3.0 人才,要求“宅心仁厚”;马斯克计划裁掉推特 75% 的员工;Linus 致开发者:不要再熬夜了 | Q 资讯


可能是最严重的云存储数据外泄事故之一:微软承认服务器错误配置导致全球客户数据泄露


上云“被坑”十年终放弃,寒冬里第一轮“下云潮”要来了?


活动推荐


致各位活跃在写作社区的开发者们!


你们是用代码书写未来的造梦者,也是用热情铸就未来开拓者。一年一度的开发者节如约而至!你们准备好和我们一起“程”风破浪,披荆斩棘了吗?



2022-10-25 18:244455

评论 2 条评论

发布
用户头像
一、标题中 debug performance 翻译不恰当
译文:令人沮丧的 C++ 性能调试

原文:the sad state of debug performance in c++

debug performance 是指没有打开优化时的 debug 模式下性能(对比启用了优化的 release 模式)。
纵观全文,作者要表达的意思在第二段最后一句有明确指出,“他们的程序在调试模式下执行得很差”。
对标题的理解是 “ C++: 令人沮丧的 Debug 模式性能”。



二、原文的一些外链接缺失, 而它们能补全代码上下文的逻辑信息
在原文第一小节 moving an int is slow

证明 call 引用的汇编代码: see for yourself on Compiler Explorer.

libcxx 里常用的 std::accumulate 库函数中 std::move 的引用: (from libcxx, cleaned up a bit):



三、翻译失误,意思完全两样。
译文:
为 std::byte 的位移操作符生成的汇编非常糟糕,导致了对 CPU 可执行的最简单、最快的操作的 call 指令。

(看着很别扭,没看懂去翻了原文)

原文:
the generated assembly for the bitwise shift operators on std::byte is dreadful, resulting in a call instruction for possibly the simplest and fastest operation that a CPU can perform.

真实的意思:
对 std::byte 的位移操作生成的汇编非常糟糕,位移可能是一个 CPU 能执行的最简单、最快速的操作了,这里却生成了一个 call 函数引用。



结尾
用同样标题结尾 —— “令人沮丧的 C++ 翻译”,翻译看得我云里雾里,翻了英语原文才知道是什么意思。从信息的准确度上说,与原文偏离有点远。个人观感翻译水平像博客机器翻译,缺失 C++ 的技术校对,不像商业出品的味道。
感谢分享了这篇文章,内容本身很有启发,打开了新的窗子。
展开
2022-10-31 15:56 · 中国香港
回复
嗯嗯,感谢指正~
2022-10-31 19:18 · 新加坡
回复
没有更多了
发现更多内容

链表只有面试有用?Redis 之父说:我不同意!

图灵社区

算法 链表 Redis 数据结构

KeeWiDB的高性能修炼之路:架构篇

腾讯云数据库

数据库 nosql redis 腾讯云数据库 KeeWiDB

「Go工具箱」web中想做到cookie值安全?securecookie库的使用和实现原理

Go学堂

golang 开源 程序员 Cookie WEB安全

CSS 如何实现五彩斑斓的“呼吸字”?速度拿去装杯!

掘金安东尼

CSS 11月月更

MindStudio模型训练场景精度比对全流程和结果分析

华为云开发者联盟

人工智能 华为云 企业号十月 PK 榜

LED显示屏设计和安装比例有什么联系

Dylan

LED显示屏 户外LED显示屏 led显示屏厂家

火山引擎钜惠双11开启,云服务器0.71折起

Geek_2d6073

数据可视化分析之新技能——魔数图

葡萄城技术团队

数据分析 前端 可视化 商业智能

SQL编写规范

默默的成长

前端 sql 11月月更

重磅发布.NET 7!更快、更强、更统一

Azure云科技

.NET 7

双11niubility的数据恢复软件,获得更专业的数据恢复服务

淋雨

数据恢复 文件恢复

七层模型

初学者

网络 服务器 11月月更

链表只有面试有用?Redis 之父说:我不同意!

图灵教育

算法 链表 Redis 数据结构

华为云发布三大生态举措,携手伙伴及开发者共创新价值

华为云开发者联盟

云计算 华为云 企业号十月 PK 榜

Databend 在 MinIO 环境使用copy 命令 | 新手篇(3)

Databend

为什么电脑主板晶振多采用14.318MHz和32.768KHz ?

元器件秋姐

晶振 元器件 元器件电商平台 元器件采购

内存数据库如何发挥内存优势?

C++后台开发

算法 内存数据库 后端开发 linux开发 C++开发

开源密码管理器更安全吗?(2)

神锁离线版

开源 网络安全 信息安全 数据安全 密码管理器

应用现代化产业联盟,正式成立

华为云开发者联盟

云计算 华为云 企业号十月 PK 榜

阿里这份Java程序性能优化指南,让你的程序快上200%

小小怪下士

Java 程序员 性能优化

云原生系列五:Kafka 集群数据迁移基于Kubernetes的内部

叶秋学长

kafka\ kurbernetes 11月月更

React组件通信

xiaofeng

React

HummerRisk 快速入门教程

HummerCloud

云安全 云原生安全 11月月更 HummerRisk

开源遇上华为云——DataX for HuaweiCloud OBS

华为云开发者联盟

开源 后端 华为云 企业号十月 PK 榜

【C语言】do 关键字

謓泽

11月月更

云BI,如何成为了企业的“贴身管家”?

夏日星河

双十一剁手快,ERP系统不能慢

力软低代码开发平台

用火山引擎DataTester,这家企业开始了“数据驱动增长”

字节跳动数据平台

Git本地提交代码推送远程并未统计贡献量问题分析

Andy

豆瓣评分8.0!深入理解Java虚拟机,把GC算法与实现讲得明明白白!

Java永远的神

程序员 面试 JVM GC Java虚拟机

Oracle 表空间创建标准(二)

默默的成长

oracle 前端 11月月更

令人沮丧的C++调试性能_语言 & 开发_Vittorio Romeo_InfoQ精选文章