🎯 什麼情境該想到我
當你「覺得」程式跑得慢、忍不住想動手把某段改快一點時。特別是當你腦中冒出「這樣寫應該比較快」——那個「應該」就是這張卡要攔下來的東西。
一種操作也許比另一種操作更快或更省空間——錯誤!當你談論效能時不存在「也許」這個概念。你的修改是對程式有益還是有害,必須通過對效能測量來判斷。(p.449)
⚙️ 怎麼用
步驟 0:先確認「調校」是不是對的工具
調校排在改善效能手段的最後一位。書列出的層次依序是:程式設計 → 模組與子程式設計 → 與作業系統的互動 → 程式碼編譯 → 硬體 → 程式碼調校(p.446–447)。
為什麼要用程式碼調校?它不是最有效的提高程式效能的辦法。程式設計、資料結構選擇和演算法選擇通常能產生更好的改進效果,它也不是最簡單的改進效能的方法,買一個新的硬體,或更好的編譯器更加簡單。(p.448)
而且編譯器常常贏過你:
用一個好的最佳化編譯器,你的程式碼速度可提高 30% 或更多,在下一章講的技術,速度只能提高 20%,為什麼不編寫一個清楚的程式碼,讓編譯器去最佳化它呢?(p.452)
步驟 1–5:書的正式流程(p.457)
- 用高度模組化的設計開發軟體,讓它易於理解、修改。
- 如果效能很差,測量系統,找出頻繁執行的位置。
- 判斷弱點是不是設計、資料結構、演算法造成的;判斷程式碼調校是否合適。若不合適,回到步驟 1(那是結構問題,局部調校救不了)。
- 只調校步驟 3 識別出的薄弱環節,測量每一個改進;如果它不能提高系統效能就放棄重來。
- 回到步驟 2 重複。
判準補充
- 什麼時候開始調校:先做出高品質、正確、模組化易改的設計;程式完整正確後再檢查效能;「如果程式設計者能使它快速、簡單,就先不要最佳化,等到你知道你需要對它最佳化時」(p.453)。
- 往哪裡找(Pareto 80/20,p.450):Boehm 報告 20% 的程式段消耗 80% 的執行時間;Knuth 1971 年對 FORTRAN 程式的實證研究發現 不到 4% 的程式常佔用超過 50% 的執行時間。p.484 另給了「約 5% 的程式碼通常消耗整個程式 50% 以上執行時間」。
- 量測要夠精確(p.452):「用數一隻象、二隻象、三隻象的方法測定程式時間是不夠精確的」。用表分析工具,或自己用系統時鐘記錄。在多使用者/多工系統上,要用分配給你程式的 CPU 時鐘週期數量測,不要用時間——否則系統把你的程式換出去時,別的程式的時間會算到你頭上。
- 改完要再量一次:確定究竟改進了多少(p.450–451)。
- 什麼時候停:不是找到第一個改進就停。「第一次最佳化後不要停止,即使第一次產生了顯著效果,第二次最佳化會更好」(p.476、p.486)。單一手法很難拿到 10 倍,但累積會很可怕——作者的 DES 加密程式做了約 30 輪調校,從 21:40 降到 0:22(98%),其中沒有任何一輪超過 5% 也照樣有意義(p.453)。真正的停止條件是事先為子系統定好的空間與速度目標達成了(p.447)。
手法清單(第 29 章,挑至今仍成立的)
迴圈
- 反切換:迴圈內判斷條件不變時,把
if提到迴圈外(C 省 21%、Basic 19%,p.459–460)。 - 合併(fusion):兩個跑同一元素集合的迴圈併成一個(2–4%,p.461)。
- 展開:一次處理多個元素(21–28%,再展開再省 10%,p.461–462)。
- 最小化迴圈內工作量:複雜指標運算式先算好存變數,迴圈裡用變數(C 13%,p.463)——這條通常同時提高可讀性。
- 檢索迴圈用標記值(sentinel):把要找的值塞在陣列尾端,三個判斷併成一個(整數陣列 28–91%,p.463–464)。
- 最忙的迴圈放內層(p.464)。
- 降低運算強度:用加法代替乘法(21–26%,p.465)。
邏輯
- 知道答案就停止判斷:短路求值、找到就 break(27%/9%,p.466–467)。
- 按頻率排 case / if-then-else 的順序:最常出現的放最前面(33–37%,p.467–468)。
- 用查表法代替複雜邏輯判斷鏈(31–60%,且 C 的例子中程式碼從 88 位元組降到 43 位元組,含表本身;規則變了也更好維護,p.468–469)。
- 偷懶改進法:需要時才算,算完存起來(p.469)。
資料與運算式
- 盡量用整數不用浮點;減少陣列維度;減少陣列存取次數;運用輔助索引(長度索引、單向串列的索引表);把常用值放進快取(p.470–474)。
- 利用數學等價關係:
sqrt(x) < sqrt(y)換成x < y(80%/60%,p.474)。 - 編譯期先算好:把
log(2)換成命名常數(25%,p.475–476)。 - 注意系統子程式的精度過剩:整數版 log2 用一串整數比較取代浮點
log(),99.5%、200:1(p.477)。書的原話是這些函式「設計精度可以把太空人送到目標上離目標只差正負 2 英尺的範圍內,如果你不需要這麼高的精度,你也不需要花費這麼長時間計算它」(p.476)。 - 預先計算結果、消除公共子運算式(p.478–483)。
子程式
- 「在程式碼調校中,最強有力的手段之一是好的程式分解。小的、明確的程式可以節省空間……它們使程式更容易最佳化」(p.482)。
🧪 我實際套用的紀錄
- (待填)
⚠️ 注意 / 什麼時候不適用
書明確標「錯誤!」的四個反模式
-
「減少高階語言中語句的行數可提高機器碼的執行速度或減小空間——錯誤!」(p.449)
五行直接賦值 vs. 一行for i=1 to 5 do a[i]=i,實測直接賦值贏:Pascal 0.660→0.110(省 83%,6:1)、Basic 0.379→0.051(省 87%,7:1)。行數少 ≠ 跑得快。 -
「一種操作也許比另一種操作更快或更省空間——錯誤!」(p.449)
「在使用一種編譯器的一台機器上所能運行的,在使用另一種編譯器的另一台機器上可能就是錯誤的。」 -
「你應該處處最佳化程式——錯誤!」(p.449–450)
這是一種只見樹木不見森林的看法,在這種情況下,程式設計師往往因為過於注重局部最佳化而忽視了更重要的整體最佳化。
書給的三個具體後果:(a) 在程式完整工作前你幾乎不知道哪個部分最弱,時間花在不需要最佳化的地方;(b) 少數猜對的人又過分注重已知弱點而忽視其他,最終反而降低系統效能;(c) 正確性、模組化、資訊隱蔽、可讀性全被降為次要目標。
即使效能改進容易,一個效能只能影響 5% 的程式碼,你想要運行中 5% 的效能改進,還是想要 10% 的可讀性!(p.450)
-
「一個快速的程式和一個正確的程式一樣重要——錯誤!」(p.450)
Weinberg 講的故事結尾,新手對著「我的程式每個輸入只要一秒」的老手說:你們說的對,像你們的程式不能工作,如果要求我的程式不工作,我也能使它快速運行還不需要花一分錢。
兩種「幫倒忙」
- 白做工:「如果你更換或改進了編譯器,新的編譯器可能會自動按照手工調整程式碼的方法最佳化程式碼,這時你的工作可能就白做了。」(p.449)
- 反效果:「更嚴重的是你的程式碼調校破壞編譯器的最佳化」(p.449)。實例在 p.471:把二維陣列改成一維,Pascal 省 32%、C 省 25%,C++ 卻是 -79%——「可能是你的程式碼調校妨礙了編譯器的自動最佳化,在這個例子中編譯器最佳化似乎比手工更有效。」
憑經驗猜,猜錯的機率很高
作者把矩陣加總的雙層迴圈改寫成指標版,估算能省掉 100 次乘法。實測結果:
沒有一點改進。對於 10×10 矩陣、3×3 矩陣、25×25 矩陣都沒改進,編譯器的最佳化器已經將第一個程式碼最佳化得足夠好了……我得到的教訓是:沒有測量效能保證的最佳化,其結果常常是使你的程式碼難讀。(p.451–452)
經驗也不能對最佳化有很大幫助,一個人的經驗可能是從一種老的機型、語言或編譯器中得來。當這些東西中的一種改變時,所有預測就都作廢。(p.451)
同一手法在不同環境結果相反(書自己給的負值)
| 手法 | 好的情形 | 壞的情形 | 頁碼 |
|---|---|---|---|
| 最忙迴圈放內層 | Pascal 4%、Ada 5% | Fortran −81% | p.464 |
| 二維陣列改一維 | Pascal 32%、C 25% | C++ −79%;索引改長整數後 −9% ~ −124% | p.470–471 |
| 快取計算結果 | Pascal 14%、C 3% | Basic −44% | p.474 |
| 檢索迴圈用標記值 | 整數陣列 28–91% | 單精度浮點陣列 C++/Pascal −3% | p.464 |
| 消除公共子運算式 | C 用指標版 13% | C −3%、Ada −11%(用值代換那版) | p.482 |
| 常數型別一致 | C 74% | C++ 0%、Fortran 0% | p.478 |
盲目聽從任何一種程式碼調校建議都是危險的,在你沒有在自己特定的環境下嘗試過建議前,你不能做出任何肯定。(p.471)
代價是可讀性與維護性——這是本章的底色
- 第 28 章開頭就把 1970 年代的教訓寫死:程式設計師「只注意效能,嚴重地破壞了系統的可讀性和維護性」(p.446)。
- 作者那個 98% 改進的 DES 例子,代價是「最後的程式碼也是我所編程式碼中最難讀、最不可維護的……通常,程式碼調校和程式碼品質間的關係都是這樣」(p.453)。
- 效能不等於品質:「你的程式碼運行速度並不代表其他效能品質,如果你提高程式碼運行速度而犧牲其他效能,這不但不能改進功能,還會損害功能。」(p.447)
- 各手法自帶的坑:反切換讓兩個迴圈必須平行維護(p.460);迴圈合併可能讓兩邊索引不再匹配、順序被破壞(p.461);迴圈展開容易出邊界錯誤(p.462);標記值必須小心選值並記得還原原值(p.464);一維化陣列的
NumRows*NumCols可能索引溢位(p.471)。
局部調校救不了結構問題
在某些工程中,有些最佳化法不能滿足效能要求,你將不得不更大地改動已編成的程式碼……這種情況中問題不是出在程式碼品質不好,而是軟體結構不能滿足要求。(p.450)
調校本身是一次「修改程式」,套用第 30 章的紀律
第 30 章(p.486–499)談的其實是軟體演化/修改(中譯標題作「軟體優化」易誤導),不是效能最佳化。它的中心規則是「提高程式的內在品質」,檢查表裡有一條直接適用於調校:「軟體是否作了重測試,確保修改未使效能變壞?」(p.498–499)另外書引 Weinberg:「十個最昂貴的程式設計錯誤都涉及修改現存的程式,最貴的幾個每個都值上千萬美元,儘管只需改動一行」(p.488)——調校改的是已經正確的程式碼,回歸測試不能省。
⚠️ 已過時(1993 年的機器與編譯器假設,僅供理解書中例子)
- 表 28-1「常用操作的耗費」(p.456–457)的相對值以浮點在軟體中模擬、而非硬體為前提(C 的浮點賦值 85、浮點指數 2000)。今天的相對成本完全不同。但「自己在自己環境做一張這樣的表」這個做法仍成立——書自己就說「在你的環境測試一下你感興趣的操作」。
- 格式化列印額外佔 1.6K–3.3K 位元組、第一次浮點運算就拉進整個約 15K 的浮點函式庫(p.454–455)——那是當年 PC 的空間問題。
- 分頁法例子用 2K 頁、每次缺頁中斷千分之一秒(p.455)——具體數字過時;「存取順序要配合資料在記憶體中的佈局」的道理仍在。
- 用組合語言重寫關鍵子程式(p.483–485,Pascal 省 65%)——今天只在極少數情境成立。
- 特定語言/編譯器的個別行為(Basic 變數預設單精度浮點、Pascal 的串長度位元組、編譯器與連結器的覆蓋支援)——全部過時。但正因為結果隨環境劇烈變動,這些過時例子反而強化了本卡的核心主張:不量測就不算數。
🔗 相關工具
- 工具-管理複雜度 —— 同書的最高準則,也是這張卡的煞車:調校常常是拿可讀性換效能,動手前先問「這讓下一個人要理解的東西變多還是變少」
- 工具-前端效能優化規則 —— 不同層級:那張管網頁交付層(HTTP 往返、資源、CDN),這張管程式碼層(迴圈、運算式、資料結構)
- 工具-快取與資源優化 —— 同為網頁交付層(高性能網站建設指南);本書 p.473–474 也講快取,但指的是程式內部把常用計算結果存起來,不是瀏覽器/CDN 快取
- 程式碼大全 —— 回到書