吃掉你加速的那 5%
到現在你已經會剖析一個程式、建立一個誠實的微基準測試、並讀懂一個延遲對吞吐量的分布、包含它的尾巴。於是你在火焰圖裡找到一個肥厚的計算迴圈、把它切到你擁有的所有核心上、期待那個理所當然的回報:八個核心、快八倍。你一量,它快了大約五倍,然後你再加八個核心,它幾乎不動。沒有任何東西壞掉。你只是遇上了 Amdahl 定律(Amdahl's law)——平行效能裡最重要的那一道算術——而它令人謙卑,正因為它如此簡單。
概念是這樣的。拿任何一個程式、把它的執行時間切成兩部分:能平行跑的比例 p,以及那頑固的、序列(serial)的比例 (1 − p)——不論你有多少核心,都必須一步接著一步發生的工作。讀進輸入檔、建立資料結構、把每個人的部分答案合起來的最終歸約、一段被鎖守護的臨界區:全都是序列的。當你把 N 個核心丟向平行的部分時,只有那個部分縮小;你把 p 除以 N,卻完全碰不到 (1 − p)。那段序列比例就杵在那裡,是你執行時間之下一塊推不動的地板。
1
speedup(N) = -----------------
(1 - p) + p / N
p = 0.95 (95% parallel), N -> infinity
1 1
speedup = -------- = ----------- = 20x <- the HARD ceiling
(1 - p) 0.05盯著那道天花板看,因為它就是整堂課。一個 95% 平行的程式——聽起來棒極了——永遠不可能快過 20 倍,不管你有 32 個核心還是 32,000 個。最後的那 5% 主宰一切。而在抵達那裡的路上更糟:在 8 核時那個 95% 平行的程式達到約 6 倍、在 32 核時約 12 倍,所以你已經把四分之三的硬體燒在遞減的報酬上了。序列比例不是一個你能忽略的捨入誤差;它是那個唯一決定「平行到底值不值得」的數字。
為什麼平行不等於並行,以及序列部分藏在哪裡
在繼續之前,先釘死一個較早那級畫過、而 Amdahl 定律讓它變得迫切的區別:並行不等於平行。並行(concurrency)是把一個程式結構成能在重疊的時間裡各自推進的獨立任務;平行(parallelism)是真的在好幾個核心上、於同一瞬間同時跑好幾個。Amdahl 定律談的是平行——它問的是「同時執行工作能省下多少牆鐘時間」——而它永遠只幫得上程式裡那段有真正平行工作可以發派出去的部分。如果一段程式碼本質上是序列的,再多的並行機制也不會讓它變平行;你只是替一個佇列穿上了華服。
所以 Amdahl 逼你面對的真正工程問題是:我的序列比例到底住在哪、我能不能把它縮小?它藏身的地方比顯而易見的初始化與收尾還多。你拿的每一把鎖都會把爭用它的執行緒序列化。每一個把 N 個部分結果漏斗成一個答案的歸約步驟,在頂端都是序列的。記憶體頻寬是共享的,所以如果所有核心都猛敲同一塊記憶體,就算沒有明確的鎖,匯流排本身也會成為一個序列瓶頸。擴展的藝術,很大程度上就是獵捕這些隱藏序列塊的藝術——要嘛把它們平行化、要嘛移除那個逼出它們的共享、要嘛把它們縮到小到讓 (1 − p) 項變小。
Gustafson 的回答:把問題長大,不只是把核心加多
如果 Amdahl 定律是故事的全部,那麼擁有上百萬核心的超級電腦就毫無意義,因為一個 95% 平行的程式永遠卡在 20 倍。然而那些機器明明白白地物有所值。解答來自 Gustafson、在 1988 年,而它是換了一個問題、不是駁倒了 Amdahl。Amdahl 問的是:我有一個固定的問題——隨著我加核心,它跑得快多少?Gustafson 問的是真實使用者實際擁有的那個問題:我有固定的一段時間——如果我加核心,我能解決多大的問題?這兩個不是同一個問題,而它們有非常不同的答案。
關鍵的洞見是:真實工作負載的序列部分傾向於在絕對量上大致保持不變,而平行部分會隨問題規模成長。讀那個設定檔,不管你模擬一千顆粒子還是十億顆,都花同樣的半秒;會爆炸的是每顆粒子的物理。所以當你把一個天氣模型擴展到更細的網格、把一次算繪擴展到更多像素、把一次訓練擴展到更多資料,平行工作就成長起來填滿新的核心,而固定的序列開銷成為整體裡越來越小的一個比例。在 Amdahl 那個固定規模世界裡判你死刑的那序列 5%,會隨問題長大、融化到 0.5%、再到 0.05%。
這給了兩種擴展情境各一個值得隨身帶著的名字。強擴展(strong scaling)是 Amdahl 的世界:固定問題、加核心、看著加速撞上 1 / (1 − p) 的天花板而趨平——困難模式,也是當一個固定的單一工作必須更早完成時的正確模型。弱擴展(weak scaling)是 Gustafson 的世界:讓問題與核心同步長大、使每個核心都保有固定的一片工作——這是加硬體仍持續划算的情境,也是那台百萬核心機器並非蠢事的原因。兩條定律都沒錯;它們回答不同的問題,而知道你的工作負載問的是哪一個問題,正是一份健全的擴展計畫與一份失望的計畫之間的差別。
過了某一點,更多核心反而更慢
Amdahl 定律在一個安靜的地方是樂觀的:它假設平行部分完美地切分、把工作分到各核心上是免費的。在一台真實的機器裡並非如此。你每加一個核心,都帶來兩項 Amdahl 忽略的新成本。第一,爭用(contention):執行緒排隊等共享資源——鎖、記憶體控制器、一個共享快取——而那個排隊是純粹的序列化、會隨著執行緒數目而成長。第二,一致性(coherency):當許多核心碰觸共享資料時,硬體必須讓它們的私有快取保持一致、在核心之間透過互連把快取列搬來搬去,而那種交談也隨核心數成長。這些在 Amdahl 的公式裡根本不存在。
後果在你第一次撞上時既鮮明又意外:一條爬升、趨平、然後往回彎下去的擴展曲線。核心少時平行加速主宰、吞吐量上升。到某個核心數,爭用與一致性的成本——它們隨你加核心而持續成長——超過了那縮小中的平行好處,於是加上下一個核心讓程式更慢、而不是更快。有一個真實的峰值、常常遠在你的核心數之前,過了它之後更多硬體是主動有害的。這正是 Amdahl 那道整潔天花板、與「通用可擴展性定律」(Universal Scalability Law)被造出來捕捉的那個雜亂現實之間的落差。
這正是 機械同理心(mechanical sympathy) 賺取它價值的地方——那個對本級與下一級都核心的習慣:寫出順著機器、而非與它作對的程式碼。兩個執行緒更新剛好坐落在同一條快取列裡的計數器,會在每一次寫入時把那條列在核心間乒乓來回,一場稱為偽共享(false sharing)的一致性風暴,能讓一個平行迴圈比單執行緒版本還慢。修法是結構性的:給每個執行緒它自己的記憶體,好讓核心停止為共享的列爭鬥。那正是資料導向設計的核心、也是本級最後一篇的主題——而它正是對 Amdahl 那道乾淨算術所略去的爭用項,那個實務上的答案。
運用這些定律:一份清醒的擴展檢查表
這些定律不是抽象的冷知識;它們是一套決策程序,能救你不必為了一個 2 倍的天花板而傾注數週。把 Amdahl、Gustafson 與那些爭用項揣在腦袋裡的意義,在於在你平行化之前跑那道算術、並在之後誠實地讀你的擴展曲線。下次有人說「就丟更多核心進去嘛」時,照這個順序走一遍。
- 先剖析、量出平行比例 p,再算出那道硬天花板 1 / (1 − p)。如果那道天花板很小(比如低於 3 倍),誠實的做法常常是去把序列部分弄快、而根本不要平行化。
- 決定你在回答哪一個問題:強擴展(固定問題、讓一個既定工作更早完成——Amdahl)還是弱擴展(在固定時間內把問題長大以填滿核心——Gustafson)。它們要求不同的設計、不同的成功指標。
- 獵捕隱藏的序列比例:鎖、最終歸約、共享記憶體頻寬、啟動與收尾。縮小 (1 − p) 項抬高天花板的幅度,遠勝過加核心所能做到的。
- 畫出真實的曲線、別信公式:在 1、2、4、8、16 個核心上跑、把加速畫成圖。一條提早趨平的曲線揭露你真正的 p;一條往回彎下去的曲線揭露那些定律早警告過你的爭用與一致性成本。
- 如果曲線往回彎下去,先懷疑偽共享與共享列爭用、別先懷疑別的,並套用機械同理心——給每個執行緒它自己的記憶體——而不是去伸手要更多核心。