快取線才是記憶體真正的單位
整個這一級都在推一個觀念:機器不是你腦中那個簡單模型,而效能來自尊重它真正運作的方式——也就是這一級一直在說的機械同理心。沒有哪裡比記憶體更咬人。在這道階梯較早處,你學會把記憶體想像成一個平坦的位元組陣列、一次伸手取一個。硬體完全不是這樣運作的。CPU 從不從 RAM 取單一個位元組;它取的是一整條快取線(cache line)——在幾乎所有現代 x86 與 ARM 晶片上是 64 位元組——並把那一整條停進它的快取裡。你碰一個位元組,就免費把它的 63 個鄰居一起拉進來了。
這正是為什麼記憶體佈局主宰了這麼多效能。如果你下一個要用的位元組已經在你拉進來的那條線裡,這次存取幾乎免費——一次 L1 命中只要幾個週期。如果不在,你就要付一次快取失誤,那可能花上數百個週期,CPU 就這麼停著等 RAM、閒置、什麼都不做。一個以對 64 位元組友善的順序逐步走過記憶體的迴圈,可以比一個四處亂跳的相同迴圈快上一個數量級——不是因為它算得比較少,而是因為它對每一條付了代價才取進來的線浪費得比較少。線,而非位元組,才是你必須繞著設計的單位。
偽共享:為一條沒人想共享的線而打架
現在把兩個執行緒放到兩個核心上,各自更新自己的計數器。邏輯上它們什麼都沒共享——執行緒 A 只寫 counter[0]、執行緒 B 只寫 counter[1]。你會預期它們完美擴展:兩個執行緒、兩個計數器、零爭用。但假設 counter 是一個樸素的 `long counter[2]`,於是 counter[0] 與 counter[1] 相距 8 位元組、兩者都落在同一條 64 位元組的快取線裡。現在 A 每次寫它的計數器,一致性就作廢 B 那份線的副本;B 每次寫,就作廢 A 的。兩個核心把這條線的獨佔所有權來回乒乓,一秒鐘數千次,彼此互等——即使它們的資料從不重疊。這就是偽共享(false sharing)。
偽共享的殘酷在於:程式碼看起來完全正確、也完全平行。原始碼裡沒有競爭條件、沒有鎖、沒有共享變數——執行緒們確實碰的是不同的位址。這份爭用在 C 的層級是看不見的;它整個活在硬體的 64 位元組顆粒度裡。症狀令人抓狂:你加上第二個執行緒、期待兩倍加速,結果程式反而比單執行緒更慢,因為一致性流量的代價超過了工作本身。這正是 Amdahl 與 Gustafson 那篇所警告的那種擴展失敗——一個隱藏的序列瓶頸——只是這裡瓶頸根本不在你的程式碼裡,它在你那兩個無辜變數恰好共享的那條快取線裡。
修法是把這兩塊資料硬塞到不同的線上,這樣一個執行緒的寫入就永遠不會作廢另一個的。你填補(pad)或對齊(align),讓每個執行緒的熱資料各自獨佔一整條 64 位元組的線。在 C 裡這正是過度對齊的用途:把每執行緒的結構宣告成對齊到一條快取線、讓線剩下的部分閒置不用。是的,你花了記憶體——每個計數器浪費 56 位元組——來買回你失去的擴展性。這是整個這一級裡最純粹的交易之一:一點空間,換來「負加速」與「線性加速」之間的差別。
// FALSE SHARING: both counters share one 64-byte line
long counter[2]; // counter[0], counter[1] are 8 bytes apart
// thread 0: counter[0]++ thread 1: counter[1]++
// -> coherence ping-pongs one line; 2 threads can be SLOWER than 1
// FIXED: each counter gets its own line (C11 / C23 alignment)
#include <stdalign.h>
struct alignas(64) padded { long value; }; // 64-byte aligned + sized
struct padded counter[2]; // now on separate lines
// thread 0: counter[0].value++ thread 1: counter[1].value++
// -> no cross-invalidation; scales as you expected資料導向設計:為機器安排佈局
偽共享是一個更大原則的單一實例。你在記憶體裡安排資料的方式,往往是對速度而言唯一最大的槓桿,而對程式設計師感覺最自然的佈局,常常對機器而言是最糟的那個。最自然的直覺是把關於一個東西的一切都綁進一個結構——位置、速度、顏色、名字、血量,全部放一起——並保留一個這種結構的陣列。這是結構陣列(array of structs,AoS)佈局。它讀起來很漂亮。它對任何只碰一個欄位的迴圈來說,也是一場快取災難。
假設你有一百萬個遊戲實體、一個只推進每個實體位置的迴圈。在 AoS 下,位置每隔 64 位元組散落一個、每個結構一個,每個都被這個迴圈從不讀的速度、顏色、名字包圍。你取的每一條快取線大部分都是浪費:你拉進 64 位元組、只用了你在乎的那 8 個。解藥是資料導向設計(data-oriented design):每個欄位存成一個陣列——所有位置連續、所有速度連續——也就是陣列結構(struct of arrays,SoA)佈局。現在你的迴圈逐步走過一個密實、緊湊的位置陣列,每一條快取線都裝滿你真正會用的資料,而硬體預取器能看出這個線性模式、在你開口之前就把後面幾條線取好。這單單一次轉置,在完全不動算術的情況下,就可能是好幾倍的加速。
配置抖動:你忘了自己在付的代價
最後一個隱藏成本,是你從 malloc() 開始能用的那天起就不再看見的那個。在這道階梯很早的地方,你學到 malloc() 與 free() 從堆積遞給你記憶體、而你像個好公民一樣檢查它們的回傳值。容易忘記的是:一次 malloc() 呼叫不是免費的算術——它要走過空閒串列、可能切割或合併區塊、可能為了執行緒安全取一把鎖,並且偶爾還會落進核心去長大堆積。單獨一次配置很便宜。一秒一百萬次配置、把同一批區塊翻進翻出,就是一筆你甚至可能不知道自己在付的吞吐量稅。這就是配置抖動(allocation churn),而在熱路徑上配置就是你不小心替自己報名參加它的方式。
配置抖動以兩種複利的方式傷害你,而兩者都連回上面的一切。首先是直接成本:配置器內部的記帳,以及許多執行緒同時配置時的鎖爭用——在不對的配置器上,這本身就成了一個直接出自 Amdahl 那篇的序列瓶頸。其次,而且常常更糟,是快取成本:新鮮的配置從配置器找得到空閒區塊的任何地方回來,所以一連串 malloc() 呼叫遞給你的是散落在整片記憶體裡的位址。走一條剛 malloc 出來的節點串列,每一步都是一次打到冷的、天涯海角的線的快取失誤。配置器不只花了你它自己的時間;它摧毀了你的區域性,而那正是上兩節拚命要保護的東西。
解藥是別在熱路徑上配置。兩個經典招式是:重用(reuse)——在迴圈外一次配好一個緩衝區、每次迭代重新填它、而不是釋放再重新配置——以及一個池或競技場配置器,它從你一次配好的一大塊連續區塊裡,刻出許多固定大小的物件。一個池以 O(1) 透過撞指標的方式發出物件、成批釋放它們,並且附帶一個好處:把它們緊湊地裝在一起,所以走訪它們在構造上就對快取友善。你並不是在寫一個更好的通用 malloc();你是在利用一個 malloc() 無從得知的模式——這些物件全都同生共死——把一千次昂貴又散落的配置,換成一次便宜又連續的配置。
把整級串起來
退一步看,這篇的三個主題其實是同一個觀念戴著三頂帽子。偽共享是兩個執行緒意外地為一條線爭用;資料導向設計是把資料安排成你取的每條線都裝滿你需要的工作;配置抖動是把資料散到冷的線上、並一遍又一遍付配置器的錢去這麼做。三者都是關於同一個稀缺資源——快取線、以及把它填滿的頻寬——而三者在原始碼裡都是看不見的、只有在你測量時才現身。這是機械同理心最純粹的形態:瓶頸不是 CPU 跑的指令,而是它等待的資料。
而這就替整級收了尾。你從學會測量、別猜測、學會讀火焰圖開始。你學會了不自欺地做微基準測試、學會盯尾端而不只盯平均,並且——透過 Amdahl 與 Gustafson——確切知道一個平行區段最多可能買到多少加速。這篇給了你再下一層:一旦剖析告訴你某個迴圈是記憶體受限而非計算受限,這些就是修它的工具——填補掉偽共享、把 AoS 重塑成 SoA、並消滅熱路徑上的配置。更快的程式碼,幾乎總是那種「向記憶體系統要得更少、又對它給的東西浪費得更少」的程式碼。