動態

詳情 返回 返回

C++ 核心指南 —— 性能 - 動態 詳情

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

  • C++ Core Guidelines, Per: Performance
  • 《性能優化的一般策略及方法》
  • 《C++ Core Guidelines 解析》

Add a new 評論

Some HTML is okay.