CSAPP 第五章: 優化程序性能


前言

最近重讀 CSAPP 第五章,這一章的主題是優化程序性能。

首先,在開始着手優化程序性能之前,需要考慮現有程序的算法和數據結構,先優化算法。這種優化獲得的提升是數量級的提升,比如從 \(O(N^2)\) 復雜度到 \(O(N)\) 復雜度,這種理論上復雜度的優化,在數據量上去之后,效果明顯。

接下來就是要相信編譯器是很聰明的,它幫助你做很多性能優化,gcc 開啟 -O 選項進行優化。但是呢,我們需要認識到編譯器的界限在哪里,編譯器不會幫你做哪些優化。對於編譯器,它要考慮的是如何保證優化后程序的行為一致。因為一些問題的存在,編譯器無法確定是否可以優化。最典型的問題是 memory aliasing,出現的場景是兩個指針指向同一塊內存地址。函數可能有 side effect,所以函數調用不會被輕易的優化,除非是 inline。知道了編譯器能與不能,就可以寫出編譯器比較好優化的代碼了。

最后是基於對現有處理器結構的理解,對內存層級的理解進行優化。現代處理器中處理指令和執行指令一般是分開的,處理指令的地方我們稱之為 ICU(Instruction control unit),執行指令的地方我們稱之為 EU(Execution unit)。ICU 負責取指令,將指令解碼成更小的可執行單元。EU 負責接收這些更小的可執行單元,這些分解的操作可以並行執行,因此可以利用這一點對程序進行優化。這章提到了三個優化的技巧:Loop Unrolling, Multiple Accumulators, Reassociation Transformation。此外,現代處理器還提供了 SIMD(Single Instruction Multiple Data),單條指令,多個數據,使用這些指令可以獲取更好的並行度,從而進一步提高程序性能。

這一章很重要的一個知識點是數據流圖的分析,借助數據流圖的分析,我們找到並定位程序的性能瓶頸。在分析數據流圖的時候,需要意識到處理器的流水線特性。一個指令可以分解為多個小操作,這些小操作可以流水並行。因此在使用數據流圖分析的時候,要考慮這一點。

優化技巧總結

  • Eliminating Loop Inefficiencies,減少循環時低效調用,比如 for 循環當中檢查邊界,如果每次都調用一個方法獲取長度,這將大大增加時間消耗,更壞的情況是導致了算法復雜度的改變。
  • Reducing Procedure Calls,減少程序調用。
  • Eliminating Unneeded Memory References,減少不必要的內存引用。使用一個寄存器變量做臨時讀寫,將最后的結果寫入到內存中。如果每次都寫入內存,那么會很低效。
  • Loop Unrolling,循環展開。for 循環每次步進 2 或者更多,提供指令級別並行。
  • Multiple Accumulators,使用多個累積變量。從數據流圖的角度去分析,使用多個累積變量可以獲得更好的指令並行。
  • Reassociation Transformation。算術運算的時候,可以先計算某些部分來獲得更好的指令並行。
  • SIMD,從指令級別的角度看,使用單條指令多條數據去加速並行。
  • Register Spilling。如果使用了太多累積變量,超過了寄存器的數量,那么會開始使用放在內存中的棧變量,這樣將會導致訪存,使性能下降。
  • 現代處理器采用了亂序執行的策略,對於分支執行,它會選擇一個分支直接執行,如果分支預測錯誤了,那么將會清理環境,重新取指令,重新執行。為了減少這種分支預測錯誤帶來的代價,可以使用 Conditional Moves 類別的指令來減少分支預測錯誤的代價。
  • Do Not Be Overly Concerned about Predictable Branches。不用過多擔心分支預測。因為很多情況下,分支預測的結果往往是正確的,只有最后一個元素預測錯誤,比如判斷 index 是否已經在數組的邊界等操作。不過對於一些比較隨機的判斷,分支預測的性能卻不會很好,所以這些隨機的分支判斷可以使用 Conditional Moves 進行優化。

CPE

這一章讓我覺得非常優雅的一個地方是 CPE,使用 Cycles Per Element 來評估性能。這有助於從理論層面上分析一段代碼的性能邊界究竟在哪里。Cycle 是處理器執行周期,比如一個 4GHz 的處理器,它每秒進行 \(4 \times 10^9\) 個操作,每個周期 \(0.25 ns\)。評估一段程序的性能,可以看看每個元素處理了多少個周期,即使用 CPE 來評估性能。

5.12 給出了處理器上操作的時間, Latency 是操作執行的時間,Issue 是兩個操作之間需要的時間,Capacity 表示可以同時執行多少個這樣的操作。后面的表中的 Throughput 分析了每個操作對每個元素執行時間 CPE 的理論上界。比如浮點數乘法,Capacity 為 2,Issue 為 1,在流水執行的情況下,平均下來一個浮點數需要 0.5 個 CPE,同理加法浮點數為 1.0 個 CPE。不過這里有個特別的例子,整數加法的 CPE 為什么是 0.5 而不是 0.25 呢?其實因為程序的瓶頸在別處,因為處理器上只有兩個 load 單元,每次只能讀取兩個元素,所以不能同時 4 個元素都在進行。

CPE 的估計

計時的方法如下。我實現了一個最簡單的求和,每個元素執行時間 2 ns。\(2 \times 1\) 循環展開、\(2 \times 2\) 循環展開、Reassociation Transformation 循環展開 的結果都是 1。看了一下服務器上的頻率是 2.3GHz,每個周期大概是 0.5 ns。所以簡單求和的 CPE 為 4,優化后的 CPE 為 2。如果要分析理論上界,我們還需要知道這個處理器的結構才行。(ps. 處理器是 Intel(R) Xeon(R) Gold 5218 CPU @ 2.30GHz)

#include <chrono>
using namespace std::chrono;

auto start = high_resolution_clock::now();
int ans = sum(x, N);
auto stop = high_resolution_clock::now();
auto duration = duration_cast<nanoseconds>(stop - start);
cout << duration.count() / N << endl;
cout << ans << endl;

多項式求和

書上有兩個問題(5.5 和 5.6)講的是多項式求和。多年前讀到這個反直覺的例子,讓我印象深刻至今。這兩道題畫個數據流圖分析一下就清楚了。

  • 在 5.5 中我們需要計算 n 次加法,2n 次乘法。在 5.6 中,我們需要計算 n 次加法,n 次乘法。所以 5.6 計算次數更少,是不是應該算的更快呢?

  • 直觀來看,5.6 執行的運算數量少,應該有更快一點才對。不過畫個數據流圖,分析一下就知道 5.6 存在依賴關系,不能指令級別並行,而 5.5 不存在這種依賴關系,所以可以指令級別並行,速度比 5.5 要快!

對於 5.5 的數據流圖分析,需要知道“流水線執行”。在計算 result 的 add 節點的時候,需要 3 個 CPE,但是因為不存在依賴關系,下一個 xpwr 可以並行執行,所以這個 add 節點的運算開銷會被 xpwr 節點的乘法運行給掩蓋。決定整個程序性能瓶頸的是 xpwr 的乘法計算。

對於 5.6 的數據流圖,我們可以看到整個計算過程的 critical path 需要經過一個乘法,一個加法。因為存在依賴關系,所以乘法和加法不能並行,所以這注定了 5.6 比 5.5 慢。

  • “粗糙”的實驗結果,x 為 2.0,隨機初始化一個長度為 100 的多項式系數,分別執行 poly 和 polyh 次數為 10000000。用運行的總時間除以執行次數,再除元素總數 100。

總結

這篇博客挺短的,將書上的優化技巧提煉總結出來。在閱讀第五章的時候,最好關注兩個層面。第一,編譯器能干什么、不能干什么,它的局限在哪里。第二,需要了解現代處理器的結構設計,了解內存層級讀寫性能,優化是建立在對於這些結構的理解之上的。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM