從 Machine Code 單元 開始,我們看到 compiler 如何把 C++ 轉成 CPU 真正執行的 instruction;透過 Data-Oriented Design 我們開始思考資料應該如何排列;Data Ownership 則更需要考量到資料由的使用權。而到了 Microarchitecture Tuning,我們將更進一步探討 CPU 要如何取得、預測、搬運以及處理這些資料。
1. 當資料已經在 Cache 裡 —— 簡單介紹 SIMD
在 Day 9 及 Day 10 的 DOD 講解中,一直重複提到 SIMD 這個詞,在這篇文中我們把它列出來更詳細地討論。SIMD 全名 Single Instruction, Multiple Data,簡單來說就是一條 instruction 同時處理多筆資料,一次做很多個相同的運算。
假設我們已經把資料排得非常漂亮:
float a[N];
float b[N];
float result[N];
for (int i = 0; i < N; ++i)
result[i] = a[i] + b[i];
此時可能會冒出一個疑問:CPU 一次只能做一個 a[i] + b[i] 嗎?答案是不一定。現代 CPU 通常提供 SIMD / vector instructions,可以讓一條指令同時對多個數值進行相同的運算。
概念上,scalar 的做法是:
a0 + b0
a1 + b1
a2 + b2
a3 + b3
而 SIMD 可以一次處理多個 float,可以把它想成:
[a0 a1 a2 a3]
ㅤ+
[b0 b1 b2 b3]
ㅤ↓
[r0 r1 r2 r3]
這也是為什麼 DOD 和 SIMD 很自然地連在一起。如果資料是:
struct Order {
float bid;
float ask;
float price;
};
記憶體中的資料會類似於以下示意圖的排列方式,相同類型的資料並非連續儲存在一起:
bid0 ask0 price0
bid1 ask1 price1
bid2 ask2 price2
bid3 ask3 price3
就不如使用 DOD 直接把資料排成:
float bid[N];
float ask[N];
float price[N];
這時候相同類型的資料是連續的,就更適合被 CPU 以向量化的方式載入,並搭配 SIMD 一次處理多筆資料。DOD 讓資料排列更適合 CPU 存取與向量化,SIMD 則讓 CPU 能一次處理多筆資料。兩者搭配起來,就能讓大量、相同型態的運算更有效率。
2. Low-Latency —— 其實是一種 Hardware Thinking
當我們開始在意 latency,思考方式就會從「這段 code 有幾個 operation」逐漸變成「CPU 需要等什麼」。
例如下方一個看似很簡單的 operation。在真正執行時,CPU 並不是拿到這行 code 就立刻算出結果。它可能需要先等待 price 和 quantity 被載入到適合運算的位置;如果資料不在 cache 裡,就可能一路往更慢的 memory hierarchy 取得資料。
result = price * quantity;
更進一步,如果這個 operation 的結果又依賴前一個 operation。那麼即使 CPU 有很多 execution units 可以同時工作,後面的運算仍然可能被前面的結果卡住。這就是 dependency 所造成的 latency。
a = b + c;
d = a * e;
f = d + g;
因此,Low-Latency 的追求並不僅僅是少寫幾行 code。到了這個層次,最佳化的對象已經不只是 C++ 程式碼,而是 CPU 執行這段程式碼時所經歷的整個路徑。這也是 Microarchitecture Tuning 最重要的觀念之一:不要只看程式寫了什麼,而要開始理解硬體實際上會如何執行。