contributed by < Xx-oX
>
運用 INIT_LIST_HEAD
,將 head
的 next
跟 prev
指向自己
利用 container_of
找到目前 list_head
指向的 element_t
,並釋放空間
for 迴圈的部份之後會改用 for_each 巨集處理
改成使用 list_for_each_entry_safe 巨集,以精簡程式碼
分開檢查 head
以避免不必要的 malloc
strlen(s)
+ 1 是為了結尾 '\0'
的空間
將 strlen(s)
+ 1 存起來,避免重複運算,以通過 trace-17
(見後面 Make test
段落)
最後利用 list_add
來加入佇列
在第12行
q_release_element(el)
中,
不確定free(el->value)
的必要性 => 我認為是多餘的,理由如下查詢
manpage
:
The free() function frees the memory space pointed to by ptr, which must have been returned by a previous call to malloc(), calloc(), or realloc().
Otherwise, or if free(ptr) has already been called before, undefined behavior occurs.
If ptr is NULL, no operation is performed.會執行這行表示
el->value
的值為NULL
,根據上面敘述,不會執行任何動作
TODO: 使用記憶體分析工具,搭配實驗來探討 :notes: jserv
設計兩個程式 test1.c test2.c
執行 gcc -S [src]
查看組合語言發現
多了一段 subroutine call
但若加入 -O1
參數執行編譯器最佳化 gcc -O1 -S [src]
之後則兩者沒有差別
而 -O1
是本次作業中採用的最佳化參數,由此可見有沒有 free(s->var)
在 s->var == NULL
時沒有區別,跟設想中的一樣。
記憶體分析:
結合 test1 (-> fun_a
), test2 (-> fun_b
) 再加入兩個對照組 (fun_c
, fun_d
)
依照順序分時執行
(沒有使用編譯器最佳化)
使用 valgrind-massif
分析 heap 的使用情形
並參考去年 jasonmatobo 同學的作法,
用 massif-visuallizer
呈現結果,得到下圖
可以看出 fun_a
, fun_b
對於 heap
的使用是一樣的,由此佐證前述之猜測。
(其中 fun_d
會導致 memory-leak,因為沒有將 strdup
產生出的空間釋出)
與 q_insert_head
相似,改用 list_add_tail
來加入佇列的尾端
斷開 head
連結,並將 element
中的內容複製到 sp
中
strlen
不會計算 null terminator
The strlen() function calculates the length of the string pointed to by s, excluding the terminating null byte ('\0').
新增巨集
min
參考自 minmax
後來使用 strnlen
取代 min 的功能
跟 q_remove_head
相似,改為移除 tail
沒有更動
照抄自作業要求中的牛刀小試
從兩邊夾擠找到中間節點,利用 index 來判斷位置
新增巨集 del_node
來刪除節點
利用雙重迴圈來查找重複的節點
新增巨集 get_value
,縮短程式碼
節點交換之後, p
的 next
也改為指向本來的下下個節點,所以直接遍歷即可
新增內部函式 swap_node
,交換任意兩個節點(相鄰 / 不相鄰)
寫成 static inline 避免額外的
branch
,類似巨集展開的方式寫成巨集的形式會無法正常執行,尚在找出問題點(或許跟編譯器最佳化有關)
與 q_delete_mid
相似,從兩端夾擠並利用 swap_node
逐步交換
使用遞迴形式的 merge sort
來實作
分成兩個函式: 拆兩半用的 merge_sort
以及合併用的 merge
利用 Floyd's Algorithm 找到中央節點 p
,並分成兩個 circular doubly-linked list
(以 h
為首以及以 p
為首)
此處的 h
是佇列中的第一個元素,而不是利用 q_new
得到的 head
因為無法配置記憶體的限制 (
qtest.c
中第 674 行set_noallocate_mode(true)
),故將原本的head
移除,直接以佇列中第一個元素取代
將兩段佇列遞迴呼叫 merge_sort
,再進行合併
先判斷是否為不平衡的情形,如果是則直接將兩個佇列相接 (參見下方 Make test
段落)
參考 list_splice
新增函式 splice
來串接兩個被移除 head
的佇列
將兩個佇列先攤平( flatten )以便進行迴圈條件判斷
攤平亦即拆開頭尾的連接部份,使其不再循環
此處參考你所不知道的 C 語言:linked list 和非連續記憶體中「從 Linux 核心的藝術談起」下的案例探討,使用 pointer to pointer
的技巧來省去一次條件判斷
即
struct list_head **cmp
,用來移動rp
跟lp
中對應元素數值較小者
第二次迴圈用來將剩下的元素填入
舉例說明:合併
r = [1, 2, 3]
與l = [4, 5, 6]
時,
r 中的元素會先被用盡(rp == NULL
),而l
卻還是滿的 (lp == l
),此時就需要第二個迴圈來將l
中的元素填入
最後將 merge_sort
的結果接回 head
即完成
原始碼中使用
#define MERGE_SORT_DEBUG
搭配#ifdef MERGE_SORT_DEBUG
,#endif
來顯示除錯訊息將原本比較第一個字元改成使用
strcmp
來比較第一個字串 => 通過make test
將找尋中間節點的方法改成 Floyd's Algorithm
但依舊過不了
trace-14
本地端 make test
可以全部通過,但是在 github action 中無法通過 trace-14 跟 trace-17
其中 trace-14 是因為排序的複雜度過高
首先改用 Floyd's Algorithm 來改良 q_sort
找尋中間節點的方法 => 但依然無法通過
後來觀察 trace/trace-14-perf.cmd
發現要對兩個極度不平衡的佇列作排序,於是針對這樣的情形作優化 => 成功通過!
此處的不平衡指的是如 [1, 1, 2, 2] 與 [3, 3, 3, 3]
依照原先的方法會遍歷兩個佇列,但其實可以直接將後者接上前者
=> [1, 1, 2, 2, 3, 3, 3, 3]
會大幅降低這類情形的複雜度
而 trace-17 則是因為多次計算 strlen
,導致在 insert_head
時超出常數時間。
因此只要先將 strlen 的結果存起來便可解決。 (已通過)
研讀程式碼以及參考 freshLiver , kdnvt , arthur-chang 等同學的筆記
This mergesort is as eager as possible while always performing at least 2:1 balanced merges. Given two pending sublists of size 2^k, they are merged to a size-2^(k+1) list as soon as we have 2^k following elements.
Thus, it will avoid cache thrashing as long as 32^k elements can fit into the cache. Not quite as good as a fully-eager bottom-up mergesort, but it does use 0.2n fewer comparisons, so is faster in the common case that everything fits into L1.
The merging is controlled by "count", the number of elements in the pending lists. This is beautifully simple code, but rather subtle.
Each time we increment "count", we set one bit (bit k) and clear bits k-1 .. 0. Each time this happens (except the very first time for each bit, when count increments to 2^k), we merge two lists of size 2^k into one list of size 2^(k+1).
This merge happens exactly when the count reaches an odd multiple of 2^k, which is when we have 2^k elements pending in smaller lists, so it's safe to merge away two lists of size 2^k.
After this happens twice, we have created two lists of size 2^(k+1), which will be merged into a list of size 2^(k+2) before we create a third list of size 2^(k+1), so there are never more than two pending.
The number of pending lists of size 2^k is determined by the state of bit k of "count" plus two extra pieces of information:
- The state of bit k-1 (when k == 0, consider bit -1 always set), and
- Whether the higher-order bits are zero or non-zero (i.e. is count >= 2^(k+1)).
There are six states we distinguish. "x" represents some arbitrary bits, and "y" represents some arbitrary non-zero bits:- 0: 00x: 0 pending of size 2^k; x pending of sizes < 2^k
- 1: 01x: 0 pending of size 2^k; 2^(k-1) + x pending of sizes < 2^k
- 2: x10x: 0 pending of size 2^k; 2^k + x pending of sizes < 2^k
- 3: x11x: 1 pending of size 2^k; 2^(k-1) + x pending of sizes < 2^k
- 4: y00x: 1 pending of size 2^k; 2^k + x pending of sizes < 2^k
- 5: y01x: 2 pending of size 2^k; 2^(k-1) + x pending of sizes < 2^k
(merge and loop back to state 2)
We gain lists of size 2^k in the 2->3 and 4->5 transitions (because bit k-1 is set while the more significant bits are non-zero) and merge them away in the 5->2 transition. Note in particular that just before the 5->2 transition, all lower-order bits are 11 (state 3), so there is one list of each smaller size.
When we reach the end of the input, we merge all the pending lists, from smallest to largest. If you work through cases 2 to 5 above, you can see that the number of elements we merge with a list of size 2^k varies from 2^(k-1) (cases 3 and 5 when x == 0) to 2^(k+1) - 1 (second merge of case 5 when x == 2^(k-1) - 1).
Data structure invariants:
- All lists are singly linked and null-terminated; prev pointers are not maintained.
- pending is a prev-linked "list of lists" of sorted sublists awaiting further merging.
- Each of the sorted sublists is power-of-two in size.
- Sublists are sorted by size and age, smallest & newest at front.
- There are zero to two sublists of each size.
- A pair of pending sublists are merged as soon as the number of following pending elements equals their size (i.e. each time count reaches an odd multiple of that size). That ensures each later final merge will be at worst 2:1.
- Each round consists of:
- Merging the two sublists selected by the highest bit which flips when count is incremented, and Adding an element from the input as a size-1 sublist.
我觀察到三個重點
count
的 bitwise 操作(6 個狀態,每當 pending
的數量符合預期,就進行合併),達到接近(但不大於)2:1 的合併,註解寫到可以減少 0.2n 次比較,雖然還是不如 fully-eager bottom-up mergesort
有點類似
state machine
,搭配 kdnvt同學的證明(雖然沒有看懂),發現是很縝密的數學
sublist
的長度都是 2 的次方,使得同時間放在 cache 中的資料長度為 可以通通放進 cache (註解寫到可以防止 cache thrashing
),同時也用來判斷該不該進行合併(配合第一點的 count
)我認知的
cache thrashing
是要重複替換在cache line
中的資料,不太了解跟 的關係
prev pointer
也不組成 cycle
,最後再處理即可 => 可以減少指令數將 list_sort.c
與 list_sort.h
移植到專案中,
並參考 SmallHanley 同學對於 likely
以及 unlikely
的處理方式,新增兩個巨集
然後移除所有的 __attribute__((nonnull(x, y)))
(參數檢查)
以及改寫 list_cmp_func_t
,將第一個參數 void *priv
(private data) 移除
最後在 qtest.c
中新增命令 lsort
與函式 do_lsort
並使用移植過來的 list_sort
來排序
設計 trace-sort.cmd
與 trace-lsort.cmd
來進行測試
使用 pref
來進行效能紀錄
命令: perf stat --repeat 10 -e branches,branch-misses,cache-references,cache-misses,instructions,cycles ./qtest -f <trace file>
結果
分別跑 10000, 50000, 100000, 500000筆隨機測資和 500000筆相同測資(跑10次取平均)
10000
50000
100000
500000
500000 same
sort\資料數 | 10000 | 50000 | 100000 | 500000 | 500000(same) |
---|---|---|---|---|---|
我的 | 0.010288s | 0.07108s | 0.15654s | 0.95146s | 0.17093s |
Linux的 | 0.008412s | 0.06218s | 0.14038s | 0.80188s | 0.18613s |
比值 | 1.22301 | 1.14313 | 1.11512 | 1.18654 | 0.91834 |
可以發現,除了測資都相同的情形,其餘的 Linux sort 的效能皆是我的 1.1~1.2 倍
我的版本對已排序的測資有特別判斷以及簡化處理
仔細觀察,發現我的版本都擁有比較少的 branch
數以及 branch-miss
率
我認為是歸功於
pointer to pointer
的使用,每輪減少了一次的branch
也有較多的 cache-reference
以及較少的 cache-miss
率
在 cache
的使用上是比較優異的
但時間上依舊輸給 Linux 的版本
發現 instruction
數跟 cycle
數皆比較高,也就是執行了較多的指令,因而花費更多時間
程式流程太複雜,可以考慮合併途中不進行
circular doubly-linked list
的維護
TODO: 測試比較次數,畢竟 merge-sort 的效率很大部份取決於進行比較的次數(演算法設計)
參考 Fisher-Yates Shuffle 撰寫一個 的 shuffle 實作
曾考慮先把每個節點用 table 紀錄,用查找的方式降低複雜度
不過更新 table 會比較麻煩,就放棄了
在 console_init
中加入
成為新的命令