# 2020q1 Homework3 (quiz4) contributed by < `OscarShiang` > ###### tags: `linux2020` ## 測試環境 ```shell $ cat /etc/os-release NAME="Ubuntu" VERSION="18.04.4 LTS (Bionic Beaver)" $ cat /proc/version Linux version 5.3.0-40-generic (buildd@lcy01-amd64-024) (gcc version 7.4.0 (Ubuntu 7.4.0-1ubuntu1~18.04.1)) ``` ## 作業要求 - [x] 重新回答測驗題 - [x] 解釋測驗 1 程式運作的原理,改寫為更精簡且等效的程式碼; - [ ] 考慮到 alignment 和 word size,改寫為執行效率更高的程式碼; - [ ] 在 Linux 核心原始程式碼找出逐 bit 進行資料複製的程式碼,並解說相對應的情境; - [x] 解釋測驗 2 程式運作的原理,比照 [quiz2](https://hackmd.io/@sysprog/BJXNYx5NL) 進行效率分析; - [x] 指出測驗 2 所述實作的限制並提出改進方案; - [x] 參照 [folly::fbvector](https://github.com/facebook/folly/blob/master/folly/docs/FBVector.md) 文件和對應原始程式碼,以測驗 2 程式為基礎,實作 vector 經典操作 ## 測驗 1 ### 題目分析 首先 `KK1` 是在剛進入 `bitcopy` 函式的地方,根據 `read_lhs`, `read_rhs` 與 `source` 位移的操作可以判斷 `_read & 7` 的目的在於計算 `_read % 8` 的數值,因為如果除數是 $2^n$ 時,可以利用將變數與 $2^n - 1$ 進行 AND 運算取得餘數,所以根據 `uint8_t *dest = _dest + (_write / 8);` 可以得知 > `KK1` = `7` 根據 `KK2` 所在的位置可以知道,`KK2` 是用來表示當前迴圈所處理的 bit 的個數,所以根據迴圈中與 bit 計算有關的 `bitsize = (_count > 8) ? 8 : _count;` 就可以知道 > `KK2` = `bitsize` ### 程式運作原理 首先我們可以先從函式的參數開始了解: 1. `*_dest`: 目標的起始位址 2. `_write`: 寫入位置的位移量 3. `*_src`: 複製對象的起始位址 4. `_read`: 讀取位置的位移量 5. `_count`: 要複製的位元數量 在函式之中,因為我們要複製的位元不一定是剛好與位元組對齊,所以需要考慮到我們要複製的位元可能會需要位移才能放進 `data` 中,所以我們需要計算 `read_lhs` 與 `read_rhs` ,並在執行複製的時候將位元利用位移並向左對齊,方便之後貼上到 `dst` 指標所指向位元中。 而在執行貼上的時候,因為 `dst` 原本可能也有自己的資料,所以我們需要透過,`write_mask` 將多餘的位元利用遮罩消除。在將 `data` 寫入 `dst` 時,也要考慮到位移的問題,所以利用 `write_lhs` 與 `write_rhs` 將 `data` 對齊 `dst` 寫入的 `offset`,並將資料寫入,最後根據寫入的位元數,更新 `_count` 的數值,重複執行直到 `_count` 歸零就表示位元複製結束 ### 改寫程式碼 根據原始的實作,每次的 while 迴圈都要重新判斷 `read_lhs`, `read_rhs` 等等變數的數值,但是其中包含太多針對特定情況的處理。 在去除針對特定情況的 `if-else` 後,可以把程式碼簡化為下列形式: ```cpp void bitcpy(void *_dest, /* Address of the buffer to write to */ size_t _write, /* Bit offset to start writing to */ const void *_src, /* Address of the buffer to read from */ size_t _read, /* Bit offset to start reading from */ size_t _count) { ... while (_count > 0) { // read data data = (*source++ << read_lhs) | (*source >> read_rhs); size_t prep = (_count & 8) >> 3, filter = 8 - _count; bitsize = (_count & ((-filter - pre) >> 31) + (8 & (filter >> 31)); data &= read_mask[bitsize]; // write data mask = read_mask[write_lhs]; *dest++ = (*dest & mask) | (data >> write_lhs); *dest &= write_mask[bitsize - write_rhs]; *dest |= (data << write_rhs); _count -= bitsize; } } ``` :::warning `bitsize = (_count > 8) ? 8 : _count;` 這樣的敘述可換為 bitwise operation :notes: jserv ::: > 已成功實作 bitwise operation 的版本 > [name= Oscar][color= orange] 使用 `perf` 測量分支數量 ```shell $ sudo perf record \ -e branch-misses:u,branch-instructions:u \ -F 30000 ./bitcopy $ sudo perf report ``` 原始版本的測量結果如下所列 ```shell Available samples 432 branch-misses:u 1K branch-instructions:u ``` 改寫的版本測量如下 ```shell Available samples 261 branch-misses:u 859 branch-instructions:u ``` 從結果可以看到減少了許多的分支 接下來進行實驗測量簡化後的版本與原始版本的差異 實驗的結果如下圖(以排除干擾因素) ![](https://i.imgur.com/W9Vzi2m.png) 從實驗結果可以看出因為去除了多餘的判斷,所以在執行上可以減少許多為了判斷特定情況而花費的時間,在 bitcopy 的數量為 $8000$ 時可以減少大約 $1 \mu s$ 的時間 ## 測驗 2 ### 題目分析 `VV1` 是位在 `NEXT_POWER_OF_2` 的 macro 之中,該 macro 在實作的就是計算大於給定 s 中最小的 2 的次方,如果符合 `VV1` 的條件,就直接回傳回去,如果不符合 `VV1` 的條件,則利用 `__builtin_clzl` 來計算 所以從程式碼中得知 `VV1` 是在判斷給定的 s 是否為 2 的次方,而其所使用的技巧就是如果 s 是一個 2 的次方的話,其在二進位的表示上就會是: $01000000_2$ 這種的形式,而 s - 1 就會是 $00111111_2$ ,表示將兩者進行 AND 運算時將會得到 0 的結果,所以在這題中: > `VV1` = `(s & (s - 1)) == 0` 而 `VV2` 的判斷上,因為`VV2` 的位置位於 `pop_back` 的 macro 中。vector 的 size 為目前元素的個數,而 `capacity` 則是最大容量,所以根據 `pop_back` 要將元素刪除的需求,`VV2` 應該為 > `VV2` = `v.size -= 1` ### 程式運作原理 在測驗 2 中,由於使用下列 macro 實作 vector 的宣告 ```cpp #define v(t, s, name, ...) \ union { \ STRUCT_BODY(t); \ struct { \ size_t filler; \ t buf[NEXT_POWER_OF_2(s)]; \ }; \ } name __attribute__((cleanup(vec_free))) = {.buf = {__VA_ARGS__}}; \ name.size = sizeof((__typeof__(name.buf[0])[]){0, __VA_ARGS__}) / \ sizeof(name.buf[0]) - 1; \ name.capacity = sizeof(name.buf) / sizeof(name.buf[0]) ``` 所以在使用 `v(type, size, name, ...)` 時,編譯器就會根據輸入的參數展開 macro,達到自訂內部元素資料型態的功能 在 `STRUCT_BODY` 的宣告中 ```cpp #define STRUCT_BODY(type) \ struct { \ size_t size : 54, on_heap : 1, capacity : 6, flag1 : 1, flag2 : 1, \ flag3 : 1; \ type *ptr; \ } ``` 採用了類似 `folly::string` 的實作方式,所以可以達到在小容量時使用 stack 儲存資料,大容量時使用 heap 儲存資料的功能。 而較為特別的是因為 `v` 本身可以傳入多個參數,所以使用 `__VA_ARGS__` 來取得在 `name` 之後的參數,並將他們存入 `buf` 之中 `__attribute_((cleanup(vec_free)))` 的用法則是設定該變數在程式結束時會呼叫綁定的函式 `vec_free` 釋放記憶體空間 ### 實作的限制與修改 #### vec_pop_back 邊界檢查 在原始的實作中, `vec_pop_back` 就是將 vector 的 `size` 直接減一,讓其不會在 `display` 的時候被印出,但是這樣的實作因為沒有檢查邊界,有可能會導致記憶體 `BAD_ACCESS` 的問題產生,所以在實作中加入檢查邊界的判斷 ```cpp #define vec_pop_back(v) \ assert(v.size > 0); \ v.size -= 1; ``` 根據 `assert` 在 `Linux Programmer's Manual` 中的定義 > The assert() macro tests the given expression and if it is false, the calling process is terminated. **A diagnostic message is written to stderr and the abort(3) function is called, effectively terminating the program.** 我們可以使用定義於 `assert.h` 的 `assert` 進行檢查,在使用 `vec_pop_back` 會超出邊界時,呼叫 `abort()` 停止程式 ```shell Assertion failed: (vec1.size > 0), function main, file vector.c, line 171. [1] 16207 abort ./vector ``` #### vec_pos 造成的記憶體存取問題 因為在 `vec_pos` 的實作如下列 ```cpp #define vec_pos(v, n) vec_data(v)[n] /* lvalue */ ``` 因為其並未實作出檢查邊界,所以在 `n` 超出 `v.size` 時會存取到未使用的元素空間或是 `n < 0` 時會產生分預期的行為。 使用函式將取值與 `assert` 包裝起來: ```cpp static NON_NULL void *__vec_pos(void *vec, size_t n, size_t elemsize) { union { STRUCT_BODY(void); struct { size_t filler; char buf[]; }; } *v = vec; assert(n >= 0 && n < v->size); uint8_t *data = (uint8_t *)(v->on_heap) ? v->ptr : v->buf; return data + (n * elemsize); } ``` 參考 `__vec_push_back` 與 `__vec_reserve` 的實作,將 `__vec_pos` 的回傳值定義為 `void *` ,並使用 macro 將回傳回來的位址轉型後取值 ```cpp #define vec_pos(v, n) *(__typeof__(v.buf[0]) *) __vec_pos(&v, n, vec_elemsize(v)) /* lvalue */ ``` 測試超出邊界的呼叫 ```shell Assertion failed: (n >= 0 && n < v->size), function __vec_pos, file vector.c, line 106. [1] 19023 abort ./vector ``` 完成實作邊界的檢查 #### 檢查 vector 元素的宣告型態 因為在 vector 中不能夠存入像是 `void` 或是 `void *` 的資料型態,所以我們可以利用 `_Static_assert` 來觸發編譯時期的錯誤 ```diff #define v(t, s, name, ...) \ + _Static_assert(SAFE_TYPE(t), "unsupported type of element"); \ union { \ STRUCT_BODY(t); \ struct { \ size_t filler; \ t buf[NEXT_POWER_OF_2(s)]; \ }; \ } name __attribute__((cleanup(vec_free))) = {.buf = {__VA_ARGS__}}; \ name.size = sizeof((__typeof__(name.buf[0])[]){0, __VA_ARGS__}) / \ sizeof(name.buf[0]) - 1; \ name.capacity = sizeof(name.buf) / sizeof(name.buf[0]); ``` 在 macro 中加入 `_Static_assert` ,接著定義 `SAFE_TYPE` 的判斷 ```cpp #define SAFE_TYPE(type) (strcmp(#type, "void") && strcmp(#type, "void *")) ``` 原本想要使用 `_Generic` 來實作,但是發現 `_Generic` 不能接受型態作為參數使用,所以改以 macro 字串化的特性來實作 `SAFE_TYPE` 宣告一個元素型態為 `void` 的 vector 來進行測試 ```cpp int main() { v(void, 2, vec0); // declare a vector with element type void v(float, 3, vec1); v(int, 2, vec2, 13, 42); printf("pos(vec2,0)=%d, pos(vec2,1)=%d\n", vec_pos(vec2, 0), vec_pos(vec2, 1)); vec_push_back(vec2, 88); vec_reserve(vec2, 100); ... return 0; } ``` 確認其在編譯時期會導致錯誤 ```shell $ gcc vector.c -o vector vector.c:146:5: error: static_assert failed due to requirement 'strcmp("void", "void")' "unsupported type of element" v(void, 2, vec0); ^~~~~~~~~~~~~~~~ ``` ### 實作 vector 經典操作 #### insert 實作 因為在實作 `vec_insert` 的時候會需要調整與移動元素的位置,所以我使用 `string.h` 的 `memmove` 來進行實作,因為根據 `Linux Programmer's Manual` 的描述 > The memmove() function copies len bytes from string src to string dst. **The two strings may overlap**; the copy is always done in a non-destructive manner. 但是如果使用 `memcpy` 而 `dst` 與 `src` 如果重疊,則是 [undefined behavior](http://man7.org/linux/man-pages/man3/memcpy.3.html) ,所以利用 `memmove` 來避免未定義行為的發生 ```cpp static NON_NULL void __vec_insert(void *restrict vec, void *restrict e, size_t pos, size_t elemsize, size_t capacity) { union { STRUCT_BODY(char); struct { size_t filler; char buf[]; }; } *v = vec; assert(pos < v->size); char *data; if (v->on_heap) { if (v->size == capacity) v->ptr = realloc(v->ptr, elemsize * (size_t) 1 << ++v->capacity); data = v->ptr; } else { if (v->size == capacity) { void *tmp = malloc(elemsize * (size_t) 1 << (v->capacity = capacity + 1)); memcpy(tmp, v->buf, elemsize * v->size); v->ptr = tmp; v->on_heap = 1; data = v->ptr; } else data = v->buf; } memmove(&data[(pos + 1) * elemsize], &data[pos * elemsize], (v->size++ - pos) * elemsize); memcpy(&data[pos * elemsize], e, elemsize); } ``` 因為在使用 `vec_insert` 的時候有可能會導致容量不足,所以需要依照情況調整容量,之後將給定 `pos` 之後的所有元素向後位移一個單位,最後將元素複製到指定位置上 接著利用 macro 實作介面: ```cpp #define vec_insert(v, e, p) \ __vec_insert(&v, &(__typeof__(v.buf[0])[]){e}, p, vec_elemsize(v), \ vec_capacity(v)); ``` #### erase 實作 在 `vec_erase` 的實作上也與 `vec_insert` 類似,但是因為不用考慮超過容量的問題,所以只需要將給定位置之後的元素向前位移即可 ```cpp static NON_NULL void __vec_erase(void *restrict vec, size_t pos, size_t elemsize) { union { STRUCT_BODY(char); struct { size_t filler; char buf[]; }; } *v = vec; assert(pos >= 0 && pos < v->size); char *data; if (v->on_heap) data = v->ptr; else data = v->buf; memmove(&data[pos * elemsize], &data[(pos + 1) * elemsize], elemsize * (--v->size - pos)); } ``` 建立 `vec_erase` 的介面 ```cpp #define vec_erase(v, p) __vec_erase(&v, p, vec_elemsize(v)) ``` ### 效能分析 接下來針對 `push_back`, `insert` 與 `erase` 分析 C++ STL vector 與我們實作版本的花費時間差異 在 `push_back` 中,因為 C++ 版本的 std::vector 有使用記錄 tail 的指標 `vector::end()` ,所以可以在常數時間完成。而我們的版本則因為有記錄 vector 長度,所以也可以實作出常數時間甚至比 `vector::push_back` (from C++ STL) 還高效率的 函式 ![](https://i.imgur.com/1UjYSoN.png) 圖中的高峰是因為兩者 `push_back` 在根據 vector 的容量在調整 兩個版本的 `insert` 的實作差異如下 ![](https://i.imgur.com/ypNDC4S.png) 可以看到因為需要將資料向後位移,元素的大小與花費時間呈正相關 ![](https://i.imgur.com/BJNQbod.png) 從結果可以看到,因為越接近 tail 在 `erase` 的時候需要搬運的元素也較少,所以花費時間與 `erase` 的位置呈負相關 而從上述的情況都可以看出 C 實作出來的 vec 在 `push_back`, `insert` 與 `erase` 執行的效率上都較 C++ STL 的 vector 好 ## 參考資料 1. [2020q1 第 4 週測驗題](/@sysprog/linux2020-quiz4) 2. [folly::fbvector](https://github.com/facebook/folly/blob/master/folly/docs/FBVector.md)