首先從資料結構中探討起,我們可以看出 S-tree 的設計上與一般的 BST 類似,結構中維護左右與親代的子節點。特別的是 hint
這個成員,這讓 S-tree 可以在追求快速的 insert/delete 操作速度的同時,也可以近乎達到 AVL tree 所期待的平衡效果以獲得更佳/更一致(consistent)的 lookup 延遲。其作用細節在後續的實作中解釋。
設計上覺得 S-tree 提供的介面(API,即 st_*
系列函式) 似乎有點彆扭? 比方說 st_insert
的實作需要由外部的 caller 先確認好 node 之間的 comparison order。假設我們確立 S-tree 就是只作為 BST 來使用,我覺得理想的介面應該是:
可能再加上做 compare 的函式指標 (*cmp
),或是為 root
的 st_node
再包裝一個更高級的資料結構並將 cmp
作為其中一員。
st_first
st_first
和 st_last
的作用是找到以 n
為 root 的 subtree 中,order 最低即最高(根據對節點間 comparison 定義)的節點。
st_replace_right
st_replace_right
的功能是用 r
來取代 n
原本在樹上的位置。更精準的說,這其實是 BST 之 remove 操作的一種形式,實際上 r
與 n
的關係必然滿足 r = st_first(st_right(n))
(見 st_remove
)。基於此前提下,這個 replace 實作才是有效且正確的。
可以想見有幾個節點間的關係可能會因應 replace 而進行調整:
r
的左子樹r
的右子樹r
的父節點r
原本的左子樹之父節點r
是 st_first(st_right(n))
,左子樹必定為 NULLr
原本的右子樹之父節點r
原本的父節點之左or右子樹r
原本的父節點之父節點n
原本的左子樹之父節點n
原本的右子樹之父節點n
原本的父節點之左or右子樹在實作 replace 時,我們要考慮這些 link 是否都調整正確。
首先判斷 r
是 rp
的左子還是右子,如果是左子,則將 rp
左子替換為 r
的右子樹(6),右子樹的 parent 也需要更新(5)。
r
是 rp
的右子,其實可以預期此時 n
就是 rp
,則此時就不必將 r
的右子樹交給其他人繼承,可想而知 rp
的左右也不可能繼承任何子樹,稍後就會被刪除r
的左子樹怎麼辦呢? 留意到因為在刪除操作中 r
必然是來自某一子樹 tree
的 st_first(tree)
,因此可預期 st_left(r) = NULL
rp
的父節點是 n
的狀況下,由於 n
即將被刪除且由 r
取代,因此 st_parent(rp)
勢必要被修改(7)。
r
將取代 n
原本的位置,這裡調整此關係(1 & 3)。
r
不正好是 n
的右子樹的情況下,r
需繼承 n
的右子樹,反過來的關係也需建立(2 & 9)。
這裡調整 n
之父節點的子代(10)。
調整 n
原本左子樹的父節點關係(8)。
st_replace_right
與 st_replace_left
類似,這裡就不多做探討。
st_remove
st_remove
將給定的 del
從樹中移除。
刪除方法的基本邏輯近似一般的 BST,若右子樹存在,則找到右子樹中 order 最小者(st_left
必然為 NULL),以其取代原本的 del
即可。
st_replace_right(del, least)
st_update(root, st_right(least))
若右子樹不存在,則找到左子樹中 order 最大者(st_right
必然為 NULL),以其取代原本的 del
。
st_replace_left(del, most)
st_update(root, st_left(most))
考慮到刪除的節點剛好是 root 的情況下,也需要更新 root 指標。
如果被被刪除的節點沒有左右都沒有節點,意即其為 leaf,只要將調整其 parent 的 child 關係,再直接刪除之就好。
EEEE = st_update(root, parent)
st_rotate_right
rotate 是為了平衡 BST 所需進行的操作,它調整數個節點間的親子關係以達此目的。方便理解,我們以下圖為例示範如何對 node 6 做 rotate right 操作。
根據程式碼定義初始的狀態應該如下。
則可以根據程式碼行為一步一步用紙筆檢查 pointer 的調整,完成旋轉後應該會變成如下圖的樣子。看到 node 7 被從右子樹往上提一個等級,藉此降低原本子樹的高度。
st_rotate_left
道理類似 st_rotate_right
,這邊就不再費時說明。
st_update
st_balance
判斷節點 n
在左右子樹的平衡程度,當 b > 0,表示左子樹的高度大於右子樹,反之 b < 0 則表示是右子樹較高。注意到嚴格來說 hint
只是對高度的估計而非完全正確的高度值。
如果右子樹比較高,我們就做 st_rotate_right
;與之相對,左子樹較高的情況是做 st_rotate_left
來平衡 BST。
旋轉之後,因為節點本身左右高度的變化要更新 hint。而當自身 hint 有變或是變成 leaf 時,也要向原本的 parent 方向(以前面 st_rotate_right
旋轉 node 6 為例,即是要 update node 4)繼續更新 hint
和調整平衡。
st_insert
st_insert
將節點插入到 S-tree 中。
實作上沒有什麼複雜之處,就是將 n
插入成指定的 parent p
之左或右子節點。
treeint
運作原理模仿 Linux 的 linked list 風格,st_node
可以被置於任何 struct 下以串連不同種類的資料結構。本題中是以包含單一 int 的節點示範。
treeint_insert
因為目前 st_insert
提供的介面,treeint_insert
必須先找出要插入的位置。
注意到嚴格的 BST 中是不存在重複 key 值的節點的,因此若存在重複我們直接回傳該已存在的相同 key 值節點即可。
找到符合 BST 的正確位置後,完成節點的插入。
__treeint_dump
經典的 inorder 走訪。
st_left(n)
st_right(n)
雖然原題目沒有要求這項改進。不過正如前面所說,我認為 S-tree 目前的 API 設計很彆扭。若我們將 S-tree 作為函式庫來看,從使用者的角度而言,他應該只關心插入和移除的鍵值為何,而不必明確知道 S-tree 內部是如何管理節點之間關係的。然而目前的設計下我們可以在 treeint_insert
和 treeint_remove
看到依然存在複雜的邏輯來走訪整個樹,後者應該被隱藏在函式庫內部的實作才是。
為此,我重新定義了 S-tree 提供的 public interface。
可以在 st_create
中看到節點之間的 order 比較(cmp
)、節點的建立與銷毀(create_node
/destroy_node
) 被從 S-tree 中獨立出來,以 callback function 的方式讓函式庫的使用者自行實作。其餘包含樹的走訪邏輯、root node 的維護等都直接在內部完成。
順帶一提,原本 treeint_insert
和 treeint_remove
中走訪 S-tree 的邏輯其實很相似。這裡我們實際上也可以重用 st_find
函式來歸納之,避免重寫相同的邏輯,以減少程式碼編譯後產生之二進位檔的大小。
完整的實作改進可以參照 s_tree.c。有了這項調整,未來就可以很輕易地將 S-tree 應用在其他合適的專案之中了!
雖然介面上變得簡單許多,不過其實如果對照 Linux 的 rbtree 實作,會發現其反而避免使用了使用 callback 的方式,作法比較接近本題原始的實作。
原因可參照原作者在 rbtree.h 中的註解說明:
To use rbtrees you'll have to implement your own insert and search cores. This will avoid us to use callbacks and to drop drammatically performances. I know it's not the cleaner way, but in C (not in C++) to get performances and genericity…
其實不是很確定作者所述的 performance drop 具體有多高? 主要來自什麼部份(cache locality?),或許值得探討一下。畢竟如果能夠以 callback 方式實作,毫無疑問對使用者的上手門檻是更低的,應用上也更為容易
首先,為利於後續加入其他演算法進行效能評比,對原始程式碼做出以下改動,以建立相關基礎建設:
benchmark
,方便測量一段程式碼完成的時間tree-perf.py
,方便將測量結果視覺化效能評比程式有了,那麼 rbtree 的函式庫該從何而來呢? 稍微搜尋了一下,一方面似乎沒有比較被大眾廣泛接受的紅黑樹相關 C 函式庫;一方面,S-tree 的實作和 Linux 中的 rbtree 介面上其實存在不少相似之處。因此我們乾脆將 Linux 的紅黑樹實作拉到 userspace 來應用。這裡參照 Linux 6.4 版本中的程式碼,並且只擷取與 insert/find/remove 三項操作相關的函式實作,具體整理出來的程式碼可以參考:
接著我們就可以來測量各個操作間的差異。
根據老師的建議將實驗分為循序輸入和亂序輸入的場景。對於循序輸入的部份,我們將從 0
一直到 tree_size - 1
的 key 值插入到樹中,然後再以相同的次序進行搜尋和刪除。過程記錄不同的樹節點數量與完成相應操作所需之累積時間的關係。則實驗的結果如下圖:
循序狀況下,似乎在樹節點數量較少時兩者差異不大,而節點增加時,S-tree 相較於 rbtree 的表現更好?
而亂序輸入的場景下,我們會隨機產生任意的插入/搜尋/移除值,實驗結果如下:
從結果來看,亂序狀況下,S-tree 整體的效能看起來稍優於 rbtree。且在亂序情形下 rbtree 似乎更容易有 worst case (圖中極端值) 的出現。
該怎麼依此給出合理的解釋呢?
S-tree 從 0 到 9 插入節點後
rbtree 從 0 到 9 插入節點後
然而在特定輸入上可以體現 S-tree 的 hint 對高度估計的不精準導致的 worst case。我們首先輸入 1,接著輸入 1000,則 S-tree 會呈現以下樣貌:
此時 node 1 的 hint 為 1,node 1000 的 hint 為 0(括號中數字)。接著插入 900,最開始會呈現以下樣貌:
然後我們對 node 900 做 st_update
後,接著往 parent 方向也對 node 1000 做 st_update
,後者的 hint 會被更新:
繼續往 parent 方向,也對 node 1 做 st_update
。此時因為 hint 條件下不 balance,將會進行右旋轉 (st_rotate_right
)。旋轉後的結果呈現以下狀態。
這邊可以看到 S-tree 潛在的問題。由於旋轉的關係,node 1000 的連結關係被改變,然而 update 的手法將不會對其 hint 進行二度調整,最後導致 hint 並不能正確反映樹的高度,進而也沒有機會將二元樹平衡。
想像上,若強硬的去追蹤每個節點的高度並平衡將迫使 S-tree 與 AVL 相近,進而失去原本設計上想帶來的優勢。那麼該怎麼權衡可以在有效率的插入/刪除的同時保持一定程度的二元樹平衡呢? 這可能就需要更深入的思考了。
MMMM = (sz + mask) & ~mask
不囉唆,直接看 align.h。
qsort 是由 C 語言標準中定義的排序介面,而內部的實作則根據函式庫的實現各有不同。值得注意的是,qsort
並不等於 quick sort,而其一般是以優化過的 hybrid sort 方式實作,後者混合多種類的 sorting 演算法,並根據輸入選擇合適者,此作法在提供普遍(general)的排序算法時更為有利,如本測驗題的實作也是一種。
有點好奇XD :rolling_on_the_floor_laughing:
翻了一下 C 語言標準中(7.22.5.2 The qsort function)的描述,似乎其沒有對 qsort 的時間複雜度給予規範? 不曉得這是否意味假如我們自己開發標準函式庫時,用 bubble sort 作為 qsort 的內部實現也是合法的? 或是我漏掉了其他的章節?
稍微搜尋了一下,本測驗題應該是基於 J. L. Bentley 和 M. D. McIlroy 在 Engineering a Sort Function (很抱歉沒付錢看不了,連結倒是可以找一個簡單的投影片) 提出的方法作為延伸,該方法的身影也可以在 FreeBSD libc、apple 的 XNU 等等專案中發現。而測驗題則善用其中 quick sort 部份會需要 partition 成兩段陣列並分別排序的特性,嘗試將這些排序平行/並行運算來提昇執行效率。
qsort_mt
qsort_mt
是本測驗題所要實作的 multithread qsort 的介面本體。
forkelem
是決定需不需要額外啟動一個 thread 來平行排序的參數,如果陣列單元數量小於此參數的情況下,可以直接用標準函式庫的 qsort
就好。
如果要啟動 multithread qsort,這裡的實作是採用 thread pool 的方式。最為直接的實作下,我們可以判斷需要平行運算時才建立 thread,並在 thread 完成後直接銷毀之。這樣的作法對於資源的控管和 thread 的設計相對輕鬆。但缺點也很明顯,頻繁的建立和銷毀 thread 將造成額外的開銷,進而帶來額外的延遲時間。
取而代之,我們可以預先建立好數個 thread worker,並在需要時才從中挑選 idle thread 出來工作,詳細的說明可以在 案例: Thread Pool 實作和改進中閱讀到。總而言之,thread pool 雖然管理上更為複雜(後續就會見識到了XD),但在分配平行任務上更為高效,而這裡我們就需要為每個 thread 建立一個專屬的 context struct qsort
,後者可用來攜帶參數,以告訴 thread 接下來要排序的陣列和其大小等資訊。
此 for 迴圈初始每個 thread context,包含初始化各自的 mutex 和 condition variable,並建立對應的 thread 等。
swaptype
的目的是確認所排序陣列的指標對齊狀況,這攸關後續將一個(swap
)或多個(vecswap
)元素作為一個單位進行 swap 的手法。首先仔細觀察其中的條件式:
((char *) a - (char *) 0) % sizeof(long)
: 確認陣列 a
是否與 long 型別長度對齊 (若否為 true)es % sizeof(long)
: 檢查陣列中的元素是否與 long 型別對齊 (若否為 true)es == sizeof(long)
: 這條件在 1 和 2 皆不成立時才檢查,換句話說 a
必然對齊 sizeof(long)
,且元素長度也對齊之,此時判斷元素大小是否直接等同 long
(若是為 true)則總結 swaptype
之值代表的意涵以及對應的 swap 手法為:
a
對齊 sizeof(long)
,且 es
等同 sizeof(long)
n
,則以 sizeof(long)
為單位交換 n / sizeof(long)
次a
對齊 sizeof(long)
,es
對齊 sizeof(long)
但不等同
n
,則以 sizeof(long)
為單位交換 n / sizeof(long)
次a
不對齊 sizeof(long)
或 es
不對齊 sizeof(long)
n
,則以 sizeof(char)
為單位交換 n / sizeof(char)
次sizeof(char) < sizeof(long)
,因此 swaptype == 2
之情況下可以預期是用較無效率的方法進行的:notes:
在 C 語言標準 6.5.3.4 The sizeof and alignof operators 中提到:
When sizeof is applied to an operand that has type char, unsigned char, or signed char, (or a qualified version thereof) the result is 1.
換句話說,在不對齊狀況下,每次只能以 1 bytes 為單位進行 swap。
struct common
的 c
攜帶一些與陣列整體相關的資訊,原則上是在各個 thread 中只可讀的資訊。(idlethreads
應該是唯一的例外?)
必要的初始化都完成後,首先就可以啟動 thread 0 來展開排序的第一步。
之後,main thread 就在 for 迴圈中藉由 pthread_join
逐一等待每個 thread 結束,並在 thread 結束時銷毀當初分配的資源。
順帶一提,在 thread pool 的方式下,要怎麼知道後續已經沒有工作需要 thread worker,可以將各個 thread 結束呢? 一種可行的做法是整理所有 thread 的狀態是否 idle。由於我們可以預期直到整個陣列的排序完成前都會有 thread 在運作,因此反過來說當所有 thread 都 idle 時該次排序應該已經完成。後續我們會在 qsort_thread
看到對應實作。
qsort_thread
每個 worker 都會執行各自的 qsort_thread
。其主要流程可以分成三個階段:
qs->st
會是 ts_idle
,直到狀態被切換至 ts_work
開始工作,或是切換到 ts_term
後結束ts_term
以結束之這三階段就對應到下方的程式碼。
這裡是第一階段行為的描述。其中,對於 HHH 的答案,從外層迴圈行為是在等待 state 的變更,以及該迴圈是搭配 mutex 建立的 critical section 使用的狀態來看,不難看出 HHHH 應該是在等待 condition variable 被 signal,因此:
pthread_cond_wait(&qs->cond_st, &qs->mtx_st)
第二階段則進行主要任務 qsort_algo
。
第三階段也是如前面的敘述,當 c->idlethreads == c->nthreads
,則將其他的 thread 都切換 state 為 st_term
,因此也需要透過 condition variable 喚醒對應 thread,則:
pthread_cond_signal(&qs2->cond_st)
qsort_algo
前面只是初始化一些 local 變數,以建立簡潔易讀的程式碼。
最開始針對 array 比較小的狀況(<7)做優化,該情況下用簡易的 insertion sort 處理即可。
在下方 swap_cnt
為 0 之處也有一段 insertion sort,感覺或許可以歸納成一個 function 以簡化程式碼?
這邊由於 C 語言不提供泛型,因應 sort 可以針對不同型別排序的情況下,需要以 (char *)a + es * i
去存取第 i 個 element,不得不說閱讀起來有點不舒服XD
陣列比較大的情況下,quick sort 會是更佳的選擇。眾所周知 quick sort 需要選擇 pivot 來將陣列進行二分。而為了避免 pivot 的選擇不好而導致容易出現 worst case (即時間複雜度為 )的狀況,一種常見的作法是透過 median of 3 (med3
) 來挑選合適的 pivot。
這裡在挑選出 pivot 後,也將其置換為 array 的首個 element。
接下來這邊是 quick sort 中經典的 partition 實作。因為主要目標是要將 order 比 pivot 小者放到 pivot 左方,order 比 pivot 大者放到右方,因此作法是從 pb
往右找到比 pivot 大的元素,從 pc
往左找到比 pivot 大者,然後將兩者交換,並重複此流程直到兩個 pointer 交會。
注意到與普通 quick sort 相比實作上還是有一些小差異,這提供演算法後續更多的優化空間:
pa
往右/從 pd
往左之處這裡的兩個 vecswap
將 pivot 最後置換到正確的位置中。
如果 swap_cnt
為 0,這表示在前面的 partition 中沒發生任何置換,即原始的陣列可以在不置換的前提下完美分成 < pivot
和 > pivot
的兩個子陣列。這情況很可能表示陣列其實已經完成排序或接近完成,因此我們嘗試直接對整個陣列進行 insert sort。
在此我們仍然持續追蹤完成 sort 所需要進行的 swap 數量,如果期間發現仍需要比較多的 swap,那麼可以反悔,還是進行標準的 quick sort。
如果選擇做標準的 quick sort,那就是以 pivot 為界線將陣列區分為左右兩組。對於左邊這組,我們可以考量左右子陣列剩餘需要排序的元素數量,以決定是否從 thread pool 中喚醒另一個 thread 來並行的完成 quick sort。
而右邊的子陣列則可以沿用原本的 thread 繼續運算。
想像上這一段寫成以下形式會不會在可讀性上更佳?
在編譯選項中加上 -fsanitize=thread
可以在程式中引進 Thread Sanitizer,後者是能夠偵測執行緒間 data race 的強大工具。另外建議也加入 -g
選項來導入 DWARF,以提升除錯訊息的詳細程度。
加入 flag 後,執行結果如下:
Thread Sanitizer 明確指出出現 data race 的程式碼位置,對照後會發現問題是出在 allocate_thread
中,在存取 st
成員前應該先對其相應的 mutex lock 進行上鎖動作。
完成上述改動後重新編譯,即可以排除 Thread Sanitizer 的警告訊息。