# Linux 核心專題: 改進 ksort
> 執行人: ChengChaoChun
> [專題解說錄影](https://youtu.be/-Sxirdm1ByE)
> [GitHub](https://github.com/ChengChaoChun/linux_project_)
### Reviewed by `yu-hsiennn`
應放上 commit 紀錄,且根據 [好的 Git Commit Message](https://blog.louie.lu/2017/03/21/%E5%A6%82%E4%BD%95%E5%AF%AB%E4%B8%80%E5%80%8B-git-commit-message/) 來書寫,只單看你的 commit message 無法快速理解到這次你的更新。
> 感謝同學的糾正,已補上 commit 紀錄
### Reviewed by `vestata`
Timsort 可以加入部分排序的資料分佈比較,因為 Timsort 演算法針對部分排序好的資料,比較能顯示差異。
> 感謝同學的建議,已加入部分排序的資料分佈比較,包含 descending order , ascending order , push front , pipe organ 的實驗。
### Reviewed by `randyuncle`
請問有辦法提供更多資料分佈的實驗嗎?例如說 [listsort.txt](https://github.com/python/cpython/blob/main/Objects/listsort.txt) 中提到的資料分佈(部份排序的資料、quick sort 的 worst case 等)。
## 任務簡介
重作第六次作業的 ksort,探討並行化的資料排序實作手法。
## TODO: 依據第六次作業規範,重作 ksort
> 要確保並行化的資料排序結果正確,善用 kernel thread / workqueue / CMWQ 來分發運算工作到有效的 CPU 核。
> 善用 CMWQ 達成排序的並行處理,需要設計對應的檢驗程式。
> 可適度引用其他學員的成果,務必註明出處並確認其實作有效益
### 並行排序實作 (`sort_impl.c`)
ksort 使用〈[Engineering a Sort Function](https://cs.fit.edu/~pkc/classes/writing/papers/bentley93engineering.pdf)〉提出的 quick sort 實作並改進,加上 Linux 核心 的 CMWQ 達成並行化的資料排序。
在 `sort_impl.c` 的 161 行多了一個 `swap_cnt` 等於 0 變數的判斷式,如果`swap_cnt` 等於 0 就切換成插入排序。往前看到 130 行到 153 行程式,可以發現一但需要使用 `q_swap` 巨集,`swap_cnt` 就會被設定為 1 。因此判斷式成立的條件是,在第一次進入 for 循環時, `pb` 指標前的元素都小於 pivot key,且 `pc` 指標之後的元素都大於 pivot key ,也就是說當前陣列有可能已經大致是有序的,此時使用插入排序可能可以達到最好的情況,即時間複雜度為 $n$。
:::danger
提供數學證明。
> 已嘗試證明
> 沒有!為何不翻閱你的教科書,從權威材料學習呢?
:::
變數 r 設定交換次數的上限。儘管此時 `swap_cnt` 為 0 ,但是使用插入排序來排序 `pb` 指標之前的元素有可能會形成最壞的情況,即時間複雜度為 $n^2$,同樣的,排序 `pc` 指標之後的元素也可能會形成最壞的情況,因此這裡限制了交換次數的上限來保證排序的效率。
```c
if (swap_cnt == 0) { /* Switch to insertion sort */
r = 1 + n / 4; /* n >= 7, so r >= 2 */
for (pm = (char *) a + es; pm < (char *) a + n * es; pm += es)
for (pl = pm; pl > (char *) a && CMP(thunk, pl - es, pl) > 0;pl -= es) {
q_swap(pl, pl - es);
if (++swap_cnt > r)
goto nevermind;
}
return;
}
```
* 證明 :
在選擇完 pivot key 之後,pivot key 會被交換到陣列的第一個位置(126行)。`swap_cnt` 等於 0 的情況如下圖,也就是 b 指標與 c 指標相遇,同時 b 指標掃描過的元素都比 pivot key 要小, c 指標掃描過的元素都比 pivot key 要大,此時就會跳出 130 行的 for 循環,且`swap_cnt`等於 0。(沒有等於 pivot key 的元素),然後就換成使用插入排序。
![2024-06-29 053803](https://hackmd.io/_uploads/SyfHRon8C.png)
`swap_cnt` 等於 0 的最好情況 (交換次數最少):
![2024-06-29 061323](https://hackmd.io/_uploads/SywTH2nLA.png)
pivot key 經過 157 行的 vecswap 之後會被置換到中間,此時時間複雜度為 $n$。
`swap_cnt` 等於 0 的最壞情況 (交換次數最少):
![2024-06-29 061347](https://hackmd.io/_uploads/rJBnHh38C.png)
pivot key 經過 157 行的 vecswap 之後會被置換到中間,此時陣列不是單調遞減,但時間複雜度接近 $n^2$。
此時無法確定陣列當前的情況,因此設置一個交換次數的上限,來確保時間複雜度不會走向 $n^2$。
* 數學證明 :
排序無非是執行 compare 和 swap 操作, compare 後再決定是否要 swap。
令 f(n) 函數表示排序 n 個元素的時間複雜度
插入排序最好情況:
f(n) = f(n-1) + 1 (+1 表示比較次數)
= f(n-2) + 1 + 1
= f(n-3) + 1 + 1 + 1
= f(n-n) + 1 * n
= n (共 n 次比較, 0 次交換)
元素每交換一次就會多一次比較次數,在這個插入排序中交換次數的上限為 $1+n/4$ ,也就是說比較次數最多是 $n+1+n/4$。
因此這個插入排序的 upper bound 就是 $5n/4+1$,一但超過就會終止插入排序。
:::danger
注意用語:
* jump 是「跳躍」,而非「跳轉」,這操作沒有「轉」
$\to$ 教育部重編國語詞典的「[轉](https://dict.revised.moe.edu.tw/dictView.jsp?ID=8114)」解釋。
> 已修改,謝謝糾正
:::
nl 與 nr 分別表示 pb 指標以前待排序的元素個數與 pc 指標以後待排序的元素個數。
* 當 nl > 100 且 nr > 100 : 新建一個 work item 並放到 workqueue 來排序 pb 指標以前的元素(並行排序),而 pc 指標以後的元素則<s>跳轉</s> 跳躍到 top 標籤排序(在當前 work item)。
* 當 nl > 0 : 遞迴執行函式來排序 pb 指標以前的元素。遞迴在當前執行流上立刻排序,而 CMWQ 是非同步的執行,因此待排序元素較少時使用遞迴更有效率。
```c
nevermind:
nl = (pb - pa) / es;
nr = (pd - pc) / es;
if (nl > 100 && nr > 100) {
struct qsort *q = kmalloc(sizeof(struct qsort), GFP_KERNEL);
init_qsort(q, a, nl, c);
queue_work(workqueue, &q->w);
} else if (nl > 0) {
qs->a = a;
qs->n = nl;
/* The struct qsort is used for recursive call, so don't free it in
* this iteration.
*/
do_free = false;
qsort_algo(w);
}
if (nr > 0) {
a = pn - nr * es;
n = nr;
goto top;
}
if (do_free)
kfree(qs);
```
## TODO: 整合第一週測驗提到的 Timsort
> 予以並行化
:::danger
注意用語:
* algorithm 是「演算法」,而非「算法」(the way to calculate)
> 已修改,謝謝糾正
> 不用急著說「已修改」,你應該依循本課程教材規範的術語,完整修改和調整後才能說「已修改」。
:::
> [commit](https://github.com/ChengChaoChun/linux_project_/commit/12c498c04b278eeda3f1bc364b23122bc80c13d5)
第一週測驗提到的 Timsort <s>算法</s> 演算法是以雙向鏈結串列來輔助排序的。在我們的排序模組中使用 copy_from_user 函式接收來自 user space 的資料(連續的記憶體空間,也就是陣列),因此這裡需要建立雙向鏈結串列,並將資料複製到雙向鏈結串列上,才能由 Timsort 演算法排序。
這裡我們走訪陣列中的每一筆資料時,配置一個節點並記錄資料,然後添加到鏈結串列的尾部。
```c
uint64_t *sort_buffer = kmalloc(size, GFP_KERNEL);
if (!sort_buffer)
return 0;
len = copy_from_user(sort_buffer, buf, size);
if (len != 0)
return 0;
struct list_head head;
INIT_LIST_HEAD(&head);
int ele_num = size / es;
for (int i = 0; i < ele_num; i++) {
element_t *elem = kmalloc(sizeof(element_t), GFP_KERNEL);
elem->val = *(sort_buffer + i);
list_add_tail(&elem->list, &head);
}
```
排序完成後我們需要把資料結構由鏈結串列轉換為陣列,然後釋放鏈結串列配置的記憶體空間,最後再使用 copy_to_user 傳遞到 user spcae。
```c
struct list_head *pos;
uint64_t *buf_p = sort_buffer;
element_t *entry;
list_for_each (pos, &head) {
entry = list_entry(pos, element_t, list);
*(buf_p) = entry->val;
buf_p++;
}
/* Free list */
element_t *node, *safe;
list_for_each_entry_safe (node, safe, &head, list) {
list_del(&node->list);
kfree(node);
}
len = copy_to_user(buf, sort_buffer, size);
if (len != 0)
return 0;
```
### Timsort 並行化
修改 Timsort 的函式介面,函式接收一個 work_struct 參數。
```c
void timsort(struct work_struct *w);
```
原先的 Timsort 需要接收三個參數,這裡定義一個結構體,用來記錄這三個參數以及 work_struct 。
```c
struct timsort_struct {
struct work_struct w;
void *priv;
struct list_head *h;
list_cmp_func_t cmp;
};
```
將 timsort_struct 結構體初始化,然後將 work_item 放到 workqueue。
```c
void timsort_init(struct timsort_struct *t,
void *priv_,
struct list_head *h_,
list_cmp_func_t cmp_)
{
INIT_WORK(&t->w, timsort);
t->priv = priv_;
t->h = h_;
t->cmp = cmp_;
};
timsort_init(&tim_, &count, &head, tim_compare);
queue_work(workqueue, &tim_.w);
drain_workqueue(workqueue);
```
接著看到 Timsort 函式,原先的 Timsort 函式要接收 void 指標, list_head 指標,以及比較函式,而我們的 Timsort 改成接收一個 work_struct 指標。這時使用 `container_of` 巨集就可以得到 timsort_struct 結構體在記憶體起始位置,並取得排序的參數。
```c
struct timsort_struct *t = container_of(w, struct timsort_struct, w);
struct list_head *head = t->h;
list_cmp_func_t cmp = t->cmp;
void *priv = t->priv;
```
:::danger
如何確保上述程式碼是正確且有效的實作?
> 已貼上實作 github 連結
> 所以呢?你到底如何驗證?只用少數的測試就能說明程式碼正確無誤嗎?倘若你搭乘的飛機,其背後的工程師也用這樣的態度去面對飛機的自動控制系統,你趕搭飛機嗎?補上驗證方式的探討、數學分析,並充分從實驗進行檢討。
:::
驗證實作有效性一 :
> [commit](https://github.com/ChengChaoChun/linux_project_/commit/b0386d89c02beac0fe113c830525edf6ac003e33)
1. 在 [CMWQ](https://www.kernel.org/doc/html/latest/core-api/workqueue.html) 提到 :
* work item 包含一個紀錄要非同步執行的函式的指標
> A work item is a simple struct that holds a pointer to the function that is to be executed asynchronously.
* kworkers 依次執行 workqueue 中的函式
> For threaded workqueues, special purpose threads, called [k]workers, execute the functions off of the queue, one after the other.
2. 參考 <Linux内核设计与实现(第三版)> 這本書 :
行程在準備就緒或者正在運行的狀態都是`TASK_RUNNING`, Linux 並沒有區分行程的 ready state 和 running state。
`TASK_RUNNING` 在 Linux 核心中的定義 :
`#define TASK_RUNNING 0x00000000`
![螢幕擷取畫面 2024-07-03 062859](https://hackmd.io/_uploads/BkeffRzvC.png)
![螢幕擷取畫面 2024-07-03 062847](https://hackmd.io/_uploads/rJG7z0zD0.png)
3.驗證方法
![螢幕擷取畫面 2024-07-03 214412](https://hackmd.io/_uploads/Sk8RSAfwA.png)
實際執行結果 :
* `sort_selector : 1 ` 選擇 Timsort
* `timsort` 執行過程中將 user 行程的狀態輸出,此時狀態為 `TASK_RUNNING`
```
sudo dmesg
[ 127.631722] sort_selector : 1
[ 127.631746] sort_read (pid=2885, comm=user, state=0)
[ 127.631844] timsort (pid=24, comm=kworker/1:0, state=0)
[ 127.631846] comm = user , task pid = 2885, task state = 0
[ 127.632194] timsort time_cost 289419 ns
[ 127.632196] sorted by timsort
```
## TODO: 實作 Pattern Defeating Quicksort (pdqsort)
> 比較 Timsort, pdqsort 和 Linux 核心 lib/sort.c,並確保可從使用者層級的程式對裝置檔案進行設定 (如 write 系統呼叫),讓這些排序實作存在於同一個 Linux 核心模組,並得以切換和比較。
> 過程中需要量化執行時間,可在 Linux 核心模組和使用者空間 (userspace) 中測量。在 Linux 核心模組中,可用 ktime 系列的 API,而在使用者層級可用 clock_gettime 相關 API,分別用 gnuplot 製圖。
> 善用統計模型,除去極端數值,過程中應詳述你的手法;
> 需要針對不同的情境去準備測試資料;
> [commit](https://github.com/ChengChaoChun/linux_project_/commit/c57e643d7ff223a3243ba0c0327c9b0e44b89888)
目前在 ksort 模組有 qsort , linux sort , pdqsort ,以及 Timsort 。
使用 write 系統呼叫來選擇使用的排序。當 sort_selector 為 1 時,使用 Timsort ,當 sort_selector 為 2 時,使用 linux sort ,當 sort_selector 為 3 時,使用 pdqsort,當 sort_selector 為 4 時,使用 qsort。
```c
static int sort_selector = 0;
static ssize_t sort_write(struct file *file,
const char *buf,
size_t size,
loff_t *offset)
{
void *set_sort_buf = kmalloc(size, GFP_KERNEL);
if (!set_sort_buf)
return 0;
unsigned long len;
len = copy_from_user(set_sort_buf, buf, size);
if (len != 0)
return 0;
int set_sort = *(int *)set_sort_buf;
if(set_sort > 0 && set_sort < 5){
sort_selector = set_sort;
printk("sort_selector : %d", set_sort);
return set_sort;
}else{
printk("sort_selector error");
return 0;
}
}
```
在 `user.c` 測試結果如下
```
sudo ./user 1
sort by timsort
set seccuess 1
Soring succeeded!
------------------------
sudo ./user 2
sort by linux lib sort
set seccuess 2
Soring succeeded!
------------------------
sudo ./user 3
sort by pdqsort
set seccuess 3
Soring succeeded!
------------------------
sudo ./user 4
sort by qsort
set seccuess 4
Soring succeeded!
```
### 排序時間比較
#### 降序
* **使用者空間 (userspace) 中測量**
![2024-06-29 002403](https://hackmd.io/_uploads/rksRED2U0.png)
* **Linux 核心模組中測量**
![2024-06-29 002314](https://hackmd.io/_uploads/BkVJHvhIC.png)
#### Fisher Yates shuffling
* **使用者空間 (userspace) 中測量**
![2024-06-29 004259](https://hackmd.io/_uploads/rJf4_w3I0.png)
* **Linux 核心模組中測量**
![2024-06-29 003736](https://hackmd.io/_uploads/r1QrDPhI0.png)
| ![2024-06-29 003813](https://hackmd.io/_uploads/SJHqwPhLA.png) | ![2024-06-29 003842](https://hackmd.io/_uploads/BJAFDD2I0.png) |
|-|-|
#### 分析
在 kernel space 中, Timsort 和 pdqsort 的表現差不多,但是明顯要優於 linux sort 。而在 user space 中, Timsort 的表現都是最差的。這是因為 userspace 在系統呼叫後, kernel 先走訪了一次陣列並且同時建立鏈結串列,才進行排序。而在排序完後又走訪二次鏈結串列,一次將資料複製到陣列上,一次釋放鏈結串列節點,最後才回到 user space 。
:::danger
上述實驗是否涵蓋多種資料分布的情境?
[orlp/pdqsort](https://github.com/orlp/pdqsort) 提到的最差、最佳,和平均狀況應予以考慮。
:::
Orsan Peters (Timsort 作者) 提到 pdqsort 對於單調遞增,單調遞減,相同元素,或者嚴格遞增單最後一個元素為 0 的陣列(作者稱為 push front)都可以有不錯的表現。下圖是作者的實驗數據,可以看到 shuffled 和 pipe organ (元素序列為小到大然後再大到小) 陣列明顯的比其他序列的陣列排序時間要長。
![螢幕擷取畫面 2024-07-06 034716](https://hackmd.io/_uploads/HyuBRTrDC.png)
下面的實驗來驗證 pdqsort 的排序效率並與 linuxsort 和 timsort 比較。
#### all equal
* **使用者空間 (userspace) 中測量**
![螢幕擷取畫面 2024-07-06 032825](https://hackmd.io/_uploads/BJ-oRaHw0.png)
* **Linux 核心模組中測量**
![螢幕擷取畫面 2024-07-06 032803](https://hackmd.io/_uploads/ry99RprvC.png)
#### ascending order
* **使用者空間 (userspace) 中測量**
![螢幕擷取畫面 2024-07-06 033451](https://hackmd.io/_uploads/rkk-JRBDC.png)
* **Linux 核心模組中測量**
![螢幕擷取畫面 2024-07-06 033426](https://hackmd.io/_uploads/HkWeJRrw0.png)
#### push front
* **使用者空間 (userspace) 中測量**
![螢幕擷取畫面 2024-07-06 034457](https://hackmd.io/_uploads/HJ6ggCSPC.png)
* **Linux 核心模組中測量**
![螢幕擷取畫面 2024-07-06 034439](https://hackmd.io/_uploads/SkfNyASDR.png)
#### pipe organ
* **使用者空間 (userspace) 中測量**
![螢幕擷取畫面 2024-07-06 032246](https://hackmd.io/_uploads/BkRKg0SvA.png)
* **Linux 核心模組中測量**
![螢幕擷取畫面 2024-07-06 032228](https://hackmd.io/_uploads/Bk_YeArvA.png)
經過一系列的實驗,可以發現 shuffled (Fisher Yates shuffling) 和 pipe organ 的排序時間確實是要比其他序列的陣列排序時間要更長。在作者提供的實驗數據中 pipe organ 的排序時間是要略長於 shuffled 的,而我實際實驗的結果是 Fisher Yates shuffling 排序時間是要比 pipe organ 更長的,這可能是陣列長度不一致(作者實驗的陣列長度是 $10^6$),也有可能是陣列洗牌方式的影響。
## TODO: 引入 PRNG
> 考慮到產生亂數的時間和可預測性,改用 xorshift128+ 作為 PRNG,並善用 xoro 核心模組。
> 資料排序要考慮到演算法最差的狀況,不只有亂數輸入。
> 針對執行結果,予以討論,附上數學分析和解讀。
### xoroshiro128+
* **使用者空間 (userspace) 中測量**
![2024-06-29 043739](https://hackmd.io/_uploads/B1lw-ihLC.png)
* **Linux 核心模組中測量**
![2024-06-29 043714](https://hackmd.io/_uploads/H1-8bih8R.png)
### xorshift128+
* **使用者空間 (userspace) 中測量**
![2024-06-29 043947](https://hackmd.io/_uploads/Sy2dWihU0.png)
* **Linux 核心模組中測量**
![2024-06-29 044005](https://hackmd.io/_uploads/Bk9Kbs2U0.png)
| ![2024-06-29 044206](https://hackmd.io/_uploads/Skysbj280.png) | ![2024-06-29 044358](https://hackmd.io/_uploads/rkEs-i3LR.png) |
|-|-|