sysprog
主講人: jserv / 課程討論區: 2017 年系統軟體課程
假設我們有兩個「有號整數」:
原本涉及到分支的陳述:
可更換為沒有分支的版本:
為什麼呢?一旦 b 右移 31 個 bit,在右移 >>
時在前面補上 sign bit ,所以移完會是 0xffff 也就是-1,故結果會是 a -= -1
,即 a++
,於是,原本為負數的值就變成 1 ,而原本正數的值就變成 0,又因為原本的行為是有條件的 a++,如此就是數值操作,沒有任何分支指令。
目的:分析電話簿搜尋程式,探討 cache miss 對於整體效能有顯著影響
題目說明
思考以下程式碼可能存在的問題,並著手改善效能。
Hint: cache miss
未優化版本
程式碼: phonebook
測試檔:
GitHub上有提供兩種字典檔當輸入 data set,以下測試使用第二種。
$ perf stat -r 10 -e cache-misses,cache-references,L1-dcache-load-misses,L1-dcache-store-misses,L1-dcache-prefetch-misses,L1-icache-load-misses ./main_origin
$ perf record -F 12500 -e cache-misses ./main_origin && perf report
優化版本
程式碼:embedded-summer2015/phonebook (Github)
我的筆電 level 1 cache 有 32 kbit,struct 的大小有 136 bytes,32 * 1024 / (136*8) =30.12,若把整個 __PHONE_BOOK_ENTRY 的 struct 都丟進 findName() 尋找,即使未考慮電腦中其他正在運行的程式,我的 level 1 cache 最多也只能放 30 個entry,一共有 35 萬個單字,必定會發生頻繁的 cache miss。
查看電腦 cache 大小 $ lscpu | grep cache
第一種優化方式:使用體積較小的 struct
根據題目要求,我們只需要知道「有沒有找到 last name」,對於 email、phone number、address、zip 等等資訊是可以在搜尋過程中忽略不看。另外我又希望能不改變原本 phonebook entry 的結構,所以我另
外設計一個 struct 只儲存 last name,並用一個指向 phonebook entry 的指標叫 detail 來儲存詳細資訊,新的 struct 大小只有 32 bytes,這樣搜尋的過程中,cache 就可以塞進 (32 * 1024) / (328) = 128 個單字,增加 cache hit 的機率。
在實作 appendOptimal() 的過程中,*detail 並沒有沒指向一塊空間,我想專心讓 appendOptimal() 產生含有 lastName 的節點就好。為什麼呢?因為若在 append 過程中 malloc() 空間給 *detail,會增加很多 cache miss,嚴重影響到效能表現,經過實測,總體效能甚至比原始版本還差一點。目前想法是將 *detail 的 assign (當有需要時)交給另外一個 function 處理,畢竟我們一開始只有 last name 的資訊。
$ perf stat -r 10 -e cache-misses,cache-references,L1-dcache-load-misses,L1-dcache-store-misses,L1-dcache-prefetch-misses,L1-icache-load-misses ./main_optimal
$ perf record -F 12500 -e cache-misses ./main_optimal && perf report
第二種優化方式:使用 hash function
第一種方式已經見到不錯的效能成長了!findName() 的 CPU time 從 0.050462
變成0.023803
,進步一倍以上。另外 cache miss 的次數從 3,519,048
降到 486,127
!
不過 35 萬個單字量畢竟還是太大,如果利用字串當作key,對 hash table 作搜尋顯然比每次從頭開始查找快多了。所以接下來的問題就是,如何選擇 hash function?如何減少碰撞發生呢?我選了兩種 hash function。不過以下先用 hash2() 和上面的結果作比較。另外 table size 我使用 42737,挑選的原因是因為有約35萬個英文單字,為了減少碰撞機會,挑了一了蠻大的「質數」。
$ perf stat -r 10 -e cache-misses,cache-references,L1-dcache-load-misses,L1-dcache-store-misses,L1-dcache-prefetch-misses,L1-icache-load-misses ./main_hash
$ perf record -F 12500 -e cache-misses ./main_hash && perf report
執行時間分析
findName():本次測試需要查找 8 個 last name,兩種優化版本顯然都比原始的還要好,hash 版本不用每次從 35 萬個單字的頭開始找起省掉許多時間,而且再配上縮小過後的 entry,所以 L1 cache 可以塞進更多的 last name,兩種優化方式一加乘,查找時間比原始快了近 300 倍!
append():先來比較 原始版本 和 第一個版本,在 append() 的算法上這兩者幾乎一模一樣唯一的差別就是 malloc() 的記憶體空間不一樣大。
cache miss 情形
perf 能偵測的 cache miss 種類非常多,基本上分為 load-miss、store-miss、prefetch-miss 三種,而再根據 cache 種類又分 Level 1 cache 和 Last Level cache,instruction cache 和 data cache,TLB cache。
我們最關心的是L1-dcache-load-misses
,也就是要尋找的 data 不在 Level 1 cache,要往更下層的 memory (我的電腦有 L2、L3)。經過縮小 entry後,version 1 的L1-dcache-load-misses
減少了 0.4 倍,而 hash 版本則因為查找的數量從 242.6 萬次降到不到 20 次,如下表。進而讓 cache miss 再更減少了近 0.8 倍!不過當然這樣的成果要歸功於一開始辛苦建立 Hash table。
不同 Table size 對效能的影響
Hash function 裡常會對字串作很多次的加法或乘法,以這邊 hash2() 來說除了加法還有乘32,如果我取的不是質數,剛好是某些數字的倍數,譬如 2 或 3 等等,mod 之後就會很容易往某個幾個 bucket 集中,若很不巧我找的字剛好在這幾的 bucket 裡,平均來講效能就不好了,list會很長,所以 table size 我會選用質數。
另外一件事情是, Table size 使用 42737,選用這麼大的數字的考量在 data set 有 35 萬個,size 越大的話,每個 bucket 的 linked list 才不會太長,findName() 起來才會快。
就前面結果我們已經知道 hash 版本速度很快,cache miss 也很低。剩下能主導效能就是查找次數了,以下列出幾個 size 的結果。而當 Table size 大於 7919 時,其查找的次數差異已經非常小了。
不同的 hash function 對效能的影響
hash2() 是有名的 djb2,兩者差別是 hashVal << 5
,也就是 hashVal * 32的意思。ASCII 字元的數值最大為 127 的整數,不管是人名還是英文單字當作輸入,平均鍵值長度大約 6~10,所以 hashVal 最多也只有 1270 種可能,不是一個均勻分佈,而在效能表現上可能就有很大的差異。
$ ./main_hash
with hash1()
==> 共 552 次
$ ./main_hash
with hash2()
==> 共 19 次
Jakub Jelinek 針對不同的 hash function 所作的分析:
應用於 GNU Hash ELF Sections,加速動態函式庫的載入時間
電腦發展早期,主記憶體 (main memory) 的速度緩慢且昂貴,CPU 的時脈也不高,但從 1980 年代開始,main memory 和 CPU 的差距急速拉大,主記憶體存取速度雖然時有提升,卻仍不及 CPU 的進展,因此需要 cache 來彌補因為兩者間速度落差帶來的效能衝擊。
原理:cache利用Temporal Locality和Spatial Locality設計記憶體架構的兩大原則
set associative:set associative的方式,也就是把cache分成多個set,CPU必須檢查指定set內的每個block是否有可用的cache。最極端的情況下就是Fully Associative,也就是CPU要檢查cache內所有的block。
更多內容如下:
Cache Miss的計算
假設硬體規格如下:
(我考慮main.c findName() & append 這2個function 因為 linked-list 跟搜尋存取花最多時間)
350000筆資料*每筆144byte=5040000byte
5040000byte / 64byte * 2次function=1574000block
讀一個block是一次 reference
可以看到 1574000 量級和 2098272 相同
cache有幾個set?
cache 32KByte/block 64Byte = 128個block
128 / 8way set = 16個
一個cache line可以存多少筆entry
144byte / 64byte = 2.25 所以是3個block
已知是 8way set 所以可以存 2 個
cache miss
1574000(總block)/ 3(一個entry 需要3 block) = 524667組entry
524667 / 16(index數) = 32790(tag數 ,填到相同index的個數)
由上知一個cache line最多可以放2組entry,所以有兩次機會
32790/2=16395(可能被對到次數)
16395*16(index數)*3(entry佔的block數) = 786960(cache hit次數)
1574000-78960 = 1495040(cache miss)
1495040/1574000 = 94%(miss rate)
修改 Makefile,在 CFLAGS 加上 -DNDEBUG
,降低 assert() 帶來的影響,重新編譯、執行,再用 perf 分析效能
2n - 1 = X << n - X
BKDR hash function
hijklmn
的前面加上多長的字串,其值仍為 3637984782