效能工程

偽共享與快取線填充

兩個同事各自有自己的筆記本,但規定是筆記本只能整個檔案櫃抽屜一起在大樓裡搬動,而不巧兩本筆記本就放在「同一個」抽屜裡。現在每當一個人想在自己的筆記本上寫字,就得把整個抽屜搬來——從另一個人手上奪走,那人接著又得搬回去才能在自己的本子上寫。他們從不碰對方的筆記本,卻不斷為抽屜爭奪。這就是偽共享:兩個核心更新著恰好坐在同一條快取線裡的不同變數,於是讓那條線在它們的快取間乒乓。

機制如下。快取一致性協定以整條快取線(通常 64 位元組)為粒度運作,而非個別變數。當核心 A 寫入一條線裡的任何一個位元組,協定就必須讓那條線在其他每個核心的快取中失效,以免它們讀到陳舊資料;接著核心 B 要在同一條線裡寫它自己(不同)的變數時,就必須重新抓取那條線,這又讓 A 的副本失效,如此往復。即使 A 與 B 從不共享一個邏輯變數,硬體共享了那條線,而那條線來回彈跳——每次彈跳都要付出一次跨核心傳輸的延遲。修法是快取線填充(與對齊):把每個被獨立且頻繁寫入的變數放到它「自己的」快取線上,方法是把它對齊到線大小,並把周圍的結構填充到沒有兩個這種變數共用一條線。在 C 裡你可以用對齊來要求這件事(例如把每執行緒計數器宣告為 alignas(64),或把一個結構填充到 64 位元組);代價是浪費一點記憶體,換來移除乒乓。

它之所以重要,是因為偽共享在原始碼裡是看不見的——程式碼看起來完全平行、變數也真的彼此獨立——它卻能無聲地把多執行緒的加速變成減速,加核心反而讓程式「更慢」,因為共享線上的爭用增加了。典型的徵兆是一個平行迴圈、或一個每執行緒計數器的陣列,毫無明顯理由地縮放得很差。誠實的提醒:對什麼都填充會浪費記憶體與快取,所以只把它用在你量測出被不同核心頻繁寫入的那幾個變數上——用 PMU 的快取一致性或 HITM 事件,或單純觀察到連續排放的每執行緒資料比間隔開的同樣資料縮放更差,就是你確認它的方法。

// 偽共享:兩個計數器共用一條 64 位元組的線 -> 核心為它爭奪 struct { long a; long b; } c; // a 與 b 相距 8 位元組 // 修法:給每個自己的線 struct { alignas(64) long a; alignas(64) long b; } c; // 現在相距 64 位元組

alignas(64) 把每個被獨立寫入的計數器推到自己的快取線上,結束跨核心乒乓。

偽共享是純粹的效能 bug,不是正確性 bug:值永遠正確,只是慢。而且修法浪費記憶體,所以只填充量測顯示很熱的那幾個變數——全面填充弊大於利。

又稱
false sharingcache-line ping-pongingpadding to a cache line偽共享快取線乒乓