技術解讀:現代化工具鏈在大規模 C++ 項目中的運用 | 龍蜥技術

簡介: 本文詳細介紹我們在實際工作中的大型 C++ 項目中現代化工具鏈的實踐以及結果。

編者按: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

傳統的編譯流程如下圖所示

640 (7).png

編譯器在編譯 *.c 文件時,只能通過 *.c 及其包含的文件中的信息做優化。

 

LTO (Linking Time Optimization)技術是在鏈接時使用程序中所有信息進行優化的技術。但 LTO 會將所有 *.o 文件加載到內存中,消耗非常多的資源。同時 LTO 串行化部分比較多。編譯時間很長。落地對環境、技術要求比較高,目前只在 suse 等傳統 Linux 廠商中得到應用。

 

爲了解決這個問題,LLVM 實現了 ThinLTO 以降低 LTO 的開銷。

 

GCC WHOPR 的整體架構如圖所示。思路是在編譯階段爲每個編譯單元生成 Summary 信息,之後再根據 Summary 信息對每個編譯單元進行優化。

640 (8).png

ThinLTO 技術的整體架構如上圖所示。都是在編譯階段爲每個 *.o 文件生成 Summary 信息,之後在 thin link 階段根據 Summary 信息對每個 *.o 文件進行優化。

640 (9).png

(圖/LLVM ThinLTO 與 GCCLTO 在 SPEC cpu 2006 上的性能比較)

 

使用 GCC LTO 的原因是 GCC 的 LTO 實現相對比較成熟。

 

從圖上可以看出,在性能收益上 ThinLTO 與  LTO 的差距並不大。而 ThinLTO 與 LTO 相比最大的優勢是佔用的資源極小:

640 (10).png

如圖爲使用 LLVM ThinLTO、LLVM LTO 以及 GCC LTO 鏈接 Chromium 時的內存消耗走勢圖。

640 (11).png

所以使用 ThinLTO 可以使我們的業務在日常開發中以很小的代價拿到很大的提升。同時開啓 ThinLTO 的難度很低,基本只要可以啓用 clang 就可以使能 ThinLTO。在我們的實踐中,一般開啓 ThinLTO 可以拿到 10% 的性能提升。

 

AutoFDO

AutoFDO 是一個簡化 FDO 的使用過程的系統。AutoFDO 會從生產環境收集反饋信息(perf 數據),然後將其應用在編譯時。反饋信息是在生產環境的機器上使用 perf 工具對相應硬件事件進行採樣得到的。總體來說,一次完整的 AutoFDO 過程如下圖可分爲 4 步:

640 (12).png

  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 仍然能夠提升其性能。

640 (13).png

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
  • ...

7. Emit and Link Functions:發射優化後代碼,重定向函數地址;

8. 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:

640 (14).png

async_simple

  • 設計借鑑了 folly 庫協程模塊。
  • 輕量級。
  • 包含有棧協程、無棧協程以及 Future/Promise 等異步組件。
  • 從真實需求出發。
  • 與調度器解藕,用戶可以選擇合適自己的調度器。
  • 經受了工業級 Workload 的考驗。
  • 開源於:https://github.com/alibaba/async_simple

最後我們來看下我們應用協程後的效果:

  • 業務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),這違背了庫作者的設計意圖。從長遠來看,類似的問題可能會導致庫用戶代碼不穩定。因爲庫作者很可能不會對沒有暴露的功能做兼容性保證。

 

這個問題的本質是頭文件的機制根本無法保證封裝。用戶想拿什麼就拿什麼。

640 (15).png

而 Modules 的機制可以保障用戶無法使用我們不讓他們使用的東西,極強地增強了封裝性:

640 (16).png

最後是編譯速度的提升,頭文件導致編譯速度慢的根本原因是每個頭文件在每個包含該頭文件的源文件中都會被編譯一遍,會導致非常多冗餘的編譯。如果項目中有 n 個頭文件和 m 個源文件,且每個頭文件都會被每個源文件包含,那麼這個項目的編譯時間複雜度爲  O(n*m)。如果同樣的項目由 n 個 Modules 和 m 個源文件,那麼這個項目的編譯時間複雜度將爲 O(n+m)。這會是一個複雜度級別的提升。

 

我們在 https://github.com/alibaba/async_simple/tree/CXX20Modules 中將 async_simple 庫進行了完全 Modules 化,同時測了編譯速度的提升:

640 (17).png

可以看到編譯時間最多可以下降 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/asio-1.22.1/doc/asio/index.html

CppCon22 鏈接地址:https://cppcon.digital-medium.co.uk/session/2022/how-much-compilation-speedup-we-will-get-from-c-modules/。

—— 完 ——

加入龍蜥社羣

加入微信羣:添加社區助理-龍蜥社區小龍(微信:openanolis_assis),備註【龍蜥】與你同在;加入釘釘羣:掃描下方釘釘羣二維碼。歡迎開發者/用戶加入龍蜥社區(OpenAnolis)交流,共同推進龍蜥社區的發展,一起打造一個活躍的、健康的開源操作系統生態!

公衆號&小龍交流羣.png

關於龍蜥社區

龍蜥社區(OpenAnolis)是由企業單位、事業單位、社會團體、個人等在共建、共治、共享的基礎上組成的非營利性開源社區。龍蜥社區成立於 2020 年 9 月,旨在構建一個開放、平等、協作、創新的 Linux 上游發行版社區及創新平臺。

 

龍蜥社區成立的短期目標是開發龍蜥操作系統(Anolis OS)作爲 CentOS 停服後的應對方案,構建一個兼容國際 Linux 主流廠商的社區發行版。中長期目標是探索打造一個面向未來的操作系統,建立統一的開源操作系統生態,孵化創新開源項目,繁榮開源生態。

 

目前,Anolis OS 8.6 已發佈,更多龍蜥自研特性,支持 X86_64 、RISC-V、Arm64、LoongArch 架構,完善適配 Intel、兆芯、鯤鵬、龍芯等芯片,並提供全棧國密和機密計算支持。

歡迎下載:

https://openanolis.cn/download

加入我們,一起打造面向未來的開源操作系統!

https://openanolis.cn

原文鏈接:https://click.aliyun.com/m/1000361509/

本文爲阿里雲原創內容,未經允許不得轉載。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章