编者按:C++ 语言与编译器一直都在持续演进,出现了许多令人振奋的新特性,同时还有许多新特性在孵化阶。除此之外,还有许多小更改以提高运行效率与编程效率。本文整理自全球 C++ 及系统软件技术大会上的精彩分享,接下来由作者带我们了解 C++ 项目的实践工作等具体内容,全文整理如下:
介绍C++ 是一门有着长久历史并依然持续活跃的语言。C++ 最新标准已经到了 C++23。Clang/LLVM、GCC 与 MSVC 等三大编译器都保持着非常频繁的更新。除此之外的各个相关生态也都保持着持续更新与跟进。但遗憾的是,目前看到积极更近 C++新标准与 C++新工具链的都主要以国外项目为主。国内虽然对 C++ 新标准也非常关注,但大多以爱好者个人为主,缺乏真实项目的跟进与实践。本文以现代化工具链作为线索,介绍我们实际工作中的大型 C++ 项目中现代化工具链的实践以及结果。对于 C++ 项目,特别是大型的 C++项目而言,常常会有以下几个特点(或痛点):高度业务导向 – 少关注、不关注编译器和语言标准 沉疴难起 – 编译器版本、语言标准、库依赖被锁死 许多 C++ 项目都是高度自治且业务导向的,这导致一个公司内部的 C++ 项目的编译器版本和语言标准五花八门,想统一非常困难。同时由于日常开发主要更关心业务,时间一长背上了技术债,再想用新标准与新工具链的成本就更高了。一来二去,编译器、语言标准与库依赖就被锁死了。同时对于业务来说,切换编译器也会有很多问题与挑战:这里的许多问题哪怕对于有许多年经验的 C++工程师而言可能都算是难题,因为这些问题其实本质上是比语言层更低一层的问题,属于工具链级别的问题。所以大家觉得棘手是很正常的,这个时候就需要专业的编译器团队了。在我们的工作中,少数编译器造成的程序行为变化问题需要完善的测试集,极少数编译器切换造成的问题在产线上暴露出来 – 本质是业务/库代码的 bug,绝大多数问题在构建、运行、压测阶段暴露并得到修复。其中更短的编译时间本身就是 clang 的一个特性,从 gcc 切换到 clang 就会得到很不错的编译加速。同时运行时性能也一直是编译器的目标。而各种各样的静态与运行时检查也是编译器/工具链开发的一个长期主线。另外更新的工具链也会带来更多的优化技术与语言特性支持,这里我们后面会重点介绍。最后是我们可以得到一个长期持续性更新升级的良性循环,这一点也是非常重要和有价值的。优化技术简介ThinLTO编译器在编译 *.c 文件时,只能通过 *.c 及其包含的文件中的信息做优化。LTO (Linking Time Optimization)技术是在链接时使用程序中所有信息进行优化的技术。但 LTO 会将所有 *.o 文件加载到内存中,消耗非常多的资源。同时 LTO 串行化部分比较多。编译时间很长。落地对环境、技术要求比较高,目前只在 suse 等传统 Linux 厂商中得到应用。为了解决这个问题,LLVM 实现了 ThinLTO 以降低 LTO 的开销。GCC WHOPR 的整体架构如图所示。思路是在编译阶段为每个编译单元生成 Summary 信息,之后再根据 Summary 信息对每个编译单元进行优化。ThinLTO 技术的整体架构如上图所示。都是在编译阶段为每个 *.o 文件生成 Summary 信息,之后在 thin link 阶段根据 Summary 信息对每个 *.o 文件进行优化。(图/LLVM ThinLTO 与 GCCLTO 在 SPEC cpu 2006 上的性能比较)使用 GCC LTO 的原因是 GCC 的 LTO 实现相对比较成熟。从图上可以看出,在性能收益上 ThinLTO 与 LTO 的差距并不大。而 ThinLTO 与 LTO 相比最大的优势是占用的资源极小:如图为使用 LLVM ThinLTO、LLVM LTO 以及 GCC LTO 链接 Chromium 时的内存消耗走势图。所以使用 ThinLTO 可以使我们的业务在日常开发中以很小的代价拿到很大的提升。同时开启 ThinLTO 的难度很低,基本只要可以启用 clang 就可以使能 ThinLTO。在我们的实践中,一般开启 ThinLTO 可以拿到 10% 的性能提升。AutoFDOAutoFDO 是一个简化 FDO 的使用过程的系统。AutoFDO 会从生产环境收集反馈信息(perf 数据),然后将其应用在编译时。反馈信息是在生产环境的机器上使用 perf 工具对相应硬件事件进行采样得到的。总体来说,一次完整的 AutoFDO 过程如下图可分为 4 步:1. 将编译好的 binary 部署到生产环境或者测试环境, 在正常工作的情况下使用 perf 对当前进程做周期性的采集。2. 将 perf 数据转化成 llvm 可以识别的格式,并将其保存到数据库中。3. 当用户再次编译的时候,数据库会将亲近性最强的profile文件返回给编译器并参与到当前构建中。对于业务而言,AutoFDO 的接入有同步和异步两种接入方式:首先编译一个 AutoFDO 不参与的二进制版本。 在 benchmark 环境下运行当前二进制并使用perf采集数据。 使用 AutoFDO 再次构建一个二进制版本,此二进制为最终发布版本。
在实际中开启 AutoFDO 可以拿到 2%~5% 的性能提升。BoltBolt 基于 LLVM 框架的二进制 POST-LINK 优化技术,可以在 PGO/基础进一步优化。Bolt 应用于其数据中心负载处理,即使数据中心已进行了 PGO(AutoFDO)和 LTO 优化后,BOLT 仍然能够提升其性能。1. Function Discovery:通过 ELF 符号表查找所有函数名字与地址。2. Read debug info:如果二进制编译时带有 Debug 信息,读取 Debug 信息。3. Read Profile data:读取 Profile 数据,用于驱动 CFG 上优化。4. Disassembly:基于LLVM将机器码翻译成保存在内存里的汇编指令。5. CFG Construction:依据汇编指令构建控制流图(Control-Flow graph)。6. Optimization pipeline:经过上述操作,汇编指令内部表示形式均含有Profile信息,就可以进行一系列的操作优化:7. Emit and Link Functions:发射优化后代码,重定向函数地址;8. Rewrite binary file:重写二进制文件。Bolt 的接入类似 AutoFDO,也需要先收集到 Perf 数据同时使用该数据重新编译。在我们的实践中性能可以提升 8%。语言特性这里我们简单介绍下两个 C++ 语言的新特性 Coroutines 与 Modules 来展示更新到现代化工具链后可以使用的 C++ 新特性。Coroutines两个数量级的切换效率提升。 更好的执行 & 切换效率。
接下来我们以一个简单的例子为例,介绍协程是如何支持以同步方式写异步代码。首先我们先看看同步代码的案例:uint64_t ReadSync(std::vector<File> Inputs) { uint64_t read_size = 0; for (auto &&Input : Inputs) read_size += ReadImplSync(Input); return read_size; } 这是一个统计多个文件体积的同步代码,应该是非常简单。template <RangeT Range, Callable Lambda> future<void> do_for_each(Range, Lambda); // We need introduce another API. future<uint64_t> ReadAsync(vector<File> Inputs) { auto read_size = std::make_shared<uint64_t>(0); // We need introduce shared_ptr. return do_for_each(Inputs, // Otherwise read_size would be [read_size] (auto &&Input){ // released after ReadAsync ends. return ReadImplAsync(Input).then([read_size](auto &&size){ *read_size += size; return make_ready_future(); }); }) .then([read_size] { return make_ready_future<uint64_t>(*read_size); }); } 肉眼可见地,异步写法麻烦了非常多。同时这里还使用到了 std::shared_ptr。但 std::shared_ptr 会有额外的开销。如果用户不想要这个开销的话需要自己实现一个非线程安全的 shared_ptr,还是比较麻烦的。Lazy<uint64_t> ReadCoro(std::vector<File> Inputs) { uint64_t read_size = 0; for (auto &&Input : Inputs) read_size += co_await ReadImplCoro(Input); co_return read_size; } 可以看到这个版本的代码与同步代码是非常像的,但这份代码本质上其实是异步代码的。所以我们说:协程可以让我们用同步方式写异步代码;兼具开发效率和运行效率。C++20 协程的目标用户是协程库作者。 其他用户应通过协程库使用协程。
同时我们在 GCC 和 Clang 中做了以下工作:与 Clang/LLVM 社区合作完善 C++ 协程。 改善&优化:对称变换、协程逃逸分析和CoroElide优化,协程帧优化(Frame reduction),完善协程调试能力、尾调用优化、Coro Return Value Optimization等。 在 Clang/LLVM14 中,coroutine 移出了 experimental namespace。 Maintaining
最后我们还实现并开源了一个经过双 11 验证的协程库 async_simple:原先为同步逻辑 协程化后 Latency 下降 30% 超时查询数量大幅下降甚至清零
原先为异步逻辑 协程化后 Latency 下降 8%
原先为同步逻辑 协程化后 qps 提升 10 倍以上性能
ModulesModules 是 C++20 的四大重要特性(Coroutines、Ranges、Concepts 以及 Modules)之一。Modules 也是这四大特性中对现在 C++ 生态影响最大的特性。Modules 是 C++20 为复杂、难用、易错、缓慢以及古老的 C++ 项目组织形式提供的现代化解决方案。Modules 可以提供:#include 'a.h' #include 'b.h' // another file #include 'b.h #include 'a.h' 在传统的头文件结构中 a.h与 b.h 的 include 顺序可能会导致不同的行为,这一点是非常烦人且易错的。而这个问题在 Modules 中就自然得到解决了。例如下面两段代码是完全等价的:对于封装性,我们以 asio 库中的 asio::string_view 为例进行说明。以下是 asio::string_view 的实现:namespace asio {
#if defined(ASIO_HAS_STD_STRING_VIEW) using std::basic_string_view; using std::string_view; #elif defined(ASIO_HAS_STD_EXPERIMENTAL_STRING_VIEW) using std::experimental::basic_string_view; using std::experimental::string_view; #endif // defined(ASIO_HAS_STD_EXPERIMENTAL_STRING_VIEW)
} // namespace asio
# define ASIO_STRING_VIEW_PARAM asio::string_view #else // defined(ASIO_HAS_STRING_VIEW) # define ASIO_STRING_VIEW_PARAM const std::string& #endif // defined(ASIO_HAS_STRING_VIEW) 该文件的位置是 /asio/detail/string_view.hpp,位于 detail 目录下。同时我们从 asio 的官方文档(链接地址见文末)中也找不到 string_view 的痕迹。所以我们基本可以判断 asio::string_view这个组件在 asio 中是不对外提供的,只在库内部使用,作为在 C++ 标准不够高时的备选。然而使用者们确可能将 asio::string_view作为一个组件单独使用(Examples),这违背了库作者的设计意图。从长远来看,类似的问题可能会导致库用户代码不稳定。因为库作者很可能不会对没有暴露的功能做兼容性保证。这个问题的本质是头文件的机制根本无法保证封装。用户想拿什么就拿什么。而 Modules 的机制可以保障用户无法使用我们不让他们使用的东西,极强地增强了封装性:最后是编译速度的提升,头文件导致编译速度慢的根本原因是每个头文件在每个包含该头文件的源文件中都会被编译一遍,会导致非常多冗余的编译。如果项目中有 n 个头文件和 m 个源文件,且每个头文件都会被每个源文件包含,那么这个项目的编译时间复杂度为 O(n*m)。如果同样的项目由 n 个 Modules 和 m 个源文件,那么这个项目的编译时间复杂度将为 O(n+m)。这会是一个复杂度级别的提升。我们在 https://github.com/alibaba/async_simple/tree/CXX20Modules 中将 async_simple 库进行了完全 Modules 化,同时测了编译速度的提升:可以看到编译时间最多可以下降 74%,这意味着 4 倍的编译速度提升。需要主要 async_simple 是一个以模版为主的 header only 库,对于其他库而言编译加速应该更大才对。关于 Modules 对编译加速的分析我们在今年的 CppCon22 中也有介绍(链接地址见文末)。编译器初步开发完成 支持 std modules 优先内部应用 已在 Clang15 中发布 探索编译器与构建系统交互 (ing)
总结https:///Asio/asio-1.22.1/doc/asio/index.html https://cppcon./session/2022/how-much-compilation-speedup-we-will-get-from-c-modules/。 —— 完 ——
|