or
or
By clicking below, you agree to our terms of service.
New to HackMD? Sign up
Syntax | Example | Reference | |
---|---|---|---|
# Header | Header | 基本排版 | |
- Unordered List |
|
||
1. Ordered List |
|
||
- [ ] Todo List |
|
||
> Blockquote | Blockquote |
||
**Bold font** | Bold font | ||
*Italics font* | Italics font | ||
~~Strikethrough~~ | |||
19^th^ | 19th | ||
H~2~O | H2O | ||
++Inserted text++ | Inserted text | ||
==Marked text== | Marked text | ||
[link text](https:// "title") | Link | ||
 | Image | ||
`Code` | Code |
在筆記中貼入程式碼 | |
```javascript var i = 0; ``` |
|
||
:smile: | ![]() |
Emoji list | |
{%youtube youtube_id %} | Externals | ||
$L^aT_eX$ | LaTeX | ||
:::info This is a alert area. ::: |
This is a alert area. |
On a scale of 0-10, how likely is it that you would recommend HackMD to your friends, family or business associates?
Please give us some advice and help us improve HackMD.
Syncing
xxxxxxxxxx
2025q1 Homework1 (lab0)
contributed by <
HenryChaing
>作業書寫規範:
[TOC]
: 筆記左上方已有 Table of Contents (TOC) 功能,不需要畫蛇添足c=
或cpp=
變更為c
或cpp
。行號只在後續討論明確需要行號時,才要出現,否則維持精簡的展現。可留意「你所不知道的 C 語言: linked list 和非連續記憶體」裡頭程式碼展現的方式diff
標示),可附上對應 GitHub commit 的超連結,列出程式碼是為了「檢討」和「便於他人參與討論」:::info
,:::success
,:::warning
等標示,儘量用清晰的文字書寫。:::danger
則僅限授課教師作為批注使用〈
和〉
,非「小於」和「大於」符號開發環境
開發指定的佇列操作
q_new
初始版本:
「為了快速實作」這句話沒辦法傳遞任何有用的資訊,不如不說。工程人員說話要精準有效。
為了快速實作,我在不考慮配置記憶體失敗的情況下做了基礎的實作。也就是新增一個
list_head
作為head
,並將其prev
及next
皆指向head
,最後回傳head
,作為空的鏈結串列。改進:
為了通過
make test
自動程式檢測,實作queue.h
中對於此方法的所有需求,包含若配置記憶體失敗則回傳 NULL 的這項預防程式執行時錯誤的機制。q_free
初始版本:
原先只有釋放之前配置的
list_head
空間,如下所示:改進:
q_free
的過程中會依序走訪各個list_head
,並透過containerof
巨集來找出該節點的起始位址,也就是由list_head
來找出element_t
這個實際的節點。解除element_t
以及其資料value
所配置的空間。最後不忘再free(head)
。若引數
l
為 NULL,則直接return
。q_insert_head
初始版本:
line 40-41
會先配置 element_t 所需的記憶體空間;line 44-48
則會用深層複製的方式將字串作複製;line 50-54
會將鏈結重新配置讓新的節點安插到 head 的下一個位置。改進:
line 51-66
新增了配置記憶體失敗以及head == NULL
的處理機制;line 72
是解決上次字串結尾沒有\0
結尾的錯誤。為了讓
insert_head
達到 \(O(1)\) ,首先將重複呼叫的strlen(s)
設為變數s_length
。避免嚴重的執行負重改進你的漢語表達。
q_insert_tail
避免非必要的項目縮排 (即
*
和-
),以清晰、明確,且流暢的漢語書寫。三次 commit 功能皆與 q_insert_head() 的紀錄相同。
q_remove_head
初始版本:
因為這裡要回傳的是
element_t
的結構變數,因此在只有給定其成員變數struct list_head
的情況下,我們必須要反推得到element_t
之記憶體起始位址。這裡會用到巨集 container_of ,也就是在給定 1.成員變數位址 2.結構名稱 3.成員名稱 之情況下,就可以反推得到結構變數的記憶體位址,也就可以對這個結構變數進行相關操作。
初始版本僅利用
line 75-76
移除節點,最後回傳container_of
得到的節點。改進:
除了增加判別佇列為空或不存在或回傳字串指標指向 NULL 等問題外,我們新增了對字串進行深層複製的功能,並且也會考慮複製後的字串其大小不會超過 buffer size 。
為了讓
remove_head
達到 \(O(1)\) ,首先將重複呼叫的strlen(s)
設為變數s_length
。 並且為了減少指標取值的動作,額外設定指標變數value
。q_remove_tail
q_size
初始版本:
line 94-97
會讓pt
指標依序拜訪各個節點,每拜訪一個節點就會增加計數,直到pt
走到佇列尾端。改進:
實踐題目的要求,在佇列不存在或為空佇列時,回傳大小為零。
q_delete_mid
初始版本:
line 107-113
會先算出該佇列中間節點的索引,並將pt
指標移至節點位址,其中佇列中間節點索引計算方式是採用floor ( length / 2 )
。line 122-123
為將節點從佇列中移除,line 124-125
則是刪除節點,刪除結構變數以及其指到的字串所配置到的空間。改進:
在進行實際測試,也就是執行
scripts/driver.py
,執行到trace-02-ops
時,發生了移除的節點與預期不同的錯誤問題,在用紙筆進行實際運算後,發現若delete_mid
的中間節點索引採用ceil (length/2)
,就會如預期結果執行完畢,因此這項改動是為了達到測試目的所作的更動。q_delete_dup
初始版本:
line 134-139
會先配置字串陣列,字串長度限九十九,共佇列長度個字串空間。line 141-160
會在走訪時紀錄節點指到的字串內容,往後若有遇到內容一致的節點,則會刪除該節點。(但最初遇到的因為沒有紀錄所以不會刪除,是疏失)line 162-165
釋放字串陣列所指到的字串們的空間,最後釋放字串陣列的空間。改進:
因為上個版本會有重複字串中首次被拜訪者不會被刪除的問題,因此改由以下版本實作,這個是在實作
ascend/descend
函式後,運用相同原理實作的版本。我們新增了兩個指標,分別是
last
及front
。外部迴圈:
last
每一回合前進一個節點,每一回合會判斷內部迴圈是否有刪除節點,若有刪除則last
所指到的節點也會刪除。內部迴圈:
front
會逐一拜訪last
之後的節點,若有內容與last
相同的節點,則會刪除front
指到的節點。q_swap
版本:
swap
要實作的是相鄰的兩個節點互換,但最複雜的是鏈結的重新配置。line 179-180
其他佇列節點對相鄰兩節點的鏈結重製。line 181-182
相鄰兩節點之間的鏈結重製。line 183-184
相鄰兩節點對其他佇列節點的鏈結重製。上述循環到相鄰兩節點其中一者為
head
為止。q_reverse
初始版本:
在實作
reverse
功能時,我使用了三個指標front
,last
,temp
,其中front
及last
每一回合會更改鏈結配置,更改彼此之間的前後關係。而temp
則是因為設計關係需要讓front
移動而存在的節點。改動:
在因應老師的要求下,我將函式寫成了更精簡的版本,這次移除了作用較少的
temp
指標。這次從每回合更改兩節點之間的前後關係,改成每回合更改單一節點的
next
及prev
指標,這樣的寫法兩個指標就可以正常移動,而無須三個指標。新增若佇列長度為零,則不進行反轉。
q_reverseK
版本:
reverseK
就是相鄰的 K 個節點要進行反轉,若不足 K 個則不反轉。外部迴圈: 在完成內部迴圈的相鄰節點反轉後,要將這些相鄰節點的頭尾與佇列重新作鏈結。
內部迴圈: 相鄰節點的反轉,採用的方式與
q_reverse
相同。q_sort
版本:
這個版本的
q_sort
是直接引用 Linux kernel 的合併排序方法。有額外實作的部份是compare
副函式,為了達成stable
的排序方法,nodeA->value
只有大於nodeB->value
時才會回傳 true 。你如何確保目前的測試已涵蓋排序演算法的最差狀況?
q_ascend
初始版本:
ascend
目的是為了讓佇列的值成升冪排列。因此我採用了類似q_delete_dup
的方式刪除會導致無法升冪排列的節點。外部迴圈: 移動
last
指標,last
所指到的節點會作為判斷之後的資料是否成升冪排列的基礎。內部迴圈: 逐一拜訪
last
之後的節點,若有節點的資料比last
所指到的節點資料要小,則移除該節點,保持升冪。改進:
在進行實際測試,也就是執行
scripts/driver.py
,執行到trace-06-ops
時,發現ascend
若未刪除而是移除節點時,會發生最後的free
回報錯誤說有被配置的區塊沒有被回收,而在改成以下刪除節點的方式後,問題也隨之解決。last = last->prev
是因為要配合函式外部迴圈last
的正確移動。q_descend
避免非必要的項目縮排 (即
*
和-
),以清晰、明確,且流暢的漢語書寫。兩次 commit 功能皆與 q_ascend() 的紀錄相同。
q_merge
q_context_t, q_chain_t
程式碼註解總是以美式英語書寫!
在開始實作
q_merge
前,這兩個是必須知道的結構變數。首先因為此題佇列是複數的,所以會有queue_context_t
紀錄某一個佇列的內容,並且再由queue_chain_t
將queue_context_t
的內容串接成佇列。merge_2_queue
這個實作參考了課程教材關於合併排序中兩個串列合併的操作,對象從單向鏈結串列轉變成包含
list_head
的雙向鏈結串列,我參考了使用指標的指標 indirect pointer 版本的實作方式,以此達到不使用記憶體配置的要求。line 501-503
a_list
b_list
會指向尚未完成合併的串列元素,間接指標ptr
則會間接指向第一個串列中的 Head 。line 506-554
比照單向串列的合併方法,差異在於須更改前後節點的鏈結。這裡紀錄一下間接指標的使用心得,指標運算中會使用到的運算子 (operator) ,首先是
&
(address of) 以及*
(value of) ,使用它們會分別得到運算元的地址以及運算元的數值。再來是=
(assign) 稱為賦值,會將第一個運算元的數值變更為第二個運算元的數值。有了這些概念會較容易理解指標操作。q_merge
q_merge
會呼叫merge_2_list
副函式,主要功能是將q_chain_t
中的每個佇列進行合併。make test
最終結果: 通過
trace-01-ops ~ trace-16-perf
,未通過 trace-17-complexity,得分: 95/100 (回歸測試結果)。運用 Valgrind 排除
qtest
實作的記憶體錯誤實作新命令
hello
在qtest
當中因為想要實作作業要求中提到的
shuffle
命令,因此先嘗試著新增命令,我選擇新增的指令是hello
,但遇到了記憶體錯誤的問題,由於不清楚是那一道指令出錯,因此選擇了 Valgrind 作為排解問題的工具。面臨的問題:
運用了 Valgrind 後,發現了問題如下:
問題出在執行
do_hello
的第880行時,存取了非法的記憶體位址,於是我檢視了該方法的實作方式:因為在
q_test
的設計當中,current
若未經過do_new
方法,則current
就會指向 NULL 位址,因此前述的第880行才會因為 dereferenced 的原因存取錯誤。之後也透過將
q_hello
改成沒有參數的方法,結束了這個問題。運用 Valgrind Massif 觀察 simulation 之記憶體使用情形
事前準備
使用 massif 得到分析數據,會得到行程在記憶體使用狀況的 snapshot 。
使用 massif-visualizer 圖形化數據。
運行 trace-017-complexity (初始化階段)
可以看到在最初期的階段,也就是前期緩慢的斜坡,這時行程都在運行
malloc_or_fail
,例如add_cmd
,add_param
,初始化我們的命令界面。再來第二階段是峰值的部份,這個階段除了有上個階段一樣的工作,還有就是多了
_IO_file_doallocate
在運作,並且這些處理都是源自於report(1, "ERROR: Could not open source file '%s'", infile_name);
這項錯誤。運行 trace-017-complexity (運作階段)
在實際指令運行階段,此時佔用堆積最大宗的函式已變為
test_malloc
,也就是在運行q_insert_head
時會觸發用來分配記憶體的函式。再來佔據的第二大的是
doit
函式(雖然僅有1-2%
),它是在設置 simulation bit 後會觸發的函式,目的是測驗 simulation 的效果。運用 Address Sanitizer 除錯
在實作
shuffle
功能的過程當中,每次更新程式碼進行測試時,我都會選擇make SANITIZER=1
來進行編譯,也就是在編譯過程當中會加入 AddressSanitizer 的程式碼,來偵測並紀錄記憶體相關的錯誤。在實作過程中,我偶然發生以下錯誤:
本次的錯誤是我在按下
ctrl+c
送出SIGINT
訊號後產生,由於我在q_shuffle
方法實作錯誤,導致q_free
遇到鏈結已被打亂的雙向鏈結串列,間接造成已經被解除配置的空間再次被使用的錯誤。以下是我的分析:
在經過 AddressSanitizer 的回報錯誤觀察後,我發現這個空間是我起初在執行
ih
命令後,所分配到的空間,因此確定這是經過test_malloc
後新增出來的節點空間,以下是出錯的程式碼:出錯是在
line 43
,並且回報的錯誤是heap-use-after-free
,因此能推論出此時pt->next
指向的節點空間已被回收,因此我思考了可能發生這個狀況的原因。以上是我思考後的可能原因之一,也就是某個節點的
next
指標,往回指向了先前的節點,也就是現在圖中c
的next
指向了a
,因此在我寫的鏈結串列回收當中,回收順序就會變成如下:a->b->c->a
,也就會發生上述的a
節點已被回收但是又要被pt
所使用的情形發生。理論上不要濫用「理論上」,你依據什麼「理論」說話呢?工程人員說話要精準明確且著重溝通的效率。
使用 perf 對
q_merge
以及lib/list_sort
實行效能分析關於
q_sort()
的實作,我使用了第二週測驗提供的快速排序法,而比較對象是 Linux 核心採用的鏈結串列排序lib/list_sort.c
。至於 perf 則是使用perf record
用來採樣硬體事件在程式碼特定段落的發生頻率,接著perf stat
則是印出硬體事件的統計數據。q_sort
首先我們來分析程式熱點,第一個對象是快速排序也就是
q_sort
。 經過perf record
幫我們採樣程式執行時 PC 指令位址,我們得知了最常執行也就是程式熱點為list_move_tail
這個函式,這段程式碼若詳閱第二週測驗一可得知這是 partition 的實作,它會將大於 pivot 的節點移至list_greater
串列,反之則移到list_less
串列。以下是
perf record
經過反組譯後得到的程式段落,由於list_for_each_entry_safe
是巨集並且list_move_tail
是 inline 函式,所以它們都屬於q_sort
這個 symbol 底下的組合語言程式碼,這是採計 cycles 作為硬體是分析後的結果。這段程式碼是程式熱點,不過編譯器很省用暫存器資源,因此在
list_for_each_entry_safe
巨集當中,最常出現的暫存器%r12
同時代表了更新後的entry
以及更新前的safe
變數,所以以這段bc
symbol 所指向的指令來說,它就是要找出safe->member.next
的位址並存到%rax
暫存器當中。可以發現使用
perf record
紀錄事件cpu_core/L1-dcache-load-misses/
,剛才推測的container_of()
為程式熱點是快取失誤造成,這個論點不成立,因為可以發現其他在 cycles 統計時沒有顯著出現的指令成為了快取失誤的熱點,而且大部分皆為mov
這類與存取記憶體相關的指令,倒是開始好奇為什麼 sub 這個單純使用暫存器計算的指令會與 L1-dcache-load-misses 有關。隨後看到
perf stat
, 這裡想用處理器效能的角度來分析這個程式的執行狀況,這裡使用的是 CPI (clock per insruction) 這個指標,忽略在這次實驗已經確定的時脈週期以及指令個數。可以發現我們只使用了cpu_core
這部份的處理器,執行使用的指令個數約為50億,而時脈則使用了60億個,這裡約莫換算 CPI 為 \(\dfrac{5}{6}\) 。接著發現有命令下達不夠準確的問題,當我將指定的硬體事件
-e
只設定為cycles,instructions
時,perf stat
就會幫我換算出 CPI ,雖然結果相當逼近,但是也隨之發現一個問題是執行效率,平均一個時脈還無法完成一道指令。list_sort
再來看到
list_sort
, 原本的函式需要提供比較函式,這裡根據q_test
的使用案例提供了簡單的字串比較函式,使用 glibc 提供的strcmp
函式。在使用perf record
後可以發現程式熱點集中於我實作的compare
函式當中,第一個部份是將container_of(...)->value
搬移到暫存器時,第二個部份是跳至strcmp
函式。接著來到
perf stat
的統計結果,可以發現到 IPC 仍就低於 1 ,因此可能是實驗設計的問題,我決定在這個章節的最後介紹實驗設計。實驗設計
我使用的是 qtest 讀取命令腳本的功能,在這個命令腳本當中我將一百萬個隨機字串依序加入到鏈結串列當中,最後依據實驗對象呼叫
q_sort()
或list_sort()
函式。因此上述兩個實驗在統計時之所以效果不彰,也可能是腳本中的其他命令導致,例如q_show
出現的次數會比排序函式還要多。改善
web
命令在介紹
web
命令之前,必須先了解linenoise
函式庫,這是一個文字編輯函式庫,qtest
執行檔就是透過linenoise
來讀取執行時使用者在鍵盤輸入的內容,在預設的linenoise
當中程式會讀取STDIN_FILENO
這個 file descriptor 當中的字元,但是linenoise
還有針對不同可以讀取的 file descriptor 作多工函式引入的程式片段。而在
qtest
當中就實作了STDIN_FILENO
與 Socket file descriptor 的多工處理函式,接著就要介紹到重要的系統呼叫 select , select 的特點在於可以等待多個 file descriptor (fd) ,在有 fd 等待的事件發生前會 Block waiting , 因此 select 常被用於實作 I/O Multiplexing 的函式。原先的 I/O multiplexing 實作
在原先的 I/O Multiplexing 的實作當中,會先使用與 select 函式相關的巨集來制定集合內容,例如在
web
功能啟動後,select
函式會監聽鍵盤的輸入以及伺服器指定的連接埠,當有其中一者為可讀時,select
函式就會回傳正值並到FD_ISSET
巨集判斷可讀的 fd ,接著才會開始真正的使用read
或是accept
系統呼叫進行讀取。但是這個實作會遇到一個狀況,
web_connfd
是已經建立連線的 socket fd ,這個函式會讀取這個 fd 當中的內容,但是若這個連線當中的客戶端沒有傳送資料,這個行程就會被迫 blocking ,因此我再這個函式作了如下改善。加入 select 的 I/O multiplexing 實作
我在建立連線並得到 socket fd 的過程後使用了
select
系統呼叫來判別STDIN_FILENO
與 socket 是否為可讀狀態,這樣就可以免除 socket 的讀取會 blocking 整個行程的問題。提供新的命令
shuffle
我們的
shuffle
所採取的演算法是Fisher–Yates shuffle
,是一個時間複雜度為 \(O(n)\) 的演算法。會執行以下步驟:交換前:
交換後:
交換前:
交換後:
實作解說
line 8
的外部迴圈,會讓i
迭代從最後一個節點到第二個節點,line 9
會讓j
隨機挑選一個節點。line 14-16
的內部迴圈會將node_i
移到指定位址。line 18-20
同理會將node_j
移到指定位址。line 24-42
將每一回合要交換的節點分成三個案例 :distance == 0
: 此案例無須交換節點。distance == 1
: 利用 swap 的方式交換節點。distance > 1
: 先移除兩個節點,在將其安插回佇列。說明遵守 Uniform distribution
我們利用 Pearson's chi-squared test 能檢驗虛無假說(Null hypothesis)的方式,來驗證我們實作的
shuffle
,落到各種結果的機率均一致。Pearson's chi-squared test
1. 計算 chi-squared test statistic \(X^2\)
\[ X^2 = \sum_{i=1}^n {(O_i - E_i)^2 \over E_i} \]
在測試 shuffle 1000000 次後,二十四種結果各自的頻率如下表:
\(X^2\) = 32.917342677482836
2. 決定自由度(degrees of freedom)
在可能結果共有二十四種的情況下,我們知道二十四種結果總和機率為一,因此這次實驗的自由度為二十三,因為有固定數值
\[ P(x_n) = 1-P(x_1)-...-P(x_{n-1}) \]
3. 選擇顯著水準及 P value
顯著水準 α 我們設定最常用的 0.05 , P value 我們藉由卡方分佈表查表,由於 \({X^2 = 32.917}\) 且自由度為二十三,因此經由查表後得到 \({0.05 < P\ value< 0.1}\) 。
4. 統計檢定的結果不拒絕虛無假說
由於 \({P\ value}\) 沒有低於 顯著水準 α ,因此虛無假說 \(H_0\) 無法被拒絕,也就是說無法否認 shuffle 的結果遵循 Uniform distribution 。
而從最後的圖表來觀察,機率傾向於一致。
井字遊戲
並行程式設計
我使用 coroutine 的方式實作「電腦 vs 電腦」的對弈模式,其中電腦 A 是使用 negamax 演算法,而電腦 B 是使用 MCTS 演算法。而電腦 A、B 分別對應到任務一及任務二。至於任務之間的切換,是採用
setjmp
+longjmp
的方法。1. setjmp/longjmp
這兩個函式可以轉換程式執行的順序,其中
setjmp
可以利用jum_buf
儲存目前程式的狀態,並且在遇到longjmp(jum_buf)
後跳回setjmp
並恢復程式的儲存狀態,這樣的函式設計可以方便程式執行時在不同任務間轉換。 TTY raw mode2. task_add/task_switch
這是主要切換任務的函式,我們用
list_head
構成的循環雙向鏈結串列存放即將執行的任務,也就是存放jmp_buf
。其中task_add
可以將任務加到串列當中,task_switch
可以切換到我們紀錄的下一個任務執行。流程設計參照下方程式碼,
schedule
函式會將兩個任務放到佇列中,而任務執行完的當下會再將這個任務加到佇列當中,若此對局勝負揭曉則不會再將加到佇列當中,佇列為空也就代表並行程式結束執行。程式碼解說
任務參數-結構變數
首先可以看結構變數
task
中,第一個成員變數env
即是用來儲存這次進入任務前的程式執行狀態,而與參考程式略有不同的是我新增了棋盤以及回合符號這兩個結構變數,目的是判斷此次任務是否有結果並且停止task_add
。任務一 、 任務二
以上是兩個任務函式的部份程式碼,其執行的流程如下:
schedule
函式呼叫,會進到第一個條件式當中,把此任務加到佇列當中,並回到schedule
當中。task_add
及task_switch
,並完成 AI 下棋步驟。task_switch
當中並執行下個任務。任務三: 鍵盤事件處理
為了在遊戲中達成
ctrl+q
,ctrl+p
控制程式流程,因此我們新增一個任務用來處理鍵盤事件,但為了不影響終端機畫面,因此我們將處理鍵盤事件的輸入輸出方式改為RAW mode
,好處是無須按下ENTER
鍵就可直接讀取輸入,以及防止輸入資訊預設的輸出到終端機上。詳閱
CONTRIBUTING.md
以理解專案偏好的程式碼風格,避免 camelCase。可以看到
process key
函式前後有啟用以及關閉RAW mode
的函式。 進到RAW mode
後會先將部份狀態關閉,例如: 程式中斷訊號、回傳輸入結果、逐位元讀取功能 等等,而開關方式就是位元操作。這裡另外值得一提的是
ctrl
,它能將與它一同輸入的英文字母不論大小寫映射到0~25
這個區間內,原理是ctrl
會將前三個位元清除,而在 ASCII 的設計當中,英文字母的最後五個位元剛好會對應0~25
的位元字串。因此在做事件處理時,我們只須觀察輸入位元組的後面五個位元,即可知道使用者輸入的資料是否為
ctrl+alphabet
,因為鍵盤無法輸入0~25
等不可顯示的字元。顯示目前時間
依照作業要求,我們要在程式執行過程中,不斷更新目前時間。而應用的就是跳脫序列 (Escape Sequence),它可以改變終端機的文字格式,其中跳脫序列的開頭都以跳脫字元作為開頭
\x1b
,後續接著的字元是要執行的內容。接著會介紹上述程式碼會用到的跳脫序列,例如參考中有但被註解掉的
\x1b[H
,它是要移動游標到指定位置,預設為\x1b[1;1H
也就是終端機第一列第一行的位置。至於\x1b[K
則是清除游標以後此行的內容。而我實作的方式,除了針對前述三個任務進行的排程,我額外在第三個任務中加入執行緒,這個執行緒會每秒定期執行
editorRefreshScreen
函式,並且把當下時間輸出在游標的位置,在離開任務時也會將\r\n
輸出在終端機以免影響板面。本程式不該使用執行緒,使用 (preemptive) coroutine 開發。
將各局過程顯示於終端機
我新增了一個二維陣列紀錄每次對局的過程,特別的是我用每列的第一個陣列元素紀錄此局所走的步數,確認這一列實際存放的元素個數。
TODO: 學習 minicoro 的手法,以行內組合語言取代 setjmp/longjmp,降低任務切換的成本。
結果呈現
- The image file may be corrupted
- The server hosting the image is unavailable
- The image path is incorrect
- The image format is not supported
Learn More →使用定點數取代 Monte Carlo 中的浮點數運算
struct node
uct_score
backpropagate
在 MCTS 演算法中,最常呼叫的函式是迴圈中的
select_move
,在這個函式中又會呼叫uct_score
進行大量的浮點數運算。在效能以及程式可移植性的考量下,我們將其改為定點數的運算。這項改動讓函式從原本的資料型態
double
改為uint_32
,而定點數的運作原理如下,我將uint_32
的二進位小數點設於第十六個位元及第十七個位元之間(LSB 從第一個位元起算)。於是紀錄的數值在處理運算時就會是原先的 \(2^{16}\) 倍,例如要將uint_32
加上零點五就會被轉為加上 \(2^{8}\) 。但是這樣的轉變就可以省去原先浮點數複雜的運算模式,換為二進位的基礎運算。對於 MCTS 迴圈中
select_move
大量呼叫的uct_score
函式,目前已經使用定點數實作改寫完成,比較的分數值會是原本的 \(2^{16}\) 倍,但這並不影響最後比較結果。backpropagate
迴圈中的兩個浮點數運算也被置換完成。Monte Carlo 亂數產生器
在原先的
jserv/ttt
專案中,就有先引入偽亂數產生器mt19937-64
輔助 negamax 演算法,而我也嘗試將其引入到 MCTS 演算法當中,取代原本標準函式庫的亂數產生函式rand()
。perf stat
於是我用效能比較工具 Perf 比較了兩者的效能,測試的項目分別是「使用的處理器時脈週期」及「使用的指令數目」這兩個項目。使用的命令是
perf stat
用來紀錄 MCTS 演算法執行三次後使用者模式下的測試項目用量,分別得到以下兩個結果。其中執行時間與本次實驗無關(因為
cmd
命令輸入也同樣記入),因此我們在意的是時脈週期以及指令數目。可以看到採用mt19937-64
偽亂數產生器後,時脈週期有了明顯的減少,約是原本的 \(\dfrac{96}{100}\) ,但是我用perf record
對函式進行程式熱點分析時又看到了不同的結果。perf record
perf record
命令可以針對測試項目對程式的函式進行熱點分析,我用了與上述同樣的比較對象以及測試項目進行比較分析,分別得到以下結果。可以發現若只觀察mcts
函式,時脈週期在變更亂數生成器後有了略微的上升,打破了perf stat
所預想的時脈週期減少的結論。但是也有可能perf record
只觀察使用者模式下的用量,導致亂數生成皆在使用者模式的mt19937-64
時脈週期偏高。clock-cycles


instructions


延伸實作
git rebase
一項 git 專案可能同時擁有多個分支,例如目前我的
lab0-c
就存在三個分支。而rebase
和merge
就是為了處理這些分支而存在的命令。按照這次作業的要求,我們要先從 GitHub 原始專案的
master
分支抓取到我們的 git 專案當中成為新的遠端分支,並且我們要將目前本地的主分支 rebase 到這個遠端分支上。這裡先用 graphviz 解釋 rebase 要處理的事情:
上圖的過程如下,首先共有兩個分支
ttt
以及master
,並且兩個分支在分支產生後皆有新的 commit 紀錄,這時我們希望將兩個分支合併並且採用 rebase 的方式,也決定了是要將ttt
分支 rebase 到master
分支。因此我們會將
c6d0450
這個 commit 所紀錄的 patch file 重新對master
分支進行 commit ,過程中可能會產生衝突, git 會自動產生標註在要 commit 的檔案當中,修改完後便繼續 commit 。重複上序流程直到ttt
中的 commit 紀錄皆重新 commit 到master
分支上。值得注意的是因為重新 commit 的關係所以 SHA-1 值以及時間點都會與之前的不同。實作流程
首先先新增遠端分支
orig_sys
,並更新這個分支到最新進度。接著確認目前所在分支,確定是要合併過去的分之後,就可以執行 rebase 命令,在預設情況下會逐一進行,直到遇到衝突為止,在實作中我遇到的衝突如下,如提示所說,須將更改後的內容留在檔案中,隨後再重新
git add <file> && git commit
。這是實際遇到的衝突之一,只要留下需要的程式碼再將其餘標註刪除,再重新 commit 即可,我是透過 VScode 之衝突解決功能並修改 commit 內容後提交。
最後要將 rebase 完的結果推到 github 上,但是會遇到不是 fast-forward 的問題,原因在於 github 紀錄的最後一次 commit 已不存在於現在推上去的紀錄中。因此我們必須改成
git push -f
強置置換原先 github 上分支的內容。〈Teach Yourself Programming in Ten Years〉閱讀感想
首先作者就如標題所述,如果我們是希望成為名符其實的程式設計師,那麼我們就要有決心付出在這個領域當中,他也提到不論是音樂神童亦或是有名樂團,都也要至少十年的時間投入練習及學習。
接著他提到了關於程式設計領域我們可以著手的項目,我列出幾項我體會較多的。首先他說若要在某個領域有卓越表現,通常有卓越表現的人並不等於經驗豐富的人,但反之如果有經驗豐富的個人願意花時間刻意練習,則非常有機會成為有卓越表現的人。再來他也有提到專業知識的重要性,包括計算機科學一定要理解的計算機結構基礎知識。最後他希望我們參與專案開發,並且要與其他程式設計師多加溝通,以了解他們的思路,這樣思維才得以融會貫通。
以目前學生的角度出發,我的感想如下: 我能認同作者所說的做中學的重要性,因為若要讓大學四年所學的有所貢獻,那麼我們還是得以程式設計師的角色出發,所以最重要的還是莫過於實作能力。而且從目前這堂核心實作課程我所得到的感想,例如 循環雙向鏈結串列的實作、(快取)記憶體用量分析、排序最差狀況的避免及優化、 LRU 快取實作、並行程式的設計。這些其實都與我們大學所學的息息相關,但是要完成這些最重要的還是閱讀程式碼以及撰寫有效率程式碼的能力。
要與其他程式設計師討論這點最近也略有感觸,因為實驗室計畫而需要與人合作,在著手寫 ROS2 程式前會先與同學討論,不僅得到不同思路而且問題目前都迎刃而解,所以頗為認同。在這堂課當中,也要嘗試批評其他同學的作業,老實說還真的有學到不一樣的觀點,HotMercury 指出要使用 Linux 核心風格的程式碼,以及佇列插入要達到 \(O(1)\)與函式呼叫次數無關等等資訊,其實讓我有機會好好重思這些問題,甚至有了更好的解決方向。老師的批改也讓我了解到了問題的直接所在,也得以再更新筆記。
最後作個結尾,作者最後強調要無所畏懼的投入學習以及練習,對我而言知識或許沒有其他修課同學那麼充足,但至少在投入練習這點我希望能超越其他同學,讓自己不會愧對於未來想要成為稱職的程式設計師這一點,就像老師說的工程師對這個社會的責任就是變強,我希望我也可以透過投入這堂課的練習讓自己變強。
課程教材回顧
這裡會主要回顧前三週的教材。首先是第一週的作業系統觀念,裡面提到了
fork
的概念是源自於並行處理的程式,fork
會產生新的行程,並透過join
來同步兩個行程的執行。還有提到 sheduler 的觀念,因為當時已經提到了行程,因此也需要設計相對應的行程對處理器之間的資源分配,因此 scheduler 的概念就此產生,接著才有我們學到的行程排程方式,包含 Round Robin , FIFO 等等。接著是第二週的數值系統,首先探討了相對二進制,三進制更容易處理負數的問題,因為二進位還有分成無號數以及有號數的運算,在轉換有號負數成無號數時計算方式是加上 \(2^n\) ( \(n\) 是字組大小),但容易產生預期外的錯誤。再來說到位元操作章節,首先講到了特殊的 ascii 設計,關於
A
及a
的設計,剛好是1000001
及1100001
,因此我們可以透過對第五個位元進行與一的互斥或運算完成A
及a
的轉換。另外一項特性是ctrl
可以遮罩第五及第六個位元,所以A
及a
可以轉換成1
,其他字母也有相同的特性。再來是記憶體管理以及對齊的章節,首先提到了記憶體的階層,不過主題是記憶體存取速度(最快的是快取,其次是主記憶體,再來才是快閃記憶體,最後是傳統硬碟),比較需要注意的是彼此階層之間速度相差十倍,因此快取是否能抓取到常用資料成為了重要的議題,以及主記憶體及硬碟中的需求分頁也是值得注意的重點。接著是記憶體對齊的議題,舉例的是最常見的結構變數,當一個結構變數當中有
int
型態變數以及char
型態變數,則編譯器 在實際配置記憶體空間時會對齊位址為四的倍數的記憶體位址,這是為了記憶體存取時能夠直接操作。第二週最後是 bit-feild 章節,它可以透過在宣告變數時讓變數透過
:
設定變數使用空間,設定為零時不得有變數名稱並且之後的變數會對齊下一個界線,老實說還不懂 bit-field 為零的記憶體對齊。然後是第三週的浮點數運算,首先提到了 IEEE754 就算有明確規範,但是不同處理器也會產生不同的結果,甚至還有處理器沒有運算單元協助浮點數運算,但是編譯器可以融入 sse2 指令集以符合 IEEE754 的規範。需要特別注意的是這會導致儲存的浮點數並非我們直觀的十進位,而是會儲存成二進位相近的數值,因此0.1 + 0.2 == 0.3
非真。盡量避免讓浮點數進行比較運算,因為實際儲存的數值並非實際程式賦予的數值。