沒人察覺自己正在享用的免費午餐
回頭望望這道階梯,看看我們為了讓單一核心變快而造的一切。我們把管線加深,讓好幾條指令同時在途;走向超純量,每個週期發射不只一條;加上亂序執行與分支預測,好讓機器在卡頓之間仍忙個不停;再把這一切裹進一座深深的記憶體階層裡,讓資料通常就等在桌上。約莫三十年裡,這套打法持續開花結果,對程式設計師而言簡直像魔法:寫一段平凡的單執行緒程式,什麼特別的事都不做,明年的晶片就是會把它跑得更快。
那份禮物有兩具引擎。摩爾定律每隔幾年就把一顆晶片上電晶體的數目翻一倍,給了架構師更多原料去做更大的快取與更寬的管線。而Dennard 縮放——那具更安靜、卻更要緊的引擎——則說:當電晶體縮小時,它的電壓與電流會同步下降,於是一顆更小的電晶體用掉的功率按比例變少,又能切換得更快。兩者合力,讓設計師能一代又一代地把時脈率往上轉,而晶片不會熔掉。這頓免費午餐,其實是 Dennard 縮放在替功率帳單買單。
功率牆:時脈為何停止攀升
大約 2005 年,帳單到期了。Dennard 縮放實質上終結了:電晶體仍持續縮小,但它的電壓再也無法同步下降,因為一旦低於某個電壓,電晶體就算「應該關著」也會漏電。於是把更多、切換更快的電晶體塞進同樣的面積,不再能維持功率不變——反而把功率往上推。那條粗略的規則很殘酷:動態功率大致隨著頻率乘以電壓平方而增長,所以追逐更高的時脈,會以超線性的方式燒掉功率。把時脈推過大約 4 GHz,你就是在試圖從一片指甲大的矽上,把一塊廚房電爐的熱量散掉。那道硬限制就是功率牆。
這就是 2005 年架構師面對的兩難。摩爾定律依然有效——每兩年他們手上就多了約莫兩倍的電晶體。但他們再也不能照老辦法花掉那些電晶體、去做一顆更快的單核心了,因為功率牆禁止了更快的時脈,而指令層級平行的極限又意味著把單核心做得更寬只會在陡峭的功率代價下報酬遞減。於是他們下了一個不同的賭注:與其要一顆越來越大、越來越燙的核心,不如在同一顆晶片上擺兩顆或更多完整的核心。多核心處理器的誕生不是出於凱旋,而是出於無奈——它是把多出來的電晶體轉成多出來的效能的、唯一剩下的辦法。
多核心給了什麼,又索取了什麼回報
兩顆核心每秒能做兩倍的活——但前提是真有兩股彼此獨立的活可做。這就是癥結。一顆多核心晶片靠的是執行緒層級平行:它讓好幾串彼此獨立的指令序列(稱為執行緒)真正地同時跑,一顆核心跑一串。單核心是從一股指令流之中把平行硬擠出來(前面幾級的管線與亂序那些把戲),多核心的平行則來自多股指令流一起跑。硬體把一份新工作交到了程式設計師手上:找出彼此獨立的活,再以執行緒的形式交給核心。
整理這件事最常見的辦法是共享記憶體多處理器:每顆核心都看見同一個單一的位址空間,於是執行緒只要讀寫同樣的記憶體位置就能彼此溝通。當這些核心一模一樣、又全都經由同一條路徑抵達記憶體時,我們稱之為對稱多處理(SMP)。這非常方便——一條執行緒寫下一個結果,另一條只要讀它就好,不需要任何明說的交接。另一條路,訊息傳遞,給每顆核心它自己的私有記憶體,再讓它們互相把資料寄來寄去;那能擴展到龐大的叢集,但寫起來更費工。對於同一顆晶片上的核心,共享記憶體是慣常的選擇。
共享記憶體很方便,但它悄悄製造了這整級的核心難題。每顆核心都有它自己私有的快取——它自己的書桌。所以如果兩顆核心都載入同一個變數,每一顆最後都會在自己桌上擁有那條記憶體行的一份影印本。此時其中一顆核心把一個新值寫進它自己的副本。另一顆核心的副本霎時就過時了,卻沒有任何東西告訴它。這正是好幾個人各自編輯同一頁的影印本、卻需要對「這一頁到底寫了什麼」取得共識的畫面——快取一致性問題——也正是下一篇導覽的主題。
平行機器的地圖:Flynn 分類法
在我們一頭栽進一致性之前,先把多核心擺到一張小地圖上會有幫助——這張地圖列出機器能平行的每一種方式。經典的那張是 Flynn 分類法,它沿著兩條軸把機器分類:有多少股指令流在跑、以及它們作用在多少股資料流上。只要兩個是非題,就給出四個格子,而幾乎每一台真實機器都落在其中一格。
one data stream many data streams
+----------------------+----------------------+
one instruction | SISD | SIMD |
stream | a classic single | one instruction, |
| core; the von | many data lanes: |
| Neumann machine | GPUs, vector units |
+----------------------+----------------------+
many instruction | MISD | MIMD |
streams | (rare; e.g. some | independent cores, |
| fault-tolerant | each its own work: |
| pipelines) | MULTICORE lives here|
+----------------------+----------------------+多核心住在 MIMD 那一格:許多彼此獨立的核心,各自帶著自己的指令流、處理自己的資料。那跟 SIMD 那一格是不同種類的平行;在 SIMD 那格裡,一條指令一次驅動許多資料車道——那是向量單元與 GPU 的世界,一千個實習生各自同步地做著一個小小的加總。GPU 並不只是一顆更快的 CPU;它是一種根本上不同形狀的機器,擅長又寬又規律的資料,卻拙於串列、滿是分支的程式碼。這一級講的是 MIMD 多核心;SIMD 與 GPU 的世界是它自己後面一級的事。
那道誠實的上限:Amdahl 定律
人們很容易以為八顆核心就等於八倍的速度。它們幾乎從不如此,而且其中有一個精確的理由——一個誠實到值得在這一級結尾自成一篇導覽的理由。Amdahl 定律說:你能從平行得到的加速,受限於你程式中那一部分本質上是串列的、無法被切分到各核心去的比例。哪怕只有一小片非得一步一步跑,那一片就成了你的加速永遠跌不破的地板。
給它套上數字。假設一支程式有 95% 能被完美地平行化,但有 5% 頑固地是串列的。用上無窮多顆核心,那平行的 95% 會朝零時間縮去——但那串列的 5% 完全不會縮。所以最好的加速是 1 / 0.05 = 20 倍,無論你扔 32 顆、1000 顆、還是一百萬顆核心進去都一樣。用現實一點的 16 顆核心,你大約得到 1 / (0.05 + 0.95/16),約莫 9 倍,而不是 16 倍。更多核心帶來急遽遞減的報酬,而那串列的比例設下了一道你怎麼花錢都越不過的硬上限。
在這一切底下還坐著一個誠實的複雜之處:並非所有記憶體都一樣遠。在一台有好幾顆多核心晶片的大機器上,每顆晶片都有它自己一組直接接在它身上的 DRAM。一顆核心抵達它自己那顆晶片的記憶體很快,但抵達鄰居晶片的記憶體就慢些——這就是 NUMA,非均勻記憶體存取。它意味著你的資料實際住在哪裡,如今會影響速度,而一條跑得離自己資料很遠的執行緒,要默默繳一筆稅。NUMA、一致性、一致性模型與同步,是我們會在這一級接下來逐一拉扯的線頭——它們全都是那個由功率牆逼出來的單一決定的後果:不再把一顆核心做得更快,而開始讓許多核心彼此合作。