為什麼我們走向「更寬」而非「更快」
這一級的前三篇講的是同一個故事:在真實硬體上,瓶頸很少是算術。記憶體階層讓一次快取失誤要花上數百個週期,所以資料局部性、以及 BLAS-3 那套「在快取裡重複使用資料」的把戲,才是決定一個核心能否逼近其峰值的關鍵。那一切談的都是「一個」處理核心拼命工作。本篇要加上硬體給你的另一根槓桿:與其讓一個核心更快,不如讓許多核心同時動工。這就是平行計算,而它已不再是選項——它是矽晶片如今唯一還在成長的方向。
數十年來,單一核心年復一年就是會變快:時脈往上跳,你什麼都不用做,重新編譯就好。大約在 2000 年代中期,這具引擎熄火了。把時脈推高,耗電量大致隨頻率的立方上升,晶片會燒掉——這就是所謂的「功耗牆」。於是設計師不再拉高時脈,改成以相同速度蓋出「更多」核心:雙核、接著數十核、再到 GPU 上數千個微小核心。免費午餐結束了。從此,更快的程式是那種把工作切給多個核心的程式,而核心之間「如何共享資料」就成了核心的設計問題。
切分工作說來容易、做好卻難,因為一個數值計算是一張依賴關係的網:x_{n+1} 通常需要 x_n。功夫在於找出那些「彼此不依賴」、可以同時執行的部分。把兩個長度為 N 的向量相加美妙地平行——每個元素 c_i = a_i + b_i 都各自獨立,所以一千個核心可以各取一段。把一個向量加總成單一數字就棘手了,因為那個不斷累加的總和是共享的。把這個對比記住;它是本篇一切的種子。
兩種記憶體模型:一間共用的房間,或許多信箱
把許多核心接在一起,有兩種根本不同的方式,這個分別就是共享記憶體 vs 分散式記憶體,也就是共享與分散式記憶體之分。用實體來想像。共享記憶體是一間大房間裡有許多工人:每個核心都能直接讀寫同一個位址空間,像同事們在同一塊共用白板上書寫。分散式記憶體則是許多分開的房間,每個工人各有自己的私人白板;如果 A 房需要 B 房的某個數字,就得有人實體地把它搬過去。這個「搬運」就是分散式計算的全部故事。
共享記憶體存在於「單一一台」電腦之內——你筆電裡的多核心晶片,或叢集中的一個節點。你用執行緒來寫它:從同一個行程分出來的輕量工人,全都看見同一份資料。經典工具是 OpenMP(你在普通程式碼上灑些提示,像「把這個迴圈平行跑」)以及底層的 pthreads。從「沒有東西要寄」這個意義上說,溝通是免費的——大家本來就看得到白板。麻煩在於,我們等一下會看到,大家也都能同時往上塗鴉。
分散式記憶體橫跨「許多台」電腦——超級電腦裡數百或數千個分開的節點,每個都有自己的記憶體,靠高速網路相連。根本沒有共用的白板,所以核心之間靠訊息傳遞合作:一個行程把一塊資料打包,明確地送過網路,另一個行程接收它。通用標準是 MPI(訊息傳遞介面),也就是訊息傳遞模型。它寫起來較囉嗦——收送都得你親手寫——但它是唯一能跨越單一機器的模型,地球上幾乎每一個大型模擬,實際上都是這樣跑的。
共享的危險:競爭條件
共享記憶體的恩賜——大家看見同一份資料——也是它的陷阱。假設兩個執行緒都想把自己那段的部分和加進「同一個」共享總和。「加到總和」看起來是一個動作,其實是三個:從記憶體讀出總和、加上你的值、把新總和寫回去。如果兩個執行緒在任一方寫回之前都先讀到了舊值,其中一次更新就會悄悄蓋掉另一次,於是一個數字憑空消失了。這就是競爭條件,也就是競爭條件:答案取決於「誰先到」這種無法預測的時序,所以程式在不同的執行中給出不同的結果。
shared: total = 0
Thread A Thread B
-------- --------
read total (sees 0)
read total (sees 0)
add 5 -> 5
add 3 -> 3
write total = 5
write total = 3 <-- A's +5 is LOST
final total = 3 (should be 8)
Run it again and timing may differ -> a DIFFERENT wrong answer解法是協調,而它要你付代價。一個鎖(mutex)讓同一時間只有一個執行緒能碰總和;一個原子操作則讓「讀—加—寫」在硬體層級不可分割。兩者都有效,但兩者都把那一步序列化了——當一個執行緒握著鎖時,其他執行緒只能等,這會吃掉你來追求的那份加速。誠實的工程做法是直接避開這種爭用:給每個執行緒「自己」的私有部分和、完全不共享,最後才把那少數幾個部分和合併起來。分散式記憶體則徹底繞過了這個危險——分開的白板無法被陌生人塗鴉——代價是你得寫明確的訊息。
切分一個真實問題:區域分解與歸約
你實際上要怎麼平行化一個偏微分方程的求解,就是有限差分那一級的那種?你把幾何切開。區域分解,也就是區域分解這個想法,把網格的一塊磚交給每個行程——行程 0 拿左邊那條、行程 1 拿下一條,以此類推。每個行程更新自己的內部時完全不需要通訊。唯一必須移動的資料,是相鄰者之間那層薄薄的邊界:要更新自己的邊緣格,一個行程需要隔壁那塊磚的一層值。那些邊界層叫做幽靈格或 halo,每一步相鄰者就用幾則 MPI 訊息交換它們。
留意這裡美妙的經濟性。一塊邊長為 L 的磚,內部約有 L^2 個點要更新(工作量隨「面積」成長),但只有約 4L 個邊界點要通訊(成本隨「周長」成長)。所以當磚變大,通訊與計算的比率會「縮小」——這就是面積對體積效應,也是大型模擬之所以能擴展的深層原因。工作便宜、局部、又豐沛;對話昂貴,卻只發生在接縫上。好的平行設計,大半就是把那個比率壓小的工夫。
有些操作無法做成純粹局部的,而開頭那個向量求和正是原型。把 N 個數字加總、或計算內積、或 L2 範數,是一種歸約:每一個值都必須匯聚成一個答案。訣竅是把它做成一棵二元樹——兩兩配對、相加,再把部分和兩兩配對、相加,每一輪數量減半——它在約 log_2(N) 步而非 N 步內完成。一個近親,平行前綴和(scan),以相同的對數深度算出「所有」的累進總和,它是平行排序、稀疏矩陣佈局與壓縮背後無名的主力。教訓是:即使是「本質上序列」的合併,往往也能被重塑成一棵淺而平行的樹。
擴展究竟為你買到了什麼
把更多核心丟給一個問題,並不會給你成比例的加速,原因是殘酷的算術。每個程式都有一部分工作頑固地是序列的——讀取輸入、最後的歸約、某段就是得照順序跑的東西。阿姆達爾定律,也就是阿姆達爾定律這個上界,說:如果有比例 s 的工作是序列的,那麼無論你加多少核心 P,加速比都永遠無法超過 1/s。即使只有 5% 是序列的,你的天花板就是 20 倍——永遠,在一百萬個核心上也一樣。設下極限的,是那道序列的細縫,而非平行的汪洋;這是平行計算裡最讓人清醒的一個事實。
這聽來像死刑判決,但有條出路,而它取決於你把什麼固定住。強擴展固定總問題大小、再加核心——這正是阿姆達爾定律所封頂的,因為序列部分的大小不變,平行部分卻隨核心數縮小。弱擴展則讓問題隨核心一起變大,所以每個核心始終有一整塊磚的工作。強擴展與弱擴展之分很重要,因為實務上我們很少想要「同一個」問題更快——我們想要「更大」的問題、更細的網格,在相同的牆鐘時間內完成。弱擴展,才是一台真實超級電腦掙錢的方式。
而這裡正是這一級整段弧線收攏之處。一個核心早已受限於記憶體而非浮點運算——它的算術強度決定它是受記憶體限制還是受計算限制。加上核心會把浮點運算加倍,但「也」把對共享記憶體頻寬與網路的需求加倍。一個平行程式可能在用光核心之前,老早就撞上頻寬牆或通訊牆。所以問題從未改變,改變的只是規模:資料在哪裡、誰需要跟誰對話、又有多大比例卡著得照順序跑?誠實的平行效能是「量」出來的,不是假設出來的——你畫一條擴展曲線、找出真正的天花板,因為這些模型是上界,而非承諾。