# 2017q3 Homework1 (phonebook) ###### tags: `sysprogram` contributed by <`st9007a`> >> 不要打錯字,預期是 "contributed",之前少了 "d" 字母 >> [name="jserv][color=red] ### Reviewed by `amikai` - 在 Hash Function 碰撞分析, y 軸的 frequency 是什麼的頻率, x 軸代表什麼意義, 這張圖有幾條 bar 是比較密的, 有幾條是比較稀疏的好像沒有特別的解釋, 上面那張圖跟下面的表格在文章裡好像沒有敘述他們有什麼關聯 (P.S. 可能程度比較差看不太懂) - 在問題與討論, 第一個問題, 建議同學可以引進真正的資料集, 並且多測試幾種, 以佐證你所說的 load factor 0.7 - 0.8 之間有教好的效果, 並且讓此實驗更完備 - 在問題與討論,第二個問題, 建議同學可以進一步嘗試丟入更龐大的資料, 以佐證自己的結論 - 這位同學的研究, 多半是在 hash 上, 在 append 裡大量的使用了 malloc ,這也是降低效能的因素之一, 建議這位同學可以嘗試引入 mmap 或是 memory pool - 老師好像只提到 `git commit --amend` ,這似乎只能修改前一個 commit message, 如果要修改很前面的 commit 可以考慮使用 `git rebase -i` 以上為小弟簡短的建議, 如有冒犯請見諒 ## [Github](https://github.com/st9007a/phonebook) ## 開發環境 ``` Linux 4.4.0-92-generic Ubuntu 16.04.1 LTS L1d cache: 32K L1i cache: 32K L2 cache: 256K L3 cache: 6144K ``` ## 原始程式碼 首先看看 `phonebook_orig.h` ```c #ifndef _PHONEBOOK_H #define _PHONEBOOK_H #define MAX_LAST_NAME_SIZE 16 /* original version */ typedef struct __PHONE_BOOK_ENTRY { char lastName[MAX_LAST_NAME_SIZE]; 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]; struct __PHONE_BOOK_ENTRY *pNext; } entry; entry *findName(char lastName[], entry *pHead); entry *append(char lastName[], entry *e); #endif ``` 可以知道 entry 的資料結構是 linked list,接著看看 `phonebook_orig.c` ```c #include <stdio.h> #include <stdlib.h> #include <string.h> #include <ctype.h> #include "phonebook_orig.h" /* original version */ entry *findName(char lastName[], entry *pHead) { while (pHead != NULL) { if (strcasecmp(lastName, pHead->lastName) == 0) return pHead; pHead = pHead->pNext; } return NULL; } entry *append(char lastName[], entry *e) { /* allocate memory for the new entry and put lastName */ e->pNext = (entry *) malloc(sizeof(entry)); e = e->pNext; strcpy(e->lastName, lastName); e->pNext = NULL; return e; } ``` 可以知道裡面的 `findName()` 實作了 linked list 的 search ,而 `append()` 則是在 entry 後面插入一個新的 entry 接著來看看原始效能如何,在 command line 上輸入 `$ perf stat --repeat 5 -e cache-misses,cache-references,instructions,cycles ./phonebook_orig` 出現如下的顯示: ``` Performance counter stats for './phonebook_orig' (5 runs): 457,6717 cache-misses # 88.239 % of all cache refs ( +- 0.33% ) 518,6740 cache-references ( +- 0.38% ) 2,6177,2530 instructions # 1.53 insns per cycle ( +- 0.12% ) 1,7155,7629 cycles ( +- 0.87% ) 0.169891497 seconds time elapsed ( +- 34.68% ) ``` 可以看到 cache miss 有 88.239% ## 縮減結構的size 根據 [cache line](http://cenalulu.github.io/linux/all-about-cpu-cache/) 裡面有個實驗,當存取的資料超過 cache line 的大小後,執行時間會上升,原因在於填充 cache line 的時間大於存取資料的時間,所以我認為縮減資料結構的大小應該可以提升效能,即盡可能讓越多資料被 cache 在同一條 cache line 上越好,所以就直接在資料結構上動手腳,其餘都先保持原樣 > access 在台灣的慣用翻譯詞是「存取」,而非「訪問」 > [name="jserv"][color=red] ```c typedef struct __PHONE_BOOK_INFO { 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]; } info; typedef struct __PHONE_BOOK_ENTRY { char lastName[MAX_LAST_NAME_SIZE]; struct __PHONE_BOOK_INFO *pInfo; struct __PHONE_BOOK_ENTRY *pNext; } entry; ``` 以上使用了一個額外的 struct 來將比較詳細的資訊記下來,而 `lastName` 是搜尋用的指標,因此保留在 `entry` 裡面,比較一下原本跟修改後的資料大小,直接執行 `phonebook_orig` 跟 `phonebook_opt` 來看結果 ``` $ ./phonebook_orig size of entry : 136 bytes execution time of append() : 0.036390 sec execution time of findName() : 0.004765 sec $ ./phonebook_opt size of entry : 32 bytes execution time of append() : 0.030494 sec execution time of findName() : 0.001589 sec ``` 可以注意到修改後 `entry` 的 size 由 136bytes 縮減為 32bytes,除此之外還可以發現 `findName()` 的執行速度提升了大約三倍左右,接著使用 perf 來看看 cache miss 的狀況如何,在 command line 輸入 `$ make cache-test` ``` Performance counter stats for './phonebook_orig' (100 runs): 453,4382 cache-misses # 88.507 % of all cache refs ( +- 0.08% ) 512,3211 cache-references ( +- 0.06% ) 2,6126,0775 instructions # 1.54 insns per cycle ( +- 0.02% ) 1,7013,9033 cycles ( +- 0.07% ) 0.104214970 seconds time elapsed ( +- 1.82% ) Performance counter stats for './phonebook_opt' (100 runs): 138,5229 cache-misses # 57.016 % of all cache refs ( +- 0.15% ) 242,9525 cache-references ( +- 0.09% ) 2,4394,8709 instructions # 2.16 insns per cycle ( +- 0.02% ) 1,1295,1297 cycles ( +- 0.06% ) 0.088586763 seconds time elapsed ( +- 1.07% ) ``` cache miss 從 88.507% 降至 57.016%,下降了 30% 左右 ## 改善查詢速度 **注:這裡的 entry 結構沿用上一節為縮減 size 後的結構** 我們都知道一般的 linked list 搜尋的時間複雜度為 $O(n)$,所以我試著加速查詢速度,首先使用 hash table 來取代單純的 linkedlist 結構,hash table 根據處理 collision 的方式分為兩種: 1. 使用 Linked list 把「Hashing 到同一個 slot」的資料串起來 2. 使用 Probing Method 來尋找 Table 中「空的 slot」存放資料 我想要盡可能不浪費 hash table 的空間,所以我先用方法 2 來實作,而 probing method 先用比較簡單的方式,也就是 linear probing,首先在 `phonebook_opt.h` 定義 hash table 的資料結構,新增 hash function,以及修改 `findName()` 跟 `append()` 的輸入參數 ```c #define HASH_TABLE_SIZE 350000 typedef struct __PHONE_BOOK_ENTRY { char lastName[MAX_LAST_NAME_SIZE]; struct __PHONE_BOOK_INFO *pInfo; } entry; typedef struct { entry *cell[HASH_TABLE_SIZE]; } hashTable; unsigned int BKDRHash(char *str); entry *findName(char lastName[], hashTable *table); void append(char lastName[], hashTable *table); ``` 這裡 hash function 我是參考 [各種字串符 Hash 函數比較](https://www.byvoid.com/zhs/blog/string-hash-compare) 這篇,使用 BKDR 雜湊法,實際狀況可能需要考慮到雜湊出來的分布,hash table 的長度則是參考 `dictionary/words.txt` 的行數定出來的,在 entry 中我拿掉了 pNext,因為這個 hash table 並不需要 linked list,接下來在 `phonebook_opt.c` 中修改 `findName()` 跟 `append()` ```c unsigned int BKDRHash(char *str) { unsigned int seed = 131; unsigned int hash = 0; while (*str) { hash = hash * seed + (*str++); } return (hash & 0x7FFFFFFF); } entry *findName(char lastName[], hashTable *table) { unsigned int i = BKDRHash(lastName) % HASH_TABLE_SIZE; while (strcmp(table->cell[i]->lastName, lastName) != 0) { i = (i + 1) % HASH_TABLE_SIZE; if (!table->cell[i]) return NULL; } return table->cell[i]; } void append(char lastName[], hashTable *table) { unsigned int i = BKDRHash(lastName) % HASH_TABLE_SIZE; while (table->cell[i]) { i = (i + 1) % HASH_TABLE_SIZE; } entry *e = (entry *) malloc(sizeof(entry)); e->pInfo = (info *) malloc(sizeof(entry)); strcpy(e->lastName, lastName); table->cell[i] = e; } ``` 然後修改 `main.c` 裡面有關 `findName` 跟 `append` 的介面後,先直接執行看看: :::danger 請尊重台灣文化和傳統,將 interface 翻譯為繁體中文的「介面」一詞,而非「接口」 --jserv ::: ``` $ make $ ./phonebook_opt size of entry : 24 bytes execution time of append() : 0.843872 sec execution time of findName() : 0.001289 sec ``` 發現 `append()` 的執行速度慢了非常多,其原因在於原本的 `append()` 的時間複雜度為 $O(1)$ 而修改後時間複雜度變成了 $O(n/k)$ 了,在理論上沒有 collision 的狀況時間複雜度應該保持 $O(1)$,所以猜測裡面的 collision 應該滿嚴重的,不過還是先用 perf 來看看效能如何 ``` Performance counter stats for './phonebook_opt' (100 runs): 173,0000 cache-misses # 3.975 % of all cache refs ( +- 0.30% ) 4352,1870 cache-references ( +- 0.19% ) 32,1506,9274 instructions # 1.06 insns per cycle ( +- 0.00% ) 30,3073,3228 cycles ( +- 0.01% ) 0.901060342 seconds time elapsed ( +- 0.11% ) ``` 可以看到 cache miss 已經降至 3.975%,然而 instruction 跟 cycle 數量遠大於上一節的狀況(10 倍以上),所以如果要繼續使用 hash table 勢必要想辦法解決 collision 的問題 ## 改善 `append()` 執行速度 首先考慮到可能是 hash table 的長度過於接近資料庫的大小,所以參考 [wikipedia](https://zh.wikipedia.org/wiki/%E5%93%88%E5%B8%8C%E8%A1%A8) 以及 [stackoverflow](https://stackoverflow.com/questions/22741966/how-to-choose-size-of-hash-table),裡面提到資料總數與 hash table 的長度比值應該要介於 0.7 與 0.8 之間,這個比值被稱為 load factor,所以這邊分別嘗試使用 $load factor = 0.7\ ,\ 0.75\ ,\ 0.8$ 來做比較 ![](https://i.imgur.com/WtRGjVl.png) >請調整圖中的數據排列 >[name=課程助教][color=red] Y 軸我使用 logscale 來表示,主要是為了讓 `findName()` 的數據間的差距表現的明顯一點,圖中是使用單次執行的時間,並且每次執行前會先使用 `$ echo 1 | sudo tee /proc/sys/vm/drop_caches` 來清除快取,因為有無清除快取影響執行時間滿多的(有快取的狀況下,執行速度大約快了 5 倍) 從上圖可以看出 load factor 如果介於 0.7 到 0.8 之間的執行時間都是差不多的,都在 0.3 秒左右,接著來比較它們的 cache miss ![](https://i.imgur.com/rrKg4Zl.png) >> 統一寫作 "cache miss",後面不加 -ing >> [name="jserv"][color=red] 很明顯的 load factor 為 0.8 時在效能上是比較好的,其 cache miss 跟縮減過資料尺寸的純 linked list ( cache miss = 57.016% )相比,下降了 23% 左右 再來,根據不同的資料庫以及 hash function,其 hash 出來的分布也會不一樣,我認為可以嘗試 load factor 大於 0.8 的狀況,所以將 load factor 調整為 0.9 看看其結果如何 ``` $ echo 1 | sudo tee /proc/sys/vm/drop_caches $ ./phonebook_opt size of entry : 24 bytes execution time of append() : 0.312205 sec execution time of findName() : 0.000001 sec ``` ``` $ make cache-test Performance counter stats for './phonebook_opt' (100 runs): 105,6438 cache-misses # 27.359 % of all cache refs ( +- 0.33% ) 386,1389 cache-references ( +- 0.05% ) 3,6838,3004 instructions # 1.55 insns per cycle ( +- 0.02% ) 2,3769,1406 cycles ( +- 0.10% ) 0.118767684 seconds time elapsed ( +- 2.17% ) ``` 跟 load factor 為 0.8 時相比,執行時間增加了 0.002sec 但是 cahce miss 減少了 7%,我認為以這個案例來說,load factor = 0.9還算可以接受 ## 試試另一種 hash table 前幾節使用了 open addressing 的 hash table,也就是利用 probing method 來尋找 hash table 中空的 slot,接著來嘗試 chaining 的 hash table,也就是使用 linked list 將 collision 的資料鏈結起來,被鏈結的資料不會佔用 hash table 的 slot,所以理論上 collision 的狀況會比較不嚴重,附上修改後的程式碼 ```c typedef struct __PHONE_BOOK_ENTRY { char lastName[MAX_LAST_NAME_SIZE]; struct __PHONE_BOOK_INFO *pInfo; struct __PHONE_BOOK_ENTRY *pNext; } entry; ``` ```c entry *findName(char lastName[], hashTable *table) { unsigned int i = BKDRHash(lastName) % HASH_TABLE_SIZE; entry *head = table->cell[i]; while (strcmp(head->lastName, lastName) != 0) { head = head->pNext; if (head == NULL) break; } return head; } void append(char lastName[], hashTable *table) { unsigned int i = BKDRHash(lastName) % HASH_TABLE_SIZE; entry *e = (entry *) malloc(sizeof(entry)); e->pInfo = (info *) malloc(sizeof(entry)); e->pNext = NULL; strcpy(e->lastName, lastName); if (table->cell[i] == NULL) { table->cell[i] = e; return; } entry *head = table->cell[i]; while (head->pNext != NULL) { head = head->pNext; } head->pNext = e; } ``` 接著直接執行看看結果如何,這裡我先設定 load factor = 0.8 ``` $ echo 1 | sudo tee /proc/sys/vm/drop_caches $ ./phonebook_chaining size of entry : 32 bytes execution time of append() : 0.302260 sec execution time of findName() : 0.000000 sec ``` 可以看到 `append()` 的速度比 open addressing 的 hash table 快了約 0.007sec,接著來看看 cache miss 狀況如何 ``` Performance counter stats for './phonebook_chaining' (100 runs): 210,3447 cache-misses # 55.627 % of all cache refs ( +- 0.09% ) 378,1313 cache-references ( +- 0.16% ) 3,1909,8415 instructions # 1.63 insns per cycle ( +- 0.03% ) 1,9627,3235 cycles ( +- 0.15% ) 0.099198112 seconds time elapse ( +- 2.84% ) ``` 發現到 cache miss 反而提升了,其原因是當 CPU 向 cache 存取資料時,如果 cache 中不存在該資料會向 main memory 複製一份包含該資料的一段 cache line size 大小的資料到 cache line 上,因此在該資料周遭的其他資料也會一起被快取住,而 chaining 的 hash table 雖然使 slot 上的資料較不會發生 collision 但是也使 slot 上的資料分布變得比較不連續,資料之間可能會被空 slot 給隔開,所以在 cache line 被快取住的有效資料就相對比較少,同時 linked list 結構的記憶體本身也並非連續分布,因此造成 cache miss 的機率上升 > 「cache line 會快取住一段連續的記憶體」的陳述不精確,請翻閱計算機組織與結構的書籍 > [name="jserv"][color=red] > 已修正陳述內容 > [name="st9007a"] ## Hash Function 碰撞分析 首先先對 dataset 中每筆資料 hash 後的結果做統計,x 軸代表資料 hash 出來後的數字,y軸代表資料經過 hash 後的結果出現的次數,例如:(15000, 3) 可以解讀為整個 dataset 中有 3 筆資料 hash 後的結果為 15000,這裡的 hash table 的 load factor 設定為 8: ![](https://i.imgur.com/y35ycOT.png) 會發現由於資料範圍太大,所以 1 到 4 次的頻率看起來差不多,所以接上圖轉換成表格來看 | hash結果重複次數 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | | ------ | - | - | - | - | - | - | - | | 資料數量 | 156580 | 126182 | 50397 | 13436 | 2740 | 516 | 49 | 上面表格是統計每筆資料 hash 後與其他資料重複的次數,可以看到只有大約 44% 的資料沒有重複,也就是說理想狀況下有 $1-(156580 +\frac{126182}{2}+\frac{50397}{3}+\frac{13436}{4}+\frac{2740}{5}+\frac{526}{6}+\frac{49}{7}) / 349900 = 31.275\%$ 的資料會發生碰撞,但是考慮到 linear probing 會把空的 slot 給佔走,經過實際測試總共有 40.742% 的資料發生碰撞 ## 使用接近真實情況的 dataset 由於原本的 dataset 中的英文姓氏為隨機的字母排列,因此不夠具有代表性,因此在 dataset 換成 `dictionary/all-names.txt`,並且對 open addressing hash table 做 load factor 對 cache miss 的重新比較 | load factor | 0.8 | 0.75 | 0.7 | | ----------- | --- | ---- | --- | | cache miss | 12.561% | 12.903% | 12.610% | 重新比較後發現到,cache miss 在 0.8, 0.75, 0.7 之間非常相近,其原因在於這次使用的 dataset 資料量較小 ( 共 16750 筆 ),接著是 load factor = 0.8 以及 0.7 時的 cache miss 非常接近,反而 load factor = 0.75 時的 cahce miss 最高 ## 問題討論 ### 本例使用的 dataset 有代表性嗎?跟真實英文姓氏差異大嗎? 我們可以從 `main.c` 中很容易的知道,程式使用的 dataset 來自於 `dictionary/words.txt`,而將其打開來檢查就可以發現,裡面的 word 應該是按字母順序隨機產生的,因此它並不具有代表性( 與真實狀況相去甚遠 ),跟英文姓氏差異極大 ### 資料難道數大就是美嗎? 根據上面的實驗以及 cache 的原理來看的話,理論上資料量越大,cache miss 發生的機率越高,同樣的,資料儲存的內容越多,cache miss 發生機率也會提高,因此在處理龐大的數據時,必須慎選資料結構 ### 如果我們要建立符合真實電話簿情境的輸入,該怎麼作? 使用符合真實情況的 dataset,接著除了查詢功能外可以加入新增與刪除功能,如果繼續選用 hash table 的話,可以適時的 resize 它的大小,保持 load factor 介於 0.7 到 0.8 之間 ## 參考資料 [關於 CPU cache -- 程序猿需要知道的那些事](http://cenalulu.github.io/linux/all-about-cpu-cache/) [各種字串符 Hash 函數比較](https://www.byvoid.com/zhs/blog/string-hash-compare) [Hash Table:Intro (簡介)](http://alrightchiu.github.io/SecondRound/hash-tableintrojian-jie.html) [wikipedia -- hash table](https://zh.wikipedia.org/wiki/%E5%93%88%E5%B8%8C%E8%A1%A8) [stackoverflow -- How to choose size of hash table?](https://stackoverflow.com/questions/22741966/how-to-choose-size-of-hash-table) [stackoverflow -- How do cache lines work?](https://stackoverflow.com/questions/3928995/how-do-cache-lines-work) [知乎 -- 請教CPU的cache中關於line,block,index等的理解?](https://www.zhihu.com/question/24612442)