contributed by < toastcheng
>
linux2021
lib/list_sort.c
的實作抽離為可單獨執行的使用層級應用程式lib/list_sort.c
最佳化手法lib/list_sort.c
進行效能比較,需要提出涵蓋足夠廣泛的 benchmarkis_power_of_2
其作用和考量chriso/intern
效能表現include/linux/list.h
container_of
container_of
包含了 __extension__
和 __typeof__
的使用,使用 __extension__()
的效果是在這其中的 GCC extension 時編譯器不會跳出警告,因為開發者有明確表示自己要使用 extension (而這裡使用到的 extension 就是 __typeof__
)。
__typeof__()
可以給出目標的型別,第 3 行中宣告了 __pmember
變數並將 ptr
的值指派給 __pmember
。
__pmember
的型別則是 __typeof__(((type *) 0)->member)
,也就是list_head
。type
和 member
是傳給 container_of
的參數,分別是 container 的型別 (listitem
),以及連結串列指標在該 container 中的名字 (list
)。
再細看 __typeof__(((type *) 0)->member)
這個技巧,是先將 0 轉型為 container 的指標 (listitem*
),再對 member (list
) 取值,因此會得到 list*
型別,傳進__typeof__
便得到 list*
。
相等於:
最後再將 ptr
的記憶體位址用 offsetof()
減去其在 container 裡的偏移量,便可得到該 container 的位置。
看到 ((type *) 0)->member
的用法令我很吃驚,0 應該是空指標指向的位址,若真的對空指標取值會造成 segmentation fault,因此應該是 __typeof__
這個 extension 可以不去 dereference 便知道,這部分尚未深入研究,但是很快地自己試了一下:
如果沒有真的 dereference,那這個指標似乎也不一定要是 0:
同樣可以達到一樣的效果。
TODO: 比較產生的組合語言列表
試著比較組合語言列表,發現完全沒有不同:
一時不知該從何分析,後來想到用完全沒有 __typeof__
的程式觀察看看:
也沒有任何組合語言上的不同,由此觀察 __typeof__
在使用上是應該是在 compile time 就完成判斷,而非 run time,與其相對的是許多程式語言中會有的 reflect 概念,在 runtime 時才去觀察一變數的型態。
兜了一圈的感覺,思考為什麼需要這樣做?後來了解這樣能得到 type safe double evaluation 的效果,拿 max
來說明:
如果使用下面的方式,看起來感覺更簡潔:
但稍微設計一下以下情境,就可以了解好處,max_nonsafe
中 increment 執行了兩次。
正式術語為 double evaluation,可見 MIN and MAX macro considered harmful
已修正!
預期輸出:
list_del
要移去一個節點不需要 head
,只需要將該節點的前後相連即可。
list_empty
和 list_is_singular
因為是 circular 的 linked list,要判斷 list 是否為空只要確認 head
的下一個是不是自己就行了。
而判斷是否只有單獨一個節點 (i.e. size = 1) 的方式則是先確認 list 非空,再檢查 head
的前一個和後一個是否相同。
list_splice_tail
將 list
接在 head
的尾端。
list_cut_position
head_to
的開頭雙向地設成 head_from
的開頭, head_to
的結尾雙向地設成 node
。head_from
的開頭雙向地設成 node
的下一個節點,head_from
的結尾維持不動。操作結束後,head_to
會是左半邊從開頭到 node
的 linked list,而 head_from
會是右半邊從 node
之後到結尾的 linked list。
get_middle
用快慢兩個指標,走訪節點,slow
每次往前一個節點,fast
每次往前兩個節點。
當 fast
到達最後一個節點或者到達倒數第二個節點時終止迴圈,假設 linked list 有 N 個節點,此時 slow
會正好在:
list_merge_sort
list_merge_sort
很符合 high level 的 merge sort 演算法,首先利用 get_middle
找到位於中間(或兩個中間節點的左邊)的節點,再用 list_cut_position
將 linked list 劃分為左右兩個,接著遞迴地呼叫。接著 list_merge
將左右已分別排序好的 linked list merge。
list_merge
真正發生 merge 的函式,執行時 lhs
和 rhs
都必須是已排序好的 linked list,list_merge_sort
必須確保這一點。
將兩個 linked list 從第一個節點開始比較,較小的就接到 head
的尾巴,然後前進到下一個節點繼續比較。
其中有一個 linked list 走訪完所有節點後,把另外一個 linked list 直接接在 head
的尾端,完成 merge。
觀察資料結構 list_ele_t
會發現矛盾的事情,list_head
的功能是,只要將其嵌入任何 struct,就能創造出以該 struct 為節點的 linked list ,也能便利地使用 list_head
提供的函式。而該 struct 就不需要知道任何前後 struct 的資訊,例如 next
,因此可以將 list_ele_t
中的 next
移除。
而 queue_t
中也有類似的矛盾:
只要有作為 linked list 開頭的 list_head
,我們就能取得開頭、結尾等資訊,queue_t
的 head
, tail
, list
其實都不需要,但 list_head
沒有 queue_t
的 size
, q_free
等功能,能不能將這些功能都實作在 list_head
全都要呢?
先將本來用到 queue_t
之處都直接換成 list_head
,改寫對應的函式。
再來考量到如果 list_head
想要支援不同的 container,那 free 的邏輯應該由 container 負責實作,因此 list_head
由一個 macro list_free
,讓 container 提供 free_func
來釋放記憶體。
list_ele_t
的 free_func
:
用 valgrind 檢查,可以確認所有記憶體都有被釋放,但出現 Invalid read of size 8
:
從 Address 0x56ac6b0 is 16 bytes inside a block of size 24 free'd
可以推敲 16 bytes
是兩個指標的大小,也就是 list_head
,而 24 bytes
則應該是 list_ele_t
,含有一個 list_ele_t
再加上一個 char *
,共 24 bytes,也就是說程式中已經 free 了 list_ele_t
,但還去讀在他其中的 list_head
。
經一番研究後,發現是走訪節點和釋放記憶體的順序問題,因為使用 list_for_each
這個 macro 時會在最後將 _node
指標往前一步,但這時該指標所屬的記憶體空間早已被釋放了,因此 _node->next
這個行為就讀取到了已經釋放的記憶體區域。
將順序修改後的版本:
順利執行:
詳細的程式碼在 GitHub。
小記:透過 offsetof
推算 list_head
所屬的 struct 的記憶體位址,簡單的 struct 只要將 list_head
加入 member ,頓時也有了像是 extends
, implements
等高階物件導向語言才有的特性,更加理解物件導向是一種設計哲學這件事(某語言是/不是物件導向語言並不是絕對)。
TODO: 研讀 Everything you never wanted to know about kobjects, ksets, and ktypes,裡頭舉了生動的案例,說明 Linux 核心如何在 C 語言基礎之上,建構物件導向程式設計環境:
the UIO code in drivers/uio/uio.c has a structure that defines the memory region associated with a uio device:
If you have a struct uio_map structure, finding its embedded kobject is just a matter of using the kobj member. Code that works with kobjects will often have the opposite problem, however: given a struct kobject pointer, what is the pointer to the containing structure? You must avoid tricks (such as assuming that the kobject is at the beginning of the structure) and, instead, use the container_of() macro, found in <linux/kernel.h>:
where:
- "pointer" is the pointer to the embedded kobject,
- "type" is the type of the containing structure, and
- "member" is the name of the structure field to which "pointer" points.
The return value from container_of() is a pointer to the corresponding container type. So, for example, a pointer "kp" to a struct kobject embedded within a struct uio_map could be converted to a pointer to the
containing uio_map structure with:For convenience, programmers often define a simple macro for "back-casting" kobject pointers to the containing type. Exactly this happens in the
earlier drivers/uio/uio.c, as you can see here:where the macro argument "map" is a pointer to the struct kobject in question. That macro is subsequently invoked with:
lib/list_sort.c
的實作抽離為可單獨執行的使用層級應用程式list_sort
原始碼閱讀先從註解開始,list_sort
支援兩種回傳值的 cmp
函式:
並提供了一個 cmp
函式的範例:
在 quick sort 裡最好的情況應該是每次都能用 pivot 對半切,達到較少的比較次數,merge sort 因為可以總是對半切,所以能保證 ,但這裡卻選擇 2:1 或更不平衡的切法:
這裡的考量不只是演算法層面,還有硬體層面,之所以要切成不平衡的比例,是為了讓較小的 list 可以完整放進 L1 cache。
而要怎麼 merge 是由變數 count
決定的,count 的第 k 個 bit 代表現存大小為 sublist 的數量,所以註解說的意思是每次多了一個大小 的 sublist 時,因為一定是消耗了 的 sublist 來 merge 成 ,所以要把 bit k-1 設成 0。再仔細想 也是從 merge 來的,所以註解才會說 "clear bits k-1 .. 0"。
(發現裡面有個 typo:beautiully
)
接著是偏向描述 merge 時的細節:
閱讀註解大概可以有個方向,但可以用 gdb 更快速的互動了解演算法的行為:
在 list_sort
裡設置斷點:
突然發現在 gdb
中非常需要 container_of
的幫助,否則再怎麼觀察只能看到一堆 list_head
的位址:
所幸,gdb
有提供 python
的工具可以使用,將這個 pr 中的 utils.py
加入自己的目錄,然後再 source
就可以使用了:
排序的狀況一目了然:
演算法的流程很像在玩 2048 這個遊戲。要能最大化的利用空間,就要不斷盡可能將可以合併的方塊合併。
External Sorting, Merge Sort, Double Buffering, Replacement Sort
想要參考不同變形的 merge sort,找到了這個課程影片,主題為資料庫,在講解如何排序太大以致無法放進 RAM 的資料,說明得非常清楚。雖然 list_sort
並非要處理巨量的資料,但同樣是想運用硬體特性將小量要進行 merge 的資料放進快速的記憶體。
list_sort
: 將資料從 memory 放進 L1merge sort 的優點不只有 stable,在切割 subproblem 非常的彈性自由,可以照需求去 unbalanced 的切出大小兩個 subproblem,甚至一次切成 3, 4, …, 500 個 subproblem 也是可以的。
This is why we use merge sort, if we have two billions things that you want to merge, you can bring them in one thousand at a time.
還有一個彈性,還記得經典 merge sort 的 base case 是單一個值,因此必定是已排序,但其實 Base case 也可以是某一個已經排序的 list,且是先用別種演算法排過,如 quicksort。影片中舉例的 base case 是一個放得進記憶體的 page,裡面是已排序好的 list。
What sort do you use (on base case) doesn't make a difference because it's in RAM. So use a really fast one, quicksort is probably the best one. Different database systems use different ones.
list_sort.c
中的 list_sort
可以排序含有 list_head
的 struct,這邊實作一個可以依照 struct 中不同 member 來排序的使用情景,考慮以下 struct:
並提供三種 cmp
函式,分別依照名字、年齡和生日排序:
並以以下程式測試:
輸出:
詳細程式碼可見 GitHub
lib/list_sort.c
最佳化手法每個 input size 重複測試 1000 次,將平均繪成散點,陰影為一個標準差:
詳細程式碼可見 GitHub:
在處理大部分的 sublist 時,不必維持本來的雙向及循環結構,省去維護的開銷,只要在最後讓其恢復成 circular doubly-linked list 即可。
likely
, unlikely
在只有少數次或大多會進入的分支中加入 unlikely
及 likely
幫助判斷要 fetch 的分支,例如:
該分支只有在第一次,及 sublist 要 merge 成目前最大的 sublist ()才會跳過,其他情況都會進入,因此加上 likely
。
cond_sched
在 merge_final
中最後的迴圈解釋道,如果 merge 時兩個 sublist 非常不平衡,那便會在迴圈重複很多次,雖然這個階段只要不斷將剩餘的 sublist 中的節點加入 tail
,但依然還是呼叫 cmp
,原因是給使用者彈性,可以在 cmp
中呼叫 cond_resched
,來讓其他 process 有機會可以搶佔。
在 fs/ubifs/gc.c
、 fs/ubifs/replay.c
中可以看到使用範例:
// TODO: 說明 cond_resched
RCU, cond_resched(), and performance regressions
lib/list_sort.c
進行效能比較,需要提出涵蓋足夠廣泛的 benchmarkfunc
的作用在於找到最大的「等於或小於 N
的 power of 2」,假設 N 最高位非 0 的 bit 為從右數來第 x 個 bit ,其想法為
若以 N = 323 舉例:
uin16_t
的最大值為 65535,若發生了 overflow 那勢必會有不如函式預期的結果,但實際測試卻可以發現沒有問題:
預期輸出:
透過 Stackoverflow 的討論找到了 integer promotion 這個術語,並在 C Standard 文件找到詳細的說明。簡單來說,在 uint16_t
進行運算時,會將其當作 int 進行運算,接著再截斷 (truncate) 回原本的大小。
在文件中這不是 undefined behavior 且有明確定義的行為,而題目的情境至多也只會多用第 17 個 bit 來運算,並不會左移碰觸到 sign bit。
但若是想要計算 uint32_t
呢?這應該不在 integer promotion 的作用範疇,若輸入大於 ,在計算時會需要用到第 33 個 bit,依然會有 overflow 的問題。
若不使用 integer promotion,可以利用 __builtin_clz
來得到最高位的位置,再將其設為 1 也能得到一樣的數值,先測試從 0 到 65535:
預期輸出:
需要注意的是對 __builtin_clz
來說輸入 0 是 undefined behavior:
Returns the number of leading 0-bits in x, starting at the most significant bit position. If x is 0, the result is undefined.
因此不應依賴 __builtin_clz
對 0 做運算。但考量函式要回傳比 N 還小的 power of 2,不論 0 或是 1 應該都不是預期的答案,最小的 power of N 應該是 1,因此 0 不應該在預期的輸入範圍內。
再來是 uint32_t
的版本,因為不佔用篇幅,只測試到 後 10 個數,可以看到原本的函式 func
都有 overflow 的問題:
預期輸出:
is_power_of_2
其作用和考量在 pgtable.c
可以看到 macro is_power_of_2
的實作:
回傳值如其名,可以判斷一個數是否為 power of 2。做法很巧妙,只有 power of 2 減 1 會得到最高位非 0 bit 後全為 1 的數,與其做 AND 運算便能得到 0。
include/linux/log2.h
延伸問題:
例如:_read = 13
read_lfs = 13 & 7 = 5
(color orchid in byte 1)
read_rhs = 8 - 5 = 3
(color lightblue in byte 1)
延伸問題:
先從 struct 觀察
__sync_lock_test_and_set
將 __cstr_ctx.lock
的值更新為 1,並回傳原本的值,因此若原本是 1,則會停留在 while 迴圈,達到 lock 的效果;而 __sync_lock_release
則是將 __cstr_ctx.lock
設回 0。
chriso/intern
效能表現延伸問題: