owned this note
owned this note
Published
Linked with GitHub
# 2020q3 Homework (dict)
contributed by < `hankluo6` >
## 環境
```
$ uname -a
Linux hank-VivoBook-ASUSLaptop-X580GD-N580GD 5.4.0-48-generic #52-Ubuntu SMP Thu Sep 10 10:58:49 UTC 2020 x86_64 x86_64 x86_64 GNU/Linux
$ gcc --versiongcc (Ubuntu 9.3.0-10ubuntu2) 9.3.0
Copyright (C) 2019 Free Software Foundation, Inc.
This is free software; see the source for copying conditions. There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
$ lscpu
架構: x86_64
CPU 作業模式: 32-bit, 64-bit
Byte Order: Little Endian
Address sizes: 39 bits physical, 48 bits virtual
CPU(s): 8
On-line CPU(s) list: 0-7
每核心執行緒數: 2
每通訊端核心數: 4
Socket(s): 1
NUMA 節點: 1
供應商識別號: GenuineIntel
CPU 家族: 6
型號: 158
Model name: Intel(R) Core(TM) i5-8300H CPU @ 2.30GHz
製程: 10
CPU MHz: 2700.405
CPU max MHz: 4000.0000
CPU min MHz: 800.0000
BogoMIPS: 4599.93
虛擬: VT-x
L1d 快取: 128 KiB
L1i 快取: 128 KiB
L2 快取: 1 MiB
L3 快取: 8 MiB
NUMA node0 CPU(s): 0-7
```
## 視覺化 ternary search tree + bloom filter 的效能表現
設計實驗來分析 bloom filter 對效能的影響,考量到 ternary search tree 為 prefix search,設計出以下實驗:
### 實驗 1:搜尋字串在第一個字元即判斷不在字典內
![](https://i.imgur.com/z8gZfbk.png)
### 實驗 2:搜尋字串在到中間字元才判斷不在字典內
![](https://i.imgur.com/IelMykq.png)
### 實驗 3:搜尋字串在到結尾字元才判斷不在字典內
![](https://i.imgur.com/xvDyowQ.png)
可以發現以上三種 bloom filter 效能都比只用 ternary search tree 還好,這是因為我們 hash table 夠大,隨機產生的字串在 hash 之後都能立刻察覺不在字典內。且可以發現 `bloom filter` 不受字元位置影響,但 ternary search tree 卻會因字元位置不同而影響效能。
### 實驗 4:字元位置對 tst 與 bloom filter 的效能差異
![](https://i.imgur.com/LvrgA42.png)
從此實驗可以看到,只要搜尋字串不在字典內,使用 bloom filter 都能增加效能。另外,能夠發現此字典建立的 ternary search tree 在不同字元位置增加時,效能還能維持在一定範圍內,推測是因城市間名字差異過大,導致搜尋時很快就能找到 leaf node。但還是有某些常見的 prefix,導致搜尋時可能會 traversal 多次,造成圖上某些特別高點。
## 以 perf 分析 TST 程式碼的執行時期行為
將 bloom filter 的部份從程式碼中移除,使用 `perf record` 來分析 CPY prefix search 的程式熱點:
```
Samples: 3K of event 'cycles', Event count (approx.): 421500000
Overhead Command Shared Object Symbol
47.98% test_common test_common [.] tst_ins_del
13.97% test_common test_common [.] tst_free_all
11.60% test_common test_common [.] main
6.38% test_common libc-2.31.so [.] _int_malloc
3.11% test_common libc-2.31.so [.] _int_free
```
用 `perf diff` 來觀察 CPY 與 REF 的差異:
```
# Event 'cycles'
#
# Baseline Delta Abs Shared Object Symbol
# ........ ......... ................ ................................
#
+14.28% test_common [.] tst_free
0.86% +0.64% libc-2.31.so [.] __strlen_avx2
3.11% -0.46% libc-2.31.so [.] _int_free
0.68% -0.45% test_common [.] 0x00000000000011a4
```
可發現兩者除了 REF 必要的 `tst_free` 外,沒有太大的差異,所以這邊只觀察 CPY 的行為。
`tst_ins_del` 為程式插入跟移除節點呼叫的函式,分析內部實作:
```c
void *tst_ins_del(tst_node **root, const char *s, const int del, const int cpy)
{
...
while ((curr = *pcurr)) {
...
if (diff == 0) {
if (*p++ == 0) {
...
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);
}
if (del)
return (void *) -1;
for (;;) {
...
if (!*root)
*root = *pcurr;
if (cpy) {
...
} else {
...
}
}
pcurr = &(curr->eqkid);
}
}
```
可以看到分支數量極多,前面 `while` 用來尋找插入位置(或將預移除的節點放入 stack 中),後面 `for` 迴圈則是插入剩下沒存在樹中的字元。觀察 Assembly Code 裡的熱點:
```
Samples: 3K of event 'cycles', 125000 Hz, Event count (approx.): 419750000
tst_ins_del
---------------------------------------------------------------------------------
Percent
│ diff = *p - curr->key; /* get ASCII diff for >, <, = */
│ b3: mov -0x830(%rbp),%rax
0.26 │ movzbl (%rax),%eax
0.39 │ movsbl %al,%edx
1.22 │ mov -0x820(%rbp),%rax
33.42 │ movzbl (%rax),%eax
1.67 │ movsbl %al,%eax
2.06 │ sub %eax,%edx
│ mov %edx,%eax
2.90 │ mov %eax,-0x834(%rb)
```
根據 [隱藏在字典裡的數學](https://hackmd.io/@sysprog/BkE3uSvdN#%E4%BD%BF%E7%94%A8-perf-%E5%88%86%E6%9E%90%E7%A8%8B%E5%BC%8F%E6%95%88%E8%83%BD) 章節中提到 [perf 的偏差問題](https://stackoverflow.com/questions/43794510/linux-perf-reporting-cache-misses-for-unexpected-instruction),這邊造成熱點的部份應為 `mov -0x820(%rbp),%rax` 這個指令,用來取出 `*p` 的值。分析後可知道此 overhead 應為 cache-misses 造成的,因為此 function 的第一次 deference,故不在 Cache 的機率相當高。
```
Samples: 33 of event 'cache-misses', 125000 Hz, Event count (approx.): 4125000
tst_ins_del
---------------------------------------------------------------------------------
Percent
│ diff = *p - curr->key; /* get ASCII diff for >, <, = */
│ movzbl (%rax),%eax
│ movsbl %al,%edx
│ mov -0x820(%rbp),%rax
│ movzbl (%rax),%eax
62.50 │ movsbl %al,%eax
```
所以程式主要消耗時間的部份是在插入跟移除節點的時候,實際上搜尋所耗的時間相當少,其實從運行時間也能看出此現象:
```
CPY mechanism
ternary_tree, loaded 206849 words in 0.105415 sec
choice: find words matching prefix (at least 1 char): Tai - searched prefix in 0.000184 sec
```
兩者有近千倍的效能差異。
## 考慮到 CPY 和 REF 這兩種實作機制,思考效能落差的緣故,並且提出後續效能改善機制
先執行 `perf test` 來看兩者在硬體上的行為是否有差異:
```
Performance counter stats for './test_common --bench CPY s Tai' (100 runs):
3,778,706 cache-misses # 38.210 % of all cache refs ( +- 0.32% )
9,889,235 cache-references ( +- 0.33% )
587,643,670 instructions # 1.19 insn per cycle ( +- 0.02% )
492,740,152 cycles ( +- 0.07% )
Performance counter stats for './test_common --bench REF s Tai' (100 runs):
3,807,348 cache-misses # 38.754 % of all cache refs ( +- 0.94% )
9,824,456 cache-references ( +- 0.25% )
554,073,126 instructions # 1.12 insn per cycle ( +- 0.00% )
492,670,490 cycles ( +- 0.40% )
```
看不太出來有什麼顯著的差別,只能看出 REF 的 cache-misses 稍微多一些。改從程式碼運行時間觀察:
![](https://i.imgur.com/2l5qlZ2.png)
大部分時間 REF 速度都比 CPY 快。
一開始認為是 locailty 的問題,CPY 因為是使用 malloc 導致其 memory 分配的較分散,進而影響到效能,但觀察 cache-misses 卻發現 CPY 竟然比 REF 還要低。
根據 `perf stat` 中另一個有差距的數據為 instructions,或許是因為 instructions 的數量影響到效能,但不知道如何設計實驗來分析 instruction 數量對效能的影響。
## 研讀 Xor Filters: Faster and Smaller Than Bloom and Cuckoo Filters 論文
[論文連結](https://arxiv.org/pdf/1912.08258.pdf)
### Algorithm 1 Membership test
用來檢查該元素是否在 $B$ 當中。
### Algorithm 2 Construction
此部份用來隨機選擇三個 hash function,並經過 Algorithm 3 的檢查確定可以使用後,透過 Algorithm 4 將值存入到 memory 中。
### Algorithm 3 Mapping Step (map)
$S$ 為建立 filter 時的所有元素集合,所有元素 $x \in S$ 經過 3 個 hash function 後會得到 $h_1(x)$, $h_2(x)$, $h_3(x)$。只要集合 $S$ 中的任意兩個元素 $x$ 與 $y$ 中,以下等式成立的話,就是合理的 hash function,回傳 true 到 Algorithm 2。否則,回傳 false 到 Algorithm 2 並重新挑選 3 個 hash function。
$(h_1(x) \neq h_1(y)) \ \vee\ (h_2(x) \neq h_2(y)) \ \vee \ (h_3(x) \neq h_3(y))$
### Algorithm 4 Assigning Step (assign)
將結果存入 $B$ 中,這邊用到第 4 個 hash function: fingerprint 做 XOR。存入到 $B[i]$ 中的為 $B[h_0(x)]\oplus B[h_1(x)]\oplus B[h_2(x)] = \text{fingerprint}(x)$ 的結果,存入的位置 $i$ 為 Algorithm 3 中 push 到 stack 時的位置。
:::info
這邊 $i$ 為 $h_0(x)$, $h_1(x)$, $h_2(x)$ 中的某一個,在論文中使用 **queue** 來儲存要處理的 index,處理完畢後再放入 **stack** 中。這樣在此步驟要處理 pop 出來的 index 上的元素 $x$ 時,該元素對應到的 $B[h_{s\in {0,1,2}}(x)]$ 都只會被處理一次,而不會被後來的元素 $y$ 蓋掉同樣的 $B[h_{s\in {0,1,2}}(x)]$,這是因為每個 pop 出來的 index 皆為在該 set 中的唯一元素。
:::
>an entry in B is modified at most once. After we modify an entry B[i], then
none of the values B[h0(x)], B[h1(x)], B[h2(x)] will ever be modified again.
### Example
$S = \{1, 2, 3\}$
$B = \text{array of 8 bits}$
假設 construction 的 hash function 如下
| $x\in S$ | $h_0(x)$ | $h_1(x)$ | $h_2(x)$ | $\text{fingerprint}(x)$ |
| -------- | -------- | -------- | -------- | ----------------------- |
| 1 | 1 | 4 | 5 | 0 |
| 2 | 1 | 2 | 7 | 1 |
| 3 | 4 | 5 | 7 | 1 |
一開始為 $H$ 為空陣列:
```graphviz
digraph G {
nodesep=.05;
rankdir=LR;
node [shape=record,width=.1,height=.1];
"H" [color=white];
node0 [label = "<f0> 0 |<f1> 1 |<f2> 2 |<f3> 3 |<f4> 4 |<f5> 5 |<f6> 6| <f7> 7",height=2.5];
node [width = 1.5];
node1 [label = "{<n> }"];
node2 [label = "{<n> }"];
node3 [label = "{<n> }"];
node4 [label = "{<n> }"];
node5 [label = "{<n> }"] ;
node6 [label = "{<n> }"] ;
node7 [label = "{<n> }"] ;
node8 [label = "{<n> }"] ;
{ rank=same; "H"; node0 }
node0:f0 -> node1:n;
node0:f1 -> node2:n;
node0:f2 -> node3:n;
node0:f3 -> node4:n;
node0:f4 -> node5:n;
node0:f5 -> node6:n;
node0:f6 -> node7:n;
node0:f7 -> node8:n;
}
```
插入 1, 2, 3 之後:
```graphviz
digraph G {
nodesep=.05;
rankdir=LR;
node [shape=record,width=.1,height=.1];
"H" [color=white];
node0 [label = "<f0> 0 |<f1> 1 |<f2> 2 |<f3> 3 |<f4> 4 |<f5> 5 |<f6> 6| <f7> 7",height=2.5];
node [width = 1.5];
node1 [label = "{<n> }"];
node2 [label = "{<n> 1 | 2}"];
node3 [label = "{<n> 2}"];
node4 [label = "{<n> }"];
node5 [label = "{<n> 1 | 3}"] ;
node6 [label = "{<n> 1 | 3}"] ;
node7 [label = "{<n> }"] ;
node8 [label = "{<n> 2 | 3}"] ;
{ rank=same; "H"; node0 }
node0:f0 -> node1:n;
node0:f1 -> node2:n;
node0:f2 -> node3:n;
node0:f3 -> node4:n;
node0:f4 -> node5:n;
node0:f5 -> node6:n;
node0:f6 -> node7:n;
node0:f7 -> node8:n;
}
```
接著使用 **queue** $Q$ 來 push 只有一個元素的 index,此例中只有 index 2
```graphviz
digraph {
node [shape=plaintext, fontcolor=black, fontsize=18];
"queue Q" [color=white];
node [shape=record, fontcolor=black, fontsize=14 fixedsize=true,width=1];
q [label="2", color=blue, fillcolor=lightblue, style=filled];
{ rank=same; "queue Q"; q }
}
```
進入到 `while` 迴圈,條件為 $Q$ 不為空。做了以下事情:
* 從 $Q$ 中 pop 元素 $i$ 出來
* 宣告 $x$ 為 $H[i]$
* 將 $(x,i)$ pair push 至 stack $\sigma$ 中
* 將所有出現的 $x$ 從 $H$ 中移除
* 如果移除 $x$ 的 index 只剩一個元素,則 push 該 index 至 queue 當中
以下模擬每次迴圈所作的事情:
loop 1:
pop 2 from $Q$
```graphviz
digraph G {
nodesep=.05;
rankdir=LR;
node [shape=record,width=.1,height=.1];
"H" [color=white];
node0 [label = "<f0> 0 |<f1> 1 |<f2> 2 |<f3> 3 |<f4> 4 |<f5> 5 |<f6> 6| <f7> 7",height=2.5];
node [width = 1.5];
node1 [label = "{<n> }"];
node2 [label = "{<n> 1}"];
node3 [label = "{<n> }"];
node4 [label = "{<n> }"];
node5 [label = "{<n> 1 | 3}"] ;
node6 [label = "{<n> 1 | 3}"] ;
node7 [label = "{<n> }"] ;
node8 [label = "{<n> 3}"] ;
{ rank=same; "H"; node0 }
node0:f0 -> node1:n;
node0:f1 -> node2:n;
node0:f2 -> node3:n;
node0:f3 -> node4:n;
node0:f4 -> node5:n;
node0:f5 -> node6:n;
node0:f6 -> node7:n;
node0:f7 -> node8:n;
}
```
```graphviz
digraph {
node [shape=plaintext, fontcolor=black, fontsize=18];
"queue Q" [color=white];
node [shape=plaintext, fontcolor=black, fontsize=18];
"stack S" [color=white];
"queue Q" -> "stack S" [color=white];
node [shape=record, fontcolor=black, fontsize=14, width=2];
q [label="<f0>1 | <f1>7", color=blue, fillcolor=lightblue, style=filled];
node [shape=record, fontcolor=black, fontsize=14 fixedsize=true, width=1,];
s [label="(2,2)", color=blue, fillcolor=lightblue, style=filled];
q:f0->s[style=invis];
{ rank=same; "queue Q"; q }
{ rank=same; "stack S"; s }
}
```
loop 2:
pop 1 from $Q$
```graphviz
digraph G {
nodesep=.05;
rankdir=LR;
node [shape=record,width=.1,height=.1];
"H" [color=white];
node0 [label = "<f0> 0 |<f1> 1 |<f2> 2 |<f3> 3 |<f4> 4 |<f5> 5 |<f6> 6| <f7> 7",height=2.5];
node [width = 1.5];
node1 [label = "{<n> }"];
node2 [label = "{<n> }"];
node3 [label = "{<n> }"];
node4 [label = "{<n> }"];
node5 [label = "{<n> 3}"] ;
node6 [label = "{<n> 3}"] ;
node7 [label = "{<n> }"] ;
node8 [label = "{<n> 3}"] ;
{ rank=same; "H"; node0 }
node0:f0 -> node1:n;
node0:f1 -> node2:n;
node0:f2 -> node3:n;
node0:f3 -> node4:n;
node0:f4 -> node5:n;
node0:f5 -> node6:n;
node0:f6 -> node7:n;
node0:f7 -> node8:n;
}
```
```graphviz
digraph {
node [shape=plaintext, fontcolor=black, fontsize=18];
"queue Q" [color=white];
node [shape=plaintext, fontcolor=black, fontsize=18];
"stack S" [color=white];
"queue Q" -> "stack S" [color=white];
node [shape=record, fontcolor=black, fontsize=14, width=4];
q [label="<f0>7 | <f1>4 | <f2> 5 | <f3> 7", color=blue, fillcolor=lightblue, style=filled];
node [shape=record, fontcolor=black, fontsize=14 fixedsize=true, width=1,];
s [label="<f0>(2,2) | <f1>(1,1)", color=blue, fillcolor=lightblue, style=filled, width=2];
q:f0->s:f0[style=invis];
{ rank=same; "queue Q"; q }
{ rank=same; "stack S"; s }
}
```
loop 3:
pop 7 from $Q$
```graphviz
digraph G {
nodesep=.05;
rankdir=LR;
node [shape=record,width=.1,height=.1];
"H" [color=white];
node0 [label = "<f0> 0 |<f1> 1 |<f2> 2 |<f3> 3 |<f4> 4 |<f5> 5 |<f6> 6| <f7> 7",height=2.5];
node [width = 1.5];
node1 [label = "{<n> }"];
node2 [label = "{<n> }"];
node3 [label = "{<n> }"];
node4 [label = "{<n> }"];
node5 [label = "{<n> }"] ;
node6 [label = "{<n> }"] ;
node7 [label = "{<n> }"] ;
node8 [label = "{<n> }"] ;
{ rank=same; "H"; node0 }
node0:f0 -> node1:n;
node0:f1 -> node2:n;
node0:f2 -> node3:n;
node0:f3 -> node4:n;
node0:f4 -> node5:n;
node0:f5 -> node6:n;
node0:f6 -> node7:n;
node0:f7 -> node8:n;
}
```
```graphviz
digraph {
node [shape=plaintext, fontcolor=black, fontsize=18];
"queue Q" [color=white];
node [shape=plaintext, fontcolor=black, fontsize=18];
"stack S" [color=white];
"queue Q" -> "stack S" [color=white];
node [shape=record, fontcolor=black, fontsize=14, width=3];
q [label="<f0>4 | <f1> 5 | <f2> 7", color=blue, fillcolor=lightblue, style=filled];
node [shape=record, fontcolor=black, fontsize=14 fixedsize=true, width=1,];
s [label="<f0>(2,2) | <f1>(1,1) | <f3>(3,7)", color=blue, fillcolor=lightblue, style=filled, width=3];
q:f0->s:f0[style=invis];
{ rank=same; "queue Q"; q }
{ rank=same; "stack S"; s }
}
```
loop 4~6
pop 4,5,6 from $Q$
**but doing nothing because the condition: ==if the set H[i] contains a single key== in the pseudocode**
因為 stack 的 size 與 元素個數相同,所以此 hash function 是可行的,進入到 Algorithm 4。
初始化陣列 $B$
Algorithm 4 的三次迴圈:
loop 1
pop (7,3) from stack
$$
\begin{align}
B[7]&=\text{fingerpoint}(3)\oplus B[h_0(3)] \oplus B[h_1(3)] \oplus B[h_2(3)]\\
&=\text{fingerpoint}(3)\oplus B[4] \oplus B[5] \oplus B[7]\\
&=1 \oplus 0 \oplus 0 \oplus 0 \\
&=1
\end{align}
$$
loop 2
pop (2,2) from stack
$$
\begin{align}
B[2]&=\text{fingerpoint}(2)\oplus B[h_0(2)] \oplus B[h_1(2)] \oplus B[h_2(2)]\\
&=\text{fingerpoint}(2)\oplus B[1] \oplus B[2] \oplus B[7]\\
&=1 \oplus 0 \oplus 0 \oplus 1 \\
&=0
\end{align}
$$
loop 3
pop (1,1) from stack
$$
\begin{align}
B[1]&=\text{fingerpoint}(1)\oplus B[h_0(1)] \oplus B[h_1(1)] \oplus B[h_2(1)]\\
&=\text{fingerpoint}(1)\oplus B[1] \oplus B[4] \oplus B[5]\\
&=0 \oplus 0 \oplus 0 \oplus 0 \\
&=0
\end{align}
$$
可以看到已經決定後的 $B[x]$,不會在被後面的元素蓋掉。而賦值給 $B[h_{s\in {1,2,3}}(x)]$ 其中一個元素用途是讓 fingerpoint(x) 與 $B[h_0(x)] \oplus B[h_1(x)] \oplus B[h_2(x)]$ 相等。
## 如何縮減頻繁 malloc 帶來的效能衝擊呢?
移除頻繁呼叫的 `calloc()`,改以從 memory pool 中取得所需記憶體。可以使用 free list 來實現 memory pool,此方法能有效的移除不需要的節點空間,減少記憶體碎片化的問題。
:::info
TODO: 實作 free list 並分析效能
:::
## 使用 bloom filter 的專案
[Apache Cassandra](https://en.wikipedia.org/wiki/Apache_Cassandra) 是一套開源分散式 [NoSQL](https://en.wikipedia.org/wiki/NoSQL) 資料庫系統。它最初由 Facebook 開發,用於改善電子郵件系統的搜尋效能的簡單格式資料,集 Google BigTable 的資料模型與 Amazon Dynamo 的完全分散式架構於一身。
Cassandra 是透過將 disk 上的資料 (SSTables) 與 RAM 上的資料 (memtables) 做 merge 來達成資料的讀取。
引入 bloom filter 有兩個好處:
* A Bloom filter can establish that a SSTable does not contain certain partition data.
* A Bloom filter can also find the likelihood that partition data is stored in a SSTable. It speeds up the process of partition key lookup by narrowing the pool of keys.
在資料庫上因為讀取資料耗費的時間較長,使用 bloom filter 可以快速知道資料庫中是否有需要的資料,或是知道哪部份沒有需要的資料,進而縮小搜尋範圍。
延伸閱讀:
* [Cassandra Bloom Filter Source Code](https://github.com/apache/cassandra/blob/trunk/src/java/org/apache/cassandra/utils/BloomFilter.java)
* [Bigtable: A Distributed Storage System for Structured Data](http://static.googleusercontent.com/media/research.google.com/en//archive/bigtable-osdi06.pdf)
* [How is data read?](https://docs.datastax.com/en/archived/cassandra/3.0/cassandra/dml/dmlAboutReads.html)
* [Cassandra Doc](https://cassandra.apache.org/doc/latest/operating/bloom_filters.html)
###### tags: `linux2020`