关于c++:技术解读现代化工具链在大规模-C-项目中的运用-龙蜥技术

2次阅读

共计 7239 个字符,预计需要花费 19 分钟才能阅读完成。

编者按:C++ 语言与编译器始终都在继续演进,呈现了许多令人振奋的新个性,同时还有许多新个性在孵化阶。除此之外,还有许多小更改以进步运行效率与编程效率。本文整顿自寰球 C++ 及系统软件技术大会上的精彩分享,接下来由作者带咱们理解 C++ 我的项目的实际工作等具体内容,全文整顿如下:

介绍

C++ 是一门有着短暂历史并仍然继续沉闷的语言。C++ 最新规范曾经到了 C++23。Clang/LLVM、GCC 与 MSVC 等三大编译器都放弃着十分频繁的更新。除此之外的各个相干生态也都放弃着继续更新与跟进。但遗憾的是,目前看到踊跃更近 C++ 新规范与 C++ 新工具链的都次要以国外我的项目为主。国内尽管对 C++ 新规范也十分关注,但大多以爱好者集体为主,不足实在我的项目的跟进与实际。

本文以现代化工具链作为线索,介绍咱们理论工作中的大型 C++ 我的项目中现代化工具链的实际以及后果。

对于 C++ 我的项目,特地是大型的 C++ 我的项目而言,经常会有以下几个特点(或痛点):

  • 我的项目高度自治 – 自主决定编译器版本、语言规范
  • 高度业务导向 – 少关注、不关注编译器和语言规范
  • 先发劣势 – 丢失利用新技术、新个性的能力
  • 沉疴难起 – 编译器版本、语言规范、库依赖被锁死

许多 C++ 我的项目都是高度自治且业务导向的,这导致一个公司外部的 C++ 我的项目的编译器版本和语言规范形形色色,想对立十分艰难。同时因为日常开发次要更关怀业务,工夫一长背上了技术债,再想用新规范与新工具链的老本就更高了。一来二去,编译器、语言规范与库依赖就被锁死了。

同时对于业务来说,切换编译器也会有很多问题与挑战:

  • 修复更严格编译器查看的问题
  • 修复不同编译器行为差别的问题
  • 修复语言规范、编译器行为扭转的问题 – 欠缺测试
  • 二进制依赖、ABI 兼容问题 – 全源码编译 / 服务化
  • 性能压测、调优

这里的许多问题哪怕对于有许多年教训的 C++ 工程师而言可能都算是难题,因为这些问题其实实质上是比语言层更低一层的问题,属于工具链级别的问题。所以大家感觉辣手是很失常的,这个时候就须要业余的编译器团队了。

在咱们的工作中,多数编译器造成的程序行为变动问题须要欠缺的测试集,极少数编译器切换造成的问题在产线上裸露进去 – 实质是业务 / 库代码的 bug,绝大多数问题在构建、运行、压测阶段裸露并失去修复。

这里咱们简略介绍下咱们在理论工作中遇到的案例:

业务 1(规模 5M)

  • 业务自身 10+ 仓库;三方依赖 50+,其中大部分源代码依赖,局部二进制依赖。
  • 二进制依赖、ABI 兼容问题 – 0.5 人月;编译器切换、CI、CD – 1.5 人月;性能剖析调优 – 1 人月。

业务 2(规模 7M)

  • 二方 / 三方依赖 30+,二进制依赖。
  • 编译器切换革新 – 2 人月;性能压测调优 – 1 人月。

业务 3(规模 3M)

  • 二方 / 三方依赖 100+,多为二进制依赖。
  • 二进制依赖、ABI 兼容问题 – 预估 2 人年。

在切换工具链之后,用户们能失去什么呢?

  • 更短的编译工夫
  • 更好的运行时性能
  • 更好的编译、动态、运行时查看
  • 更多优化技术 – ThinLTO、AutoFDO、Bolt 等
  • 更新的语言个性反对 – C++20 协程、C++20 Module 等
  • 持续性更新降级 – 良性循环

其中更短的编译工夫自身就是 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% 的性能晋升。

AutoFDO

AutoFDO 是一个简化 FDO 的应用过程的零碎。AutoFDO 会从生产环境收集反馈信息(perf 数据),而后将其利用在编译时。反馈信息是在生产环境的机器上应用 perf 工具对相应硬件事件进行采样失去的。总体来说,一次残缺的 AutoFDO 过程如下图可分为 4 步:

  1. 将编译好的 binary 部署到生产环境或者测试环境,在失常工作的状况下应用 perf 对以后过程做周期性的采集。
  2. 将 perf 数据转化成 llvm 能够辨认的格局,并将其保留到数据库中。
  3. 当用户再次编译的时候,数据库会将亲热性最强的 profile 文件返回给编译器并参加到以后构建中。
  4. 将编译好的二进制进行归档和公布。

对于业务而言,AutoFDO 的接入有同步和异步两种接入形式:

  • 同步接入:
  • 首先编译一个 AutoFDO 不参加的二进制版本。
  • 在 benchmark 环境下运行以后二进制并应用 perf 采集数据。
  • 应用 AutoFDO 再次构建一个二进制版本,此二进制为最终公布版本。
  • 异步接入:
  • 在客户线上机器进行周期性采集,将采集数据进行合并和保留。
  • 构建新版本的时候将对应的数据文件下载,并参加以后版本的编译。

在理论中开启 AutoFDO 能够拿到 2%~5% 的性能晋升。

Bolt

Bolt 基于 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 信息,就能够进行一系列的操作优化:
  • BasicBlock Reordering
  • Function Reordering
  1. Emit and Link Functions:发射优化后代码,重定向函数地址;
  2. Rewrite binary file:重写二进制文件。

Bolt 的接入相似 AutoFDO,也须要先收集到 Perf 数据同时应用该数据从新编译。在咱们的实际中性能能够晋升 8%。

语言个性

这里咱们简略介绍下两个 C++ 语言的新个性 Coroutines 与 Modules 来展现更新到现代化工具链后能够应用的 C++ 新个性。

Coroutines

首先能够先简略介绍一下 Coroutines:

  • 协程是一个可挂起的函数。
  • 反对以同步形式写异步代码。
  • C++20 协程是无栈协程。在语义层面不保留调用上下文信息。
  • 比照有栈协程
  • 两个数量级的切换效率晋升。
  • 更好的执行 & 切换效率。
  • 比照 Callback
  • 更简洁的编程模式,防止 Callback hell。

接下来咱们以一个简略的例子为例,介绍协程是如何反对以同步形式写异步代码。首先咱们先看看同步代码的案例:

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 协程的实现:

  • C++20 协程是无栈协程,须要编译器染指能力实现。
  • 断定协程并搜寻相干组件。(Frontend Semantic Analysis)
  • 生成代码。(Frontend Code Generation)
  • 生成、优化、保护协程桢。(Middle-end)
  • C++20 协程只设计了根本语法,并没有退出协程库。
  • C++20 协程的指标用户是协程库作者。
  • 其余用户应通过协程库应用协程。

同时咱们在 GCC 和 Clang 中做了以下工作:

  • GCC
  • 与社区单干进行协程的反对。
  • GCC-10 是第一个反对 C++ 协程个性的 GCC 编译器。
  • 仅反对,无优化。
  • Clang/LLVM
  • 与 Clang/LLVM 社区单干欠缺 C++ 协程。
  • 改善 & 优化:对称变换、协程逃逸剖析和 CoroElide 优化,协程帧优化(Frame reduction),欠缺协程调试能力、尾调用优化、Coro Return Value Optimization 等。
  • 在 Clang/LLVM14 中,coroutine 移出了 experimental namespace。
  • Maintaining

最初咱们还实现并开源了一个通过双 11 验证的协程库 async_simple:

  • async_simple
  • 设计借鉴了 folly 库协程模块。
  • 轻量级。
  • 蕴含有栈协程、无栈协程以及 Future/Promise 等异步组件。
  • 从实在需要登程。
  • 与调度器解藕,用户能够抉择适合本人的调度器。
  • 禁受了工业级 Workload 的考验。
  • 开源于:https://github.com/alibaba/as…

最初咱们来看下咱们利用协程后的成果:

  • 业务 1(1M Loc、35w core)
  • 原先为同步逻辑
  • 协程化后 Latency 降落 30%
  • 超时查问数量大幅降落甚至清零
  • 业务 2(7M Loc)
  • 原先为异步逻辑
  • 协程化后 Latency 降落 8%
  • 业务 3(100K Loc、2.7w core)
  • 原先为同步逻辑
  • 协程化后 qps 晋升 10 倍以上性能

Modules

Modules 是 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 中就天然失去解决了。例如上面两段代码是齐全等价的:

import a;
import b;

import b;
import a;

对于封装性,咱们以 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/as… 中将 async_simple 库进行了齐全 Modules 化,同时测了编译速度的晋升:

能够看到编译工夫最多能够降落 74%,这意味着 4 倍的编译速度晋升。须要次要 async_simple 是一个以模版为主的 header only 库,对于其余库而言编译减速应该更大才对。对于 Modules 对编译减速的剖析咱们在往年的 CppCon22 中也有介绍(链接地址见文末)。

最初对于 Modules 的停顿为:

  • 编译器初步开发实现
  • 反对 std modules
  • 优先外部利用
  • 已在 Clang15 中公布
  • 摸索编译器与构建零碎交互 (ing)

总结

最初咱们再总结一下,应用现代化工具链带来的益处:

  • 更短的编译工夫
  • 更好的运行时性能
  • 更好的编译、动态、运行时查看
  • 更多优化技术 – ThinLTO、AutoFDO、Bolt 等
  • 更新的语言个性反对 – C++20 协程、C++20 Module 等
  • 持续性更新降级 – 良性循环

心愿更多的我的项目能够应用更现代化的工具链。

相干链接:

asio 官网文档链接地址:

https://think-async.com/Asio/…

CppCon22 链接地址:

https://cppcon.digital-medium…。

—— 完 ——

正文完
 0