NVIDIA 初创加速计划,免费加速您的创业启动 了解详情
写点什么

令人沮丧的 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:244030

评论 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 · 新加坡
回复
没有更多了
发现更多内容

鲜衣怒马散尽千金,Vue3.0+Tornado6前后端分离集成Web3.0之Metamask钱包区块链虚拟货币三方支付功能

刘悦的技术博客

Python 区块链 Vue 加密货币 虚拟货币

Linux环境快速搭建elasticsearch6.5.4集群和Head插件

程序员欣宸

Java elasticsearch 7月月更

小程序助力智能家居生态平台

Geek_99967b

物联网,

SpringBoot整合Minio 项目中使用自己文件存储服务器

宁在春

springboot Minio 7月月更

SpringBoot 整合 Swagger 自动生成在线API文档

宁在春

springboot swagger 7月月更

借助Elephant Swap打造的ePLATO,背后的高溢价解析

EOSdreamer111

算法题每日一练---第12天:算式900

知心宝贝

程序员 算法 前端 后端 7月月更

网络安全漏洞分析与漏洞复现

网络安全学海

网络安全 安全 渗透测试 WEB安全 漏洞挖掘

数据库故障容错之系统时钟故障

CnosDB

时序数据库 开源社区 CnosDB 工程师有话说 CnosDB Tech Talk

机器学习如何做到疫情可视化——疫情数据分析与预测实战

是Dream呀

人工智能 机器学习 爬虫 数据可视化 疫情分析

计算机视觉中Python如何实现图像操作与处理

迷彩

Python 计算机视觉 图像处理 Pillow 7月月更

Starfish Os打造的元宇宙生态,跟MetaBell的合作只是开始

EOSdreamer111

Bootstrap警告和轮播插件详解【前端Bootstrap框架】

恒山其若陋兮

7月月更

Starfish Os X MetaBell战略合作,元宇宙商业生态更进一步

股市老人

Redis设计规范

知识浅谈

redis' redis 精讲

融云 IM & RTC 能力上新盘点

融云 RongCloud

SRv6初登场

穿过生命散发芬芳

7月月更 SRv6

Prometheus 运维工具 Promtool (四)TSDB 功能

耳东@Erdong

Prometheus 7月月更 签约计划第三季 Promtool

汽车智能应用生态的下一个趋势:车载小程序

Geek_99967b

车联网 物联网,

MySQL进阶--存储过程以及自定义函数

Java学术趴

7月月更

【函数式编程实战】(十) 优雅的处理代码中的时间类

小明Java问道之路

Lambda java8 Stream API 7月月更 签约计划第三季

一文读懂Plato Farm的ePLATO,以及其高溢价缘由

股市老人

数据中台建设(三):数据中台架构介绍

Lansonli

数据中台 7月月更

leetcode 452. Minimum Number of Arrows to Burst Balloons 用最少数量的箭引爆气球(中等)

okokabcd

LeetCode 数据结构与算法 贪心算法

算法题每日一练---第11天:第39级台阶

知心宝贝

程序员 算法 前端 后端 7月月更

Starfish Os打造的元宇宙生态,跟MetaBell的合作只是开始

鳄鱼视界

C# 之 方法参数传递机制

陈言必行

7月月更

C# 窗体应用使用对象绑定 DataGridView 数据绑定

IC00

C# 7月月更

《我的Vivado实战—单周期CPU指令分析》

攻城狮杰森

cpu 计算机组成原理 7月月更 vivado 计算机科学与技术

【Meetup预告】OpenMLDB+OneFlow:链接特征工程到模型训练,加速机器学习模型开发

第四范式开发者社区

人工智能 机器学习 数据库 AI 特征平台

浏览器视频帧操作方法 requestVideoFrameCallback() 简介

devpoint

html5 视频处理 requestAnimationFrame 7月月更

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