# 2019q1 Homework7 (skiplist) contributed by < `LiunuXXX` , `jeffcarl67`> ## 預期目標 1. 思考 Linux 核心內部的資料結構,特別是 cache-oblivious data structures 的考量 2. 實作 Skip list,設計對應的效能分析框架 3. 開發適用於使用者層級和核心層級的程式碼 ## 開發環境 <`jeffcarl67`> ``` $ lscpu 架構: x86_64 CPU 作業模式:  32-bit, 64-bit Byte Order: Little Endian Address sizes: 39 bits physical, 48 bits virtual CPU(s): 4 On-line CPU(s) list: 0-3 每核心執行緒數:   2 每通訊端核心數:    2 Socket(s): 1 NUMA 節點: 1 供應商識別號: GenuineIntel CPU 家族: 6 型號: 60 Model name: Intel(R) Core(TM) i5-4210M CPU @ 2.60GHz 製程: 3 CPU MHz: 859.812 CPU max MHz: 3200.0000 CPU min MHz: 800.0000 BogoMIPS: 5190.32 虛擬: VT-x L1d 快取: 32K L1i 快取: 32K L2 快取: 256K L3 快取: 3072K NUMA node0 CPU(s): 0-3 ``` ``` $ uname -r 4.19.45-1-MANJARO ``` ``` $ gcc --version gcc (GCC) 8.3.0 ``` ## 作業要求 * 完成 [第 11 週測驗題 (中)](https://hackmd.io/s/BkFJPHriE) 和所有延伸題目 * 在 Linux 核心原始程式碼使用 skip list 的案例,介紹其原理,設計 Linux 核心模組的實驗 * 需要涵蓋 kernel API 同步機制的運用 * 執行時期的分析 (提示: 可善用 eBPF) ## 作業區 ==完成 [第 11 週測驗題 (中)](https://hackmd.io/s/BkFJPHriE) 和所有延伸題目== ### 測驗 `1` 給定一個 circular linked list 實作如下: (檔案 `list.h`) ```cpp #ifndef INTERNAL_LIST_H #define INTERNAL_LIST_H /* circular doubly-linked list */ typedef struct __list_t { struct __list_t *prev, *next; } list_t; /* * Initialize a list to empty. Because these are circular lists, an "empty" * list is an entry where both links point to itself. This makes insertion * and removal simpler because they do not need any branches. */ static inline void list_init(list_t *list) { list->prev = list; list->next = list; } /* * Append the provided entry to the end of the list. This assumes the entry * is not in a list already because it overwrites the linked list pointers. */ static inline void list_push(list_t *list, list_t *entry) { list_t *prev = list->prev; entry->prev = prev; entry->next = list; prev->next = entry; list->prev = entry; } /* * Remove the provided entry from whichever list it is currently in. This * assumes that the entry is in a list. You do not need to provide the list * because the lists are circular, so the list's pointers will automatically * be updated if the first or last entries are removed. */ static inline void list_remove(list_t *entry) { list_t *prev = entry->prev; list_t *next = entry->next; prev->next = next; next->prev = prev; } /* * Remove and return the first entry in the list or NULL if the list is empty. */ static inline list_t *list_pop(list_t *list) { list_t *foo = list->prev; if (foo == list) return NULL; LL1 } #endif ``` 請參照上方程式註解,補完對應程式碼。 LL1 = ? --- 首先我們看看`list_pop`的要求 ```clike /* * Remove and return the first entry in the list or NULL if the list is empty. */ ``` 可知我們需要用到 `list_remove` 將 list 的 first entry 移除後並且回傳,若 list 為空就回傳 NULL 接著看 `list_remove` 的實作 ```clike /* * Remove the provided entry from whichever list it is currently in. This * assumes that the entry is in a list. You do not need to provide the list * because the lists are circular, so the list's pointers will automatically * be updated if the first or last entries are removed. */ static inline void list_remove(list_t *entry) { list_t *prev = entry->prev; list_t *next = entry->next; prev->next = next; next->prev = prev; } ``` * prev 記錄 entry 的前一個 node ```clike list_t *prev = entry->prev; ``` ![](https://i.imgur.com/IRbbWOR.jpg) --- * next 記錄 entry 的後一個 node ```clike list_t *next = entry->next; ``` ![](https://i.imgur.com/SEhNd5t.jpg) --- * prev 的 next 指標指向 next ```clike prev->next = next; ``` ![](https://i.imgur.com/peLAuI0.jpg) --- ```clike next->prev = prev; ```` * next 的 prev 指標指向 prev ![](https://i.imgur.com/wooGjJR.jpg) * 最終狀態如下圖 ![](https://i.imgur.com/9Me2JPT.jpg) --- 以上完成了 `list_remove` 的一次操作,可以注意到此方法僅改變前後指標, entry 並未被移除,其 prev 及 next 指標並未變動。 接下來回到 `list_pop` 中的第一行 ```clike list_t *foo = list->prev; ``` * foo 記錄 list 的前一 node ,此 node 即為 list 的 first entry ![](https://i.imgur.com/wgO8lMA.jpg) --- 因此我們用 `list_remove` 將 foo 移除,再將其回傳即可,如下圖 ![](https://i.imgur.com/d0Hi20l.jpg) 故答案選擇 `(e)` list_remove(foo); return foo; --- ## 延伸問題: ### 1. 研讀 2019q3 Week11 的 cache-oblivious data structures 參考資料,記錄所見所聞: --- [Maximize Cache Performance with this One Weird Trick: An Introduction to Cache-Oblivious Data Structures](https://rcoh.me/posts/cache-oblivious-datastructures/) 文章中提到,Postgres 利用固定大小的 blocks ,又稱為 "pages" 實作 B-Tree,其目的是為了與 OS 中的 page sizes 做對應,而 OS 又對應到硬體。 然而,上述做法實作於不同硬體時,必須針對其挑選一最適合的 page size 以達到較好的效能。 因此本篇的主題便是想設計不被 underlying cache size 影響的資料結構及演算法,如此我們不需自己調整 cache size,程式碼便會最佳化的利用 cache layers,擁有此性質的資料結構及演算法,我們稱之為 "cache-size oblivious"。 首先,我們來看一張 BST 的效能比較圖 ![](https://rcoh.me/images/cache-oblivious-graph.png) 上圖中我們可以看到三種不同策略的 BST 走訪: 1. blue line : level by level 的走訪,又稱為 "bread first traversal". 2. orange line : (preorder) "depth first traversal". 3. green line : 利用 "recusive blocking" 法實作上述的 cache obivious 概念. 由上圖我們發現,在 elements in tree 達到 1600 萬的時候,"recursive blocking" 法的效率為另外兩種方法的近兩倍之外。 接下來我們探討如何實作 green line 的方法,以及其如何效能遠高於其他兩種方法的原因。 #### A new scheme for analyzing algorithms 一般分析演算法效能的 Big-$O$ notation,是假設讀取成本與記憶體中的 byte 成正比,然而真實世界的運作方式是以 block 為單位,即便你只想讀少量 bytes 的資料,還是得付出其所在整個 block 的成本,反之,若你想從同一個 block 讀取更多的 bytes,幾乎不用負擔任何額外成本。 因此 cache 利用率較好的演算法應該有以下特質: When we read a block of data, we make use of every byte in that block. 然而使用一般演算法分析無法看出 "cache-oblivious" 的效益,因此我們介紹一種較接近真實世界運作 (work in blocks) 的演算法分析模型。 探討這種不受 cache size 影響又能最佳化運作的演算法時,我們假設 memory 有兩個區塊,small fast layer 及 big slow layer,其擁有以下特性: 1. memory 只可以從 slow layer 移動到 fast layer,且單位為固定大小的 block, $B$ 2. slow layer 的大小為無限大,而 fast layer 的大小為 $M$ 3. 我們分析演算法時以 $M$ 為 cache 的單位,$B$ 為 block 的單位 4. 分析時一單位的工作量為從 slow memory 載入 a block 至 fast memory,其他皆視為極快速的工作,不納入考量 回到前面那張 BST 效能比較圖 ![](https://rcoh.me/images/cache-oblivious-graph.png) 我們可以發現,在 Elements in Tree($N$)很小時,效能幾乎沒有差異,那是因為所有樹都在同一 block 內,讀取成本相差無幾,此外,blue and orange lines 中平坦的部分發生在特定的 internal cache sizes,而當$N$越大,cache-oblivious 的優勢就越大。 接著我們看看上圖三種方法的運作方式: #### Blue line : By Level (Breadth) ![](https://rcoh.me/images/breadthfirst.svg) * 其在 memory 內的觀點是以上圖的橘框為單位,elements 內的數字即為其在 array 內的位置 * 這種方法有個明顯的缺點,當 level 夠大時,每個 level 就代表一個 block,不但前面低階層的 block 利用率極低,如第一個 block 只存了 1 個 element ,且從樹根走訪至樹葉時,每經過一 level 就相當於讀取不同的 block,導致讀取成本高達 $\log_2N$ #### Orange line : Preorder (Depth First) ![](https://rcoh.me/images/depthfirst.svg) * 這種方法在讀取至下一個 block 之前,會持續往下走訪,在上圖的橘框內作樹根至樹葉走訪時,都在同一個 block 內讀取,為最佳情況。 * 最靠右下的 block 只存放一個 element,若是做最右邊的走訪,讀取成本也是 $\log_2N$ * 此方法僅比 BFS 好一點 #### Green line : Recursive Block Approach ![](https://rcoh.me/images/van-layout.svg) * 利用 divide and conquer strategy 實現 cache-oblivious data structures and algorithms * 從樹高 $(logN)$ 一半處 split ,則上半部有 $2^{\log_2(N)/2}=\sqrt{N}$ 個 nodes,而其 child nodes 也有大約 $\sqrt{N}$ 個 nodes,如此遞迴 split,如上圖 * 每個綠框為一個 block,橘框為 superblock * blocks 間以及 superblocks 間皆是連續的 * 再讀取下一個 block 前,隨著讀取的 block size 變大,能走訪的 level 也越大 例如 : 讀取一 block of size $B$,可以走訪 $\log_2B$ 的 levels 樹有 $\log_2N$ 個 levels,則讀出的 blocks 數為 $\frac{\log_2N}{\log_2B}=\log_BN=F(N)$ ### 2. 依據之前給定的 Linux 核心 linked list 實作,開發 cache-oblivious 的版本,並證實其效益 --- 一般在設計 cache-oblivious 的 data structures and algorithms 時有三個常用策略: 1. van Emde Boas (vEB) 2. Weighted 平衡樹 3. Packed Memory Structure 而前面所利用的 Recursive Block Approach 便是採用 vEM 策略,如下圖 ![](https://www.itread01.com/uploads/images/20161007/1475818713-3600.jpg) 而設計 cache-oblivious linked list 常用的策略為 Packed Memory Structure 在 [Cache-Oblivious Algorithms and Data Structures](http://erikdemaine.org/papers/BRICS2002/paper.pdf) 中提到: The packed-memory structure maintains N elements consecutive in memory with gaps of size $O(1)$, subject to insertions and deletions in $O(\log2N)$ amortized time and $O((\log_2N)/B)$ amortized 接著我們介紹在 [Data Structures Libraries](http://www.lsi.upc.edu/~lfrias/thesis/thesis-lfrias.pdf?fbclid=IwAR0r3a8tTkC2QDVxR6cyEWN41JhZiQwdnQht8IJBJz3cGjVhfrr2aEYGqT8) 中與 packed-memory structure 相關的三種資料結構,其概念是在 linked list 配置 array 儲存資料,並將整個結構稱為 bucket ![](https://i.imgur.com/HDqAKp6.png) ( a ) Contiguous: 在 linked list 內配置的 array 作連續性配置 * 優點:在同一個 bucket 內的 array 存取資料時,因為容易在同一個 memory block 內,存取速度會相較於原本的 linked list 快 * 缺點:和 array 一樣,插入或刪除資料時必須付出移動資料的成本 ( b ) With gaps: bucket 內部 array 的元素間允許存在空隙 * 減少因插入刪除而移動資料的成本,較 Contiguous 好一些 * 每一個 element 必須耗費額外空間記錄此空間是否存資料 ( c ) Linked: 內部元素間由 link 連接 * 須耗費額外空間儲存 link * 插入或刪除時不須負擔額外移動資料的成本 * scalability 較佳 --- [github](https://github.com/jeffcarl67/unrolled_linked_list) 以下是 contiguous 的 unrolled list 實作: * Data Structure of unrolled lsit ```clike= struct listitem { struct list_head list; int i[CAPACITY]; int stored; int cap; }; ``` * Initialization ```clike= void init_listitem(struct listitem *item) { if (!item) return; item->cap = CAPACITY; item->stored = 0; } ``` 每個 listitem 沿用了 list.h 的 list_head 之外,另有一陣列儲存資料,stored 記錄佔用格數,cap 為每個 bucket element 格數,以下是幾個基本操作 (a) insertion ```clike int insert_unrolled(struct list_head *list, int item) { struct listitem *tmp = NULL; struct listitem *last = NULL; if (!list) return 0; last = list_entry(list->prev, struct listitem, list); if (list_empty(list) || last->stored >= (last->cap / 2 + 1)) { tmp = (struct listitem *)malloc(sizeof(struct listitem)); if (!tmp) { printf("malloc error\n"); return -1; } init_listitem(tmp); list_add_tail(&tmp->list, list); last = list_entry(list->prev, struct listitem, list); } last->i[last->stored] = item; last->stored++; return 0; } ``` (b) deletion ```clike= int delete_unrolled(struct list_head *list, int index) { struct listitem *entry = NULL; int i = 0; list_for_each_entry(entry, list, list) { if (i <= index && i + entry->stored > index) { memcpy(entry->i + index - i, entry->i + index - i + 1, sizeof(int) * (i + entry->stored - index) - 1); entry->stored--; } i += entry->stored; } return 0; } ``` 備註: [演算法筆記](http://www.csie.ntnu.edu.tw/~u91029/Data.html?fbclid=IwAR3dF04fF4gthJ_RuaC1jYpPr-TaO9ChuptqolrmBHUUiiQau6g3xn7yWnM)的資料結構中,假設 $N$ 筆資料,分成 $A$ 個 Bucket,每個 bucket 約 $B=N/A$ 個元素,而在刪除資料時,若是相鄰兩塊小於等於 $B$ 就要合成一塊。 本實作演算法尚未加上上述合併的功能。 ### 效能分析 --- ### 開發環境 <`LiunuXXX`> ``` $ lscpu Architecture: x86_64 CPU op-mode(s): 32-bit, 64-bit Byte Order: Little Endian CPU(s): 4 On-line CPU(s) list: 0-3 Thread(s) per core: 2 Core(s) per socket: 2 Socket(s): 1 NUMA node(s): 1 Vendor ID: GenuineIntel CPU family: 6 Model: 58 Model name: Intel(R) Core(TM) i5-3230M CPU @ 2.60GHz Stepping: 9 CPU MHz: 2934.994 CPU max MHz: 3200.0000 CPU min MHz: 1200.0000 BogoMIPS: 5188.25 Virtualization: VT-x L1d cache: 32K L1i cache: 32K L2 cache: 256K L3 cache: 3072K NUMA node0 CPU(s): 0-3 ``` ``` $ uname -r 4.15.0-51-generic ``` ``` $ gcc --version gcc (Ubuntu 5.4.0-6ubuntu1~16.04.11) 5.4.0 20160609 ``` ``` $ gnuplot gcc (Ubuntu 5.4.0-6ubuntu1~16.04.11) 5.4.0 20160609 ``` ### Traversal * Traversal Test of list ```clike= void test_traverse(struct list_head *list, int num) { struct timeval start; struct timeval end; struct timeval result = {0, 0}; struct listitem *entry, *next, *item; int i; int ret; INIT_LIST_HEAD(list); for (i = 0; i < num; i++) { item = (struct listitem *)malloc(sizeof(struct listitem)); item->i = i; list_add_tail(&item->list, list); } system("echo 3 > /proc/sys/vm/drop_caches"); gettimeofday(&start, NULL); list_for_each_entry(entry, list, list) {} gettimeofday(&end, NULL); timersub(&end, &start, &result); printf("%d %lu %lu\n", num, result.tv_sec, result.tv_usec); list_for_each_entry_safe(entry, next, list, list) { list_del(&entry->list); free(entry); } ``` * Traversal Test of unrolled list (aware) ```clike void test_traverse(struct list_head *list, int num) { struct timeval start; struct timeval end; struct timeval result = {0, 0}; struct listitem *entry, *next; int i; int ret; INIT_LIST_HEAD(list); for (i = 0; i < num; i++) { ret = insert_unrolled(list, i); } system("echo 3 > /proc/sys/vm/drop_caches"); gettimeofday(&start, NULL); list_for_each_entry(entry, list, list) { i = 0; while (i < entry->stored) { i++; } } gettimeofday(&end, NULL); timersub(&end, &start, &result); printf("%d %lu %lu\n", num, result.tv_sec, result.tv_usec); list_for_each_entry_safe(entry, next, list, list) { list_del(&entry->list); free(entry); } ``` 利用 timeval 分析走訪 list 及 unrolled list (aware) 的時間,結果如下圖 ![](https://i.imgur.com/pyd2Iuy.png) 推測此處差異主要是在時 unrolled list 內的 array 使其在讀取時較有優勢,而原本的 list 在存取時較常跳到不同的 block 而產生 cache miss 。 ### Insertion * Insertion test of list ```clike void test_insert(struct list_head *list, int num) { struct timeval start; struct timeval end; struct timeval result = {0, 0}; struct listitem *entry, *next, *item; int i; int ret; INIT_LIST_HEAD(list); system("echo 3 > /proc/sys/vm/drop_caches"); gettimeofday(&start, NULL); for (i = 0; i < num; i++) { item = (struct listitem *)malloc(sizeof(struct listitem)); item->i = i; list_add_tail(&item->list, list); } gettimeofday(&end, NULL); timersub(&end, &start, &result); printf("%d %lu %lu\n", num, result.tv_sec, result.tv_usec); list_for_each_entry_safe(entry, next, list, list) { list_del(&entry->list); free(entry); } } ``` * Insertion test of unrolled list (aware) ```clike void test_insert(struct list_head *list, int num) { struct timeval start; struct timeval end; struct timeval result = {0, 0}; struct listitem *entry, *next; int i; int ret; INIT_LIST_HEAD(list); system("echo 3 > /proc/sys/vm/drop_caches"); gettimeofday(&start, NULL); for (i = 0; i < num; i++) { ret = insert_unrolled(list, i); } gettimeofday(&end, NULL); timersub(&end, &start, &result); printf("%d %lu %lu\n", num, result.tv_sec, result.tv_usec); list_for_each_entry_safe(entry, next, list, list) { list_del(&entry->list); free(entry); } } ``` Insertion 效能比較如下圖 ![](https://i.imgur.com/zjsdW5P.png) 類似 traversal,效能差異來自於 unrolled list 內的 array 於循序寫入時的效益。 ### 3. 實作 Skip list,並且比照之前的 unit test 提供對應的測試、效能分析,還有改善機制 --- ==在 Linux 核心原始程式碼使用 skip list 的案例,介紹其原理,設計 Linux 核心模組的實驗== * 需要涵蓋 kernel API 同步機制的運用 * 執行時期的分析 (提示: 可善用 eBPF) # References 1. [Maximize Cache Performance with this One Weird Trick: An Introduction to Cache-Oblivious Data Structures](https://rcoh.me/posts/cache-oblivious-datastructures/#fnref:assum) 2. [關於算法,那些你不知道的事](https://www.itread01.com/articles/1475818714.html?fbclid=IwAR39mapd0cR55YSU4rgLAPqOjR0L6gBE9JxbTzdIq2H8sOF44kbiFs3xL9s) 3. [Cache-Oblivious Algorithms and Data Structures](http://erikdemaine.org/papers/BRICS2002/paper.pdf)