用 C++ 測試記憶體延遲
許多的 benchmark 工具 (e.g., geekbench v4, aida64) 都具有測量記憶體延遲的功能,有些甚至還能測試快取的延遲。 於是我就很好奇,到底這些工具是怎麼定義記憶體延遲的呢? 進一步說,有可能用簡單的 C++ 程式就重現出這些工具的測量結果嗎?
估計記憶體延遲之組合語言指令
經過了一番資料搜尋跟測試之後,我得出了結論——僅需 100 行左右的程式碼,便有辦法重現出 aida64 的記憶體延遲數據。 而這個方法,也早在下面這篇 1996 年的論文就被提出:
Jan. 96 Usenix, Larry McVoy and Carl Staelin, Larry McVoy, lmbench: Portable tools for performance analysis.
這篇論文中提到,因為 CPU superscalar 之特性,CPU 同一個時間內允許發送多個記憶體要求。 換句話說,讓指令間有相依性方能得到正確的記憶體延遲數據,而最簡單的方法就是重複下面這個指令:
1mov r4, (r4)
把 register 4 所指到的位置讀出來,把他當作 pointer 塞回 register 4。這個用法初見有點奇怪,但事實上 C++ 中的 linked list traversal 經過編譯之後就是這個組合語言指令。
1Node *p;
2const int kCOUNT = 1000000;
3for (int i = 0; i < kCOUNT; ++i) {
4 p = p->next;
5}
接著,手動 unroll 可以減少 loop 對於執行時間的影響:
1Node *p;
2const int kCOUNT = 1000000;
3for (int i = 0; i < kCOUNT; ++i) {
4 p = p->next; p = p->next; p = p->next; p = p->next;
5 p = p->next; p = p->next; p = p->next; p = p->next;
6}
於是我們只要計算「執行時間」除以「執行 next 的總次數」就可以了,上面這個例子是 8000000 次。
創造環狀 linked list
剩下的步驟就是創造一個可以無限 traverse 的 link list,方便計算時間。自然而然我們會想到把 list 的 nodes 串成一圈(注意本文中提到的 list 都是單向的),這也是 1996 年論文的作法:
如果我們把 node 的總數控制好,就能讓所有的 node 都落在特定的記憶體層級 (e.g., DRAM or cache),例如圖中 1 MB 的設定大約是 2020 年 x86 CPU 的 L3 cache 的大小,一個 pointer 8 bytes 的話,總共是 k_NUM_NODE
=131072 個 nodes。對應到以下程式碼:
1struct Node {
2 Node *next;
3};
4for (int i = 0; i < kNUM_NODE-1; ++i) {
5 nodes[i].next = nodes+i+1;
6}
7nodes[kNUM_NODE-1].next = nodes;
在我的 Ryzen 3700X + DDR4 3200 環境下執行,跑出了 1.08 ns 的 DRAM 延遲,這跟 2020 x86 常見的 50~150 ns 的數據相去甚遠,應該是搞錯了什麼,下面我們來解決這個問題。
創造環狀 linked list version 2——考慮 cache
一個明顯的問題是因為現在的 CPU 的 cache line 很大,通常是 64 bytes,因此上面這種連續的存取方式會有 7/8 的 cache hit,根本量不准。
解決方法就是要讓 pointer 落在不同的 cache line 裡面:
只要用 union 把 Node 變成 64 bytes 就好:
1struct Node { union {
2 Node *next;
3 char padding[kCACHE_LINE];
4};};
同樣,在我的 Ryzen 3700X + DDR4 3200 環境下執行,跑出了 3.6 ns 的 DRAM 延遲,雖然慢了三倍,但是還是跟合理數字差了一個數量級,這應該還是搞錯了什麼 ,於是下面只好多寫一個章節。
創造環狀 linked list version 3——考慮 prefetch
經過仔細思考後,2020 的 CPU 相較 1996 年的 CPU 強了非常多,例如 prefetch,這是一個 CPU 會猜你要的資料,先把資料抓進來的功能 。 雖然也造成了像是 spectre/meltdown 這種完全沒人想到的漏洞。
對於 CPU 來說,這種線性存取的記憶體存取實在是太好猜了,所以我們量出來的 DRAM 速度才會比實際上快 10 倍以上。解決的方式當然就是把 node 的連接方式隨機化,稍後的結果也可以看到這的確解決了問題。
因此,我們必須建立一個隨機連結的環狀 linked list(下稱隨機環),而隨機就會讓然想到 leetcode 偶而會出現的水塘抽樣法,因此下面使用水塘抽樣來建立隨機環。當然,水塘抽樣並不是唯一的方法,只是我覺得這樣作比較看起來厲害而已。
水塘抽樣最經典的實用例子如下:
在未知確實行數的文字檔中抽取任意一行,換句話說,不可以先把整個檔案掃描以判定總行數再作抽樣。必須保每一行抽取的機率相等,即是說如果最後發現文字檔共有 N 行,則每一行被抽取的機率均為 1/N。
而解法如下:
抽取第一行為最終結果 (answer) →
抽取第二行,用 1/2 的機率替換掉 answer →
抽取第三行,用 1/3 的機率替換掉 answer → ……
套用類似精神,我們假設現在前面已經建立一個大小為 4 的隨機環,並且想把第五個 node 隨機插入,構成大小為 5 的隨機環:
接著我們隨機找出一位苦主(綠色的 cur)以及他的後面那個(藍色的 nxt),我們的第五個 node 就要插入兩者中間:
完成!
其對應之程式碼如下:
1std::default_random_engine rng;
2std::uniform_int_distribution<long> uid;
3nodes[0].next = nodes;
4for (int i = 1; i < kNUM_NODE; ++i) {
5 Node *cur = nodes + uid(rng) % i;
6 Node *nxt = cur->next;
7 Node *tail = nodes + i;
8 cur->next = tail;
9 tail->next = nxt;
10}
順帶一提,上面這段程式碼第三行的初始狀態是這樣:
結果驗證
下方我找了多台電腦系統的組合,可以看到跟 aida64 測量結果一致。
Overall
我們的結果
3700x | 8700 | 2400G | 4700U | 9300H | |
---|---|---|---|---|---|
1 KB | 0.93 | 0.88 | 1.12 | 0.98 | 1.00 |
2 KB | 0.92 | 0.87 | 1.09 | 0.96 | 0.99 |
4 KB | 0.93 | 0.88 | 1.09 | 0.96 | 0.99 |
8 KB | 0.93 | 0.88 | 1.08 | 0.96 | 0.99 |
16 KB | 0.93 | 0.87 | 1.08 | 0.96 | 0.99 |
32 KB | 0.93 | 0.87 | 1.86 | 0.96 | 0.99 |
64 KB | 2.04 | 2.62 | 3.25 | 2.74 | 2.98 |
128 KB | 2.53 | 2.62 | 3.27 | 2.79 | 3.48 |
256 KB | 2.71 | 2.62 | 3.48 | 3.37 | 4.75 |
512 KB | 4.48 | 7.28 | 7.13 | 5.38 | 9.02 |
1 MB | 7.76 | 8.97 | 10.68 | 10.07 | 10.64 |
2 MB | 9.31 | 9.77 | 35.40 | 11.22 | 11.29 |
4 MB | 9.97 | 10.14 | 71.87 | 28.43 | 14.25 |
8 MB | 10.35 | 10.93 | 89.10 | 93.18 | 33.28 |
16 MB | 18.35 | 33.20 | 99.03 | 108.28 | 62.40 |
32 MB | 65.19 | 58.66 | 103.62 | 116.48 | 69.11 |
64 MB | 78.60 | 67.38 | 108.02 | 121.05 | 73.54 |
AIDA64
3700x | 8700 | 2400G | 4700U | 9300H | |
---|---|---|---|---|---|
L1 | 1.0 | 0.9 | 1.1 | 1.0 | 1.0 |
L2 | 2.8 | 2.6 | 3.2 | 2.9 | 3.0 |
L3 | 10.4 | 10.9 | 10.2 | 10.4 | 11.8 |
DRAM | 76.5 | 61.5 | 94.3 | 119.7 | 69.3 |