C++ 核心指南 —— 效能

Zijian/TENG發表於2023-12-25

C++ 核心指南 —— 效能

閱讀建議:先閱讀 《效能最佳化的一般策略及方法》

截至目前,C++ Core Guidelines 中關於效能最佳化的建議共有 18 條,而其中很大一部分是告誡你,不要輕易最佳化!

非必要,不最佳化

  • Per.1: 不要無故最佳化
  • Per.2: 不要過早最佳化
  • Per.3: 只最佳化少數關鍵程式碼

前三條可以總結為:非必要,不最佳化。所謂的“最佳化”,是指犧牲可讀性、可維護性,以換取效能提升(否則應該作為程式設計的標準實踐)。最佳化可能引入新的 bug,增加維護成本。軟體工程師應把重心放在編寫簡潔、易於理解和維護的程式碼,而不是把效能作為首要目標。

先測量,再最佳化

如果效能非常重要,應該透過精確地測量,找到程式的 hot spots,再有針對性地最佳化。

Per.4: 不要假設複雜的程式碼比簡單的程式碼快

  • 多執行緒未必比單執行緒快:考慮到執行緒間同步的開銷、上下文切換開銷,多執行緒未必比單執行緒快
  • 利用一系列複雜的最佳化技巧編寫的複雜程式碼未必比直接編寫的簡單程式碼快,如
// 好:簡單直接
vector<uint8_t> v(100000);

for (auto& c : v)
    c = ~c;
// 不好:複雜的最佳化技巧,本意想更快,但往往更慢!
vector<uint8_t> v(100000);

for (size_t i = 0; i < v.size(); i += sizeof(uint64_t)) {
    uint64_t& quad_word = *reinterpret_cast<uint64_t*>(&v[i]);
    quad_word = ~quad_word;
}

Per.5: 不要假設低階語言比高階語言快

不要低估編譯器的最佳化能力,很多時候編譯器產生的程式碼要比手動編寫低階語言更高效!

Per.6: 沒有測量就不要對效能妄下斷言

  • 效能最佳化很多時候是反直覺的,針對某些條件下的效能最佳化技巧在另一個環境下可能會劣化效能,因此必須要測量才知道某個改動到底會“最佳化”還是“劣化”效能
  • 小於 4% 的程式碼能佔用 50% 的程式執行時間。只有測量才知道時間花在哪裡,才能有針對性地最佳化

以上 6 條建議在 《效能最佳化的一般策略及方法》 中有更詳細的描述。

具體最佳化建議

Per.7 設計應當允許最佳化

如果設計之初完全忽視了將來最佳化的可能性,會導致很難修改。

過早最佳化是萬惡之源,但這並不是輕視效能的藉口。一些經過時間檢驗的最佳實踐可以幫助我們寫出高效、可維護、可最佳化的程式碼:

  • 資訊傳遞:介面設計要乾淨,但還要攜帶足夠的資訊,以便後續改進實現。
  • 緊湊的資料結構:預設情況下,使用緊湊的資料結構,如 std::vector,如果你認為需要一個連結串列,嘗試設計介面使使用者看不到這個結構(參考標準庫演算法的介面設計)。
  • 函式引數的傳遞和返回:區分可變和不可變資料。不要把 資源管理 的任務強加給使用者。不要把假想的 indirection 強加給使用者。使用常規的方式傳遞資訊,非常規或為特定實現“最佳化”過的資料傳遞方式可能會導致後續難以修改實現。
  • 抽象:不要過度泛化。試圖滿足每種可能的使用情況(包括誤用),把每個設計決策推遲(編譯或執行時 indirection)會導致複雜、臃腫、難以理解。不要對未來需求的猜測來進行泛化,從具體示例中進行泛化。泛化時保持效能,理想狀態是零開銷泛化。
  • 庫:選擇具有良好介面設計的庫。如果沒有現成的,自己寫一個,模仿具有良好介面風格的庫(可以從標準庫找靈感)。
  • 隔離:把你的程式碼和舊的、亂的程式碼隔離開。可以按照自己的風格,設計一個介面風格良好的 wrapper,把那些不得不用的舊的、亂的程式碼封裝起來,不要汙染到我們自己的程式碼。

"indirection"(間接)通常指的是透過引入額外的層級或中介來訪問資料或功能。在 C++ 中,這可能涉及使用指標、引用或其他間接方式來訪問變數、物件或函式。

  1. 設計介面時,不要只考慮第一版的用例和實現。初版實現之後,必須 review,因為一旦部署之後,彌補錯誤將很困難。
  2. 低階語言並不總是高效,高階語言的程式碼不一定慢。
  3. 任何操作都有開銷,不用過分擔心開銷(現代計算機都足夠的快),但是需要大致瞭解各種操作的開銷。例如:記憶體訪問、函式呼叫、字串比較、系統呼叫、磁碟訪問、網路通訊。
  4. 不是每段程式碼都需要穩定介面,有的介面可能只是實現細節。但還是要停下來想一下:如果要使用多個執行緒實現這個操作,需要什麼樣的介面?是否可以向量化?
  5. 本條目和 Per.2 並不矛盾,而是它的補充:鼓勵開發者在必要且時機成熟時進行最佳化。

移動語義

《C++ Core Guidelines 解析》針對本條目重點補充了移動語義:寫演算法時,應使用移動語義,而不是複製。移動語義有以下好處:

  • 移動開銷比複製低
  • 演算法穩定,因為不需要分配記憶體,不會出現 std::bad_alloc 異常
  • 演算法可以用於“只移型別”,如 std::unique_ptr

需要移動語義的演算法遇到不支援移動操作型別,則自動“回退”到複製操作。
而只支援複製語義的演算法遇到不支援複製操作的型別時,則編譯報錯。

Per.10 依賴靜態型別系統

弱型別(如 void* )、低階程式碼(如把 sequence 作為單獨的位元組來操作)會讓編譯器難以最佳化。

《解析》中還給出了一些額外的幫助編譯器生成最佳化程式碼的技巧:

  1. 原生程式碼。“本地”指在同一個編譯單元(如同一個 .c/.cpp 檔案中)。例如 std::sort 需要一個謂詞,傳入本地 lambda 可能會比傳入函式(指標)更快。
    因為對於本地 lambda,編譯器擁有所有可用的資訊來生成最優程式碼,而函式可能定義在另一個編譯單元中,編譯器無法獲取有關該函式的細節,從而無法進行深度最佳化。
  2. 簡單程式碼。最佳化器會搜尋可以被最佳化的已知模式,簡單的程式碼更容易被匹配到。如果是手寫的複雜程式碼,反而可能錯失讓編譯器最佳化的機會。
  3. 額外提示。constnoexceptfinal 等關鍵字可以給編譯器提供額外的資訊,有了這些額外的資訊,編譯器可以大膽地做進一步最佳化。當然要先搞清楚這些關鍵字的含義及產生的影響。

Per.11 將計算從執行時提前到編譯期

可以減少程式碼尺寸和執行時間、避免資料競爭、減少執行期的錯誤處理。

constexpr

將函式宣告為 constexpr,且引數都是常量表示式,則可以在編譯期執行。

注意:constexpr 函式可以在編譯期執行,但不意味著只能在編譯期執行,也可以在執行期執行。

constexpr 函式的限制:

  • 不能使用 staticthread_local 變數
  • 不能使用 goto
  • 不能使用異常
  • 所有變數必須初始化為字面型別

字面型別:

  • 內建型別(及其引用)
  • constexpr 構造的類
  • 字面型別的陣列

例 1

// 舊風格:動態初始化
double square(double d) { return d*d; }
static double s2 = square(2);

// 現代風格:編譯期初始化
constexpr double ntimes(double d, int n)   // 假設 0 <= n
{
    double m = 1;
    while (n--) m *= d;
    return m;
}
constexpr double s3 {ntimes(2, 3)};

第一種寫法很常見,但有兩個問題:

  • 執行時函式呼叫開銷
  • 另一個執行緒可能在 s2 初始化之前訪問 s2

注:常量不存在資料競爭的問題

例 2

一個常用的技巧,小物件直接存在 handle 裡,大物件存在堆上。

constexpr int on_stack_max = 20;

// 直接儲存
template<typename T>
struct Scoped {
    T obj;
};

// 在堆上儲存
template<typename T>
struct On_heap {
    T* objp;
};

template<typename T>
using Handle = typename std::conditional<
    (sizeof(T) <= on_stack_max),
    Scoped<T>,
    On_heap<T>
>::type;

void f()
{
    // double 在棧上
    Handle<double> v1;
    // 陣列在堆上
    Handle<std::array<double, 200>> v2;
}

編譯期可以計算出最佳型別,類似地技術也可用於在編譯期選擇最佳函式。

實際上大多數計算取決於輸入,不可能把所有的計算全部放到編譯期。除此之外,複雜的編譯期計算可能大幅增加編譯時間,並且導致除錯困難。甚至在極少場景下,可能導致效能劣化。

程式碼檢查建議

  • 檢查是否有簡單的、可以作為(但沒有) constexpr 的函式
  • 檢查是否有函式的所有引數都是常量表示式
  • 檢查是否有可以改為 constexpr 的宏

Per.19 以可預測的方式訪問記憶體

快取對效能影響很大,一般快取演算法對相鄰資料的簡單、線性訪問效率更高。

當程式需要從記憶體中讀取一個 int 時,現代計算機架構會一次讀取整個快取行(通常 64 位元組),儲存在 CPU 快取中,如果接下來要讀取的資料已經在快取中,則會直接使用,快很多。

例如:

int matrix[rows][cols];

// 不好
for (int c = 0; c < cols; ++c)
    for (int r = 0; r < rows; ++r)
        sum += matrix[r][c];

// 好
for (int r = 0; r < rows; ++r)
    for (int c = 0; c < cols; ++c)
        sum += matrix[r][c];

在 C++ 標準庫中,std::vector, std::array, std::string 將資料存在連續的記憶體塊中的資料結構對快取行很友好。而 std::liststd::forward_list 則恰恰相反。
例如在某測試環境中,從容器中讀取並累加所有元素:

  • std::vectorstd::liststd::forward_list 快 30 倍
  • std::vectorstd::deque 快 5 倍

很多場景下,即使需要在中間插入/刪除元素,由於快取行的原因,std::vector 的效能也可能好於 std::list

除非測量的結果表明其他容器效能好於 std::vector,否則應將 std::vector 作為首選容器。

其他

剩下的條目截至目前還只有標題,缺少詳細描述:

  • Per.12 Eliminate redundant aliases/消除冗餘別名
  • Per.13 Eliminate redundant indirections/消除冗餘間接
  • Per.14 Minimize the number of allocations and deallocations/儘可能減少分配和釋放
  • Per.15 Do not allocate on a critical branch/不在關鍵分支上分配
  • Per.16 Use compact data structures/使用緊湊的資料結構:效能主要由記憶體訪問決定
  • Per.17 Declare the most used member of a time-critical struct first/對於時間關鍵的結構體,把最常用的成員定義在前
  • Per.18 Space is time/空間就是時間:效能主要由記憶體訪問決定
  • Per.30 Avoid context switches on the critical path/避免關鍵路徑上的上下文切換

總結

  • 非必要,不最佳化
  • 先測量,再最佳化
  • 為編譯器最佳化提供必要資訊:
    • 正確使用 constfinalnoexcept 等關鍵字
    • 為函式實現移動語義、如果可能,使之成為 constexpr
  • 現代計算機架構為連續讀取記憶體而進行了最佳化,應該將 std::vector, std::array, std::string 作為首選

Reference

相關文章