contributed by < hongpei100
>
在終端機執行 export LC_ALL=C
,再執行上述命令。lscpu
套件目前的中文翻譯品質不佳。
:notes: jserv
:bulb: 已使用上述
指令命令,將lscpu
命令的語言從中文敘述轉成英文敘述
注意資訊科技詞彙翻譯,command 的翻譯是「命令」。
透過閱讀 你所不知道的 C 語言: linked list 和非連續記憶體 :
透過上面兩張圖可以發現,資料結構為 Circular doubly linked list,並且有以下觀察 :
head
不存資料,而是透過 next
指向第一個有存資料的節點head->next
會等於 head
並且可透過 Linux 提供的巨集,來簡化程式碼的維護。
struct list_head
的記憶體給變數 q
q
有被成功分配到記憶體,並透過 INIT_LIST_HEAD
對佇列做初始化:bulb: INIT_LIST_HEAD
會將 head
的 prev
與 next
指標指向 head
malloc
不需要額外的轉型,亦即 (struct list_head *) malloc(...)
非必要:notes: jserv
clang-format -i queue.c
來排版,符合程式碼風格規範在實作清除佇列記憶體時,回想起教材內部有針對 linked list 的 NULL
與 empty
狀態做討論,並且為了盡量減少分支:
NULL
或者是 empty
,會直接回傳list_for_each_entry_safe
對 list 走訪,並參考 kdnvt 學員 的 code review,得知可以善用 queue.h
內部提供的 q_release_element
來移除 linked list element在實作這部份時,發現沒有提供函式來新增 linked list element,故先在 queue.c
當中,定義一個 q_new_ele
的函式來創造新的節點 :
:bulb: 記得複製長度為 n
的字串的時候,記憶體要分配 n + 1
個空間,留一個空間給 \0
有了上述函式,則可實作 LIFO
的節點插入 :
q_new_ele
創立一個節點,並觀察是否為 NULL 以確認創建成功list_add
來將節點放至 linked list 的開頭:bulb: 觀察 list_add
的函式,可知函式參數兩者皆為 struct list_head*
,因此第一個參數放入的是節點 new_node
底下的 list
跟 q_insert_head
的概念相似,可重複利用新增節點的函式 q_new_ele
,並透過巨集 list_add_tail
來將節點插入尾端.
這邊提供三個函式參數,在閱讀 queue.h
此函式的解釋後,知道可利用 buf_size - 1
來複製第一個 entry 的字串到 sp
內。
而透過 list.h
可找到巨集 list_del_init
來輔助 remove
element
步驟如下 :
NULL
或是 empty
,就回傳 NULL
,表示沒有任何被移除的節點head->next
和 list_first_entry
來取得 linked list 開頭的 element 和該元素封裝的 entrystrndup
做字串複製到 sp
,避免 sp
沒有被分配到記憶體list_del_init
將第一個節點從 linked list 移除:bulb: 不論 sp
是否為 NULL,仍然要把該資料節點 remove
跟 q_remove_head
的概念相似,差別在於要取的節點為 linked list 尾端節點,因此可透過 head->prev
與 list_last_entry
取得 linked list 開頭的 element 和該元素封裝的 entry
list_for_each
來對 linked list 走訪,計算節點數量:bulb: 若 linked list 能夠維護一個整數變數 cnt
,在插入或刪除節點時更新 cnt
,這個函式應該能從 O(n)
變到 O(1)
這樣對每個節點佔用的空間會有顯著衝擊。工程就是一系列的取捨 (trade-off)
:notes: jserv
若 linked list 的節點數量為n
,則一個 linked list 的中間節點,為在 0-base indexing
之下的第 floor(n/2)
的 element :
q_size
來算出中間節點的 element 為第 idx
個list_for_each
走訪 linked list,找到第 idx
個 elementlist_del_init
來移除中間節點q_release_element
,來將節點記憶體釋放:bulb: 注意到 : 這個函式為 delete
,但是巨集提供的 list_del_init
只會把節點移除,須呼叫巨集 q_release_element
函式來把節點記憶體空間歸還給作業系統
這個函式想的比較久,有幾個想法: 令整個鏈結串列有 \(n\) 個節點。
O(n^2)
我試著找尋更有效率的解法來尋找重複的節點,於是想到第二種方法:
2. hash table 的 Chaining :同樣以鏈結串列實作,且對於搜尋單一字串,至多是 O(n)
。
ASCII table
觀察 ASCII table 的字母對應的十進位,可發現最小值為 32
,最大值為 126
,因此建立 hash table 並使其具備 \(126 - 32 + 1 = 95\) 個 bucket,並使用每個節點字串的第一個字母,來計算 hash value,就可以進行建表與搜尋。
但是在實作到一半的過程發現,我們只有維護一個鏈結串列,若為了要刪除重複資料的節點,需要在走訪鏈結串列過程中,一併建出 hash table。這樣不僅時間複雜度沒有改善,反而還浪費了空間。
分析如下 : 針對 worst case 分析,若當前鏈結串列有 10 個節點,字串各別為 a
開頭,且每個字串皆相異。依據上述方法,會建出 1 個 bucket,但有 10 個節點鏈結串列。
在此過程中,若走訪至第一個節點,則需要檢查 bucket 第一個節點 ; 走訪第二個節點,則需要檢查 bucket 前兩個節點 ; 依此類推,故時間複雜度仍然為 O(n^2)
,且還浪費與原本鏈結串列相同大小的空間 O(n)
結論 : 此方法比第一種方法來的更差
:bulb: 此方法是讓 unsorted list 轉變成 sorted list,並走訪整個 linked list,因此只需要花 O(n^logn)
即可完成。若題目嚴謹要求只能在 unsorted list 上操作,則此方法無效,須採用前兩種方法。
:bulb: 最後發現在作業要求與 leetcode 題目說明之下,給定的 linked list 為已經排序的,故選擇第三種方法。
:bulb: 這邊採用 marked 變數來紀錄重複資料節點刪除的狀況: 若有遇到兩兩相鄰節點資料相同,則設立 marked 為 1 ; 反之若資料不相同,則設為 0。這個變數的重點在於 : 當重複資料節點刪除時,遇到 safe
繞回 head
,或者是重複資料節點刪除到只剩下一個的時候,需要把剩下那一個節點也刪除掉,也就是代表 marked 從 1 變回 0。
根據 leetcode 題目的描述,linked list 的節點交換,不能夠直接交換兩個節點的資料,須更改節點本身的 link。
一開始想到的作法比較直觀 : 利用 list_for_each(tmp, safe, head, list)
走訪整個 linked list,並額外使用 struct list_head *prev, *next
分別當作 tmp
節點的前一個節點,與 safe
節點的後一個節點,來做 swap :
後來經由觀摩 kdvnt 的 q_swap 想法,得知可以善用 list.h
裡面的巨集 list_move_tail
來調動節點。
但是在這邊思考了一下,有兩個疑惑的點 :
list_move_tail
? 用 list_move
一定也能達到相同的操作的確可以做到,兩者差別在於 :
list_move_tail 強調把 current node 當作 head,把其下一個節點,放至 head->prev ;
list_move 則強調把 current node 下一個節點當 head,把 current node 放至 head->next;
:bulb: 注意 : 佇列的 head,跟 list_move
與 list_move_tail
的參數的 head 不能混淆
list_for_each_safe
?node->next != head
,否則會繼續 swap綜合以上描述,新的 q_swap
函式如下 :
想法與 q_swap
相似,利用 list_move
,讓每個 current node 的下一個節點,都被調到 linked list head
的下一個節點
想法與 q_reverse
相似,每一次以 k 個節點作為單位,進行反向順序排列。
唯一不同的點在於,當每 k 個節點反向排序完成時,需要更新 tmp_head
至當前 k 個節點的最後一個,作為下一群 k個節點的 head
,才能正確操作 list_move
。
若走訪到後面,已經剩餘不足 k 個節點,就不需要 reverse。
根據 linked list sort,這邊採用 Merge sort 來排序 linked list,而 Merge sort 可分成 partition 和 merge 的部份來思考 :
left
與 right
。這邊透過兩個 struct list_head*
的指標,分別為 slow
和 fast
,來決定這一個 linked list 的中間節點,再根據中間節點來分割成兩個 linked list。在 while 迴圈中,fast
每次都會比 slow
走的多一個節點,這個代表每次第一個與第二個 linked list都會各新增一個節點,直到 fast
遇到 head
:bulb: 不過 queue.c
已經有實作 q_size
,可透過迭代尋找在 0-based indexing 之下的第 floor(n/2)
個節點,當作 slow
,而 fast = slow->next
。
:bulb: 當找到第一個與第二個 linked list 的分界點,會發現 Merge sort 在遞迴呼叫時,放入的參數是 linked list
的 head
,其不存放任何資料。此時發現,可使用 LIST_HEAD
來產生 left
跟 right
的 head
接著,再透過 list_cut_position
,把包含 slow
以前的 linked list 放入 left
,剩下的放入 right
內,並遞迴的進行 partition。
2. Merge:
不斷分別比較第一個與第二個 list
內的節點資料大小,並呼叫 list_move_tail
來把節點的 value
較小者,放入 head
linked list 的尾端。
等到 left
與 right
其中一個為 empty
,就呼叫 list_splice_tail_init
來把另一個不為 empty
的 linked list 內部所有節點,都放到 head
的尾端,並呼叫 free
來把 q1
和 q2
的記憶體歸還給作業系統,最後回傳 head
。注意這個函式不需要在額外 malloc 一塊空間來放資料,而是直接放入 head
即可,因為 head
傳入時必定為 empty
。
根據 leetcode - Remove Nodes From Linked List 題目可知,對於 linked list 內的每個節點,若當前節點右邊有任意一個節點的值,嚴格大於其本身的值,則當前節點會從 linked list 內被刪除。
由上敘述,可知從 linked list 的最後一個節點開始,依序向左檢驗每一個節點的值,並持續以 max_ele
紀錄當前遇到擁有最大 ASCII 值的節點,並刪除字串小於該最大 ASCII 字串的節點,最後再透過 q_size
回傳當前的節點數量。
一開始看到這個函式,非常疑惑為什麼不是給雙重指標,如 struct lish_ead **head
的形式,這樣才能存取到每一個 queue 的開頭。
後來經由觀摩 paul90317 的 q_merge
,得知這個 struct list_head *head
確實是每一個 queue 的開頭,經由 queue.h
內部的下列結構體可知,head
就是 queue_context_t
結構下的 struct list_Head *q
。
於是開始思考可以利用 Merge sort 內的 merge
函式,每走訪一次所有的 queue,就讓兩兩相鄰的 queue 合併。
想法如下:
celling(k/2)
次,由於 C 的整數除法為無條件捨去,因此可改寫成 (k + 1) / 2
queue
的數量的一半cur
和 empty
當指標走訪所有的 queues。cur
每一次都會合併當前的 queue 和下一個 queue,放到 temp
empty
則指向第一個為 empty 的 queue
,讓 cur
合併好的 temp
queue 放到 empty
目前得到 95 分,剩下 5 分目前無法得知不是 constant time 的原因,在多次重跑的情況之下,q_insert_tail, q_insert_head, q_remove_tail, and q_remove_head
皆有出現 Probably constant time
過。
+++ TESTING trace trace-17-complexity:
Test if time complexity of q_insert_tail, q_insert_head, q_remove_tail, and q_remove_head is constant
ERROR: Probably not constant time or wrong implementation
ERROR: Probably not constant time or wrong implementation
Probably constant time
ERROR: Probably not constant time or wrong implementation
–- trace-17-complexity 0/5
–- TOTAL 95/100
而封裝 linked list 的結構 element_T
結構體宣告如下 :
為什麼 element_t
內的成員 list
,不是以指標的形式宣告? 每一個 linked list 的 prev
與 next
都是指標,那照理來講,element_t
的成員 list
應該是指標,才能讓其它節點的 prev
或 next
指向 list
這個節點。
根據 Fisher–Yates shuffle 演算法,若資料結構內有 n 個 element,則以陣列實作的方法如下 :
n
個 element,代表索引從 0 ~ n-1
i
從陣列索引 n-1
至 0
,不斷從範圍 [0,i]
內取出一個隨機數值 j
,把第 i 個索引元素,跟第 j 個索引元素交換。注意,[0,i] 是包含 0 與 i 索引。
Pseudo code 引用自維基百科:
在 linked list 內的 Fisher–Yates shuffle,則可以透過巨集 list_move tail
與 list_move
的輔助來完成 shuffle,想法如下 :
q_size
取得共 cnt
個節點,並逐次遞減 cnt
safe
節點為不會被修改的節點,每次的 safe->prev
就是會被交換的節點之一,safe
會不斷的移動至 safe->prev
,直到 head->next
等同於 safe
safe->prev
對應到陣列實作版本的 a[i]
元素[0,cnt]
隨機抽取一個數值 j
,並以 cur
指標,從 linked list head,移動 j + 1
次至要被交換的令一個元素。
cur
對應到陣列實作版本的 a[j]
元素temp = cur->prev
當作傳入 list_move
的 head
參數 ; 並令 safe
當作傳 list_move_tail
的 head
參數 :
list_move_tail
與 list_move
來移動 cur
節點與 safe->prev->prev
節點:bulb: 在 linked list 交換節點有兩個要注意的地方 :
i
個節點與第 j
個節點相同,就不需要交換j+1
個節點與第 i
個節點相同,使用 list_move_tail
與 list_move
,才不會發生相同節點呼叫 list_move
或 list_move_tail
的巨集時,存取到 list_del
過後的節點並在 qtest.c
內部加入下列程式碼 :
根據 What-is-the-most-efficient-way-to-randomize-shuffle-a-linked-list 的討論,可以得知 :
O(1)
space complexity 之下,可以做到最快的 random shuffle 為 O(nlogn)
time complexityO(n)
space complexity 之下,可以做到最快的 random shuffle 為 O(n)
time complexity並且 Fisher–Yates shuffle 可以在 linked list 內節點數量多的時候,得到較好的效率,其方法是透過一個額外的指標陣列,以索引的方式存取到節點,來做到節點交換,時間複雜度可以達到 O(n)
。若像上述實作的方式,利用第二層 for 迴圈找到相應的節點,則時間複雜度 O(n^2)
。
根據 Dude, is my code constant time? :
Student's t-distribution :
Welford’s method :
N
與第 N - 1
差平方和,可以透過簡化,以 bottom-up 的方式來推出第 N
的變異數與標準差ttest.c
內的 t_push
函式Welch's t-test 原理 :
:bulb: 兩個 t 分佈的變異數若不相同的話,自由度需要做加權調整,才可用於 t 檢定
:bulb: 若兩者分佈的變異數相同的話,我們可以雙尾檢定的 significance level 來查表 ; 若兩個分佈中,一方的變異數大於另一方,則採用單尾檢定的 significance level。由於論文要做 fix-vs-random test,須採單尾檢定。
fixture.c
內的 report
函式:bulb: Welch's t-test 的計算式與範例可參考此網站
程式實作原理 : 先做 Code trace,再做分析。
首先看到 fixture.c
的前幾行註解可知,該檔案以多次不同的輸入來衡量一個函式的執行時間,並利用 Welch's t-test
來決定其是否為 Constant time implementation。
根據 fixture.c
:
test_const
其接收參數 char *text
是被衡量的 function 名稱,其執行多次的 doit
來衡量此函式。doit
就是用來執行一整個衡量流程的函式 :constan.c
內的 prepare_inputs
來準備 N_MEASURES = 150
筆隨機字串當測試資料,也就是一次 test 會有 150 筆 measurements:measure
函式,會針對不同的函式,會從剛剛準備好的測試資料內隨機存取一筆資料來衡量其 CPU cycles,方法為 :
beforeticks[i] = cpucycles()
,紀錄當前在第幾個 cpu cycleafterticks[i] = cpucycles()
,紀錄執行後在第幾個 cpu cycle:question: 不知道為何每一個函式 case,內部都有一個 dut_insert_head( get_random_string(), *(uint16_t *) (input_data + i * CHUNK_SIZE) % 10000);
differentiate
來將函式測試時,執行完畢時的 clocks cycle 減去開始執行時的 clocks cycle,得到針對不同 input 時,執行一次函式所需要的 clocks cycle :update_statistics
來執行 Welford’s method,以 single-pass 的方式計算變異數report
,執行 Welch's t-test 來看是否要拒絕 Null hypothesis,其 Null hypothesis 為 : 我們寫的函式等同於 Constant time :p percentile
來捨棄掉大於 p
的 measurements:bulb: Data-independent noise 像是執行函式時,被 OS 發出中斷訊號,或者其它影響函式測試的行為
根據上述, 如果沒有做 Cropping 的話,因為 fixture.c
對於每一個函式,都會做 ENOUGH_MEASURE / (N_MEASURES - DROP_SIZE * 2) + 1
次的測試,並且測試回合數為 TEST_TRICES = 10
,因此在執行過程中,執行次數多且時間長,upper tail 容易被影響,因此 t 檢定值可能會隨著 data-independent noise 而使得每次測試結果不同。
在我每一次以 simulation mode 跑測試時,四個函式的結果每次測出來的結果都不太一樣,經過追蹤程式碼,並畫出當自動評分認為不是 constant time 時, insert_head
一個測試的Cycle count 如下 :
可發現在接近 x = 80 的 measurements,有峰值接近 1000 的 cycle,可能是函式被 OS 的 interrupt 的中斷影響,而中斷可能來自於 Linux 的排程,也有可能來自於 I/O。
根據 linux/list_sort.c,可分析 linux 核心的 linked list 排序由三個函式組成 : merge
, merge_final
和 list_sort
為了引入 linux 的排序演算法,並且跟我的 merge sort 能夠互相切換,我先在 queue.h
內引入了下列程式碼:
其中:
u8
代表 unsigned 型態且為 8 bit 的資料,故這邊直接以 typedef unsigned char
來定義__bultin_expect
是 gcc 的內建 function ,用來將 branch 的相關資訊提供給 compiler,告訴 compiler 設計者期望的比較結果,以便對程式碼改變分支順序來進行優化。likely(x)
代表我們認為它發生 x == 1
的機率比較高,也告訴 Compiler 要條件式內 x == 1
對應到的程式碼,接在後面執行unlikely(x)
則代表發生x == 0
的機率比較高,告訴 Compiler 要條件式內 x == 0
對應到的程式碼,接在後面執行:bulb: 因此 likely
和 unlikely
有助於程式減少分支跳躍的成本,但編譯時 gcc 要開啟最佳化, __builtin_expect
才有效果
:bulb: __builtin_expect
使用 !!(x)
的原因在於,連續兩次的 NOT 運算,能夠保證其值為 0 或 1,例如 !!(5) = !(0) = 1
並重新改寫了 queue.c
內的 q_sort
與新增 linux_cmp
:
list_cmp_func_t
的定義,再參考 Function pointer,實作出 linux_cmp
接著我使用 linux
系統的 perf
,為一套系統效能分析的工具,可提供多個面向的效能比較,使用方式可參考 成大資工wiki - perf
先寫好執行各別在 \(10^6,10^7, 10^8\) 排序資料的命令
:bulb: perf 缺點在於若要量測一段程式碼,須將其獨立出來。這邊使用的命令,測量上也包括了 new
,無法最公平的比較
並利用 Python 作為腳本,執行 perf
來觀察每一個排序的效能比較 :
得到的結果比較如下 :
資料數量 | branches | instructions | context-switches | cpu-cycles | cache-misses |
---|---|---|---|---|---|
\(10^6\) | 679,012,523 | 4,875,172,271 | 978 | 7,829,057,985 | 101,640,677 |
\(10^7\) | 788,000,774 | 5,622,147,933 | 1,623 | 9,277,850,351 | 140,175,351 |
\(10^8\) | 630,853,888 | 5,085,136,193 | 1,423 | 9,329,504,976 | 138,576,715 |
資料數量 | cache-references | L1-dcache-loads | system_time | user_time |
---|---|---|---|---|
\(10^6\) | 209,742,446 | 1,001,857,248 | 3,010,816,900 | 10,992,776,200 |
\(10^7\) | 255,647,029 | 1,152,871,723 | 3,329,508,000 | 12,598,190,500 |
\(10^8\) | 254,038,731 | 1,173,757,396 | 3,321,200,400 | 12,635,215,600 |
資料數量 | branches | instructions | context-switches | cpu-cycles | cache-misses |
---|---|---|---|---|---|
\(10^6\) | 526,535,184 | 4,222,727,130 | 956 | 7,149,526,207 | 91,477,708 |
\(10^7\) | 606,694,876 | 5,152,602,028 | 1,298 | 8,656,871,637 | 98,835,155 |
\(10^8\) | 778,221,524 | 5,612,350,276 | 1,043 | 8,469,879,742 | 93,550,735 |
資料數量 | cache-references | L1-dcache-loads | system_time | user_time |
---|---|---|---|---|
\(10^6\) | 159,253,765 | 840,097,790 | 2,892,750,700 | 9,682,878,100 |
\(10^7\) | 198,423,050 | 1,179,638,533 | 3,351,852,000 | 11,284,926,800 |
\(10^8\) | 196,866,365 | 1,118,001,716 | 3,265,051,300 | 11,206,414,100 |
由上述統計結果,我們進行下列的討論與結論 :
排序不需要大量 kernel-space 才能執行的函式,因此排序消耗的時間大多都是在 user-space :
merge sort
消耗在 user-space 的時間就多於 linux 的 list_sort
再來看到 branches
,我實作的 merge sort
也比 linux 的 list_sort
還要多 :
list_sort
有利用 __builtin_expect
來減少分支跳躍指令的情形,可以讓編譯器最佳化指令的排序list_sort
是以 bottom up 的方式直接把元素組合起來,因此所需的條件分支,比我的少更多最後看到 cache-misses
的部份,我實作的 merge sort
,明顯在每個層級比 list_sort
的快取失誤還要來的多 :
list_sort
在合併的時候有考量到 2:1 的原則,也就是維持已合併與合併的 linked list 為 2:1,防止 cache threashing 的現象