contributed by < MathewSu-001 >
查閱教育部重編國語辭典「完善」,可知:
於是「完善 queue.c」的寓意跟你現在的投入狀況顯得無關 —— 作業自 2 月 20 日指派以來,遲至 3 月 1 日才見到一項你提交的零星變更,後者不符合作業規範 (缺乏清晰的 git commit message,也難以得知程式碼變更的考量因素,程式碼更是存在若干值得改進之處),你的程式碼何以自稱完美呢?
改進你的漢語表達。
詳閱作業規範,避免 Implement queue function 這樣在單一 commit 含入大量彼此無直接關聯的程式碼,務必改進。可用 git rebase -i
修改。
參閱「2023 年 Linux 核心設計/實作課程第一次作業檢討」,留意各式細節,唯有重視小處並步步為營,方可挑戰原始程式碼規模超越三千七百萬行的 Linux 核心。
q_free
實作使用 list.h
內提供的 list_for_each_entry_safe
來遍歷每個節點。雖然可以利用 safe
來確保佇列中的每個鏈結串列都被刪除,但最後佇列的 head
不會被刪除,因此需要額外使用 free
釋放佇列所配置的記憶體空間。
q_insert
實作使用 list.h
內提供的 list_add
跟 list_add_tail
就可以完成該 function。但需要注意的是需引用strdup
,根據該網站
char * strdup( const char *str1 );
Returns a pointer to a null-terminated byte string, which is a duplicate of the string pointed to by . The returned pointer must be passed to free to avoid a memory leak.
在引用完後,需要額外使用 free
,以防止記憶體洩漏。雖然原本有使用它,但在測試後發現會出現亂碼。猜測原因應該是在函式使用完後就會將暫存記憶體刪除,因此不需要再進行額外的處理。
q_reverse
系列實作使用list.h
內提供的 list_for_each_entry_safe
來走訪每一個節點,llist_move
提供任一指定節點移動到佇列開頭。
透過上述 code 的步驟,將 k 個節點移動到一個新的佇列上並呼叫 q_reverse,然後再重新接回原來的鏈結串列的前面。
q_sort
實作以 merge sort 的方法來排序鏈結串列的節點。q_sort
主要的功能為分割鏈結串列,並且呼叫merge_list
。
這段程式碼的核心思想是宣告一個新的佇列開頭 tmp
,逐一比對兩個鏈結串列的節點,將符合條件的節點移動到 tmp
的佇列尾端。由於 list_move_tail
的作用是將指定節點移動到佇列尾端,因此兩個鏈結串列中必定會有一個先移完,進而使得 for
迴圈終止。
q_descend
系列實作在概念上,會從鏈結串列尾端做起,並且設置一個變數 min
來與它的前一個節點 node
做比較。只要 min
比較小的話就會將 node
刪除;否則 min
會被指派更新為 node
位址。
q_merge
實作這一個函式必須要先思考 head 所代表的函義是甚麼,在q_test.c
中呼叫q_merge
程式碼為
len = q_merge(&chain.head, descend);
因此需要處理的地方是,當已經合而為一個新的已排序佇列後,需要擺放在哪裡。所以我的作法就會是宣告一個新的佇列開頭tmp
,然後用list_for_each_entry_safe
將每一個節點與tmp
接在一起。最後,用list_splice
接在佇列chain
的開頭。
q_shuffle
核心思想為: 以被選中的亂數節點做為區隔線,new_list
以及以亂數節點當作佇列開頭的 head
。運用list_move
將亂數節點移動到 tmp
,並且head
佇列尾端移動到開頭。最後將tmp
接回head
。
console.h
可以發現以下add_cmd
會在命令列表中添加新命令,要新增一條新命令 shuffle
則要實作 do_shuffle
,並在qtest.c
的 console_init()
替新命令加上 ADD_COMMAND
。
最後就是執行 shuffle.py
後的成果
shuffle
避免非必要的項目縮排 (即 *
和 -
),以清晰、明確,且流暢的漢語書寫。
= 21.197523160370565
3. 選擇顯著水準
根據 2024 年 Linux 核心設計/實作課程作業 —— lab0
list_sort()
在分析函式之前,先了解函式的 prototype ,每個函式的宣告都擁有函式屬性 __attribute__((nonnull()))
,在5.24 Declaring Attributes of Functions裡有提到
The keyword
__attribute__
allows you to specify special attributes when making a declaration. This keyword is followed by an attribute specification inside double parentheses.
意思是它用於標記函數的參數,指示這些參數不能為空指針。
而 nonnull(n) 屬性表示函數的第 n 個參數不能為空指針。
在 do-while 迴圈中,對 likely()
的用途有很大疑惑
在 /include/linux/compiler.h 找到定義,而且有兩種
而不管是第一種或第二種方法,都會是由 __builtin_expect
來做決定,而這個巨集是 gcc 的預設巨集,參考6.61 Other Built-in Functions Provided by GCC中給的定義:
You may use to provide the compiler with branch prediction information. In general, you should prefer to use actual profile feedback for this (), as programmers are notoriously bad at predicting how their programs actually perform. However, there are applications in which this data is hard to collect.
但就這樣的解釋我還是沒有很瞭解使用原因,參考了Linux內核入門– likely和unlikely後,大致上可以解釋為:
CPU在處理當前指令的同時,會先取出後面的多條指令進行預處理,如果存在跳轉指令,那麼之前預取的指令都沒有用了,需要從記憶體中重新取出跳轉后的指令繼續執行。
likely 和 unlikely 的存在讓編譯器判斷條件跳轉的預期值,把“很有可能發生”的條件分支放在順序執行指令段,而不是jmp指令段,如此可避免因執行jmp跳轉指令造成時間浪費。
從以上的分析可以得到 likely()
主要的功能
__builtin_expect(!!(x), 1)
會告訴編譯器 !!(x)
為 1
的機率很高,因此編譯器可以根據這樣的訊息對程式碼做優化,加速執行速度!!(x)
用於將任何非零值轉換為 1,並將零值保持為零。換句話說,x 為 1 的可能性很高。再來就是要思考何時會觸發 likely()
,也就是何時要進行合併?
那這邊就是根據 count 來判定,依據lab0 (E)以及 list_sort
上的註解解釋
This merge happens exactly when the count reaches an odd multiple of , which is when we have elements pending in smaller lists, so it's safe to merge away two lists of size .
After this happens twice, we have created two lists of size , which will be merged into a list of size before we create a third list of size , so there are never more than two pending.
count | pending list | merge |
---|---|---|
1(1) | [1] | No |
2(10) | [1,1] | Yes |
3(11) | [1,1,1] -> [(2),1] | No |
4(100) | [ (2) ,1 , 1] | Yes |
5(101) | [(2), 1, 1, 1] -> [(2), (2), 1] | Yes |
6(110) | [(2), (2),1 ,1] -> [(4), 1, 1] | Yes |
7(111) | [(4), 1, 1, 1] -> [(4), (2), 1] | No |
8(1000) | [ (4), (2), 1, 1] | Yes |
可以推斷為,只有在 count 為 時才不會進行合併。
再來就是圖解整個 list_sort
的運作流程(假設五個節點):
do-while
迴圈,count = 0,沒有觸發 for 迴圈,也沒有進行合併。需要注意的是,這裡的 count 所代表的是上一階段待處理列表(pending list)中的節點數量。因此,合併操作應在待處理列表中節點數量不是 2 的冪( count + 1 != )時發起。
最後所有的 list
都合併到 pending
後,用 merge
函式合併所有待處理列表,merge_final
函式重建 prev 鏈接。
merge
以及 merge_final
運作過程在作業二 linux2024-homework2中的 timsort 中有提到。
lab0-c
將 linux/lib/list_sort.c 以及 linux/list_sort.h 引進 lab0-c 當中:
#include "list_sort.h"
list_cmp_func_t
給替換掉,這邊我利用 strcmp()
來比較兩值大小q_listsort
紀錄在執行 make
時出現的一些報錯,以及解決方法。
undefind reference to list_sort
,確認在 queue.c 有加上 #include "list_sort.h"
,但依舊出現這個錯誤。在 Risheng1128 裡發現要在 Makefile 加入 OBJS 變數,新增要編譯出的目標檔案。git commit
出現以下報錯該錯誤原表示我的程式碼中存在空指標的問題,根據報錯訊息是指向 list_head a 和 list_head b 有可能為空指標。因為我在 queue.c 的 q_listsort 有先設立條件確保鏈結串列非空且大於一個節點,可以使用 cppcheck-suppress nullPointer 注釋來告訴 cppcheck 忽略這些特定的警告。
mergesort
與 list_sort
參考Slipet的實驗方法後,定義實驗的腳本如下:
首先比較運算時間(四捨五入到小數點第三位)(單位 : s)
q_sort | q_listsort | |
---|---|---|
第一次 | 0.884 | 0.869 |
第二次 | 0.989 | 0.897 |
第三次 | 0.926 | 0.880 |
第四次 | 0.891 | 0.867 |
第五次 | 0.892 | 0.878 |
平均值 | 0.916 | 0.878 |
使用perf 觀察兩者的 instruction 、 cpu cycle 、 cache miss/references
q_sort | q_listsort | |
---|---|---|
cache-misses | 54,910,492 | 48,288,386 |
cache-references | 89,689,860 | 71,261,586 |
instructions | 4,914,653,177 | 4,658,927,024 |
cycles | 6,515,988,823 | 6,302,248,693 |
elapsed time(s) | 1.3629 ± 0.0272 | 1.34207 ± 0.00609 |
從數據上可以得知,運行速度並沒有太大的變化。但在使用 perf 後,可以發現 cache miss/references 是有減少的。那可以印證出lib/list_sort.c 中的演算法的其中一個特點:快取使用效率高,通過深度優先演算法來合併子串列,它減少 cache trashing 現象,提高快取的利用率。
閱讀完 Timsort 並且與 linux/lib/list_sort.c 經過比較後,我作出的修改是在合併時增加 Galloping mode,主要基於對 lib/list_sort.c 中鏈結串列的部分有序性考量。特別是在最後的 merge_final 過程中,利用 Galloping mode 可顯著減少比較次數,進而提升效率。
解釋這段程式碼時,首先比較了 ab 兩個值的大小,然後將較小者直接掛接到 tail->next。假設現在是 a 被掛接到 tail,接下來需要使用 search 函數尋找 b 串列的起始位置在 a 串列的哪個位置中。為了考慮穩定性(stable),也就是確保 a 串列在 b 串列之前,search 函數需要傳入 0 和 1 這兩個參數。
timsort 在找尋串列位置使用的方法為:
In the first stage it performs an exponential search, also known as a galloping search, until finding a k such that R1[] < x <= R1[], i.e. a region of uncertainty comprising $consecutive elements of R1. The second stage performs a straight binary search of this region to find the exact location in R1 for x.
所以第一步我用 count 去找尋 的位置,然後跟 val 比大小。第二步就用 head 去找尋最後精確位置在哪並回傳。
在執行的報錯:
參考以 Valgrind 分析記憶體問題,執行 make valgring
時會跑所有 trace 裡面的檔案,我有改動 scripts/driver.py 加進實驗腳本去做測試。
更新後
hlist
相關的程式碼抽出,成為 hlist.h
qtest
程式,使其新增 ttt
命令在 lab0-c
中增添檔案夾 agents
裡面有 mcts.[ch]
,在 Makefile 加入 OBJS 變數,新增要編譯出的目標檔案。
在qtest.c
的 console_init()
替新命令加上 ADD_COMMAND
。
觀察 mcts.c
的程式碼中,主要有兩個部份是需要將浮點數改成定點數:
simulate
裡要計算 score ,於程式碼中是設定玩家贏得 1.0 分、平手 0.5 分、輸則得 0 分,這部份應該要改成整數。以下為改動內容:
參考 vax-r 同學後以及閱讀 Q (number format) 後,理解到需要預留給 fraction 幾個 bits 是一門學問。因此我先考慮到的點是 UCT score 的最大值會是多少
最大值會出現在當 時,且 ,也就是我只走訪過這個節點一次且勝利,所以式子會變成
於程式碼中最大迭代次數為 ,一般設
這邊我使用 unsigned
來當成我設計之定點數的資料型態。以 32 bits 來說如果 fraction 取到 16 bits 的話,定點數的值會超過 ,再做四則運算會有遺失整數部分的資料,所以最後選擇只取 8 bits,以 Texas Instruments version 來表示我的定點數表示法即是 UQ23.8 。
我將所有 score 的資料型態從 double
改為 unsigned
。另外在原本的 jserv/ttt 中,贏家、輸家、平手給予的分數在 calculate_win_value
以及 simulate
也要改為定點數。
然後就會出現此錯誤
分析原因為在一開始的時候,所有節點的 UCT 都一樣是 0 ,所以在 select_move
函式會回傳 NULL ,所以多新增一個但段機制讓系統隨機選擇一個節點。
再來是改動計算 uct_score 的方法,這邊同樣參照 Q (number format) 裡的數學計算方法取代原先的乘法和除法
log
實做log
函數用於計算給定數字的自然對數(底數 e)。參考 Natural logarithm 中的敘述,可以了解到如何透過泰勒展開式對自然對數使用進行近似。然後設計一個程式可以比較透過 log
以及自定義的 fixed_log
的結果差距,考慮到 fixed_log
的輸入是目前節點的親代節點的模擬次數,所以會是整數且最大值為 100000 ,每次增加 1.0 且泰勒展開之項次設為 50,比較後結果如下
將最大值縮小到 1000 後,結果圖如下
發現兩者結果差距很大,而且 fixed_log
會是以階梯式上升。我認為原因是出在泰勒展開的項次太少,且 fraction 只有到小數點 8 位造成精度不正確。
所以我做出的改動就是將原本 score 的資料型態從 unsigned
改為 unsigned long
,Texas Instruments version 定點數表示法變成 UQ47.16;然後泰勒展開項次改成 100 次。
將最大值縮小到 1000 後,結果圖如下
可以看到 x 在小數目的精確度是有明顯變好,但是當 x 超過 10000 時獲得的值都是一樣的,可能泰勒展開的項次增加會讓結果會變好,但是就會讓效能下降,這部份的取捨有待研究。
sqrt
實做unsigned long
,所以 m 的初始值要從 63 開始減去。另外在最後第 16 行計算得出 z 值需要再乘以 ,解釋如下:假設我們有一個數 ,它的表示是定點數,形式為:
在計算平方根時,由於平方根的性質,結果 z 需要被調整
與浮點數計算比較後結果如下
當切換到「電腦 vs.電腦」的對弈,我引入的另一個演算法是使用 negamax
,並且新增函式 show_time
可以在螢幕顯示當下的時間 (含秒數)。
並且在 qtest.c
引入新的 option
: opponent
來決定是誰再跟電腦對奕。