# 2020q3 Homework3 (dict)
contributed by < `JimmyLiu530` >
###### tags: `進階電腦系統理論與實作`
[詳細作業解說](https://hackmd.io/@sysprog/2020-dict#I04-dict)
## 程式原理
### CPY 與 REF 機制
在 `test_common.c` 程式碼中,針對字串地址的特性有兩種不同的機制:
> - copy 機制 (`CPY`): 傳入 `tst_ins_del` 的字串地址所表示的值**隨時會被覆寫**,因此 `eqkid` 需要 `malloc` 新空間將其 copy 存起來;
> - reference 機制 (`REF`): 傳進來的字串地址所表示的值**一直屬於該字串**,因此可將其當作 reference 直接存起來,不用 `malloc` 新空間。
```c=
if (!strcmp(argv[1], "CPY") || (argc > 2 && !strcmp(argv[2], "CPY"))) {
CPYmask = 0;
REF = DEL;
printf("CPY mechanism\n");
} else
printf("REF mechanism\n");
```
而在下面這兩段程式碼完全體現出這兩種機制的差異:
#### Memory pool
```c=
if (CPYmask) {
/* memory pool */
pool = (char *) malloc(poolsize * sizeof(char));
Top = pool;
}
```
只有 REF 機制才會配置 [memory pool](https://en.wikipedia.org/wiki/Memory_pool)
#### 對於儲存字串空間的差別
```c=
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);
}
```
從 cities.txt 中逐行讀取,並以 ',' 及 '\n' 分割出名稱。
接著在 `tst_ins_del` 函式中若是 `CPY` 機制,會去呼叫 strdup 而這個 strdup 會再去呼叫 malloc,因此符合上述所說的**需要新空間儲存**。也因為已配置新的空間給字串儲存了,因此需要將 `Top -= offset` 回到儲存前的位置;
若是 `REF` 機制,不會配置新的空間,字串直接儲存在 memory pool 中,因此 Top 的位置不需要改變。
### tst_ins_del (tst_node **root, const char *s, const int del, const int cpy)
主要處理字串插入及刪除 ternary search tree 的函式。分三個部份來看:
#### 變數意義及條件
```c=
int diff;
const char *p = s;
tst_stack stk = {.data = {NULL}, .idx = 0};
tst_node *curr, **pcurr;
if (!root || !s)
return NULL;
if (strlen(s) + 1 > STKMAX / 2)
return NULL;
```
- `stk`會紀錄是如何走訪的,以便在 `tst_del_word` 函式中能夠照順序處理
- TST 為空 或是 字串為空,則 return
- 當字串長度(含'\0')超過 `STKMAX / 2`,也直接 return
#### 字串已在 TST
```c=
pcurr = root;
while ((curr = *pcurr)) {
diff = *p - curr->key;
if (diff == 0) {
if (*p++ == 0) {
if (del) {
(curr->refcnt)--;
return tst_del_word(root, curr, &stk, cpy);
} else
curr->refcnt++;
return (void *) curr->eqkid; /
}
pcurr = &(curr->eqkid);
} else if (diff < 0) { /
pcurr = &(curr->lokid);
} else {
pcurr = &(curr->hikid);
}
if (del)
tst_stack_push(&stk, curr);
}
```
首先,會去比較字串 p 及 TST 節點的字元大小,因此又會分成三種可能
- 相等: 若 `if (*p++ == 0)`,代表已走到字串 p 的 '\0' 了(即此字串已在 TST 中),所以
- 若要刪除字串,則將 `(curr->refcnt)--` 並呼叫 `tst_del_word` 函式去處理;
- 若要插入字串,則將`curr->refcnt++` 並回傳 `curr->eqkid`。
否則,往下一個 eqkid 繼續走
- 小於: 往下一個 lowkid 繼續走
- 大於: 往下一個 hikid 繼續走
#### 字串不完全在 TST
```c=
for (;;) {
if (!(*pcurr = calloc(1, sizeof **pcurr))) {
fprintf(stderr, "error: tst_insert(), memory exhausted.\n");
return NULL;
}
curr = *pcurr;
curr->key = *p;
curr->refcnt = 1;
curr->lokid = curr->hikid = curr->eqkid = NULL;
if (!*root) /
*root = *pcurr;
if (*p++ == 0) {
if (cpy) {
const char *eqdata = strdup(s);
if (!eqdata)
return NULL;
curr->eqkid = (tst_node *) eqdata;
return (void *) eqdata;
} else {
curr->eqkid = (tst_node *) s;
return (void *) s;
}
}
pcurr = &(curr->eqkid);
}
```
將剩餘未在 TST 的字元加入到 TST。
- 配置一個空間給剩餘的字元 `*pcurr = calloc(1, sizeof **pcurr` 並初始化
- 持續加入字元到 TST 直到字串結尾,分成 CPY 或 REF 機制
- CPY: 複製字元到一個新配置空間,並將新空間的位置 assign 給 `curr->eqkid`
- REF: 直接將此字元的位置 assign 給 `curr->eqkid`
----------------------------------------
## 視覺化 ternary search tree + bloom filter 的效能表現並分析
### [gnuplot](https://hackmd.io/@sysprog/Skwp-alOg?type=view#gnuplot-script)
將畫圖的設定寫入 gnuplot script `CPYplot.gp`
```gnuplot=
reset
set xlabel 'experiment'
set ylabel 'time(msec)'
set title 'bench for prefix search without bloom filter'
set term png enhanced font 'Verdana,10'
set output 'CPYbench.png'
set format x "%8.0f"
set xtic 50000
set xtics rotate by 90 right
plot [:250000][:1.0]'bench_ref.txt' using 1:2 with points title 'CPY'
```
接著直接在 terminal 上輸入
```shell=
$ gnuplot CPYplot.gp
```
或 若將 gnuplot script 放在 scripts 中
```shell=
$ gnuplot scripts/CPYplot.gp
```
就會產生對應的圖
### 效能分析
為了方便進行實驗,在原始程式碼中額外寫一段程式類似如下:
```c=
if (argc == 3 && strcmp(argv[1], "--bench") == 0) {
...
else if (argc == 3 && strcmp(argv[1], "--perf") == 0) {
/* 測試用的程式 */
}
```
因此,只須透過 `./test_common --perf CPY`的命令,就可以根據撰寫的程式設計對應的實驗並計算運行的時間。
#### 實驗1: 以 `cities.txt` 中城市、國家作為搜尋字串
![](https://i.imgur.com/RRJYX04.png)
| | 0.01-0.02 msec | 0.02up msec |
| ------------ | ------------ | ------------|
| no bloom CPY | 1 | 0 |
| no bloom REF | 1 | 0 |
| bloom CPY | 7 | 0 |
| blomm REF | 14 | 1 |
> 此為不同機制分別達到 0.01-0.02 msec 及 0.02 msec以上的數量
不管是有沒有使用 `bloom filter` 或是採取不同的 `CPY` 還是 `REF` 機制,大多數的 `tst_search` 所花的時間幾乎趨近於 0 msec,只有少部份的花到 0.01 msec 或是以上。雖然如此還是可以看出使用 bloom filter 後,導致速度變慢。這是因為這實驗是以 `cities.txt` 中的城市跟國家去做搜尋的,當初也是用這些去建 TST,所以一定可以在 TST 中搜尋的到,也就說 bloom filter 在此實驗毫無作用,只會增加整體的運算時間而已。為了體現出 bloom filter 的優點,於是設計了實驗2
#### 實驗2: 搜尋字串與 `cities.txt` 中的字串僅最後一個字元不同
如同實驗1所說, bloom filter 的功用在於以極低的錯誤率提早測得該字串未在 TST 中,避免後續的搜尋。而實驗2就是實驗1的另一個極端 - 欲搜尋的字串都不在 TST 中,得到以下的效能數據:
![](https://i.imgur.com/WqLtwi6.png)
| | 0.01-0.02 msec | 0.02up msec |
| ------------ | ------------ | ------------|
| no bloom CPY | 22 | 0 |
| no bloom REF | 6 | 1 |
| bloom CPY | 0 | 0 |
| blomm REF | 1 | 0 |
> 此為不同機制分別達到 0.01-0.02 msec 及 0.02 msec以上的數量
發現此實驗 bloom filter 發揮他的作用,提早發現字串並未在 TST 中,減少執行的時間。而未使用 bloom filter 則會呼叫 `tst_search` 直到最後一個字元才發現不符合。
#### 結論
上面這兩種實驗都是極端的例子,實驗1完全偏向不使用 bloom filter,而實驗2則偏向使用 bloom filter,當然,現實生活中鮮少有這麼極端的應用,但說穿了也只是搜尋的字串存不存在 TST 中的比例多寡而已。不過我認為在現實生活的應用中,bloom filter 的確對於減少執行時間是有幫助的,畢竟不太可能將所有可能會被搜尋的字串預先 insert 到 TST 中。
-------------------------------------------------
## 以 perf 分析 TST 程式碼的執行時期行為並且充分解讀
### [perf](https://zh-blog.logan.tw/2019/07/10/analyze-program-performance-with-perf-events/) 介紹
- Linux 核心內建的系統效能分析工具,主要分為三個部分:
- 在 User Space 的 `perf` 指令
- 在 Kernel Space 負責處理和儲存事件記錄的機制
- 在 Kernel Space 的事件來源:硬體效能計數器、中斷、Page Fault、系統呼叫、KProbe、UProbe、FTrace、Trace Point 等等。
- 硬體效能計數器(PMU):
- 一般的手機或桌上型電腦 CPU 內建的 PMU 通常是若干個[效能計數器(Performance Counter)](https://en.wikipedia.org/wiki/Hardware_performance_counter)取樣指定事件
- 準備工作
```shell=
$ sudo apt-get install linux-tools-generic linux-tools-$(uname -r)
```
:::info
**NOTE:**
`perf` 指令會隨著 Linux 核心版本而有所變動,所以上面的指令同時安裝了 `linux-tools-generic` 與 `linux-tools-$(uname -r)`,其中的 $(uname -r) 會被代換為使用中的 Linux 核心版本。如果你更新了 Ubuntu 的 Linux 核心,需**重新執行**以上指令。
:::
- 常見指令
- `perf list`
- 列出 Linux 核心與硬體支援的事件種類
- `perf stat [-e [events]] [cmd]`
- 執行指定程式。執行結束後,印出各事件的總體統計數據
- 選項 `-e` 用來指定要測量的事件種類。各事件種類以 , 分隔
- `perf record [-e [events]] [-g] [cmd]`
- 執行指定程式。執行過程中,取樣並記錄各事件。這個指令能讓我們能大略知道事件的發生地點。
- 選項 `-e` 用來指定要測量的事件種類。各事件種類以 , 分隔
- 選項 `-g` 讓 perf record 在記錄事件時,同時記錄取樣點的 Stack Trace。
- 選項 `-o` 用來指定一個檔案名稱作為事件記錄儲存位置。預設值為 `perf.data`。
- `perf report [-g graph,0.5,caller]`
- 讀取 `perf record` 的記錄
- 選項 `-g` 用來指定樹狀圖的繪製方式。 `graph,0.5,caller` 代表以呼叫者(Caller)作為父節點,以被呼叫者(Callee)作為子節點。
- 選項 `-i` 用來指定一個檔案名稱作為事件記錄讀取來源。預 設值為 perf.data
:::info
備註:若測量對象沒有安全疑慮,以上所有指令都建議使用 `sudo` 執行
:::
### 分析 TST 程式碼
CPY 機制:
```shell=
$ sudo perf stat --repeat 50 -e cache-misses,cache-references ./test_common --bench CPY
Performance counter stats for './test_common --bench CPY' (50 runs):
1,112,451,175 cache-misses # 39.550 % of all cache refs
( +- 0.86% )
2,812,780,115 cache-references
( +- 0.07% )
11.7365 +- 0.0685 seconds time elapsed ( +- 0.58% )
```
REF 機制:
```shell=
$ sudo perf stat --repeat 50 -e cache-misses,cache-references ./test_common --bench REF
Performance counter stats for './test_common --bench REF' (50 runs):
1,049,437,015 cache-misses # 34.055 % of all cache refs
( +- 2.85% )
3,081,589,827 cache-references
( +- 0.19% )
12.059 +- 0.193 seconds time elapsed ( +- 1.60% )
```
發現兩種機制 cache miss 比例反而 `CRY (39.55%)` 較 `REF (34.055%)` 多,這與作業的講解有些出入,作業講解是說:
> `CPY` 機制在 insert 時,建立 leaf 後會間接呼叫 `malloc` 來保存字串,在這之前的最後一次 `malloc` 是爲了配置該 leaf 的空間。一直 `malloc` 時,heap 也會形同 stack 一般地增長,所以當給定測試條件很規律的狀況下,字串地址與 leaf 的地址很可能都很靠近。
>
> 換言之,CPY 機制在一開始 `p->eqkid` 後,字串有很大可能就在 cache 裡面了,因此 cache miss 機率較低,這就是它相對來說在 prefix-search 上效率比較好的原因。
但整體花費的時間卻是 `CPY (約11.7365sec)`少於 `REF (約12.059sec)`。再者,在我連續觀察兩者的 cache miss 比例時,曾有幾次的 cache miss 比例是由 `CPY` 勝出,不過真的佔少數,綜合上述幾個觀點後我認為有可能是因為 `REF` 機制的 cache miss 比例變動幅度比 `CPY` 來得大,所以造成數次比較中由`CPY` 勝出
但為了釐清 `CPY` 與 `REF` 的 cache miss 比例到底是誰多,因此用 `perf record` 做更進一步函式級別的效能分析,方便我們對程序「熱點」做更精細的分析和優化。
CPY 機制:
```shell=
$ sudo perf record -e cache-misses -g ./test_common --bench CPY
Samples: 37K of event 'cache-misses', Event count (approx.): 774136217
Children Self Command Shared Object Symbol
+ 99.50% 0.00% test_common libc-2.31.so [.] __libc_start_main
+ 99.50% 0.00% test_common test_common [.] main
+ 99.22% 0.02% test_common test_common [.] bench_test
+ 99.14% 0.26% test_common test_common [.] tst_search_prefix
+ 98.88% 98.79% test_common test_common [.] tst_suggest
```
```shell=
| tst_suggest(p->lokid, c, nchr, a, n, max);
0.64 │ movsbl -0xc(%rbp),%esi
0.14 │ mov -0x8(%rbp),%rax
0.23 │ mov 0x8(%rax),%rax
37.96 │ mov -0x10(%rbp),%r8d
0.23 │ mov -0x28(%rbp),%rdi
0.26 │ mov -0x20(%rbp),%rcx
0.05 │ mov -0x18(%rbp),%rdx
0.95 │ mov %r8d,%r9d
1.60 │ mov %rdi,%r8
0.11 │ mov %rax,%rdi
0.04 │ → callq tst_suggest
```
REF 機制:
```shell=
$ sudo perf record -e cache-misses -g ./test_common --bench REF
Samples: 39K of event 'cache-misses', Event count (approx.): 785801109
Children Self Command Shared Object Symbol
+ 99.49% 0.00% test_common libc-2.31.so [.] __libc_start_main
+ 99.48% 0.00% test_common test_common [.] main
+ 99.21% 0.02% test_common test_common [.] bench_test
+ 99.14% 0.24% test_common test_common [.] tst_search_prefix
+ 98.90% 98.81% test_common test_common [.] tst_suggest
```
發現無論是 CPY 或 REF 機制,`tst_suggest` 函式佔的 cache miss 高達98%,因此深入分析到底是 `tst_suggest` 中哪一行程式碼導致的
```shell=
| tst_suggest(p->lokid, c, nchr, a, n, max);
0.54 │ movsbl -0xc(%rbp),%esi
0.18 │ mov -0x8(%rbp),%rax
0.24 │ mov 0x8(%rax),%rax
32.26 │ mov -0x10(%rbp),%r8d
0.24 │ mov -0x28(%rbp),%rdi
0.20 │ mov -0x20(%rbp),%rcx
0.04 │ mov -0x18(%rbp),%rdx
0.88 │ mov %r8d,%r9d
1.44 │ mov %rdi,%r8
0.11 │ mov %rax,%rdi
0.07 │ → callq tst_suggest
```
結果在 `mov -0x10(%rbp),%r8d` 這行指令佔的比例最高
-------------------------------------------------
## 考慮到 CPY 和 REF 這兩種實作機制,思考效能落差的緣故,並且提出後續效能改善機制
在上面 [程式原理](https://hackmd.io/nfsG8OuKQtWIPcfnpfMFFA?view#%E7%A8%8B%E5%BC%8F%E5%8E%9F%E7%90%86) 有提到 `CPY` 與 `REF` 兩種實作機制的不同,
-------------------------------------------------
## 研讀 [Xor Filters: Faster and Smaller Than Bloom and Cuckoo Filters](https://arxiv.org/abs/1912.08258) 論文並參閱對應程式碼實作 [xor_singleheader](https://github.com/FastFilter/xor_singleheader),引入上述 dict 程式碼中,需要比較原有的 bloom filter 和 xor filter 在錯誤率及記憶體開銷
-------------------------------------------------
## 如何縮減頻繁 malloc 帶來的效能衝擊呢