在 Low-Latency C++ 系統中,效能瓶頸往往不在 CPU arithmetic,而是在 memory access。當 CPU 執行速度遠快於主記憶體時,一次 cache miss 可能帶來數十甚至上百個 cycle 的 latency。因此,在 latency-sensitive 的系統,例如 trading engine、market data processing、matching engine 或 real-time simulation 中,如何組織資料往往比如何寫一個更複雜的 algorithm 更重要。
1. Struct of Arrays 打造 Low-Latency C++
其中一個重要的 Data-Oriented Design(DOD)技巧,就是將傳統的 Array of Structs(AoS) 改寫成 Struct of Arrays(SoA)。
AoS 的設計非常直覺。假設我們有一批 Order:
struct Order {
int64_t price;
int64_t quantity;
int64_t timestamp;
uint64_t id;
};
std::vector<Order> orders;
記憶體排列大致如下,適用於程式經常需要同時讀取一個 Order 的所有欄位時:
[price quantity timestamp id]
[price quantity timestamp id]
[price quantity timestamp id]
...
但問題出現在另一種常見 workload:
for (const auto& order : orders) {
total_volume += order.quantity;
}
這裡我們只需要 quantity,但 CPU 每次載入 cache line 時,往往也會把 price、timestamp 和 id 一起載入。CPU 真正需要的是 quantity,但 memory hierarchy 搬運的是整個 Order。當 Order 結構變大、資料量增加時,這些用不到的 bytes會降低 cache efficiency,增加 memory bandwidth pressure。
SoA 則將資料拆成不同的 contiguous arrays:
struct Orders {
std::vector<int64_t> price;
std::vector<int64_t> quantity;
std::vector<int64_t> timestamp;
std::vector<uint64_t> id;
};
Memory layout 變成:
[P0][P1][P2][P3][P4]...
[Q0][Q1][Q2][Q3][Q4]...
[T0][T1][T2][T3][T4]...
[I0][I1][I2][I3][I4]...
現在如果 hot path 只需要處理 quantity,CPU 可以連續讀取 quantity array,而不需要把其他欄位帶進 cache:
for (std::size_t i = 0; i < orders.quantity.size(); ++i) {
total_volume += orders.quantity[i];
}
而這會帶來兩個重要優勢:
2. Cache Line 是真正值得關注的單位
現代 x86 CPU 通常以 cache line 作為 cache transfer 的基本單位,常見大小是 64 bytes。以上述的 struct Order 為例,每個 Order 是 32 bytes。一條 64-byte cache line 大約只能放兩個 Order:
┌───────────────────────────┐
│ Order 0 ㅤ│ Order 1 ㅤ│
│ price quantity timestamp idㅤ│ price quantity timestamp idㅤ│
└───────────────────────────┘
如果我們只掃描 quantity,CPU 為了取得兩個 quantity,卻同時搬運大量不需要的資料。這時候就是 SoA 的重要性 —— 讓資料按照 Access Pattern 排列。SoA 則可以讓一條 cache line 放入更多真正需要的資料:
┌─────────────────────┐
│ Q0 │ Q1 │ Q2 │ Q3 │ Q4 │ Q5 │ Q6 │ Q7 │
└─────────────────────┘
假設每個 quantity 是 8 bytes,一條 64-byte cache line 可以包含 8 個 quantity。因此 sequential scan 的 cache efficiency 可以大幅提高。
SoA 不只是 cache-friendly,也更容易讓 compiler 使用 SIMD。
for (std::size_t i = 0; i < quantity.size(); ++i) {
quantity[i] *= 2;
}
因為 quantity 是連續排列的,compiler 更容易將它 vectorize 成一次處理多個 element 的 SIMD instruction:
Q0 Q1 Q2 Q3 Q4 Q5 Q6 Q7
概念上可以從原本的 scalar:
Q0 -> Q0 * 2
Q1 -> Q1 * 2
Q2 -> Q2 * 2
Q3 -> Q3 * 2
變成 SIMD:
[Q0 Q1 Q2 Q3] * [2 2 2 2]
這也是為什麼 SoA 在大量資料掃描、filter、aggregation、vectorized computation 等 workload 中通常非常有效。而 AoS 將一個 object 的相關資料放在一起,CPU 讀取一個 Order 時,可以很自然地把整個 object 帶入 cache:
[price | quantity | timestamp | id]
SoA 並不是永遠比較快,AoS 亦有優勢。資料結構應該由 access pattern 決定,而不是由 object-oriented abstraction 決定。