資料整理: jserv
YouTube: 羅輯思維 85 集: 費馬大定理
asymptotic [/ˌæs.ɪmˈtɑː.t̬ɪk] 漸近的
最佳化編譯器的重要性
Code Motion
n * i
運算)
但要注意以下不適用 code motion 的例外狀況:
可見到 C 的 array subscripting 會被替換為對應指標操作
2009 年的 Nehalem (採用 45 奈米製程,後期改用 32 奈米製程); 2013 年的 Haswell (和 Ivy Bridge 微架構一樣採用 22 奈米製程)
common subexpression elimination (CSE)
Optimization Example: Bubblesort
Procedure to Convert String to Lower Case Page 351
你所不知道的 C 語言:動態連結器篇 提及 LTO (Link Time Optimization),有機會對這類案例進行最佳化,但現有技術仍有相當多限制
延伸閱讀: strict aliasing
*v1 + *v2
的算式往迴圈外面提,然後將 *v1 + *v2
的結果存到一個暫時變數上,再把迴圈裡的算式直接用那個暫時變數代換以節省重複運算的時間。不過這是不對的!loop invariant 是證明某個不變的條件 (稱做 invariant Condition),這個條件在程式進入迴圈前跟進入迴圈後,此條件仍不變。
以 while 迴圈為例:
假設一個 invariant 條件叫做 X
,這個 X
條件在 while 檢查 A 條件時都維持原狀,一進入 Body 時可能改變 X
條件,但一執行完 Body 後 X 又恢復原貌,這種條件就是一個典型的 invariant condition。
證明 loop invariant 需要 3 步驟:
strict-aliasing 衍生的型態問題非常複雜,可見 C99 and type-punning – making sense of a broken language specification 和 Understanding Strict Aliasing
考慮以下程式碼:
地址 0xBAD 的內容會是什麼?
0xBAD
這樣用 16 進位數值表達特定人類可理解的意思,稱為 hexspeak
延伸閱讀: The Meaning of Memory Safety
這也讓許多 C subset/extension 存在,例如 Cyclone
Cycles Per Element (CPE) Page 345
考慮到以下向量運算操作的程式碼: Page 347
如果我們施加以下最佳化手段:
vec_length
搬出迴圈get_vec_element
從迴圈中拿出來,CPE 基本上沒有改變,因爲這些檢測總是預測索引是在界內的,所以是高度可預測的)除了上述和處理器架構無關的最佳化,事實上進階最佳化手段往往鎖定現代處理器的特徵: Page 357
回頭閱讀 CS:APP 第 4 章 以學習計算機結構。
考慮以下加總函式,而 ret
為返回地址:
兩個函式的功能完全一樣,而且 sum1 也來得更直觀些,sum2 有時就會讓人不解為何要引入一個中間變數 s 來保存累加和。
反組譯這兩段程式碼:
sum1 中對 ret 的 deference 會導致從 %edi (ret)
的地址中取值 (*ret) 指派給 %eax
,然後再從 %eax
指定回( %edi) 這個過程。而循環 len 次就會多出 2 * len 道指令。所以 sum2 的效率要高,那麼高多少呢,在 Intel Core i5 M520 2.40GHz 做實驗可得:
注意,這裡的時間單位是 ms (10-3s, 讀作 Milliseconds)。當 len 的長度不斷以 10 的數量級遞增時,sum2
的時間優勢其實並不明顯。在 len = 108 時,差距僅僅是 140 ms(加速因子k = 1.34, k = sum1 時間 / sum2 時間),得益於現代硬體架構的進化,如此效能差距會縮減。
對 sum1 函式的進一步優化:
len = 108 時,sum3 相對於 sum2 縮減 172ms (k = 1.74),相對 sum1 縮減 312ms (k = 2.33)。為什麼會有這種提高?可以看到在循環中步長變為了3,那麼為什麼要是 3 而不是其他呢?
這就需要理解計算機結構。
理想的計算機中 (例如 Alan Turing 的抽象機器),硬體資源可以是無窮無盡,於每個週期我們都有無限的計算器件可用。看到在第三週期用到了 jl
, cmpl
, incl
硬體資源,但其實都要用到加法器。
反過來看,真實計算機的硬體資源也不可能無限多。考慮到只能有兩個加法器的狀況,在同一週期,我們只能有兩個涉及加法的指令。那麼就有了如下的現實中的計算版本。顯然效率要比理想版本差,效率拖後約 1 倍。
如何用手頭有限的資源創造最大的效益,就是最佳化的策略。load 指令的週期是一般指令的 3倍,而 load 可以 pipeline 執行,那麼儘量讓 load 做事,就是我們所期望的改進。於是就有上述的 sum3
:
目的很明顯,儘量減少 addl
, cmpl
和 jl
這些指令的執行,這樣就不至於太受加法器資源的限制,另外儘量利用 load 的 pipeline 執行特性。那麼如何減少 jl
的執行呢?終於想到了加大每次步長了吧。又為什麼是 3 呢?想到了load的週期是 3 了吧。謎團終於解開,看看最終版本的 sum3 是如何在機器中執行的:
看到 3 步一循環的方法能有效地降低 jl
的執行,同時充分地利用了load 的 pipeline 特性。最後,這個版本相對理想版本效率拖後約 0.33倍。
既然能增加性能又為什麼要謹慎呢?問題是,以後呢?再以後呢?讓我們看得遠一點,再遠一點。硬件的發展總是如此之快,加法器會有的,load 會更快的。然後呢,然後就是,你做的優化還得隨著硬體不斷修正。
繼續 sum1 的討論,如果我們把加法操作改為乘法呢?
書本給予一種最佳化實作:
m0 計算偶數下標的乘積,而 m1 計算奇數下標。最後合併。看看到底有多快:
Len = 108,差異 140ms, k = 1.43。另外,由於之前的 loop unrolling 也基本上能猜出個大概了。乘法器件耗費的週期巨大,又由於其 pipeline 特性。所以考慮增加每次循環中的乘法次數,而不必等到乘積結果迭代到下次而產生的延時。
重點提示:
電腦程式中,副程式直接或間接呼叫自己就稱為遞迴。遞迴算不上演算法,只是程式流程控制的一種。程式的執行流程只有兩種:
迴圈是一種特別的分支,而遞迴是一種特別的副程式呼叫。
不少初學者以及教初學者的人把遞迴當成是複雜的演算法,其實單純的遞迴只是另一種函數定義方式而已,在程式指令上非常簡單。初學者為什麼覺得遞迴很難呢?因為他跟人類的思考模式不同,電腦的兩種思維模式:窮舉與遞迴(enumeration and recursion),窮舉是我們熟悉的而遞迴是陌生的,而遞迴為何重要呢?因為他是思考好的演算法的起點,例如分治與動態規劃。
分治:一刀均分左右,兩邊各自遞迴,返回重逢之日,真相合併之時。
分治 (Divide and Conquer) 是種運用遞迴的特性來設計演算法的策略。對於求某問題在輸入 S 的解 P(S) 時,我們先將 S 分割成兩個子集合 S1 與 S2,分別求其解 P(S1) 與 P(S2),然後再將其合併得到最後的解 P(S)。要如何計算 P(S1) 與 P(S2) 呢?因為是相同問題較小的輸入,所以用遞迴來做就可以了。分治不一定要分成兩個,也可以分成多個,但多數都是分成兩個。那為什麼要均分呢?從下面的舉例說明中會了解。
從一個非常簡單例子來看:在一個陣列中如何找大最大值。迴圈的思考模式是:從前往後一個一個看,永遠記錄著目前看到的最大值。
分治思考模式:如果我們把陣列在i的位置分成前後兩段 a[0, i - 1] 與 a[i, n],分別求最大值,再返回兩者較大者。切在哪裡呢?如果切在最後一個的位置,因為右邊只剩一個無須遞迴,那麼會是
這是個尾遞迴 (Tail Recursion),在有編譯優化的狀況下可跑很快,其實你可以發現他的行為就是上面那個迴圈的版本。如果我們把他切在中點:
效率一樣是線性時間,會不會遞迴到 stack overflow 呢?放心,如果有200 層遞迴陣列可以跑到 2200,地球已經毀滅了。
再來看個有趣的問題:假設我們有一個程式比賽的排名清單,有些選手是女生有些是男生,我們想要計算有多少對女男的配對是男生排在女生前面的。若以 0 與 1 分別代表女生與男生,那麼我們有一個 0/1 序列 a[n],要計算
迴圈的思考方式:對於每一個女生,計算排在他前面的男生數,然後把它全部加起來就是答案。
效率取決於如何計算cnt。如每次拉一個迴圈來重算,時間會跑到 O(n2),如果利用類似前綴和 (prefix-sum)的概念,只需要線性時間。
接下來看分治思考模式。如果我們把序列在 i 處分成前後兩段 a[0, i - 1] 與 a[i, n],任何一個要找的 (1, 0) 數對只可能有三個可能:都在左邊、都在右邊、或是一左一右。所以我們可以左右分別遞迴求,對於一左一右的情形,我們若知道左邊有 x 個 1 以及右邊有 y 個 0,那答案就有 xy 個。遞迴終止條件是什麼呢?剩下一個就不必找了。
時間複雜度呢?假設把範圍內的資料重新看一遍去計算 0 與 1 的數量,那需要線性時間,整個程序的時間變成 T(n) = 2T(n/2) + O(n),結果是O(nlogn),不好。比迴圈的方法差,原因是因為我們計算 0/1 的數量時重複計算。我們讓遞迴也回傳 0 與 1 的個數,效率就會改善了,用 python改寫:
時間效率是 ,所以結果是 。
如果分治可以做的迴圈也可以,那又何必學分治?第一,複雜的問題不容易用迴圈的思考找到答案;第二,有些問題迴圈很難做到跟分治一樣的效率。你可以不信我講的第一點,我們可以來看看第二點。上述男女對的問題其實是逆序數對問題的弱化版本:給一個數字序列,計算有多少逆序對,也就是
迴圈的思考模式一樣去算每一個數字前方有多少大於它的數,直接做又搞到O(n^2),有沒有辦法像上面男女對問題一樣,紀錄一些簡單的資料來減少計算量呢?你或許想用二分搜,但問題是需要重排序,就我所知,除非搞個複雜的資料結構,否則沒有簡單的辦法可以來加速。那麼來看看分治。基本上的想法是從中間切割成兩段,各自遞迴計算逆序數並且各自排序好,排序的目的是讓合併時,對於每個左邊的元素可以笨笨地運用二分搜去算右邊有幾個小於它。
時間複雜度呢?即使我們笨笨地把兩個排好序的序列再整個重排,T(n) = 2T(n/2) + O(nlog(n)),結果是 O(nlog2(n)),十萬筆資料不需要一秒,比迴圈的方法好多了。為什麼說笨笨地二分蒐與笨笨地重新排序呢?對於兩個排好序的東西要合併其實可以用線性時間。那二分搜呢?沿路那麼多個二分搜其實可以維護兩個註標一路向右就好了。所以事實上不需要複雜的資料結構可以做到 O(nlogn),熟悉演算法的人其實看得出來,這個方法基本上就是很有名的 merge sort,跟 quick sort 一樣常拿來當作分治的範例。另外,如果merge sort的分割方式如果是 [left, right - 1] [right - 1, right],右邊只留一個,那就退化成 insertion sort 的尾遞迴。
Tail recursion 是遞迴的一種特殊形式,副程式只有在最後一個動作才呼叫自己。以演算法的角度來說,recursion tree 全部是一脈單傳,所以時間複雜度是線性個該副程式的時間。不過遞迴是需要系統使用 stack 來儲存某些資料,於是還是會有 stack overflow 的問題。但是從編譯器的角度來說,因為最後一個動作才呼叫遞迴,所以很多 stack 資料是沒用的,所以他是可以被優化的。基本上,優化以後就不會有時間與空間效率的問題。
注意: Python 沒有 tail recursion 優化,而且 stack 深度不到 1000。C 要在編譯器開啟最佳化時才會進行 tail recursion 的最佳化。
延伸閱讀:
cs:app
, csapp