# 2018q1 Homework1 (phonebook) contributed by <`afcidk`> ### Reviewed by `ryanpatiency` * Could you explain under which condition trie will be faster than hash ? ### Reviewed by `LinYunWen` * 後面的 commit message 寫得很不錯,甚至還有畫些圖,不過有兩個 commit message 的 title 一樣,雖然內文不同,但是會有點奇怪 ([commit 29f0572](https://github.com/afcidk/phonebook/commit/29f0572097f0fc84e38b5bd62a601ad4284faf97)、[commit ebca2de](https://github.com/afcidk/phonebook/commit/ebca2be088e6041ef6fced91eb454772ca7f9f2e)) * 最後的 trie 可以多說明一些 cache miss 上的比較或解釋,多偏向只有時間上的看法 >對應的程式碼修改應一併傳到 github 上 >[color=red][name=課程助教] ## 開發環境 ``` afcidk@Lubuntu1:~$ lscpu Architecture: x86_64 CPU op-mode(s): 32-bit, 64-bit Byte Order: Little Endian CPU(s): 4 On-line CPU(s) list: 0-3 Thread(s) per core: 1 Core(s) per socket: 4 Socket(s): 1 NUMA node(s): 1 Vendor ID: GenuineIntel CPU family: 6 Model: 94 Model name: Intel(R) Core(TM) i5-6400 CPU @ 2.70GHz Stepping: 3 CPU MHz: 2700.000 CPU max MHz: 3300.0000 CPU min MHz: 800.0000 BogoMIPS: 5424.00 Virtualization: VT-x L1d cache: 32K L1i cache: 32K L2 cache: 256K L3 cache: 6144K NUMA node0 CPU(s): 0-3 ``` ## 目標 * 理解 cache 是什麼 * 優化程式碼 * [減少 entry size](#減少-entry-size) * [hash function](#嘗試3-使用hash-function) * [trie](#嘗試4-使用-trie-資料結構) * fuzzy search >中英文字間請記得以空白隔開 >[color=red][name=課程助教] ## TODO * binary search * fuzzy search * memory pool ## 理解 cache 是什麼 因為 CPU 的速度太快了,而主記憶體( DRAM )和 CPU 的速度也不是只有差一點點。<s>為了避免整體速度被拖延到,產生了 cache 的技術。</s> cache 的存在就是為了解決 DRAM 和 CPU 的處理速度差距而產生的瓶頸。為什麼這樣說呢? CPU 處理的速度很快,但是因為 DRAM 速度比較慢,所以會讓 CPU 有空閒的時間。想要讓 CPU 忙一點,我們勢必要讓輸入趕上處理速度,在硬體的部份就產生了快一點的 cache( SRAM ) 來當中介。可是又有新問題了,cache 裡面的資料要放什麼呢? cache 裡面的資料遵守 *Locality of Reference* *Locality of Reference* 代表的是 cache 在儲存資料時會選擇 * 頻繁被要求的資料 ( temporal locality ) * 現在使用中的資料附近的資料 ( spatial locality ) 這讓 CPU 在處理的時候可以優先從 cache 裡面選擇這些**比較有可能**會用到的資料,進而提昇效能 :::danger 上面這句話不精確,請翻閱計算機組織的教材,重新用自己的認知表達過。 :notes: jserv ::: > 已重新表達! cache( SRAM ) 比主記憶體( DRAM )還要快,可以用來銜接 CPU 和 DRAM 之間的訊息傳輸。SRAM 是由電晶體構成,DRAM 是由電容構成,充放電的速度導致在讀寫速度上 SRAM > DRAM。 :::danger memory 一般翻譯為「記憶體」,是通用詞彙,而 cache 是一種 memory,你在表達時,應該區分 cache 和 DRAM 一類的「主記憶體」 :notes: jserv ::: > 已更改敘述! 在[关于CPU Cache -- 程序猿需要知道的那些事](http://cenalulu.github.io/linux/all-about-cpu-cache/)介紹完什麼是 cache line 後,他有提出兩段相差不大的C語言程式碼,但是執行速度卻有很大的落差。 ```clike //比較快 int num = 0; for(int i = 0; i < n; i++) { for(int j = 0; j < n; j++) { //code arr[i][j] = num++; } } ``` ```clike //比較慢 int num = 0; for(int i = 0; i < n; i++) { for(int j = 0; j < n; j++) { //code arr[j][i] = num++; } } ``` <s>根據自己對於 cache line 的理解</s>,第二份程式碼比較慢的原因是因為它每次要被assign的位置都不是相鄰的,有比較大的機會讓需要操作到的記憶體空間不在 cache 上,導致 cache miss 比較大 :::warning 「根據自己對於 ... 的理解」這種話就不需要在共筆提及,這裡本來就是你的理解。你應該要精確地描述 locality 的影響。 :notes: jserv ::: >關鍵字:row major, column major >[color=red][name=課程助教] 在 C 語言中,陣列的儲存方式採用 row major 的方式,再回頭看看上述的例子 假設陣列大小( n )是 3 x 3 第一個版本儲存的資料按照順序會是 0 1 2 3 4 5 6 7 8 ```= 0 _ _ _ _ _ _ _ _ 0 1 _ _ _ _ _ _ _ 0 1 2 _ _ _ _ _ _ 0 1 2 3 _ _ _ _ _ 0 1 2 3 4 _ _ _ _ 0 1 2 3 4 5 _ _ _ 0 1 2 3 4 5 6 _ _ 0 1 2 3 4 5 6 7 _ 0 1 2 3 4 5 6 7 8 ``` 第二個版本儲存的資料按照順序會是 0 3 6 1 4 7 2 5 8 ```= 0 _ _ _ _ _ _ _ _ 0 _ _ 1 _ _ _ _ _ 0 _ _ 1 _ _ 2 _ _ 0 3 _ 1 _ _ 2 _ _ 0 3 _ 1 4 _ 2 _ _ 0 3 _ 1 4 _ 2 5 _ 0 3 6 1 4 _ 2 5 _ 0 3 6 1 4 7 2 5 _ 0 3 6 1 4 7 2 5 8 ``` 可以發現第一個版本的數字是一個一個放上去的,而第二個版本的數字是跳著放上去的 (從 0 依序放到 8 ) 實際執行看看 cache 的變化大不大 (使用 n = 3 ) 理論上比較快的程式碼 ``` 793 cache-misses # 2.302 % of all cache refs ( +- 16.37% ) 34,459 cache-references ( +- 0.46% ) ``` 理論上比較慢的程式碼 ``` 819 cache-misses # 2.387 % of all cache refs ( +- 15.70% ) 34,307 cache-references ( +- 0.53% ) ``` 在陣列大小不大時影響似乎看不太出來,因為 cache 都還放得下,但是如果數據量大一點呢? 可以使用指令`lshw`( list hardware )查看電腦的 cache 大小 ``` *-cache:0 description: L1 cache physical id: 3e slot: L1 Cache size: 128KiB capacity: 128KiB capabilities: synchronous internal write-back data configuration: level=1 ``` $128*2^{10} = 2^{17}$ $2^{17}/2^2 = 2^{15} = 32768$ 這次讓 n = 3000,再比對一次 cache 的變化 > 為什麼選 3000 的原因是要讓 cache 小於陣列的大小 > $3000*3000>32768$ ``` 751,144 cache-misses # 87.156 % of all cache refs ( +- 0.07% ) 861,836 cache-references ( +- 0.06% ) ``` ``` 1,700,256 cache-misses # 5.942 % of all cache refs ( +- 0.13% ) 28,616,391 cache-references ( +- 0.21% ) ``` 這次 cache-miss 就增加了兩倍以上,cache-reference 也跟著增加到三倍以上 ## 原本 performance entry size 為 136 bytes ``` Performance counter stats for './phonebook_orig' (100 runs): 453,7987 cache-misses # 92.087 % of all cache refs ( +- 0.07% ) 492,7930 cache-references ( +- 0.05% ) 2,7522,5199 instructions # 1.41 insn per cycle ( +- 0.02% ) 1,9559,6852 cycles ( +- 0.07% ) 0.062733108 seconds time elapsed ( +- 0.48% ) ``` ## 減少 entry size 要減少 cache miss 的其中一個方法是讓 entry size 小一點,這樣 cache 裡面可以放的資料就比較多 原本的 entry size 是 136 bytes,也許可以<del>試著讓裡面的資料壓縮一點</del>,或是把某部份只是用來紀錄的資料搬到別的地方 :::danger 用語要==精確==!搬動資料不算「壓縮」,理工人如果連說話都不精確,要怎麼說服他人呢? :notes: jserv ::: > 謝謝老師的提醒!其實我最初的想法是要陳述可以壓縮空間或是搬動資料兩件事的,但是後來覺得我不應該在這方面要求 phonebook 的其他資料大小一定要壓縮成怎樣。 > 已更改敘述方式! [name=afcidk][color=blue] ### 嘗試1 只留 lastname 觀察`main.c`後發現 append 和 findName 這兩個函式都只有用到 lastname,先把`__PHONE_BOOK_ENTRY `其他的變數都拿掉試試看 編譯完後執行:`$ make cache-test` 修改後的 entry size 為 24 bytes ``` Performance counter stats for './phonebook_opt' (100 runs): 100,8296 cache-misses # 62.527 % of all cache refs ( +- 0.42% ) 161,2565 cache-references ( +- 0.12% ) 2,5082,2518 instructions # 2.06 insn per cycle ( +- 0.02% ) 1,2185,8684 cycles ( +- 0.10% ) 0.039152981 seconds time elapsed ( +- 0.14% ) ``` ![](https://i.imgur.com/36PJbXc.png =x400) 發現 cache-miss 和原本的差了 4 倍左右,時間也差了快一倍。但是 phone book 顯然不會只有 lastname 而已,其他的資料應該也要放上去,查詢到正確的 lastname 時才可以顯示 ### 嘗試2 entry 多加一個 pointer 指向其他資料存放位置 增加了一個 structure ,因為不會參與到查詢,所以把它額外放在別的地方,並用一個指標存取記憶體位址 ```clike //size of entry: 32 bytes typedef struct __PHONE_BOOK_ENTRY { char lastName[MAX_LAST_NAME_SIZE]; struct __PHONE_BOOK_ENTRY *pNext; struct __OTHER_PHONE_BOOK_DATA *pData; } entry; ``` ```clike typedef struct __OTHER_PHONE_BOOK_DATA { char firstName[16]; char email[16]; char phone[10]; char cell[10]; char addr1[16]; char addr2[16]; char city[16]; char state[2]; char zip[5]; } phonebookData; ``` 編譯完後執行:`make cache-test` ``` Performance counter stats for './phonebook_opt' (100 runs): 148,4340 cache-misses # 66.452 % of all cache refs ( +- 0.39% ) 223,3716 cache-references ( +- 0.14% ) 2,5438,6330 instructions # 1.97 insn per cycle ( +- 0.02% ) 1,2896,4063 cycles ( +- 0.09% ) 0.042576904 seconds time elapsed ( +- 0.28% ) ``` ![](https://i.imgur.com/D5Ye3f5.png =x400) 發現效能雖然比只留 lastname 差了一點,但是和優化前比較 entry size 仍然大幅度降低,所以整體的效能還是有提高,cache-miss 也有減少 後來發現忘記在 append 時 malloc 空間給`pData`,加上去後發現效能大幅度的降低了,甚至比沒優化前還要低 ![](https://i.imgur.com/o345S72.png =x400) 看起來應該是 malloc 惹的禍,可是更改`append()`竟然也會影響到`findName()` 為什麼會這樣要再想一下 :::danger 你需要證明上述推論,請試著撰寫更多程式來釐清 `malloc()` 的影響 :notes: jserv ::: > 原本以為是`malloc()`花費太多時間,沒想到是 cache-miss 導致效能變差 首先,比對程式碼的 cache-miss 狀況 分別是刪除`e->pData = (phonebookData *) malloc(sizeof(phonebookData));`和沒有刪除的版本 刪除的版本 (沒有配置空間) ``` 1,528,953 cache-misses # 68.037 % of all cache refs ( +- 0.29% ) 2,247,223 cache-references ( +- 0.21% ) 260,220,582 instructions # 1.97 insn per cycle ( +- 0.02% ) 131,995,699 cycles ( +- 0.08% ) 0.049236369 seconds time elapsed ( +- 0.49% ) ``` 沒刪除的版本 (有配置空間) ``` 5,855,719 cache-misses # 95.576 % of all cache refs ( +- 0.09% ) 6,126,743 cache-references ( +- 0.08% ) 371,742,089 instructions # 1.37 insn per cycle ( +- 0.01% ) 271,899,335 cycles ( +- 0.21% ) 0.090920051 seconds time elapsed ( +- 0.49% ) ``` 發現 cache-miss 差了將近四倍,再用`perf top`計算出哪個地方耗費的時間最多 ``` 26.40% phonebook_opt [.] main 14.22% libc-2.26.so [.] _int_malloc 9.03% libc-2.26.so [.] __strcasecmp_l_avx 8.83% libc-2.26.so [.] _IO_fgets ``` ``` 24.60% libc-2.26.so [.] __strcasecmp_l_avx 14.54% phonebook_opt [.] main 11.19% libc-2.26.so [.] _int_malloc 4.14% phonebook_opt [.] findName ``` 變化最大的是`strcasecmp()`,代表有沒有配置空間對`strcasecmp()`有很大的影響 因此,就算把不是查詢用的資料移到別的地方,在每次配置空間時仍會讓 cache-miss 提高,再加上每次呼叫`malloc()`的時間,整體效能會比沒有搬動資料還要差。 但是如果不需要事先配置好位置給額外的資料,而是需要填入資料時再配置空間,仍然可以提昇部份效能。 ## 嘗試3 使用hash function 使用類似 Rabin-Karp algorithm 的 rolling hash 方法,因為每個字串的每個字元都會計算到,所以在`append()`花費的時間比較多。使用質數 40009 作為 hashTable 一開始 hashTable 想要建到 150000 附近,但是因為`main.c`是共用的, hashTable 太大會讓原本的`phonebook_orig`無法執行。 後來參考[陳冠廷同學](https://hackmd.io/s/r1qMdqji-#fn1)的筆記,發現以數據量為 300000 來說,假設資料平均分佈, 300000/150000 = 2 ,而 300000/40000 = 7.5 ,花費在`findName()`的時間只有極微小的差距。 :::warning 不要寫「這位同學」,人家有名字 (至少有 GitHub 帳號),你應該清楚提及 :notes: jserv ::: > 已補上! ![](https://i.imgur.com/hky7NnB.png =x400) 雖然說查詢一個 lastName 的時間已經降到 1e-6 以下,但是查詢量低的時候其實沒有什麼差別,反倒是`append()`和原本的`phonebook_orig`相比,效能相差了 4 倍以上,後面應該要找一些 hash function 來比對看看 :::warning 用 perf top/stat 找出效能瓶頸再來分析 :notes: jserv ::: > 又腦補了...QQ 直覺就認為是 hash function 的問題,要看數據說話! 使用`perf top`查看效能瓶頸,得到以下結果 ``` 48.68% libc-2.26.so [.] __strncmp_sse42 33.80% phonebook_hash [.] hashFunc 4.48% phonebook_hash [.] main 3.65% phonebook_hash [.] append 1.91% libc-2.26.so [.] _int_malloc 1.53% libc-2.26.so [.] _IO_fgets 1.48% libc-2.26.so [.] __strncpy_sse2_unaligned 1.29% libc-2.26.so [.] malloc ``` 發現`append()`裡面處理 hash collision 的字串比對佔用最多的時間,其次是產生 hash value > 後來又看了一下程式碼發現當初想錯了,處理 hash collision 不用比對字串,直接把新的資料放到 linked list 的尾端就好了 更正後又跑了一次`perf top` ``` 61.06% phonebook_hash [.] hashFunc 8.73% phonebook_hash [.] append 6.06% phonebook_hash [.] main 4.56% libc-2.26.so [.] _int_malloc 3.39% libc-2.26.so [.] __strlen_avx2 ``` 發現先前錯誤的程式碼中,`append()`裡的`strcmp()`幾乎佔據了總時間的一半。改善過後效能提高了,瓶頸也如同預期的一樣卡在生成 hashValue ![](https://i.imgur.com/P4t5tk0.png =x400) 再來我參考[這篇文章](https://softwareengineering.stackexchange.com/questions/49550/which-hashing-algorithm-is-best-for-uniqueness-and-speed)使用 djb2 作為 hash function,其餘程式碼沒有變動,執行`make plot`後產生的效能比對圖如下 ![](https://i.imgur.com/AmDnERr.png =x400) 可以發現`append()`的效能提昇了不少,雖然和未優化前的版本相比仍然降低一些,但是透過 hash function 讓查詢資料時的時間大幅縮短 使用`perf top`指令查看,發現 hash function 佔用的時間縮短許多 ``` 24.82% phonebook_hash [.] append 19.83% phonebook_hash [.] main 13.92% phonebook_hash [.] hashFunc 8.33% libc-2.26.so [.] _int_malloc ``` 這讓我想到一些問題: * 原本使用 rolling hash 當 hash function ,效能低落是不是因為`hashFunc()`沒有寫好? * 這個版本我一樣把不會用來查詢的資料搬到另外一個 structure 以減少 entry size ,但是沒有事先給予這些資料空間來儲存。如果和上面的作法一樣事先配置空間給這些資料,`findName()`會不會像`phonebook_opt`實做時一樣效能降低?也許可以從這裡發現什麼東西 對於第二個問題,我做了兩個變動來比對效能 * 讓 entry size 恢復成 136 bytes * 每次執行`append()`時順便配置空間給未用來查詢的資料 ### 使用 dbj2 hash function,維持 entry size 為 136 bytes ![](https://i.imgur.com/titrhmL.png =x400) ``` Performance counter stats for './phonebook_hash' (100 runs): 4,068,228 cache-misses # 69.258 % of all cache refs ( +- 0.06% ) 5,874,013 cache-references ( +- 0.06% ) 300,915,071 instructions # 1.16 insn per cycle ( +- 0.03% ) 259,404,732 cycles ( +- 0.05% ) ``` ``` 28.00% phonebook_hash [.] append 13.01% phonebook_hash [.] hashFunc 12.86% phonebook_hash [.] main 5.29% libc-2.26.so [.] _int_malloc ``` 發現`append()`時間變長了 ### 使用 dbj2 hash function,每次 append 時配置空間給未用來查詢的資料 ![](https://i.imgur.com/XOuRHUB.png =x400) ``` Performance counter stats for './phonebook_hash' (100 runs): 3,954,543 cache-misses # 66.471 % of all cache refs ( +- 0.19% ) 5,949,307 cache-references ( +- 0.17% ) 384,800,899 instructions # 1.43 insn per cycle ( +- 0.02% ) 268,910,185 cycles ( +- 0.08% ) ``` ``` 24.67% phonebook_hash [.] append 13.83% phonebook_hash [.] main 9.63% libc-2.26.so [.] _int_malloc 8.22% phonebook_hash [.] hashFunc ``` 比對兩張圖的 hash 部份,雖然花費的時間相差不大,但是由`perf stat/top`可以看出這應該是 cache-miss 和 malloc 花費的時間剛好互相彌補而已,兩個應該沒有直接關係。 ## 嘗試4 使用 trie 資料結構 實做前預想可能得到的結果是 * entry size 大幅度增加 -> cache-miss 提高 -> `append()`花費時間較多 * `findName()` 時間大幅度縮短,在找 lastName 時複雜度為 O(n),n 是 lastName 的字元個數 * 因為想到可能比較不出來 hash 和 trie 在`findName()`的表現,所以會提昇查詢次數 假設 lastName 只有大小寫英文字母,如果有非英文字母以外字元出現會略過 ```ctype typedef struct __PHONE_BOOK_ENTRY { struct __PHONE_BOOK_ENTRY *nch[52]; //A-Z a-z 共52字母 char firstName[16]; char email[16]; char phone[10]; char cell[10]; char addr1[16]; char addr2[16]; char city[16]; char state[2]; char zip[5]; } entry; ``` 這代表這個方式只適用在英文上,中文可能不行(字太多)。不過如果替換成注音符號或是羅馬拼音應該是可行的 `append()`和`findName()`很相似,就是把要查詢/加入的字串一個字元一個字元找下去 節錄`append()`部份程式碼 ```cytpe while (c = *lastName++) { int ind; if (islower(c)) ind = c-'a'+26; else if (isupper(c)) ind = c-'A'; else printf("invalid name"); if (iter->nch[ind] == NULL) { iter->nch[ind] = (entry *) malloc(sizeof(entry)); } iter = iter->nch[ind]; } ``` 這個方法 entry size 為 528 bytes,效能比對如下 ![](https://i.imgur.com/vxCBNHe.png =x400) 發現在查詢量大的時候 hash 和 trie 佔了很大的優勢,畢竟`append()`只要做一次,而`findName()`會依使用次數而增加 * 缺點 太佔空間,無法負荷太多資料 `append()`會花費較多時間 * 優點 速度快,<s>**某些情況下會比 hash 快**</s> > 後來發現並不是會比 hash 還要快,而是查詢的速度會浮動,為什麼會造成這種結果還要再找找看 ## 參考資料 * [作業解說影片](https://www.youtube.com/watch?v=ZICRLKf_bVw) * [dram、flash貴到炸,你還搞不懂記憶體的差異嗎?](https://hellolynn.hpd.io/2017/05/06/dram%E3%80%81flash%E8%B2%B4%E5%88%B0%E7%82%B8%EF%BC%8C%E4%BD%A0%E9%82%84%E6%90%9E%E4%B8%8D%E6%87%82%E8%A8%98%E6%86%B6%E9%AB%94%E7%9A%84%E5%B7%AE%E7%95%B0%E5%97%8E%EF%BC%9F/) * [Which hashing algorithm is best for uniqueness and speed?](https://softwareengineering.stackexchange.com/questions/49550/which-hashing-algorithm-is-best-for-uniqueness-and-speed) ## [心得與啟發](https://hackmd.io/s/HkP3eEzFG#)<因為自動飲料機而延畢的那一年>