# 2020q1 Homework3 (quiz3) contributed by < `ryanwang522` > > [測驗題目描述](https://hackmd.io/@sysprog/linux2020-quiz3) > [GitHub](https://github.com/ryanwang522/linked-list-sorting) ## 測驗 1 這題是探討 XOR linked list 的操作,其實主要就是利用 XOR 的運算特性: * $A \oplus A = 0$ * $A \oplus 0 = A$ * $A \oplus 1 = \neg A$ * $(A \oplus B) \oplus B = A$ 簡單來說,假設串列 `1->2->3` 以 XOR list 表的結構如下: ``` addr A B C data 1 2 3 link 0 xor B A xor C B xor 0 ``` 所以假設我們現在有一個指向 `B` 的指標,以及一個指向 `A` 的指標 ,則可以透過 `XOR(B->link, A) = C` 得到 `C` 的位址 。 ### 解釋 XOR linked list 排序實作的原理,並指出其中可改進之處 主要的概念跟 [quiz1](https://hackmd.io/@sysprog/linux2020-quiz1) 類似,排序邏輯可以參考我之的 [解題思路](https://hackmd.io/@Ryspon/HJVH8B0XU)。 要看懂這次測驗,首先要先知道大量使用的一段程式碼的作用為何 ```cpp xor_list *next = left->addr; if (next) next->addr = XOR(left, next->addr); merge = left; left = next; ``` * `left` 一開始指向一串串列的開頭,`left->addr` 為下一節點的位址和 `0` 做 XOR 的結果。 * `next->addr = XOR(left, next->addr);` 把下一個節點和當前的節點切開,以下圖為例,就是把 `A` 跟 `B` 切開,讓 `B->addr = XOR(left, B->addr) = C` (A xor A xor C = C = 0 xor C) ``` addr A B C A | B C data 1 2 3 ---> 1 | 2 3 link 0 xor B A xor C B xor 0 0 xor B | 0 xor C B xor 0 ``` * `left` 再指到 `next`,也就是說這段程式碼是在**把一串 list 的起點切出來給 `merge`,讓 `left` 指到剩下的 list 開頭**。 接著,就看 `left` 和 `right` 誰的開頭的值比較小,把它切出來連接在 `merge` 的後面(**`merge` 永遠指向 sorted list 的最後一個節點**)。左右的部分邏輯都一樣,這裡以 *`right` 的開頭比較小*的情況為例說明: ```cpp // 將 `right` 的起點切出來,`next` 指向剩下的 list 的開頭 xor_list *next = right->addr; if (next) next->addr = XOR(right, next->addr); if (!merge) { // merge 為空,代表最終的 sorted list 還沒有東西 // 將 sorted list 記錄在 `start` // 由於目前只有一個節點,因次 merge 也指向他 // 加上前後都還沒有節點,`merge->addr` = NULL start = merge = right; merge->addr = NULL; } else { // merge 不為空, merge 同時代表 sorted list 最後一個節點 // 將 merge 和切出來的 right 接起來 // 現在換成 right 是最後一個節點了 merge->addr = XOR(merge->addr, right); // 因為 right 的前一個是 merge,因此 `right->addr` = XOR(merge, NULL) right->addr = merge; // merge 持續指向最後的節點 merge = right; } // right 指向剩下的 right-list 的開頭 right = next; ``` 根據以上分析 * RR1 = `XOR(merge->addr, right)` * RR2 = `merge` 同理 * LL1 = `XOR(merge->addr, left)` * LL2 = `merge` 懂了之後發現其實整個 for-loop 就是在把 `left` `right` 兩個排序好的串列進行合併,跟先前作業的 `sorted_merge()` 是在做一樣的事情,只是這裡的 `left` 只有一個節點,因此就退化成了 insertion sort。 ### 優化 merge sort 根據 [Optimizing merge sort](https://en.wikipedia.org/wiki/Merge_sort#Optimizing_merge_sort) 提到的策略,在子序列大小達到 $S$ 時就不再繼續分割,此時這個 $S$ 為可完整放進處理器 cache 中的元素數量。每個大小為 $S$ 的子序列,都透過適用於小規模 in-place (即不需要額外配置記憶體) 排序演算法(如插入排序或泡沫排序)進行排序,以減少在記憶體內交換特定區域的次數。 > For example, the tiled merge sort algorithm stops partitioning subarrays when subarrays of size S are reached, where S is the number of data items fitting into a CPU's cache. [name=Wikipedia] 這裡目標為設計一個實驗,來找出最佳的 $S$ 為多少。 首先先看一下硬體組成 * OS : Ubuntu 16.04 LTS * L1d cache : 32K * L1i cache : 32K * L2 cache : 256K * L3 cache : 12288K * CPU(s) : 12 * Model name : Intel(R) Core(TM) i7-3930K CPU @ 3.20GHz 實作 `insertion_sort` 不難,即使是 XOR linked list,我改寫測驗中的 for-loop 部分為 `sorted_merge()`,每次取還沒排序好的串列中的第一個出來,插入到排序好的串列裡面(i.e. 等同於合併兩個排序好的 list,但其中一個 list 只有一個節點)。 ```cpp static void *insertion_sort(void *start) { if (!start || !((xor_list *)start)->addr) return start; xor_list *sorted = (xor_list *)start; xor_list *next = sorted->addr; if (next) next->addr = XOR(sorted, next->addr); sorted->addr = NULL; for (xor_list *curr = next; curr;) { next = curr->addr; if (next) next->addr = XOR(curr, next->addr); curr->addr = NULL; sorted = sorted_merge(curr, sorted); curr = next; } return sorted; } ``` #### Experiments 接著是實驗的部分,主要的概念就是令測試的 list 長度為 10000,我把 $S$ 從 1 跑到 1000,繪圖紀錄不同的 $S$ 排序完成所花的時間,並且跟 naive merge sort 比較。 * Optimize merge sort 實作 * 首先先實作 `front_back_split()` 將陣列切分成前後長度各半的兩個 sublist,並且得到左右 sublist 長度 ( 若輸入長度為奇數,則右 sublist 長度會多 1 )。 * 概念很簡單,當 sublist 的長度小於 $S$ 時,就使用 `insertion_sort()`,反之則繼續遞迴呼叫 `opt_merge_sort()` * 另外我這裡也延續 quiz1,把 singly linked list 、 kernel list.h 的 optimized sort 實作也補上,請參考 [GitHub](https://github.com/ryanwang522/linked-list-sorting)。 XOR linked list 實作程式碼如下 ```cpp static void front_back_split(xor_list* src, xor_list **front, xor_list **back, int *front_len, int *back_len) { int len = 0; xor_list *fast = src, *fast_prev = NULL, *fast_next; xor_list *slow = src, *slow_prev = NULL, *slow_next; while (fast && XOR(fast->addr, fast_prev)) { // fast = fast->next->next fast_next = XOR(fast->addr, fast_prev); fast = XOR(fast_next->addr, fast); fast_prev = fast_next; // slow = slow->next slow_next = XOR(slow->addr, slow_prev); slow_prev = slow; slow = slow_next; len++; } *front = src; if (!src) { *back = NULL; } else if (fast != NULL) { // odd xor_list *next = XOR(slow->addr, slow_prev); next->addr = XOR(next->addr, slow); *back = next; slow->addr = XOR(slow->addr, next); *front_len = len; *back_len = len + 1; } else { // even slow_prev->addr = XOR(slow_prev->addr, slow); slow->addr = XOR(slow->addr, slow_prev); *back = slow; *front_len = *back_len = len; } } static void *opt_merge_sort(void *start, int list_len, int split_thres) { xor_list *hd = (xor_list *)start; if (hd == NULL || hd->addr == NULL) return hd; if (list_len <= split_thres) return insertion_sort(start); xor_list *left, *right; int left_len = 0, right_len = 0; front_back_split(hd, &left, &right, &left_len, &right_len); left = opt_merge_sort(left, left_len, split_thres); right = opt_merge_sort(right, right_len, split_thres); return sorted_merge(left, right); } ``` 實驗的程式碼如下,由於有不同版本(singly、list.h、XOR)的實作,我把各自版本的實作封裝在 `Sorting` 的結構介面中 ```cpp typedef struct __INTERFACE { void *(*initialize)(); void (*push)(void **head_ref, int data); void (*print)(void *head, bool new_line); void *(*sort)(void *start); void *(*opt_sort)(void *start, int list_len, int split_thres); bool (*test)(void **head_ref, int *ans, int len, bool verbose, struct __INTERFACE *sorting); void (*list_free)(void **head); } Sorting; ``` ```cpp // find optial split size int testcase_len = 10000; for (int thres = 1; thres < MAX_SPLIT_SIZE; thres++) { int *testcase = malloc(sizeof(int) * testcase_len); for (int j = 0; j < testcase_len; j++) testcase[j] = rand() % testcase_len; void *head = sorting_impl->initialize(); for (int i = testcase_len - 1; i >= 0; i--) sorting_impl->push((void **)&head, testcase[i]); // optimized sorting struct timespec start, end; clock_gettime(CLOCK_REALTIME, &start); head = sorting_impl->opt_sort(head, testcase_len, thres); clock_gettime(CLOCK_REALTIME, &end); printf("%lf", diff_in_second(start, end)); sorting_impl->list_free(&head); head = sorting_impl->initialize(); for (int i = testcase_len - 1; i >= 0; i--) sorting_impl->push((void **)&head, testcase[i]); // naive mergesort clock_gettime(CLOCK_REALTIME, &start); head = sorting_impl->sort(head); clock_gettime(CLOCK_REALTIME, &end); printf(" %lf\n", diff_in_second(start, end)); sorting_impl->list_free(&head); free(testcase); } ``` 依據結果製圖,在跑實驗的過程中有使用`taskset`,將 process 固定在 `cpu1` 上跑。 ``` $ make expr 2 ``` * $S$ := 1~1000,list 長度為 10000 ![](https://i.imgur.com/WXjmLkJ.png) * $S$ := 1~100,list 長度為 10000 ![](https://i.imgur.com/g6jS7ks.png) * 來看一下比 merger sort 還快的區間 $S$ := 1~25,list 長度為 10000 ![](https://i.imgur.com/HLAYEMC.png) 做了一連串實驗發現: * 當 list 長度越長,且 $S$ 大小選擇適當時,optimize merge sort 的優化就越顯著。 * 隨著 $S$ 越大,插入排序跟合併排序在時間複雜度的差距就明顯影響了排序時間。 * 也可以看到 sorting time 反應出了 memory hierarchy 的概念。 接著固定 $S$ 來比較不同 sorting algo. 的時間: * $S = 10$, list 長度為 10000 * insertion sort $O(n^2)$ 跟 merge sort $O(nlogn)$ 的差異顯現出來 ![](https://i.imgur.com/SzJBdJ9.png) * $S = 10$, list 長度為 100 * optimized merge sort 有稍微快一點,但優化的程度沒有想像中多 ![](https://i.imgur.com/C0jhkj2.png)