---
tags: sysprog
---
# 2019q3 Homework5 (dict)
contributed by < `yxguo2536` >
## 使用 test_common.c 統合 test_ref.c 和 test_cpy.c
以 `diff test_ref.c test_cpy.c` 比較 `test_ref.c` 和 `test_cpy.c` 之間的差別:
1. output 檔案不同
```c
20c20
< #define BENCH_TEST_FILE "bench_ref.txt"
---
> #define BENCH_TEST_FILE "bench_cpy.txt"
```
```c
77c74
< output = fopen("ref.txt", "a");
---
> output = fopen("cpy.txt", "a");
```
2. error 訊息不同,但此處應該是 test_ref.c 原本寫錯了。
```c
45c45
< fprintf(stderr, "error: file open failed '%s'.\n", argv[1]);
---
> fprintf(stderr, "error: file open failed '%s'.\n", IN_FILE);
```
3. REF 機制會事先配置一個 memory pool
```c
47a48
>
52,57c53,54
< /* memory pool */
< char *pool = (char *) malloc(poolsize * sizeof(char));
```
4. REF 機制以 `Top` 變數 ( momery pool ) 儲存所有使用者輸入, CPY 機制則是以 `word` 變數暫時存放一筆資料
```c
< 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 */
---
> while ((rtn = fscanf(fp, "%s", word)) != EOF) {
> if (!tst_ins_del(&root, word, INS, CPY)) {
```
```c
62c59
< bloom_add(bloom, Top);
---
> bloom_add(bloom, word);
```
```c
64d60
< Top += strlen(Top) + 1;
```
```c
103,104c100,101
< strcpy(Top, argv[3]);
< else if (!fgets(Top, sizeof word, stdin)) {
---
> strcpy(word, argv[3]);
> else if (!fgets(word, sizeof word, stdin)) {
```
```c
108,109c105
< rmcrlf(Top);
<
---
> rmcrlf(word);
```
```c
111c107
< if (bloom_test(bloom, Top)) /* if detected by filter, skip */
---
> if (bloom_test(bloom, word)) /* if detected by filter, skip */
```
```c
114,115c110,111
< bloom_add(bloom, Top);
< res = tst_ins_del(&root, Top, INS, REF);
---
> bloom_add(bloom, word);
> res = tst_ins_del(&root, word, INS, CPY);
```
```c
120,121c116
< Top += (strlen(Top) + 1);
< printf(" %s - inserted in %.10f sec. (%d words in tree)\n",
---
> printf(" %s - inserted in %.12f sec. (%d words in tree)\n",
```
```c
189,190c184
< /* FIXME: remove reference to each string */
< res = tst_ins_del(&root, word, DEL, REF);
---
> res = tst_ins_del(&root, word, DEL, CPY);
```
5. TST free 的行為不同 : REF 機制只需要釋放每個 TST node 的記憶體,而 CPY 機制還要額外釋放 TST leaf 指向的字串
```c
66a63
>
72c69
< tst_free(root);
---
> tst_free_all(root);
```
```c
208c202
< tst_free(root);
---
> tst_free_all(root);
```
可以看到,test_ref.c 和 test_cpy.c 的程式碼架構幾乎一樣,主要差別在於函式呼叫的參數 和 memory pool 機制。
所以,我使用 MACRO 機制合併兩者:
- [ ] test_common.c
```c
#if !defined(CPY) && !defined(REF)
#define REF
#endif
#ifdef REF
#define MODE 0
#define BENCH_TEST_FILE "bench_ref.txt"
#define OUT_FILE "ref.txt"
#define TST_FREE tst_free
#endif
#ifdef CPY
#define MODE 1
#define BENCH_TEST_FILE "bench_cpy.txt"
#define OUT_FILE "cpy.txt"
#define TST_FREE tst_free_all
#endif
```
```c
int main(int argc, char **argv)
{
#if defined(REF)
/* memory pool */
char *pool = (char *) malloc(poolsize * sizeof(char));
char *Top = pool;
#define BUFFER Top
#elif defined(CPY)
char word[WRDMAX] = "";
#define BUFFER word
#endif
...
```
```c
#ifdef REF
BUFFER += strlen(BUFFER) + 1;
#endif
```
```c
else if (!fgets(BUFFER, WORDMAX, stdin)) {
...
```
當要編譯 test_ref,我就需要在以 gcc 編譯 test_common.c 時加入額外參數 `-DREF`,反之, test_cpy 則加入 `-DCPY`
- [ ] Makefile
```
test_%: test_%.o $(OBJS_LIB)
$(VECHO) " LD\t$@\n"
$(Q)$(CC) $(LDFLAGS) -o $@ $^ -lm
test_ref.o: test_ref.c
$(VECHO) " CC\t$@\n"
$(Q)$(CC) -o $@ $(CFLAGS) -c -MMD -MF .$@.d test_common.c -DREF
test_cpy.o: test_cpy.c
$(VECHO) " CC\t$@\n"
$(Q)$(CC) -o $@ $(CFLAGS) -c -MMD -MF .$@.d test_common.c -DCPY
```
## 優化 Bloom filter 空間效率
Bloom filter 空間浪費: 整個 table 都把 byte 當 bit 來用,浪費 1/8 的 table 空間
```c
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.c
```c
bloom_t bloom_create(size_t size)
{
bloom_t res = calloc(1, sizeof(struct bloom_filter));
res->size = size;
// res->bits = malloc(size);
res->bits = calloc((size + 7) >> 3, 1);
bloom_add_hash(res, djb2);
bloom_add_hash(res, jenkins);
return res;
}
```
首先,在 `bloom_create` 時將要配置的空間縮小到約 1/8
1. 對於函式參數 `size` 要先對齊 8 (bits),然後在除以 8 ,也就是 `(size + 7) >> 3`
2. 不用 `malloc` 而改用 `calloc` ,因為後者會對配置空間做初始化 ( 填0 ),以便在 `bloom_add` 可以 `|=` 運算將特定 bit 設成 1
```c
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;
bits[hash >> 3] |= (0x40 >> (hash & 7));
h = h->next;
}
}
```
在 `bloom_add` 中,則根據 $hash \div 8 = N ... M$ ,定位到 table 中的第 N+1 個 Bytes,並把第 M 個 bit 設為 1
```c
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 (!(bits[hash])) {
if (!(bits[hash >> 3] & (0x40 >> (hash & 7)))) {
return false;
}
h = h->next;
}
return true;
}
```
在 `bloom_test` 也是以類似於 `bloom_add` 的技巧取到特定 bit 的值
## 去除城市後面的逗號
原本程式碼中,使用 `fscanf` 讀取 cities.txt,所以會把 城市名 和 國家名 分開,這是我們想要的。 但問題是 : cities.txt 中,城市名後面多了一個逗號,這就不是我們想要的。
解決辦法:如果 `rmcrlf` 函式,另外在創建一個函式針對 `fscanf` 後的字串做逗號處理:
```c
static void rmcomma(char *s)
{
sscanf(s, "%[^,]", s);
}
```
這是 [scanf format](https://yodalee.blogspot.com/2012/01/scanf.html) 的規則,意思是「從 s(前者) 中不斷讀取直到遇到逗號(或空格和\n),並把結果輸出到 s(後者)」
然後把這個函式加到原本的 `fscanf` 後面做處理:
```c
while ((rtn = fscanf(fp, "%s", word)) != EOF) {
rmcomma(word);
if (!tst_ins_del(&root, word, INS, CPY)) {
...
}
...
}
```
>根據 [kaeteyaruyo的共筆](https://hackmd.io/@kaeteyaruyo/2019q3_dict#dict-%E7%A8%8B%E5%BC%8F%E7%A2%BC%E6%AA%A2%E6%9F%A5%E4%BA%8B%E9%A0%85) 得知,cities.txt 裡面紀錄的資料不是只有 `城市名, 國家` 一種 pattern,也有少數例外,如 `建築物名稱, 城鎮名, 城市名, 國名`。 所以程式碼還有改進空間。
## Memory leaks 的偵測與校正
使用 `valgrind --leak-check=full ./test_ref --bench` 檢查:
```
==19155== HEAP SUMMARY:
==19155== in use at exit: 512,633,248 bytes in 6 blocks
==19155== total heap usage: 502,763 allocs, 502,757 frees, 528,736,216 bytes allocated
==19155==
==19155== 8,192 bytes in 1 blocks are definitely lost in loss record 3 of 6
==19155== at 0x4C2FB0F: malloc (in /usr/lib/valgrind/vgpreload_memcheck-amd64-linux.so)
==19155== by 0x108E83: bench_test (bench.c:46)
==19155== by 0x109290: main (test_ref.c:96)
==19155==
==19155== 625,056 (24 direct, 625,032 indirect) bytes in 1 blocks are definitely lost in loss record 5 of 6
==19155== at 0x4C31B25: calloc (in /usr/lib/valgrind/vgpreload_memcheck-amd64-linux.so)
==19155== by 0x10AB43: bloom_create (bloom.c:45)
==19155== by 0x109124: main (test_ref.c:75)
==19155==
==19155== 512,000,000 bytes in 1 blocks are possibly lost in loss record 6 of 6
==19155== at 0x4C2FB0F: malloc (in /usr/lib/valgrind/vgpreload_memcheck-amd64-linux.so)
==19155== by 0x10904D: main (test_ref.c:51)
==19155==
==19155== LEAK SUMMARY:
==19155== definitely lost: 8,216 bytes in 2 blocks
==19155== indirectly lost: 625,032 bytes in 3 blocks
==19155== possibly lost: 512,000,000 bytes in 1 blocks
==19155== still reachable: 0 bytes in 0 blocks
==19155== suppressed: 0 bytes in 0 blocks
```
1. bench.c:46
```c
sgl = (char **) malloc(sizeof(char *) * max); // <-- not freed
while (fscanf(dict, "%s", word) != EOF) {
if (strlen(word) < sizeof(prefix) - 1)
continue;
strncpy(prefix, word, sizeof(prefix) - 1);
t1 = tvgetf();
tst_search_prefix(root, prefix, sgl, &sidx, max);
t2 = tvgetf();
fprintf(fp, "%d %f msec\n", idx, (t2 - t1) * 1000);
idx++;
}
fclose(fp);
fclose(dict);
return 0;
```
可以看到,原因是`sgl` 沒有被釋放掉。 這邊只要多加一行 `free(sgl)` 就解決了。
2. bloom.c:45
```c
bloom_t bloom_create(size_t size)
{
bloom_t res = calloc(1, sizeof(struct bloom_filter));
res->size = size;
// res->bits = malloc(size);
res->bits = calloc((size + 7) >> 3, 1);
bloom_add_hash(res, djb2);
bloom_add_hash(res, jenkins);
return res;
}
void bloom_free(bloom_t filter)
{
if (filter) {
while (filter->func) {
struct bloom_hash *h = filter->func;
filter->func = h->next;
free(h);
}
free(filter->bits);
free(filter);
}
}
```
這邊的問題出在 `bloom_create()` 的 `res` 沒有被釋放。但奇怪的是:雖然 `bloom_create()` 沒釋放,但我明明有在 `bloom_free()` 釋放掉。 怎麼就報錯了?
後來想到可能是 `bloom_free()` 根本沒被呼叫,所以去找了一下 `bloom_free()` 到底在哪裡被呼叫到:
* test_ref.c
```c
int main(int argc, char **argv)
{
....
if (argc == 2 && strcmp(argv[1], "--bench") == 0) {
int stat = bench_test(root, BENCH_TEST_FILE, LMAX);
tst_free(root);
return stat;
}
...
quit:
tst_free(root);
bloom_free(bloom);
return 0;
}
```
可以看到,`bloom_free()` 唯一被呼叫到的只有在 `quit` 標籤下。 但如果以 `test_ref --bench` 執行時根本不會跑到那邊去。
改進 :
```c
int main(int argc, char **argv)
{
....
if (argc == 2 && strcmp(argv[1], "--bench") == 0) {
int stat = bench_test(root, BENCH_TEST_FILE, LMAX);
tst_free(root);
bloom_free(bloom);
return stat;
}
....
}
```
3. test_ref.c:51
```c
int main(int argc, char **argv)
{
char *pool = (char *) malloc(poolsize * sizeof(char));
char *Top = pool;
}
```
這邊可以很顯然看到,`pool`沒有被釋放過。
解決辦法:
```c
int main(int argc, char **argv)
{
....
if (argc == 2 && strcmp(argv[1], "--bench") == 0) {
int stat = bench_test(root, BENCH_TEST_FILE, LMAX);
tst_free(root);
bloom_free(bloom);
free(pool); // free pool here for it won't goto "quit"
return stat;
}
....
quit:
tst_free(root);
bloom_free(bloom);
free(pool); // free pool at the end
return 0;
}
```
因為 `test_cpy` 的程式碼結構與 `test_ref` 幾乎一樣,所以解決辦法也大同小異,在此就不贅述了。
## 使用 perf 分析程式效能
在 Makefile 新增 `perf-search` 標的 :
```
perf-search: $(TESTS)
@for test in $(TESTS); do\
echo 3 | sudo tee /proc/sys/vm/drop_caches; \
perf stat \
-e cache-misses,cache-references,instructions,cycles \
./$$test --bench; \
done
```
執行 `make perf-search`
```
Performance counter stats for './test_cpy --bench':
1,084,007,920 cache-misses # 36.204 % of all cache refs
2,994,198,462 cache-references
51,159,308,833 instructions # 1.04 insn per cycle
49,093,968,870 cycles
11.456455024 seconds time elapsed
Performance counter stats for './test_ref --bench':
1,175,720,371 cache-misses # 35.819 % of all cache refs
3,282,393,446 cache-references
51,117,407,691 instructions # 0.95 insn per cycle
54,010,192,527 cycles
12.812377055 seconds time elapsed
```
可以看到,在進行字串搜尋、自動補完時,test_ref 的 cache-misses 比 test_cpy 多
閱讀程式碼,不難發現 test_cpy 和 test_ref 兩個版本僅有的差異在於 : `p->eqkid` 指向的字串地址的來源不同。
如果說由此會產生 cache misses 上的差異,代表這跟字串的 dereference 脫不了關係,因此可以推斷 cache misses 主要應是發生在 `tst_suggest()`,畢竟只有在此 function 中會需要提取 TST leaf 存放的字串。
為求證明,將 `tst_suggest()` 中有關字串 dereference 部份暫時註解 :
```c
void tst_suggest(const tst_node *p,
const char c,
const size_t nchr,
char **a,
int *n,
const int max)
{
if (!p || *n >= max)
return;
tst_suggest(p->lokid, c, nchr, a, n, max);
if (p->key)
tst_suggest(p->eqkid, c, nchr, a, n, max);
else if (/**(((char *) p->eqkid) + nchr - 1) == c &&*/ *n < max){ // <-- here
a[(*n)++] = (char *) p->eqkid;
}
tst_suggest(p->hikid, c, nchr, a, n, max);
}
```
重新測量 cache misses:
```
Performance counter stats for './test_cpy --bench':
767,307,410 cache-misses # 28.908 % of all cache refs
2,654,274,239 cache-references
47,559,423,073 instructions # 1.17 insn per cycle
40,555,871,794 cycles
9.277660146 seconds time elapsed
Performance counter stats for './test_ref --bench':
689,772,296 cache-misses # 25.824 % of all cache refs
2,671,073,305 cache-references
47,539,126,772 instructions # 1.20 insn per cycle
39,667,629,399 cycles
9.233345834 seconds time elapsed
```
實驗證明 test_ref 的 cache misses 甚至降到比 test_cpy 低了。
接下來,進一步從組合語言層面觀察 test_cpy 和 test_ref 的 cache misses 到底都是分佈在哪些指令上 :
* test_ref :
2 處熱點
![](https://i.imgur.com/hcqWXK5.png)
![](https://i.imgur.com/dIOo1yv.png)
* test_cpy
1 處熱點
![](https://i.imgur.com/YrnZfLz.png)
![](https://i.imgur.com/ct99yNK.png)
可以看到,在 test_ref 中有 2 個 cache misses 熱點,分別是 `mov -0x10(%rbp),%r8d` 和 `cmp %al,-0xc(%rbp)`。
但因為 CPU 會合併特定組合的指令一起執行,所以 [有時 perf 的測量會不準](https://stackoverflow.com/questions/43794510/linux-perf-reporting-cache-misses-for-unexpected-instruction),在此例,實際上造成 cache misses 的是他們的上一道指令,即 `mov 0x8(%rax),%rax` 和 `movzbl (%rax) %eax`
先看 `mov 0x8(%rax),%rax` : 它對應到的 C 語言為 `p->lokid`。 其中,`(%rax)` 對應到 `*p`, `0x8` 的則是 `lokid` 的 offset,因為 `p->lokid` == `*(p+8)`。
而從 C 程式上下文可以知道這裡是 tst_suggest() 中第一次對 p 做 dereference,所以原本 cache 中沒有存放其值也無可厚非。 但由於現代 cache 都有資料預取的機制,當我們第一次取得 `0x8(%rax)`,電腦就會預測、並將附近的資料事先搬入 cache。所以可以看到後續執行 `0xN(%rax)` 所造成的 cache misses 有顯著降低。
所以,真正造成兩版本 cache misses 差距的是 test_ref 的 `movzbl (%rax) %eax`
從組語上下文可知,`movzbl (%rax) %eax` 對應的 C 程式是 `*(((char *) p->eqkid) + nchr - 1)` ,即「 提取 TST 中字串 」的指令。 這行指令在 test_ref 上跑出了全程式第二高的 cache misses,在 test_cpy 上則沒有特別容易 cache misses 的跡象,怎麼會這樣? 這就關係到上述 cache 的資料預取機制。
先由程式打印出字串地址跟 p 之間的距離:
```c
void tst_suggest( ... )
{
...
if (p->key)
...
else if (*(((char *) p->eqkid) + nchr - 1) == c && *n < max){
printf("%ld\n", (intptr_t)(void*)(((char *)p->eqkid) + nchr - 1) - (intptr_t)(void*)&p->eqkid);
...
}
...
}
```
執行結果 :
* test_ref
```
45899055606528
45899061029716
45899051142739
45899059059294
...
```
* test_cpy
```
34
34
34
34
...
```
在 test_cpy 中,leaf `&p->lokid` 與 字串 `p->eqkid` 的地址很接近,所以當前面做完 `mov 0x10(%rax),%rax` ,即 `p->eqkid` 時,電腦就很有可能會把在字串也一起載入 cache 中。
在 CPY 機制中,建立 leaf 後會間接呼叫 malloc 配置新空間給字串。 而在此之前最後一次 malloc 是發生在配置 leaf 空間時,因此兩者在記憶體內部的位置很有可能會是相鄰的 :
```c
void *tst_ins_del(tst_node **root, const char *s, const int del, const int cpy)
{
...
for (;;) {
if (!(*pcurr = calloc(1, sizeof **pcurr))) { // tst node allocation
...
}
...
if (*p++ == 0) {
if (cpy) { // CPY
const char *eqdata = strdup(s); // string allocation
if (!eqdata)
return NULL;
curr->eqkid = (tst_node *) eqdata;
return (void *) eqdata;
} else { // REF
curr->eqkid = (tst_node *) s;
return (void *) s;
}
}
...
}
}
```
而 REF 機制則是在進入 `tst_ins_del()` 建立 TST 前就把字串的空間配置出來了,所以兩者地址差距會很大,也因此 cache misses 機率高。
## 參考資料
1. [colinyoyo26 的共筆](https://hackmd.io/@colinyoyo26/2019q3dict)
2. [kaeteyaruyo 的共筆](https://hackmd.io/@kaeteyaruyo/2019q3_dict)
3. [Makefile 語法和示範](https://hackmd.io/@sysprog/SySTMXPvl?type=view)