# 2019q3 Homework5 (dict)
contributed by < `colinyoyo26` >
## Trie & Ternary tree
Trie 能夠實現 prefix search 但會有大量空指標
- 每個 node 需要 $|alphabet set|$ 個指標
而 ternary tree 可以解決這個問題,其中每個 node 只需要三個指標, 在 [dict](https://github.com/sysprog21/dict) 實作中的 tst.c 可以看到資料結構如下
```cpp
typedef struct tst_node {
char key; /* char key for node (null for node with string) */
unsigned refcnt; /* refcnt tracks occurrence of word (for delete) */
struct tst_node *lokid; /* ternary low child pointer */
struct tst_node *eqkid; /* ternary equal child pointer */
struct tst_node *hikid; /* ternary high child pointer */
} tst_node;
```
假設目前搜尋到的 prefix = x 被搜尋的 node key = cur 則
- if x == cur
- x = next prefix
- node = node->eqkid
- 繼續比對下個 prefix
- if x > cur
- node = node->hikid
- 往右搜尋
- if x < cur
- node = node->lokid
- 往左搜尋
而其中 `key` 為 0 時代表 `struct tst_node *eqkid` 不是指向下個 node 而是該路徑所代表的字串
## CPY & REF 機制差異
先看到 `test_cpy.c` 和 `test_ref.c` 以及 `tst.c` 的一小段程式碼
#### tst.c (in `tst_ins_del` function)
```cpp
/* Place nodes until end of the string, at end of stign allocate
* space for data, copy data as final eqkid, and return.
*/
if (*p++ == 0) {
if (cpy) { /* allocate storage for 's' */
const char *eqdata = strdup(s);
if (!eqdata)
return NULL;
curr->eqkid = (tst_node *) eqdata;
return (void *) eqdata;
} else { /* save pointer to 's' (allocated elsewhere) */
curr->eqkid = (tst_node *) s;
return (void *) s;
}
}
```
從這裡可以看出 cpy 機制在插入時會 malloc 而 ref 不會,因為在 main function 已經 malloc 過了
- Linux Programmer's Manual
- The strdup() function returns a pointer to a new string which is a duplicate of the string s. Memory for the new string is obtained with malloc(3), and can be freed with free(3).
#### test_cpy.c
```cpp
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++;
}
```
cpy 機制中,每次插入新的字串才會在 `tst_ins_del` function 內 malloc 一個小 chunck 給該字串
#### test_ref.c
```cpp
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;
}
```
從這段程式碼可以看出, ref 機制一開始就在 main function malloc 很大的 chunk 然後每次加入新的字串都會增加指標位置避免覆蓋,所以在插入 ternary tree 時不用在 malloc
## Bloom filter 空間浪費改善
原本整個 table 都把 byte 當 bit 來用,浪費 7/8 的 table 空間
```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_create
```cpp
bloom_t bloom_create(size_t size)
{
bloom_t res = calloc(1, sizeof(struct bloom_filter));
res->size = size;
//res->bits = malloc(size + 7);
res->bits = calloc((size + 7) >> 3, 1);
bloom_add_hash(res, djb2);
bloom_add_hash(res, jenkins);
return res;
}
```
為了改善空間浪費,在 `bloom_create` 內做以上更改
- 因為從 byte-level 改成 bit-level 所以 res->bits 只需要 malloc 1/8 的空間
- 其中 `res->bits = calloc((size + 7) >> 3, 1)` 先對齊 1-byte (round up) 再 shift 3 bits
- 這邊不用 `(size + 7) & -8` 因為會 shift 掉
- `res->bits = calloc((size + 7) >> 3, 1)` 改成 `calloc` 因為要確保原本 memory content 為 0
#### bloom_add
```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;
bits[hash >> 3] |= 0x40 >> (hash & 7);
h = h->next;
}
}
```
#### bloom_test
```cpp
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 >> 3] & (0x40 >> (hash & 7)))) {
return false;
}
h = h->next;
}
return true;
}
```
在以上兩個 function 內,因為 table 的儲存已經改成 bit-level 所以
- `bits[hash >> 3]` 先找到存放位置在第幾個 byte
- `0x40 >> (hash & 7)` 再找到要放在第幾個 bit
- 在 little-endian 往右 shift 會往低位址移動,但是不影響結果
- 這裡也可以寫成 `1 << (hash & 7)` 只是前者邏輯上比較直觀
## 引入 test-common.c 統合 test_cpy.c 和 test_ref.c
#### test_common.c
從 `test_ref.c` 做些微更動
- `REF` 改成用 `int` 宣告才能動態改變他的值
```cpp
int REF = INS;
```
- 並在一開始加入以下判斷,要求在使用時輸入 `CPY` 或 `REF` 判斷使用機制
```cpp
int CPYmask = -1;
if (argc < 2) {
printf("too less argument\n");
return 1;
}
if (!strcmp(argv[1], "CPY") || (argc > 2 && !strcmp(argv[2], "CPY"))) {
CPYmask = 0;
REF = DEL;
printf("CPY mechanism\n");
} else
printf("REF mechanism\n");
......
if(CPYmask){
/* memory pool */
pool = (char *) malloc(poolsize * sizeof(char));
Top = pool;
}
......
/* 更改 argc */
if (argc > 2 && strcmp(argv[1], "--bench") == 0)
```
CPYmask 的用途在這,為了最小化影響執行時期效能所以用 bitwise 運算判斷是否增加 `Top` 位址
```cpp
Top += (strlen(Top) + 1) & CPYmask;
```
原本想說要從編譯時期選擇機制,但以下的地方實在不知道怎麼在 Makefile 只做小幅度更動就可以插入 -D 選擇機制,所以改用以上方法只要把 `test_cpy` 和 `test_ref` 的地方改成 `test_common` 並在 `make test` 的地方加入引數就好
```cpp
%.o: %.c
$(VECHO) " CC\t$@\n"
$(Q)$(CC) -o $@ $(CFLAGS) -c -MMD -MF .$@.d $<
```
## 紀錄多餘逗號修正
`fscanf` 會在空白還有換行停下來,所以原本城市名後的逗號也被紀錄進去了,造成在搜尋時需要加入逗號才能搜尋的到,所以以下對讀檔迴圈進行修正
```cpp
char buf[WORDMAX];
while (fgets(buf, WORDMAX, fp)) {
int offset = 0;
for (int i = 0, j = 0; buf[i + offset]; i++) {
Top[i] = (buf[i + j] == ',' || buf[i + j] == '\n') ? '\0' : buf[i + j];
j += (buf[i + j] == ',');
}
while (*Top) {
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++;
int len = strlen(Top);
offset += len + 1;
Top += len + 1;
}
Top -= offset & ~CPYmask;
memset(Top, '\0', WORDMAX);
}
```
上次沒注意到 CPY 機制會出現無窮迴圈,重新修正
- 用 `fgets` 一次讀取一行
- 然後把分隔符號 `,` 和 `\n` 都換成 `\0`
- 然後下面 `while (*Top)` 把字串分別存入
- `memset(Top, '\0', WORDMAX)` 避免 memory content 不為零而進入無窮迴圈
- `offset` 為 CPY 機制紀錄 Top 位移了多少,並在跳出迴圈後減回去
- 第一個 `for` 迴圈中的 `j` 是避免寫入 `,` 後面的空格
## Perf 分析 CPY & REF 機制效能
重現 [隱藏在字典裡的數學](https://hackmd.io/@sysprog/BkE3uSvdN) 實驗
```
$ echo 3 | sudo tee /proc/sys/vm/drop_caches && sudo perf stat -e cache-misses,cache-references,instructions,cycles ./test_common --bench CPY
3
CPY mechanism
ternary_tree, loaded 259112 words in 0.169224 sec
Performance counter stats for './test_common --bench CPY':
1,743,066,134 cache-misses # 69.060 % of all cache refs
2,524,002,636 cache-references
42,059,333,849 instructions # 0.96 insn per cycle
43,804,369,382 cycles
16.446560243 seconds time elapsed
```
```
$ echo 3 | sudo tee /proc/sys/vm/drop_caches && sudo perf stat -e cache-misses,cache-references,instructions,cycles ./test_common --bench REF
3
REF mechanism
ternary_tree, loaded 259112 words in 0.163192 sec
Performance counter stats for './test_common --bench REF':
1,921,314,348 cache-misses # 70.353 % of all cache refs
2,730,957,226 cache-references
42,059,741,056 instructions # 0.85 insn per cycle
49,411,281,282 cycles
22.104687647 seconds time elapsed
```
可以看出 REF 明顯有較多的 `cache-misses` 兩者的差異在於 ternary tree 中的 node `eqkid` 指向的字串位址不同,但為什麼會造成 `cache-misses` 差異這邊還看不出來
### perf record 找 cache-misses 熱點
```
$sudo perf record -e cache-misses ./test_common --bench REF && perf report
```
![](https://i.imgur.com/X8Uby8F.png)
```
$sudo perf record -e cache-misses ./test_common --bench CPY && perf report
```
![](https://i.imgur.com/iUNoUcc.png)
結果熱點都是在 `tst_suggest` function 內部,而 REF 比 CPY 多了一個指令的熱點,雖然不懂 x86_64 組語,但從 `cmp` 接續 `jne` 可以推測出是某個 `if condition`
:::warning
趕快研讀 CS:APP 第 3 章,及早熟悉 x86-64
:notes: jserv
:::
`movzbl (%rax),%eax` 從 `%rax` 放的 address 搬 1 byte 資料到 `%eax` 剩下左邊填零,是這個指令發生 cache-miss 才對
[StackOverflow](https://stackoverflow.com/questions/43794510/linux-perf-reporting-cache-misses-for-unexpected-instruction) 有討論過類似回報偏差的情形
- Many modern Intel CPUs (and AMD probably too) can and will fuse (combine) some combinations of operations together, and cmp + conditional jump very common instruction combination to be fused. Fused pair may be reported in perf/PMUs/PEBS incorrectly with skew of most events towards one of two instructions.
> 合併指令可以增加 decode bandwidth 但可能使 perf 回報錯誤 [全文](https://www.realworldtech.com/nehalem/5/)
#### 繼續針對相異處做實驗
因為 REF 會在一開始就 malloc 一大塊 memory pool 而且測試的大小會使系統呼叫 `mmap` 在 stack 和 heap 之間分配空間,而 CPY 會在 malloc 完放字串的節點後才會 malloc 空間給字串,所以節點和字串會有很大的機率分配到連續的 chunk
- 以下把節點和字串位址差印出來驗證
```cpp
else if (*(((char *) p->eqkid) + nchr - 1) == c && *n < max){
a[(*n)++] = (char *) p->eqkid;
printf("%ld\n" ,(intptr_t) p->eqkid - (intptr_t) p);
}
```
結果如下
```
./test_common --bench REF
REF mechanism
ternary_tree, loaded 259112 words in 0.204291 sec
140265660153125
140265654550218
140265650179896
140265647186476
140265651175941
......
```
```
./test_common --bench CPY
CPY mechanism
ternary_tree, loaded 259112 words in 0.175229 sec
48
48
48
48
48
......
```
可以看出 CPY 機制在 virtual address 的差異只有 48 byte 所以資料很大機率會在同一個 cache block 內
## memory leaks 的偵測及修正
先用 valgrind 觀察 heap 情況
```
$ valgrind ./test_common --bench REF
==5207== HEAP SUMMARY:
==5207== in use at exit: 512,633,248 bytes in 6 blocks
==5207== total heap usage: 422,602 allocs, 422,596 frees, 526,171,064 bytes allocated
==5207==
==5207== LEAK SUMMARY:
==5207== definitely lost: 8,216 bytes in 2 blocks
==5207== indirectly lost: 625,032 bytes in 3 blocks
==5207== possibly lost: 512,000,000 bytes in 1 blocks
==5207== still reachable: 0 bytes in 0 blocks
==5207== suppressed: 0 bytes in 0 blocks
==5207== Rerun with --leak-check=full to see details of leaked memory
```
很容易看出 REF 機制的 memory pool 沒有 free 所以在結束時把它 free 掉
```cpp
quit:
free(pool);
tst_free(root);
bloom_free(bloom);
return 0;
```
而執行 CPY 機制時有很多 block 沒有 free ,發現在 `tst_free_all` 內有 free 字串而 `tst_free` 沒有
```cpp
void tst_free_all(tst_node *p)
{
if (!p)
return;
tst_free_all(p->lokid);
if (p->key)
tst_free_all(p->eqkid);
tst_free_all(p->hikid);
if (!p->key)
free(p->eqkid);
free(p);
}
void tst_free(tst_node *p)
{
if (!p)
return;
tst_free(p->lokid);
if (p->key)
tst_free(p->eqkid);
tst_free(p->hikid);
free(p);
}
```
所以把 `test_common.c` 最後改成以下
- CPY 機制需要用 `tst_free_all(root)` 把字串也 `free` 掉
- REF 機制如果也用 `tst_free_all(root)` 會發生 segmentation fault
```cpp
quit:
free(pool);
/* for REF mechanism */
if(CPYmask)
tst_free(root);
else
tst_free_all(root);
bloom_free(bloom);
return 0;
```
改過之後 CPY 和 REF 都剩下 5 個 block 的 memory leak 用 `--bench` 測試時會出現,目前還沒找到在哪裡
```
$ valgrind ./test_common --bench CPY
==4346== HEAP SUMMARY:
==4346== in use at exit: 633,248 bytes in 5 blocks
==4346== total heap usage: 505,075 allocs, 505,070 frees, 14,946,207 bytes allocated
==4346==
==4346== LEAK SUMMARY:
==4346== definitely lost: 8,216 bytes in 2 blocks
==4346== indirectly lost: 625,032 bytes in 3 blocks
==4346== possibly lost: 0 bytes in 0 blocks
==4346== still reachable: 0 bytes in 0 blocks
==4346== suppressed: 0 bytes in 0 blocks
```
## 參考資料
[隱藏在字典裡的數學](https://hackmd.io/@sysprog/BkE3uSvdN)
[抓漏 - Gdb 和 Valgrind 合體技](http://wen00072.github.io/blog/2014/11/29/catch-the-leak-gdb-and-valgrind-siamese-juggling/)