# dict 改善 & 效能探討 ###### tags: `C語言` contributed < `asd757817`,`yichung279` > ## 原專案 bugs 修正 1. 原專案在讀取 cities.txt 時,遇到地名含有空白會有 bug fscanf 在遇到空白、換行都會停止讀取,因此城鎮名稱含有空白的會儲存錯誤,修改後只會紀錄地名或城鎮名(cities.txt 內的格式為:地名, 城鎮, 國家 OR 城鎮名, 國家) * test_cpy.c 修改 ```C /* * 將這段程式碼內容修改成下面程式碼 while ((rtn = fscanf(fp, "%s", Top)) != EOF) { ...省略... } */ char buf[WORDMAX]; while (fgets(buf, WORDMAX, fp)) { char *token = ","; char *s = strtok(buf, token); char *p = s; if (!tst_ins_del(&root, &p, INS, CPY)) { fprintf(stderr, "error: memory exhausted, tst_insert.\n"); fclose(fp); return 1; } idx++; } ``` * test_ref.c 修改 ```C /* * 將這段程式碼內容修改成下面程式碼 while ((rtn = fscanf(fp, "%s", Top)) != EOF) { ...省略... } */ char buf[WORDMAX]; while (fgets(buf, WORDMAX, fp)) { char *token = ","; strcpy(Top, strtok(buf, token)); rmcrlf(Top); char *p = Top; /* insert reference to each string */ if (!tst_ins_del(&root, &p, INS, REF)) { /* fail to insert */ fprintf(stderr, "error: memory exhausted, tst_insert.\n"); fclose(fp); return 1; } else { /* update bloom filter */ bloom_add(bloom, Top); } idx++; Top += (strlen(Top) + 1); } ``` 2. 原專案 bloomfilter 宣告 table 時,使用 malloc(),但未將 table 初始化,因此改用 calloc()。 ``` res->bits = malloc(size); ``` 改為 ``` res->bits = calloc(size, 1); ``` 3. 使用易於增加數量的雜湊函數 由於舊的 bloom filter 使用雜湊函數 (hash function) 時,只將兩種不同的雜湊函數放進 bloom filter 中,如此一來難以更改函數數量,也給測試帶來許多不變,因此改用 murmur3 這個能加入種子 (seed) 的函數做為雜湊函數。 ```clike unsigned int murmur3(const void *_str, unsigned int seed) { size_t len = strlen(_str); const uint8_t *key = _str; uint32_t h = seed; if (len > 3) { const uint32_t *key_x4 = (const uint32_t *) key; size_t i = len >> 2; do { uint32_t k = *key_x4++; k *= 0xcc9e2d51; k = (k << 15) | (k >> 17); k *= 0x1b873593; h ^= k; h = (h << 13) | (h >> 19); h = (h * 5) + 0xe6546b64; } while (--i); key = (const uint8_t *) key_x4; } if (len & 3) { size_t i = len & 3; uint32_t k = 0; key = &key[i - 1]; do { k <<= 8; k |= *key--; } while (--i); k *= 0xcc9e2d51; k = (k << 15) | (k >> 17); k *= 0x1b873593; h ^= k; } h ^= len; h ^= h >> 16; h *= 0x85ebca6b; h ^= h >> 13; h *= 0xc2b2ae35; h ^= h >> 16; return h; } ``` ## bloom filter 結構修改 * bloom filter: 包含 hash function,一個 table 還有 table size。 * hash function: 以 linked list 的結構串在一起,每個結點包含一個指向函式的指標。修改之後增加 seed,讓每個 hash function 自行記錄自己的 seed。 ```clike typedef unsigned int (*hash_function)(const void *data, unsigned int seed); typedef struct bloom_filter *bloom_t; struct bloom_hash { hash_function func; unsigned int seed; struct bloom_hash *next; }; struct bloom_filter { struct bloom_hash *func; void *bits; size_t size; }; ``` ## bloom filter error rate 視覺化 以 hash function的數量、table size 的大小做為橫軸縱軸,以錯誤率做為深淺。而畫面中紫色的線為 $y=ln2\cdot\frac{x}{93827}$,其中93827 為字典中 item 數量。 ![](https://i.imgur.com/6SKmS2x.png) * 把線性規劃的概念代入,我們想要最小的空間及執行速度,並符合一定的錯誤率,我們可以很清楚的知道我們想要的值在可行域的左下角。 為了讓圖片不明顯的部分,能被看得清楚,將圖片轉為灰階後做直方圖均衡化 ![](https://i.imgur.com/DxibxrA.png) ![](https://i.imgur.com/pXWBJuw.png) * 把此圖視為高線圖,可以發現山谷都在 $k=ln2\frac{m}{n}$上,而在這裡實際的意義就是錯誤率最小值發生在 $k=ln2\frac{m}{n}$與推導的結果相符 透過開四次方根把錯誤率之間的差距加大在畫一次圖,我們能更明顯的看出上述的內容。 ![](https://i.imgur.com/53VgivI.png) ## Bloom filter 的 hash function 數目、 table size 與搜尋時間之關係探討 使用 make plot 呈現不同 hash function 數量與 table 大小的搜尋效能 ``` plot: $(TESTS) python gen_test.py ./test_cpy --bench ./test_ref --bench gnuplot scripts/bloom_err_rate.gp gnuplot scripts/runtime3.gp eog runtime3.png eog bloom_err_rate.png ``` * hash function 數量從 1 至 15,hash table 大小從 1 至 40,統計各種組合的總搜尋時間,與單純使用 prefix search 做 600次比較。 * 測試檔案:將原字典內字串第二字取代為\$,即第二個字元便不在 tree 中。 ### 測試檔說明 原 cities.txt 檔案內容 ``` Shanghai, China Buenos Aires, Argentina Mumbai, India Mexico City, Distrito Federal, Mexico Karachi, Pakistan İstanbul, Turkey ...以下省略... ``` * 測試檔以原 cities.txt 為基礎,將第 n 個字元取代成 "$",表示搜尋到第 n 字元即可判斷搜尋字串不在字典內部 * 測試檔命名為: case_n.txt,n 表示第 n 個字元取代成 "$" 如: case_02.txt 內容如下 ``` S$anghai,China B$enos Aires,Argentina M$mbai,India M$xico City,Distrito Federal,Mexico K$rachi,Pakistan ...以下省略... ``` ### 實驗: * bloom filter 雜湊函式數量從 1 ~ 15、table size 由 1~40 共600種組合 * 與不加 bloom filter 做600次進行比較 ### 測試環境 ``` Architecture: x86_64 CPU 作業模式: 32-bit, 64-bit Byte Order: Little Endian CPU(s): 8 On-line CPU(s) list: 0-7 每核心執行緒數:2 每通訊端核心數:4 CPU MHz: 1600.092 CPU max MHz: 1600.0000 CPU min MHz: 400.0000 L1d 快取: 32K L1i 快取: 32K L2 快取: 256K L3 快取: 6144K ``` ### 情境1: 搜尋字串全部在字典內(以 cities.txt 作為輸入搜尋) ![](https://i.imgur.com/PxjAxi5.png) 從結果中可以發現: 1. tst_search 重複測試 600 次的搜尋時間大致上相同 2. 因為全部的搜尋字串都在字典內,因此 bloom filter 進行的預測時間是浪費的,隨著table size、雜湊函式數量提高,浪費的時間愈多 ### 情境2: 搜尋字串在第一個字元即可判斷不在字典內(case_1.txt) ![](https://i.imgur.com/eJiaj8f.png) 1. 加上 bloom filter 後的執行時間為 L 型: 在這個情境中,若 bloom filter 預測錯誤,進行一個搜尋所花的時間為 bloom filter + tst_search 的時間;若是預測正確則是只需要花 bloom filter 的時間,因此預測的錯誤率愈低搜尋所需要的時間愈短。 已知: ==bloom filter 預測的錯誤率計算公式為 $(1-e^{-\frac{kn}{m}})^k$== **其中 k為 hash function 數量、m 為 table size、n 為字串數量** 依據上述公式,當 k 固定時,m 增加錯誤率會逐漸下降,繪製出的圖形也是呈現 L 形與輸出結果相符。 2. 把這張圖 bloom filter 的部分與 bloom filter錯誤率的 heat map 一起看,由於錯誤率與執行時間成正比,這張圖 bloom filter 的部分,正是一列一列拆開的 heat map。 3. 因為在第一個字元就可以判斷出字串不在字典中,tst_search 進行搜尋的時間反而少於進行 hash 的時間。 ### 情境3: 搜尋到最後一個字元才發現字串不在字典內 ![](https://i.imgur.com/7OTUiPM.png) 1. 第三個 L 之後加入 bloom filter 的搜尋時間全在不加 bloom filter 之上,這與==搜尋的字串長度==有關,雖然搜尋字串全部不在字典中且需要到最後一個字元才能發現,但因為字串長度不長,當雜湊函式的數量大於3個之後,進行雜湊運算與預測的時間反而大於 tst_search,因此在輸入的搜尋字串長度與本次情境相近時,bloom filter 的雜湊函式數量小於3個才可以使效率提高。 ## 以 Perf 效能分析工具探討程式效能 Perf 可以取樣的事件種類很多,利用其中的 cache-misses 可以分析程式是否有善用 Locality of reference. 分析 ./test_cpy --bench ### 測試情境1: 以 cities.txt 作為輸入,檢查程式在空間局部性的利用狀況 ``` ternary_tree, loaded 93827 words in 0.076877 sec Performance counter stats for './test_cpy --bench': 2,989,918 cache-misses 0.155304671 seconds time elapsed ``` miss 的數量非常多,可見程式在空間局部性的利用很差,進一步使用 perf 查看造成高 miss 的函式 ```shell $ perf record -e cache-misses ./test_cpy --bench && sudo perf report ternary_tree, loaded 93827 words in 0.091907 sec [ perf record: Woken up 1 times to write data ] [ perf record: Captured and wrote 0.041 MB perf.data (715 samples) ] 29.06% test_cpy test_cpy [.] tst_free_all 17.41% test_cpy test_cpy [.] tst_search 5.95% test_cpy test_cpy [.] tst_ins_del 5.13% test_cpy libc-2.23.so [.] free 4.07% test_cpy [kernel.kallsyms] [k] clear_page_erms . . . ``` 可以發現 tst_free_all 與 tst_search 兩函式是造成高 miss 的主因。 * tst_free_all 的部分: 將整個資料結構釋放時,由於資料量過於龐大,很自然地,我們的快取不會存有整個資料結構,這部分的 cache misses 無可厚非。 * tst_search 的部分: ```shell while (curr) { /* loop over each char in 's' */ │ ↓ jmp 68 │ int diff = *s - curr->key; /* calculate the difference */ 3.47 │ e: mov 0xc(%ebp),%eax │ movzbl (%eax),%eax 0.69 │ movsbl %al,%edx 0.69 │ mov -0x8(%ebp),%eax 0.69 │ movzbl (%eax),%eax 39.58 │ movsbl %al,%eax 1.39 │ sub %eax,%edx 2.78 │ mov %edx,%eax │ mov %eax,-0x4(%ebp) │ //*s - curr->key; │ if (diff == 0) { /* handle the equal case */ ``` 發現是 tst_search 中計算搜尋字串與當前節點的差 `int diff = *s - curr->key;` 一步實驗後,發現`*s` dereference 搜尋字串是罪魁禍首,由於每次搜尋的字串都略有不同,對於 cache 來說每次都是第一次存取,自然有 cache misses。 相反地,若短時間內重複搜尋,便有時間上的 locality,進而避免 cache misses。我們將在下方的實驗證實這件事。 分析過後,我們知道除了搜尋外,還有其他無可避免的 cache misses,我們搜尋空字串後,觀察程式運作基本 miss 數量。 ```shell $ perf stat --repeat 10 -e cache-misses ./test_cpy --bench Performance counter stats for './test_cpy --bench' (10 runs): 2,116,940 cache-misses ( +- 1.17% ) 0.105038320 seconds time elapsed ( +- 1.17% ) ``` 以上面得到的數量作為程式運作基本 miss 數量。 ### 情境2:驗證搜尋過的字串確實有存入 cache 中 分為3個情況進行檢查: 1. 原輸入 輸入檔1: ``` 's Gravenmoe$,Netherlands 's-Gravenzand$,Netherlands 's-Hertogenbosc$,Netherlands 't Hofke,Netherlands A Coruña,Spain A Estrada,Spain A dos Cunhado$,Portugal Aabenraa,Denmark Aach,Germany Aachen,Germany Aadorf,Switzerland ...以下省略... ``` 2. 同一個輸入重入輸入3次 輸入檔2: ``` 's Gravenmoe$,Netherlands 's Gravenmoe$,Netherlands 's Gravenmoe$,Netherlands 's-Gravenzand$,Netherlands 's-Gravenzand$,Netherlands 's-Gravenzand$,Netherlands 's-Hertogenbosc$,Netherlands 's-Hertogenbosc$,Netherlands 's-Hertogenbosc$,Netherlands 't Hofke,Netherlands 't Hofke,Netherlands 't Hofke,Netherlands A Coruña,Spain A Coruña,Spain A Coruña,Spain A Estrada,Spain A Estrada,Spain A Estrada,Spain ...以下省略... ``` 3. 原輸入搜尋後再以原輸入搜尋一次 輸入檔3 ``` 's Gravenmoe$,Netherlands 's-Gravenzand$,Netherlands 's-Hertogenbosc$,Netherlands 't Hofke,Netherlands A Coruña,Spain ...以下省略... 's Gravenmoe$,Netherlands 's-Gravenzand$,Netherlands 's-Hertogenbosc$,Netherlands 't Hofke,Netherlands A Coruña,Spain ...以下省略 ``` 結果1 ```shell $ perf stat --repeat 10 -e cache-misses ./test_cpy --bench Performance counter stats for './test_cpy --bench' (10 runs): 3,120,522 cache-misses ( +- 2.02% ) 0.154313338 seconds time elapsed ( +- 0.84% ) ``` 結果2 ```shell $ perf stat --repeat 10 -e cache-misses ./test_cpy --bench Performance counter stats for './test_cpy --bench' (10 runs): 3,066,592 cache-misses ( +- 2.71% ) 0.181553031 seconds time elapsed ( +- 0.60% ) ``` 結果3 ```shell $ perf stat --repeat 10 -e cache-misses ./test_cpy --bench Performance counter stats for './test_cpy --bench' (10 runs): 3,840,617 cache-misses ( +- 2.04% ) 0.199821574 seconds time elapsed ( +- 0.37% ) ``` 結果1、2、3都必須再減去程式基本的 miss 數量。 依據輸出結果1、2來推斷:由於第一次搜尋時會把資料存放在 cache 中,因次再次搜尋時並沒有發生 cache miss,因此兩個情境的 cache miss 數量相差不多。 結果1、3在減去基本的 miss 數量後,兩者差距約2倍,表示在第二次搜尋時 cache 中已經沒有前一次的輸入,出現第二次的 miss。 將輸入每 n 個分成一組,以組為單位重複搜尋,測試 cache 大約可以存放多少筆輸入: n = 20 ```shell perf stat --repeat 10 -e cache-misses ./test_cpy --bench Performance counter stats for './test_cpy --bench' (10 runs): 3,178,390 cache-misses ( +- 1.05% ) 0.187150742 seconds time elapsed ( +- 0.62% ) ``` n = 1000 ```shell $ perf stat --repeat 10 -e cache-misses ./test_cpy --bench Performance counter stats for './test_cpy --bench' (10 runs): 3,187,476 cache-misses ( +- 1.48% ) 0.195568548 seconds time elapsed ( +- 0.66% ) ``` n = 1500 ```shell $ perf stat --repeat 10 -e cache-misses ./test_cpy --bench Performance counter stats for './test_cpy --bench' (10 runs): 3,325,723 cache-misses ( +- 1.52% ) 0.197077394 seconds time elapsed ( +- 0.53% ) ``` n = 2000 ```shell $ perf stat --repeat 10 -e cache-misses ./test_cpy --bench Performance counter stats for './test_cpy --bench' (10 runs): 3,667,668 cache-misses ( +- 5.20% ) 0.205070631 seconds time elapsed ( +- 2.07% ) ``` 依照實驗的電腦配備,約1000個輸入內重複的輸入可以直接在 cache 中取得資料