# 2018q3 Homework3 (dict)
contributed by < `ofAlpaca` >
###### tags: `CSIE5006` `HW`
## 實驗環境
```
$ lscpu
Architecture: x86_64
CPU op-mode(s): 32-bit, 64-bit
Byte Order: Little Endian
CPU(s): 8
On-line CPU(s) list: 0-7
Thread(s) per core: 2
Core(s) per socket: 4
Socket(s): 1
NUMA node(s): 1
Vendor ID: GenuineIntel
CPU family: 6
Model: 60
Model name: Intel(R) Xeon(R) CPU E3-1231 v3 @ 3.40GHz
Stepping: 3
CPU MHz: 3548.766
CPU max MHz: 3800.0000
CPU min MHz: 800.0000
BogoMIPS: 6799.62
Virtualization: VT-x
L1d cache: 32K
L1i cache: 32K
L2 cache: 256K
L3 cache: 8192K
NUMA node0 CPU(s): 0-7
```
## Makefile 解析
* 先從變數開始看 :
* `TESTS` 指的是 repo 中的 `test_cpy.c` 與 `test_ref.c`
* `TEST_DATA` 為在執行 `test_cpy` 與 `test_ref` 時 `--bench` 的參數
* `CFLAGS` 為 gcc 的參數
```clike=
TESTS = test_cpy test_ref
TEST_DATA = s Tai
CFLAGS = -O0 -Wall -Werror -g
```
* 此處使用 `.PHONY` 來指定了 `all` 與 `clean` 為 fake 項目,因為他們並非是檔案但卻又需要執行,如此一來 make 就不會去檢查是否存這兩個檔案而直接執行。
* `all` target 需要 `$(TESTS)` 的 dependency ,也就是 `test_cpy` 與 `test_ref` 這兩個檔案。
```clike=
.PHONY: all clean
all: $(GIT_HOOKS) $(TESTS)
```
* 因為 `all` 的 `($TESTS)` dependency ,會跑到 target `test_%` 來檢查 dependency ,會發現需要四個 `.o` 檔,分別是 `test_cpy.o` 、 `test_ref.o` 、 `tst.o` 、 `bloom.o` ,接著再跑到 target `%.o` 檢查 dependency 後執行命令 (第一次 make 沒有 `.o` 檔)。
* target `%.o` 下的命令有兩道 :
* `$(VECHO) " CC\t$@\n"` 是用於印出訊息,如 `CC test_cpy.o` 。
* `$(Q)$(CC) -o $@ $(CFLAGS) -c -MMD -MF .$@.d $<` 需要一一分析 :
* `$(Q)` 變數是 `@` ,不顯示所執行的指令
* `-o $@` 意思是 output , `$@` 即是工作目標檔名,如 : `test_cpy.o`
* `-MMD -MF .$@.d` 意思是令 gcc 自動產生相依性文件 ,如 : `.test_cpy.o.d`
* `-c` 是使 compile source code
* dependency file 就是其他的 Makefile 檔
```
$ cat .test_cpy.o.d
test_cpy.o: test_cpy.c bench.c bench.h tst.h
```
* 可以透過 `include` 來引入其他 Makefile ,加 `-` 意思是是不報錯誤訊息,繼續執行。
* object 檔出來後,接著會回去執行 target `test_%` 。
* `$(Q)$(CC) $(LDFLAGS) -o $@ $^ -lm` 主要目的是連結各個 object 檔並產生執行檔, `-l` 是代表連結 static library , `-lm` 表示是連結 libm.a 這個 library ,其效果與 `/usr/lib/libm.a` 是一樣的。libm.a 是 math 的 library 。 ([source](http://www.network-theory.co.uk/docs/gccintro/gccintro_17.html))
* 最後產生 `test_cpy` 與 `test_ref` 兩個執行檔。
* `CC` 、 `LDFLAGS` 、 `CFLAGS` 、 `RM` 都是 GNU make 的 predefined variables 。 ([source](https://www.gnu.org/software/make/manual/html_node/Implicit-Variables.html))
```clike=
OBJS_LIB = \
tst.o bloom.o
OBJS := \
$(OBJS_LIB) \
test_cpy.o \
test_ref.o
deps := $(OBJS:%.o=.%.o.d)
test_%: test_%.o $(OBJS_LIB)
$(VECHO) " LD\t$@\n"
$(Q)$(CC) $(LDFLAGS) -o $@ $^ -lm
%.o: %.c
$(VECHO) " CC\t$@\n"
$(Q)$(CC) -o $@ $(CFLAGS) -c -MMD -MF .$@.d $<
-include $(deps)
```
---
## [Bloom filter](https://www.youtube.com/watch?v=bEmBh1HtYrw) TL;DR
Bloom filter 利用 hash function 不透過尋訪來**預測**字串是否存在於資料結構中。
因此時間複雜度是 $O(1)$ 而非傳統尋訪的 $O(n)$ 。
* n-bits 的 table
* k 個 hash fucntion , h~1~ 、 h~1~ ... h~k~
* 每當要加入新字串 s 時,會將 s 透過這 k 個 hash function 各自轉換為 table index (範圍 : 0~n-1) ,所以有 k 個 hash function ,就該有 k 個 index ,然後將該 table[index] 上的 bit set
* 下次若要檢查該字串 s 是否存在時,只需要再跑一次那 k 個 hash function ,並檢查是否所有的 k 個 bit 都是 set 即可
![](https://i.imgur.com/qa6qAzi.png)
但這樣做是會**存在錯誤率的**,例如原本沒有在資料結構中的字串 s1 經過 hash 轉換後得出的 bit 的位置和另一個存在於資料結構中的字串 s2 經過 hash 之後的結果相同,如此一來,原本不存在的 s1 便會被認為存在了,這就是 `false positive` 。
---
## Makefile 修改
* 透過 `scripts` 底下的 `.gp` 檔與 Makefile 可以看出應該要有哪些 output 檔,透過缺失的檔案來判斷該如何修補程式碼。
* 應該要有的 output 為 :
* `bench_cpy.txt` : 可以透過下 `$ ./test_cpy --bench` 取得。
* `cpy.txt` : 執行 `$ ./test_cpy` 時建制 ternary search tree 的時間。
* `output.txt` : 要透過編譯 `calculate.c` 並執行才會產生。
* `bench_ref.txt` 、 `ref.txt` : 都沒有辦法產生,所以需要修補。
* 基於以上問題,首先對 Makefile 進行了些許調整 :
* 新增了編譯 `calculate.c` 並產生執行檔。
* 將 target clean 最後一行命令的 `caculate` 改為 `calculate` ,這應該是錯字。
---
## Output 檔的意義
* `cpy.txt` 、 `ref.txt` : 是紀錄 `test_cpy` 與 `test_ref` 在載入 TST 時花費的時間,每次載入就會新增一筆時間,單位是**秒**。
* `bench_cpy.txt` 、 `bench_ref.txt` : 是紀錄 `bench_test` 中每次執行 `tst_search_prefix()` 函式所花費的時間。每次的 `prefix` 為 `cities.txt` 中各字串的前三字元。
* `cities.txt` 中的字串是以 whitespace 來區隔,所以 `Hisel, Germany` 視為兩個字串, `Hisel,` 與 `Germany` 。
* 檔案的單位應是**微秒**。
* `output.txt` 是 `calculate.c` 將 `cpy.txt` 與 `ref.txt` 裡的時間各別加總起來除以 100 的平均時間,也可以說是載入 TST 的平均時間,用於畫長條圖。
---
## `test_ref.c` 修改
* `test_ref.c` 無法產生 `ref.txt` 與 `bench_ref.txt` ,所以需要增加 `test_cpy.c` 中的部份程式碼。
* 但並非完全一樣,原本的 `tst_free_all()` 須改為 `test_free` 。
* 如此一來,執行 `$ ./test_ref` 即可產生 `ref.txt` , 執行 `$./test_ref --bench` 即可產生 `bench_ref.txt` 。
```clike=
if (argc == 2 && strcmp(argv[1], "--bench") == 0) {
int stat = bench_test(root, BENCH_TEST_FILE, LMAX);
tst_free(root);
return stat;
}
FILE *output;
output = fopen("ref.txt", "a");
if (output != NULL) {
fprintf(output, "%.6f\n", t2 - t1);
fclose(output);
} else
printf("open file error\n");
```
---
## `test_cpy.c` 修改
* CPY 與 REF 還有個差別是前者有 Bloome filter ,後者沒有,為了能夠比較兩實作的差異,所以也需要替 CPY 加上 Bloom filter 的功能。
* 主要的修改部份是在載入 TST 時需要新增 Bloom filter table 。
* 下 `a` 或 `f` 指令時需要先使用 `bloom_test` 來偵測字串是否存在,但這可能會有錯誤的狀況發生 (false positives)。
* [詳細修改內容](https://github.com/ofAlpaca/dict/commit/66f3475eff21af27085cfe7709dd5f030064c542#diff-28acc58b9a966cc6418269e7116fd14c)
---
## `test_cpy.c` 與 `test_ref.c` 共同修改
* `test_cpy` 和 `test_ref` 的執行參數:
* `--bench` : 可以執行 `bench_test` 並產生 `bench_cpy` 或 `bench_ref`
* `--bench [as] <WORD>` : 可以執行 `a` 新增與 `s` search prefix ,使用 `f` 、 `d` 指令會有永遠卡在該指令的 bug 。
* 修補 : 在 `f` 、 `d` 指令也新增 `--bench` 判斷與 `goto quit` 。
* `bench_test` 也有 bug ,下方會解說。
* 由於 `cities.txt` 裡是以 `,` 區隔城市名稱與國家,因為國家大多重複,且重複載入沒意義,故乾脆只取城市名稱來載入至 TST :
* 將 `fscanf()` 的 regular expression 改為 `%256[^,], %256[^\n]\n` 。
* 分別取得**城市名稱**與**國家**。
* `test_cpy.c` 、 `test_ref.c` 、 `bench.c` 都需要修改。
* 改法如下 :
```clike
fscanf(dict, "%256[^,], %256[^\n]\n", word, nation)
```
---
## `bench.c` 修改
* 在 `bench_test` 中有個嚴重的 bug ,那就是 `char prefix[3]` 長度不夠。
* `strncpy(prefix, word, 3)` 的 `prefix` 長度不夠,導致複製了 `word` 的 3 個字元過去後會沒位置放 `\0` ,造成接下來的 `tst_search_prefix` 永遠找不到字串。
* 在 `tst_search_prefix` 中印出 `prefix` 可以發現如下的現象,但是 `prefix` 的 length 不是只有 3 嗎?怎除了前三字元後面還有重複的,那是因為在 `prefix` 範圍內並沒有 `\0` 所以導致在擷取字串時和 `word` 重疊,也就是說 `Ger` 是 `prefix` 而 `Germany` 是 `word` 。
* 可以透過將 `prefix` length 改為 4 ,就能解決這問題。
* 如果沒發現這問題,可能還會覺得怎麼 `bench_test` 速度這麼快,原來是根本沒找到。
```
...
BerBergewöhrden,
GerGermany
SomSomolinos,
SpaSpain
MerMerlscheid,
GerGermany
CasCasas
GarGarcimolina,
SpaSpain
...
```
* 出問題的程式碼
```clike=
char prefix[3] = ""; // make it to 4 bytes to hold '\0'
char word[WORDMAX] = "";
...
while (fscanf(dict, "%s", word) != EOF) {
if (strlen(word) < 4)
continue;
strncpy(prefix, word, 3);
t1 = tvgetf();
tst_search_prefix(root, prefix, sgl, &sidx, max);
t2 = tvgetf();
fprintf(fp, "%d %f sec\n", idx, (t2 - t1) * 1000000);
idx++;
}
```
* 觀察了 `bench_cpy.txt` 總覺的哪裡怪怪的,上面時間動輒幾百秒,但執行時不見得有這麼久。
* 後來看到是 `fprintf(fp, "%d %f sec\n", idx, (t2 - t1) * 1000000)` 有些不對勁, `t1` 、 `t2` 是由 `tvgetf()` 函式產生的,其時間單位是**秒**,那為什麼相減後乘上 10^6^ 還是**秒**呢?
* 所以單位應該是**微秒 (microsecond)** 才是。
---
## Make plot
* 新增 `make plot` 功能,此功能是要將 TST + Bloom filter 效能視覺化。
* target `load` 與 `output.txt` 是要比較 CPY 與 REF 載入 TST 時的效能比較。
* target `bench_%.txt` 則是執行 `bench_test` 來測試 prefix search 的統計分佈。
* `make plot` 後會產生 `runtime3.png` 與 `runtime2.png` ,前者是 CPY
```
load: $(TESTS)
perf stat --repeat 100 \
-e cache-misses,cache-references,instructions,cycles \
./test_cpy --bench q
perf stat --repeat 100 \
-e cache-misses,cache-references,instructions,cycles \
./test_ref --bench q
plot: bench_cpy.txt bench_ref.txt output.txt
$(Q)gnuplot scripts/runtime3.gp
$(Q)gnuplot scripts/runtimept.gp
$(Q)gnuplot scripts/runtime.gp
$(Q)eog plots/runtime3.png
bench_%.txt: test_%
$(VECHO) " benching...\t$@\n"
$(Q)./$< --bench
output.txt: calculate load
$(VECHO) " calculating...\t$@\n"
$(Q)./$<
```
* `runtimept.gp` 與 `runtime3.gp` 也做了些修改,將原本的範圍擴大與將產生的圖放 plots 目錄下。
```
reset
set xlabel 'prefix'
set ylabel 'time(sec)'
set title 'perfomance comparison'
set term png enhanced font 'Verdana,10'
set output 'plots/runtime2.png'
set format x "%10.0f"
set xtic 5000
set xtics rotate by 45 right
plot [:90000][:1000]'bench_cpy.txt' using 1:2 with points title 'cpy',\
'bench_ref.txt' using 1:2 with points title 'ref',\
```
* CPY 在 prefix search 時的統計分佈
![](https://i.imgur.com/3Yz2Kqr.png)
* CPY 與 REF 的 prefix search 比較
![](https://i.imgur.com/xjtPsHp.png)
* 就此來看 CPY 的分佈較為集中些, REF 的極端值較明顯。
---
## TST 對 prefix search 的影響
* 根據 TST 的特性可以得知,如果按照**字串大小順序加入**字串,比較會讓 TST 產生出來的樹歪斜。當在搜尋字串時會導致需要比對更多次。
* 原本認為隨機選擇的插入順序在操作 TST 時效能應該都會比較好,因為隨機插入所生成的 TST 較會是平衡樹,但是 prefix search 卻並非如此。
* 將 `cities.txt` 排序成 `cities_sorted.txt` 後下指令 `s Tai` 測試效能 ( 為了比對,都使用 CPY )
```
Performance counter stats for './test_cpy --bench s Tai' (100 runs):
270,092 cache-misses # 55.784 % of all cache refs ( +- 0.94% )
484,176 cache-references ( +- 0.21% )
559,474,006 instructions # 1.63 insn per cycle ( +- 0.01% )
343,459,668 cycles ( +- 0.14% )
0.093505691 seconds time elapsed ( +- 0.15% )
```
* 使用原本的 `cities.txt` 下指令 `s Tai` 測試效能
```
Performance counter stats for './test_cpy --bench s Tai' (100 runs):
1,101,592 cache-misses # 49.866 % of all cache refs ( +- 0.89% )
2,209,089 cache-references ( +- 0.24% )
503,962,290 instructions # 1.18 insn per cycle ( +- 0.01% )
428,791,962 cycles ( +- 0.29% )
0.117321684 seconds time elapsed ( +- 0.40% )
```
* 從以上兩種載入順序可以發現按照大小順序載入的 prefix search 竟然會比較快。實驗 TST 的插入順序是否影響 prefix search 效能 :
* 執行 `make plot` 來得到 prefix search 的統計分佈
* 排序後插入 TST 的 prefix search
![](https://i.imgur.com/ji8QzV8.png)
* 沒排序插入 TST 的 prefix search
![](https://i.imgur.com/A7Nb6OH.png)
* 從上圖可以得知,使用排序後插入的 prefix search 速度大優於沒有排序的,結果出人意料。
* 為了調查原因,去觀察一下 `tst_search_predfix()` 的程式碼,會發現到當找到 prefix 最後字元所在的 node 後,會透過 `tst_suggest()` 遞迴進入 left child 、 mid child 、 right child 將符合 prefix 字串的 node 的指標都存入 `char **a` 裡。
```clike=
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)
a[(*n)++] = (char *) p->eqkid;
tst_suggest(p->hikid, c, nchr, a, n, max);
}
```
* 使用 `$ perf record ./test_cpy --bench` 與 `$perf report` 來查看哪個函式是 hot spot 。
* 排序後 :
```
Overhead Command Shared Object Symbol
92.67% test_cpy test_cpy [.] tst_suggest
2.46% test_cpy test_cpy [.] tst_search_prefix
0.77% test_cpy libc-2.27.so [.] _IO_vfscanf
0.75% test_cpy test_cpy [.] tst_ins_del
...
```
* 非排序 :
```
Overhead Command Shared Object Symbol
95.60% test_cpy test_cpy [.] tst_suggest
0.56% test_cpy test_cpy [.] tst_ins_del
0.51% test_cpy test_cpy [.] tst_search_prefix
0.49% test_cpy libc-2.27.so [.] _IO_vfscanf
...
```
由此可見 `tst_suggest()` 函式的 Overhead 比例最高,可見其對於執行速度最具影響力。而排序與非排序也影響著 `tst_suggest()` 上的 Overhead ,所以說排序後插入的 TST 對於遞迴的效率較好,至於原因想不太出來。
參考 :
[Hackmd 共筆](https://hackmd.io/c/rJKbX1pFZ/https%3A%2F%2Fhackmd.io%2Fs%2FB11109rdg)
[Linux下的系統性能調優工具——Perf](https://hk.saowen.com/a/b6b4110cddc0a8d10a9a5a14a44e9ab3317f88324c133bca50d1fc0ce73afed5)
---
## CPY 與 REF 實作差異
* 兩者最大的差別在於 REF 使用了 memory pool 的機制來分配記憶體,而 CPY 則是正常的 `malloc()` 與 `free()` 。
* 從 `test_ref.c` 可以看到 memeory pool 的機制分配了一塊大記憶體 (`poolsize * sizeof(char)`) ,使用 `pool` 指標指向 memory pool 的開頭, `Top` 指標指向下一個要分配出的記憶體位址,以此來控制記憶體存取。
* REF 和 CPY 相比少了頻繁使用 `malloc` 。
```clike=
/* memory pool */
char *pool = (char *) malloc(poolsize * sizeof(char));
char *Top = pool;
while ((rtn = fscanf(fp, "%256[^,], %256[^\n]\n", Top, nation)) != EOF) {
char *p = Top;
...
```
* 可以在 `tst.c` 下的 `tst_ins_del()` 函式看到 CPY 是使用 `strdup` 來分配記憶體,而 REF 僅僅是回傳指標。 `cpy` 為 0 則為 CPY 呼叫此函式。
```clike=
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;
}
```
* `make plot` 後生成 TST 所花費的時間 ( 跑 100 次取平均 ),可看到 REF 略快於 CPY 。
![](https://i.imgur.com/aou5apL.png)
* 可以看到 REF 的 cache-misses 較低, `./test_ref --bench q` 只做完載入動作後就離開程式。
```
Performance counter stats for './test_ref --bench q' (100 runs):
985,229 cache-misses # 44.418 % of all cache refs ( +- 0.48% )
2,218,064 cache-references ( +- 0.08% )
467,074,659 instructions # 1.16 insn per cycle ( +- 0.00% )
403,330,172 cycles ( +- 0.12% )
0.109016797 seconds time elapsed ( +- 0.15% )
...
Performance counter stats for './test_cpy --bench q' (100 runs):
1,010,358 cache-misses # 45.856 % of all cache refs ( +- 0.50% )
2,203,335 cache-references ( +- 0.15% )
503,410,105 instructions # 1.20 insn per cycle ( +- 0.01% )
419,903,519 cycles ( +- 0.13% )
0.113889864 seconds time elapsed ( +- 0.16% )
```
* 透過這篇 [CPU Cache 原理探討](https://hackmd.io/s/H1U6NgK3Z#) 可以得知,當 CPU 在 CPU cache 找不到需要的資料時就會產生 cache-misses 。
* 使用 memory pool 的 REF 一開始就分配大量記憶體,所以資料彼此是連續的,也就是有比較好的 Data locality ,因此 cache-misses 才會比 CPY 少,載入速度較快。
---