# 2019q3 Homework5 (dict) contributed by < `kaeteyaruyo` > ## 作業要求 ### 詳細閱讀 [隱藏在字典裡的數學](https://hackmd.io/@sysprog/BkE3uSvdN),並在自己的電腦環境重現裡頭的實驗,應該充分記錄所見所聞 ### fork [dict](https://github.com/sysprog21/dict),研讀並修改 `Makefile` * 允許 `$ make plot` 時,視覺化 ternary search tree + bloom filter 的效能表現。 * 需要設計實驗,允許不同的字串輸入並探討 TST 回應的統計分佈 * 要考慮到 prefix search 的特性還有詞彙的涵蓋率 * 限用 gnuplot 來製圖 * 參閱 [Hash Table Benchmarks](http://incise.org/hash-table-benchmarks.html) ### 以 perf 分析 TST 程式碼的執行時期行為並且充分解讀 ### 考慮到 CPY 和 REF 這兩種實作機制,思考效能落差的緣故,並且提出後續效能改善機制 * 充分量化並以現代處理器架構的行為解讀 ### 實作 [Quotient filter](https://en.wikipedia.org/wiki/Quotient_filter) 並整合進 [dict](https://github.com/sysprog21/dict) 程式碼中,需要比較兩者的錯誤率及記憶體開銷 ### 研讀 [dict 改善 & 效能探討](https://hackmd.io/@j4ywJU0OTzS2BlwpJo6THA/r1ZdcbFpm),改善現有程式碼 改善部份如下。 ## [dict](https://github.com/sysprog21/dict) 程式碼檢查事項 1. Bloom filter 空間浪費: 整個 table 都把 byte 當 bit 來用,浪費 1/8 的 table 空間 :::info 應為 7 / 8? ::: ```cpp void bloom_add(bloom_t filter, const void *item) { struct bloom_hash *h = filter->func; uint8_t *bits = filter->bits; while (h) { unsigned int hash = h->func(item); hash %= filter->size; bits[hash] = 1; // 每個 uint8_t 不是 1 就是 0 h = h->next; } } ``` 在 `bloom_filter` 的結構定義中,使用了 `size_t` 這個型態記錄 bit table 的大小,其單位為 byte。 從上述 `bloom_add()` 的程式碼中也可以發現我們是使用 `uint8_t` 這樣的型態在使用這塊記憶體。但其實一個 byte 可以當作 8 個 bit 在使用,也就是說,我們實際上浪費了 7 / 8 的空間。換句話說,如果空間使用得當 (8 個 bit 全用上),我們實際上只需要配置現在的 1 / 8 的空間。 因此,我們先從 `bloom_create()` 開始修改。原本傳入的 `size` 參數是以 byte 為單位,我們保留原本的數字,但是在解讀這個數字時改以 bit 為單位,並且在 `calloc()` 時只配置 `size >> 3`, 也就是這個數字的 1 / 8 個 byte 的空間。 ```cpp bloom_t bloom_create(size_t size) { bloom_t res = calloc(1, sizeof(struct bloom_filter)); res->size = size; // Store size in bit res->bits = calloc(size >> 3, 1); // But alloc memory in byte, with 1 / 8 size bloom_add_hash(res, djb2); bloom_add_hash(res, jenkins); return res; } ``` 為了完整用上全部的空間,我們引入了 [bit array](http://www.mathcs.emory.edu/~cheung/Courses/255/Syllabus/1-C-intro/bit-array.html) 的機制,分別實作了 `bloom_add()` 中用到的 set 操作,和 `bloom_test()` 當中用到的 test 操作: ```cpp #define set_bit(table, index) (table[(index / 8)] |= (1 << (index % 8))) #define test_bit(table, index) (table[(index / 8)] & (1 << (index % 8))) ``` 如此一來,便能將上述兩個函式改成這樣,達到 100% 的空間使用率: ```cpp void bloom_add(bloom_t filter, const void *item) { struct bloom_hash *h = filter->func; uint8_t *bits = filter->bits; // Convert from void* to uint8_t* while (h) { unsigned hash = h->func(item); hash %= filter->size; // Index in bit set_bit(bits, hash); h = h->next; } } bool bloom_test(bloom_t filter, const void *item) { struct bloom_hash *h = filter->func; uint8_t *bits = filter->bits; while (h) { unsigned int hash = h->func(item); hash %= filter->size; if (!test_bit(bits, hash)) { return false; } h = h->next; } return true; } ``` 需注意,此實作版本僅在要求的 table 大小恰為 8 的倍數個 bit 時才能正常運作。若 table 的大小不為 8 的倍數,尾端 `filter->size % 8` 個 bit 的部份不會被配置到,在使用到這部份的 bit 時,會引起 array index out of range 的錯誤。 2. `test_cpy.c` 和 `test_ref.c` 兩個檔案有許多部分重複,是否能引入 `test-common.c` 來統合呢? 執行 `diff test_cpy.c test_ref.c`, 找出兩個檔案不同的部份如下: * 20 行處,使用的測試檔檔名不同 ```bash 20c20 < #define BENCH_TEST_FILE "bench_cpy.txt" --- > #define BENCH_TEST_FILE "bench_ref.txt" ``` * 45 行處,推測應該是沒有改到參數,所以我統一改成 `IN_FILE` ```bash 45c45 < fprintf(stderr, "error: file open failed '%s'.\n", IN_FILE); --- > fprintf(stderr, "error: file open failed '%s'.\n", argv[1]); ``` * 53 處的 while 迴圈, CPY 的版本只利用一個字串變數做暫存,而 REF 的版本則是用一個 memory pool 儲存所有讀進來的字串,所以有關於 memory pool 的額外程式碼 ```bash < while ((rtn = fscanf(fp, "%s", word)) != EOF) { < if (!tst_ins_del(&root, word, INS, CPY)) { < fprintf(stderr, "error: memory exhausted, tst_insert.\n"); < fclose(fp); < return 1; < } < bloom_add(bloom, word); < idx++; < } --- > /* memory pool */ > char *pool = (char *) malloc(poolsize * sizeof(char)); > char *Top = pool; > while ((rtn = fscanf(fp, "%s", Top)) != EOF) { > /* insert reference to each string */ > if (!tst_ins_del(&root, Top, INS, REF)) { /* fail to insert */ > fprintf(stderr, "error: memory exhausted, tst_insert.\n"); > fclose(fp); > return 1; > } > bloom_add(bloom, Top); > idx++; > Top += strlen(Top) + 1; > } ``` * CPY 69 行 / REF 74 行處,CPY 版本把資料儲存在 tst 裏面,而 REF 把資料儲存在 tst 外,所以使用了不同版本的 free。 CPY 202 / REF 210 行處亦同。 ```bash 69c74 < tst_free_all(root); --- > tst_free(root); ... 203c210 < tst_free_all(root); --- > tst_free(root); ``` * 74 / 79 行處,使用的 output 檔名不同,可以像 `BENCH_TEST_FILE` 一樣用定義的解決 ```bash 74c79 < output = fopen("cpy.txt", "a"); --- > output = fopen("ref.txt", "a"); ``` * `switch` 內, `case 'a'` 處,因為 CPY 和 REF 版本使用不同的方式儲存字串(`word` 和 `Top`),所以會有 memory pool 有無被使用的差別 ```bash < case 'a': < printf("enter word to add: "); < if (argc > 1 && strcmp(argv[1], "--bench") == 0) < strcpy(word, argv[3]); < else if (!fgets(word, sizeof word, stdin)) { < fprintf(stderr, "error: insufficient input.\n"); < break; < } < rmcrlf(word); < < t1 = tvgetf(); < if (bloom_test(bloom, word)) /* if detected by filter, skip */ < res = NULL; < else { /* update via tree traversal and bloom filter */ < bloom_add(bloom, word); < res = tst_ins_del(&root, word, INS, CPY); < } < t2 = tvgetf(); < if (res) { < idx++; < printf(" %s - inserted in %.12f sec. (%d words in tree)\n", < (char *) res, t2 - t1, idx); < } else < printf(" %s - already exists in list.\n", (char *) res); < < if (argc > 1 && strcmp(argv[1], "--bench") == 0) // a for auto < goto quit; < break; --- > case 'a': > printf("enter word to add: "); > if (argc > 1 && strcmp(argv[1], "--bench") == 0) > strcpy(Top, argv[3]); > else if (!fgets(Top, sizeof word, stdin)) { > fprintf(stderr, "error: insufficient input.\n"); > break; > } > rmcrlf(Top); > > t1 = tvgetf(); > if (bloom_test(bloom, Top)) /* if detected by filter, skip */ > res = NULL; > else { /* update via tree traversal and bloom filter */ > bloom_add(bloom, Top); > res = tst_ins_del(&root, Top, INS, REF); > } > t2 = tvgetf(); > if (res) { > idx++; > Top += (strlen(Top) + 1); > printf(" %s - inserted in %.12f sec. (%d words in tree)\n", > (char *) res, t2 - t1, idx); > } else > printf(" %s - already exists in list.\n", (char *) res); > > if (argc > 1 && strcmp(argv[1], "--bench") == 0) // a for auto > goto quit; > break; ``` * CPY 185 行 / REF 191 行處,使用了不同版本的參數 ```bash 185c191,192 < res = tst_ins_del(&root, word, DEL, CPY); --- > /* FIXME: remove reference to each string */ > res = tst_ins_del(&root, word, DEL, REF); ``` 統整以上差異後,可以發現大部分不同的程式碼在於使用的參數 / 檔案名稱不同,以及有無使用到 memory pool的部份。由於差異不大,可以用 define macro 的方式,在編譯時期選擇不同版本的程式碼。 我將整合後的版本實作成一個 [test_common.c](https://github.com/kaeteyaruyo/dict/blob/master/test_common.c),檔案中的部份內容如下: ```cpp #if !defined(REF) && !defined(CPY) #define REF #endif #ifdef REF // for reference mode, tst_ins_del()'s cpy argument should be zero #define MODE 0 #define BENCH_TEST_FILE "bench_ref.txt" #define OUTPUT_FILE "ref.txt" #define TST_FREE tst_free #endif #ifdef CPY // for cpy mode, tst_ins_del()'s cpy argument should be non-zero #define MODE 1 #define BENCH_TEST_FILE "bench_cpy.txt" #define OUTPUT_FILE "cpy.txt" #define TST_FREE tst_free_all #endif ... #ifdef REF /* memory pool */ char *pool = (char *) malloc(poolsize * sizeof(char)); char *buffer = pool; #endif #ifdef CPY char *buffer = word; #endif ``` 搭配 gcc 的 `-Dmacro` 參數,在編譯時若使用 `-DREF`,就會編譯出 REF 的版本,反之用 `-DCPY`, 就會編譯出 CPY 的版本,如果忘了打,會預設編譯出 REF 的版本。 但這個專案有引入了 makefile, 所以我嘗試把這項功能也整合進 makefile 裡面: ```shell test_exe: test_common.o $(OBJS_LIB) $(VECHO) " LD\t$@\n" $(Q)$(CC) $(LDFLAGS) -o test_$(shell echo $(MODE) | tr A-Z a-z) $^ -lm %.o: %.c $(VECHO) " CC\t$@\n" $(Q)$(CC) -D$(MODE) -o $@ $(CFLAGS) -c -MMD -MF .$@.d $< ``` 執行 `make test_exe MODE=[REF, CPY]` ,就能編譯出對應的版本。 此外,為了讓執行 `make [all]` 可以跑出和以前一樣的結果,我另外加了幾條規則: ```shell test_ref: FORCE rm -f test_common.o $(MAKE) test_exe MODE=REF test_cpy: FORCE rm -f test_common.o $(MAKE) test_exe MODE=CPY FORCE: ; ``` 由於 `test_exe` 的規則相依於 `test_common.c` 但是這個規則沒有參數就無法編譯,並且其內容物也不應該共享,所以只能強制讓 `test_[ref, cpy]` 這兩條規則無條件執行,因此加入了 `FORCE` 規則(不這麼做的話,就算 `test_common.c` 有所更新,也不會重新編譯),並且在編譯另一個版本之前,先將 `test_common.o` 清理掉,這樣才會重新編譯到這個檔案。 3. 城市名後面跟着逗號: 應該區分城市名和國家名。另外,記錄城市名時不應該把逗號也存下來 首先我必須先確定這個檔案當中到底有哪幾種格式的資料,才能決定如何處理 input。在 VS code 當中以 regular expression 搜尋過整個檔案,可以發現: * `,(.*)?,(.*)?,(.*)?,`: 有 4 個逗號的資料,共有 1 個結果 * `Villa Presidente Frei, Ñuñoa, Santiago, Chile, Chile`, 依序為 `建築物名稱, 城鎮名, 城市名, 國名`。其中城市名的部份為 `Santiago, Chile`, 猜測為 "Santiago de Chile" 的縮寫 * `,(.*)?,(.*)?,`: 有 3 個逗號的資料,共有 2 個結果 * `Oliver-Valdefierro, Oliver, Valdefierro, Spain`, 依序為 `城市名, 區名, 區名, 國名`。 其中 Oliver 和 Valdefierro 是兩個相同層級的行政區 * `Earth, TX, Texas, United States`, 依序為 `城市名, 州名縮寫, 州名, 國名`。 這算是例外,因為除此之外的所有美國的地名都是遵循 `城市名, 州名, 國名` 的格式。 * `,(.*)?,`: 有 2 個逗號的資料,共有 19188 個結果 * 無法全數列出,但大部份為美國、加拿大、墨西哥、澳洲等有州層級的行政區規劃的國家,所以可以粗略推測這種情況的資料,中間的部份都是州 * 剩下的就是只有一個逗號的國家,格式為 `城市名, 國名` 以資料科學的角度來看,在拿到一筆 raw data 時應當先做前處理,整理並儘量去除當中的雜訊(即如上述的例外狀況)。由於我們已經偵測出所有例外狀況了,並且這些資料並不多,因此可以直接根據我們的需求將這些資料整理成正確的格式。 就目前這份文字檔,我會選擇將上述有三個以上逗號的資料取代如下: * `Villa Presidente Frei, Ñuñoa, Santiago, Chile, Chile` -> `Ñuñoa, Chile` * 因為智利其他的地名都是遵循 `城市名, 國名` 的格式,而且 `Santiago, Chile` 已經存在了 * `Oliver-Valdefierro, Oliver, Valdefierro, Spain` -> `Oliver-Valdefierro, Spain` * 因為就西班牙的行政區分類,這兩個地名本來就被視為是[一個區](https://es.wikipedia.org/wiki/Oliver-Valdefierro),且大部份西班牙的地名都是遵循 `城市名, 國名` 的格式 * `Earth, TX, Texas, United States` -> `Earth, Texas, United States` * 因為除此之外的所有美國的地名都是遵循 `城市名, 州名, 國名` 的格式 還有一個小例外是 `Xeraco,Jaraco, Spain` ,他的城市名的逗號後方沒有空白鍵(就只有這筆資料是這樣)。 Google 了一下發現是因為 Xeraco 和 Jaraco 是同一個地方,只是拼法不同,因此保留西班牙本地常用的 Xeraco 即可。 如此一來,這份資料就只剩下「有一個逗號的資料」跟「有兩個逗號的資料」了,在我們假設「有兩個逗號的資料」都是`城市名, 州名/省名, 國名`的情況下,我們可以用以下程式碼讀取輸入(此方法會將逗號和換行符都排除): ```cpp char line[WRDMAX]; while (fgets(line, WRDMAX, fp) != NULL) { char city[WRDMAX] = "", province[WRDMAX] = "", nation[WRDMAX] = ""; sscanf(line, "%[^,^\n], %[^,^\n], %[^,^\n]", city, province, nation); // Do what you want } ``` 在本專案中,我單純將所有取得的地名插入樹中而已。詳細實作方法請見 [test_common.c 第 68 行處](https://github.com/kaeteyaruyo/dict/blob/master/test_common.c)。 4. memory leaks 的偵測及修正 執行 `valgrind --leak-check=full ./test_ref --bench`,測得 memory leak 的狀況如下: ```bash ==17885== HEAP SUMMARY: ==17885== in use at exit: 517,008,248 bytes in 6 blocks ==17885== total heap usage: 502,763 allocs, 502,757 frees, 533,111,216 bytes allocated ==17885== ==17885== 8,192 bytes in 1 blocks are definitely lost in loss record 3 of 6 ==17885== at 0x4C2DB8F: malloc (in /usr/lib/valgrind/vgpreload_memcheck-amd64-linux.so) ==17885== by 0x400DEC: bench_test (bench.c:46) ==17885== by 0x4011FE: main (test_ref.c:73) ==17885== ==17885== 5,000,056 (24 direct, 5,000,032 indirect) bytes in 1 blocks are definitely lost in loss record 5 of 6 ==17885== at 0x4C2FB55: calloc (in /usr/lib/valgrind/vgpreload_memcheck-amd64-linux.so) ==17885== by 0x402A57: bloom_create (bloom.c:45) ==17885== by 0x401078: main (test_ref.c:51) ==17885== ==17885== 512,000,000 bytes in 1 blocks are possibly lost in loss record 6 of 6 ==17885== at 0x4C2DB8F: malloc (in /usr/lib/valgrind/vgpreload_memcheck-amd64-linux.so) ==17885== by 0x40108E: main (test_ref.c:54) ==17885== ==17885== LEAK SUMMARY: ==17885== definitely lost: 8,216 bytes in 2 blocks ==17885== indirectly lost: 5,000,032 bytes in 3 blocks ==17885== possibly lost: 512,000,000 bytes in 1 blocks ==17885== still reachable: 0 bytes in 0 blocks ==17885== suppressed: 0 bytes in 0 blocks ==17885== ==17885== For counts of detected and suppressed errors, rerun with: -v ==17885== ERROR SUMMARY: 3 errors from 3 contexts (suppressed: 0 from 0) ``` * `test_ref.c` 73 行,呼叫至 `bench.c` 46 行處, `sgl` 使用的空間在 `bench_test()` 結束前都沒有被釋放。考慮到 `bench_test()` 被呼叫過後沒多久程式必然會結束,且 `sgl` 最終亦不會在此函式外部被使用,因此應當在此函式內直接釋放該記憶體。 * 修正方法:在 `bench_test()` 尾端 `return 0;` 前增加一行 `free(sgl);` * `test_ref.c` 51 行,呼叫至 `bloom.c` 45 行處, 使用 `bloom_create()` 配置的記憶體最終沒有被釋放。這是因為在 `--bench` 模式下,程式最終不會跑到 `quit` 區塊,而是在執行完 `bench_test()` 後直接結束。 雖然 tst 本身的記憶體有記得被釋放掉,但是 bloom filter 和 memory pool 的部份卻被遺忘了。 * 修正方法:在 73 行呼叫完 `bench_test()` 後,增加一行 `bloom_free(bloom);`, 再行 `return 0;` * `test_ref.c` 54 行處, `pool` 沒有被釋放。無論是在 `--bench` 模式還是一般模式下,都沒有會釋放 `pool` 的程式碼。 * 修正方法:在 73 行呼叫完 `bench_test()` 後,以及 `main()` 尾端程式結束前,增加一行 `free(pool);`, 再行 `return 0;` 此外,在非 `--bench` 模式下執行 `a` 指令新增字串時,會有以下錯誤 ```bash ==18909== Conditional jump or move depends on uninitialised value(s) ==18909== at 0x402C94: bloom_test (bloom.c:102) ==18909== by 0x40141F: main (test_ref.c:115) ``` * 在 `bloom.c` 當中的 `bloom_test()` 函式,有一條 `if (!(bits[hash])` 判斷式,這個判斷式中的 `bits` 是來源於 `uint8_t *bits = filter->bits;`,而 `filter->bits` 是用 `malloc()` 配置出來的,因此導致這項警告。 * 修正方法:將 `bloom_create()` 中的 `res->bits = malloc(size);` 改為 `res->bits = calloc(size, 1);` 即可 以 `valgrind --leak-check=full ./test_cpy --bench` 測得的錯誤及原因大致相同(因為錯誤多半來自共用的檔案),修正方法亦相同,因此不再贅述 5. 如何縮減頻繁 malloc 帶來的效能衝擊呢?提示: [memory pool](https://en.wikipedia.org/wiki/Memory_pool) 由於儲存字串用的 memory pool 在 REF 版本中已經實作了,因此我猜想這裡指的應該是在 `tst_ins_del()` 當中頻繁以 `calloc()` 配置節點空間造成的效能衝擊。這部份可以仿照 REF 版本,先直接配置一個足夠大的連續 heap 空間,並且在需要空間時直接從中取得。 然而 REF 版本中的 pool 只會一直增添新的資料,並沒有從中移除資料的操作發生,因此可以用 stack 的方式管理;而 tst 則會有移除節點的操作,我們無法確定要被刪除的節點他使用的記憶體落在 memory pool 的哪個地方,因此將其視為 stack 管理是不可行的。如何管理 memory pool, 使之不會有記憶體碎片產生,是這個問題的關鍵。(待補完) ## 參考資料 * [Select a function at compile time - Stack Overflow](https://stackoverflow.com/questions/1640567/select-a-function-at-compile-time/1640592) * [[C] 使用gcc參數 -Dmacro的編譯小技巧 - KT0nE's 程式筆記](http://kt0ne.pixnet.net/blog/post/63048996-%5Bc%5D-%E4%BD%BF%E7%94%A8gcc%E5%8F%83%E6%95%B8--dmacro%E7%9A%84%E7%B7%A8%E8%AD%AF%E5%B0%8F%E6%8A%80%E5%B7%A7) * [How can I force Make to execute a recipe all the time - Stack Overflow](https://stackoverflow.com/questions/26226106/how-can-i-force-make-to-execute-a-recipe-all-the-time) * [Array of bits](http://www.mathcs.emory.edu/~cheung/Courses/255/Syllabus/1-C-intro/bit-array.html)