iT邦幫忙

2026 iThome 鐵人賽

DAY 9
0
Software Development

從 C++ 菜鳥到 Low-Latency 勇者:一場分秒必爭的賽局系列 第 9

[Day 9] Hardware-Aware Memory & CPU Architecture: Data-Oriented Design

  • 分享至 

  • xImage
  •  

在 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 時,往往也會把 pricetimestampid 一起載入。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];
}

而這會帶來兩個重要優勢:

  • 更高的 cache-line utilization。
  • 更低的 memory bandwidth consumption。

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 決定。


上一篇
[Day 8] Hardware-Aware Memory & CPU Architecture: Machine Code II
下一篇
[Day 10] Hardware-Aware Memory & CPU Architecture: Data-Oriented Design II
系列文
從 C++ 菜鳥到 Low-Latency 勇者:一場分秒必爭的賽局11
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言