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

OpenAnolis小助手發表於2022-10-11

編者按: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 檔案時,只能透過 *.c 及其包含的檔案中的資訊做最佳化。

LTO (Linking Time Optimization)技術是在連結時使用程式中所有資訊進行最佳化的技術。但 LTO 會將所有*.o 檔案載入到記憶體中,消耗非常多的資源。同時 LTO 序列化部分比較多。編譯時間很長。落地對環境、技術要求比較高,目前只在 suse 等傳統 Linux 廠商中得到應用。

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

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

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

ThinLTO 技術的整體架構如上圖所示。都是在編譯階段為每個*.o檔案生成 Summary 資訊,之後在 thin link 階段根據 Summary 資訊對每個*.o檔案進行最佳化。

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

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

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

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

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

如圖為使用 LLVM ThinLTO、LLVM LTO 以及 GCC LTO 連結 Chromium 時的記憶體消耗走勢圖。

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

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

AutoFDO

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

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

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

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

  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:

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

  • async_simple

  • 設計借鑑了 folly 庫協程模組。

  • 輕量級。

  • 包含有棧協程、無棧協程以及 Future/Promise 等非同步元件。

  • 從真實需求出發。

  • 與排程器解藕,使用者可以選擇合適自己的排程器。

  • 經受了工業級 Workload 的考驗。

  • 開源於:

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

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

這個問題的本質是標頭檔案的機制根本無法保證封裝。使用者想拿什麼就拿什麼。

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

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

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

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

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

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

可以看到編譯時間最多可以下降 74%,這意味著 4 倍的編譯速度提升。需要主要 async_simple 是一個以模版為主的 header only 庫,對於其他庫而言編譯加速應該更大才對。關於 Modules 對編譯加速的分析我們在今年的 CppCon22 中也有介紹(連結地址見文末)。

最後關於 Modules 的進展為:

  • 編譯器初步開發完成

  • 支援 std modules

  • 優先內部應用

  • 已在 Clang15 中釋出

  • 探索編譯器與構建系統互動 (ing)

總結

最後我們再總結一下,使用現代化工具鏈帶來的好處:

  • 更短的編譯時間

  • 更好的執行時效能

  • 更好的編譯、靜態、執行時檢查

  • 更多最佳化技術 – ThinLTO、AutoFDO、Bolt 等

  • 更新的語言特性支援 – C++20 協程、C++20 Module 等

  • 持續性更新升級 – 良性迴圈

希望更多的專案可以使用更現代化的工具鏈。

相關連結:

asio官方文件連結地址:

CppCon22 連結地址:

—— 完 ——


來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/70004278/viewspace-2917841/,如需轉載,請註明出處,否則將追究法律責任。

相關文章