contributed by < tinyynoob
>
因為之前嘗試寫過 2021 年之作業,因此需要對 lab0-c 重新進行 fork。
首先,將原有的遠端 repo 刪除,之後建立一個新的遠端 repo 2021-lab0 ,並將舊的本地 repo 推到該遠端庫。最後,重新 fork 新版 lab0-c。
Linux tinyynoob-notebook 5.13.0-30-generic #33~20.04.1-Ubuntu SMP Mon Feb 7 14:25:10 UTC 2022 x86_64 x86_64 x86_64 GNU/Linux
gcc (Ubuntu 9.3.0-17ubuntu1~20.04) 9.3.0
加入以下兩行,用於檢測 NULL 佇列及空佇列
觀察 list.h
,得知 list_for_each()
為一 macro ,其作用為走訪 head
中的每一項元素。
因此可透過完整走訪整個 list 來計數 list 中的元素個數。
配置空間並初始化,若配置失敗則回傳 NULL 。
撰寫程式碼如下
在 newNode
配置成功後先將其所有成員初始化避免錯誤操作。
其中第 8 行之用法源於 list.h
中對於 INIT_LIST_HEAD()
之註解
為 newNode->value
配置空間時需要加 1 ,才能為 \0
安排空間。另外因 char 不一定佔據 1 byte ,因此不可將 sizeof(char) 省略。
接著第 10 至 17 行將 s
內的字串複製至 newNode->value
,過程中使用 strncpy()
以避免 strcpy()
之潛在危險。
等上述操作皆成功完成後,才呼叫 kernel API 進行鏈結串列之相連。
內容與 q_insert_head()
幾乎相同,僅將第 18 行之 list_add()
置換為 list_add_tail()
。
完成一部份後先 ./qtest
進行測試,發現 q_size() 的 return 值有錯
因此將 it 修正為 size
程式碼如下
首先須判斷 l
是否為 NULL 來避免被使用者 doubly free 。
而後使用迴圈依序刪除節點,先使用 list_del()
將節點移出鏈結串列,再將整個容器清除,其中 q_release_element()
定義如下
串列為空後執行 free(l)
I think q_release_element()
should be an inline function.
詳述你的考量、設計實驗,並拿實際的效益來說服其他人。
jservImage Not Showing Possible ReasonsLearn More →
- The image file may be corrupted
- The server hosting the image is unavailable
- The image path is incorrect
- The image format is not supported
移除首節點,若 sp
非空,則將 entry 內之字串複製至 sp
。
根據 man page
有如下敘述
The
strnlen()
function returns the number of bytes in the string pointed to by s, excluding the terminating null byte ('\0'
), but at most maxlen.
因此題目關於 bufsize 的要求可以用 strnlen()
來達成,此外因其計算長度時並未計入 \0
,所以程式第 10 行直接存取第 len 個元素將剛好是 \0
需要在的位置。
實作方式與 q_remove_head()
相似。
我們可以利用環狀且雙向的特性,讓兩個指標從 head
開始反方向移動,直到它們交會時便是串列中點的位置。
但需要特別注意 list size 為偶數的情況,為了找到中點 ,我們不能讓兩個指標同時動作,需要由 forwd
先走一步,觀察是否交會,再由 backwd
走一步,否則在偶數的情況下兩者將擦身而過。
或許可以先使用 q_size()
判斷奇偶數,再視結果用不同的方式尋找中點。
不過考量 q_size()
需要走訪全部節點,因此我估計其執行速度將不如目前版本。
建立一條 list trashcan
收集欲刪除的節點,再於最後一口氣刪除。
顧及走訪的速度,我們讓非 duplicate 的節點快速通過,有 duplicate 的情況放到迴圈後半部處理。
由於 duplicate 節點被要求全數刪除,因此在發現 duplicate 後需要先倒退一步(第 16 行),再把資料內容等於 curr
的節點一一移到 trashcan
。
發現 duplicate :
倒退一步:
接著由 17 到 20 行循環移除 it->next
,直到
此函式要求每遇到兩個節點便進行 swap ,因此我們引入 swap_pair()
使用,其功能為交換兩個節點。
before :
after swap_pair()
:
經由畫圖的過程,我發現了 swap_pair()
的錯誤,於是在第 3 行之後加入
藉著 swap_pair()
我們可以簡潔實作 q_swap()
如下
由於我們的 list 是 doubly-linked ,因此可以直接交換每個節點的 next
跟 prev
達成 reverse ,程式碼如下:
需要注意到迴圈方向 it = it->prev
其實是在往前走,因為我們已經交換了 next
跟 prev
。另外,迴圈不可由head
起始,否則會直接觸發中止條件結束。
目前的交換函式如上,我希望能藉由 xor
來達成不佔額外空間的交換,然而語法:
卻會導致錯誤訊息 運算式必須是可修改的 Lvalue ,或許需要再查看規格書尋找原因。
gcc 內建的中文訊息是個悲劇,Lvalue 不該翻譯為「左值」,在 C99 後,lvalue 正式名稱是 locator value,跟「左」無關。
jservImage Not Showing Possible ReasonsLearn More →
- The image file may be corrupted
- The server hosting the image is unavailable
- The image path is incorrect
- The image format is not supported
我發覺到不應於 assignment 左方進行轉型,然而改成
編譯時仍出現錯誤訊息
根據 C99 規格書:
An integer may be converted to any pointer type. Except as previously specified, the result is implementation-defined, might not be correctly aligned, might not point to an entity of the referenced type, and might be a trap representation.
我認定整數型別轉為指標型別應是合法操作。
推測可能是指標型態不合,再將程式碼修改為
終於編譯通過並得到正確的交換結果。
我不確定規格書上 intptr_t
與這個 __intptr_t
的相異之處
註:由於此節內容略多,因此我提高其層級
基於上一次的經驗,我直接選擇使用 merge sort 來進行排序。
稍微瀏覽這篇筆記後,嘗試自己撰寫。首先將 list 切成單一節點來跳過慢慢切細的過程,目前我想不出 doubly-linked 的特性能做什麼利用,因此仍以和 singly-linked 相同的方式一步一步 merge 回去,第一個版本使用迭代實作。
n / 2 + (n & 1)
之功能為將整數除以 2 後無條件進位
head
,並重新走訪排序後的 list ,將每個節點的 prev
成員指向上一個試著使用 conditional operator 以及 indirect pointer 來簡化 merge 的程式碼。
我對於這個版本不大滿意,仍有多處不夠優美。目前期望的改進方向是在 merge 的過程中就處理掉 prev
成員的問題,另外我也希望能避免使用 lists
陣列來降低記憶體用量。
發現雖然正確性測試都有通過,但 q_sort()
的速度不夠快,還需要再想辦法做改進。
將原先 q_sort()
第 11 行中的 it->prev
替換為 lists[i]
減少不必要的取值。
接著我參考了 linux/lib/list_sort.c 的實作,將重建 prev
的部份融入到最後一次的 merge 中,減少一個 pass 的處理。修改成:
其中 lists
合併的方式略有調整,為了避免每次都要計算合併對象,直接在迴圈中引入 j
變數進行頭尾合併,如此也降低了 array index 對 n
的依賴。
此外也可以看見最後一次 merge 被移出迴圈並改寫為 final_merge()
,其結構大致與 merge_sorted_singly()
相似,主要差異在於增加了對 prev
成員的處理。
以 為例圖解 q_sort()
:
然而,經過修改後的程式碼仍無法通過速度測試,目前對於接續該如何優化沒有頭緒。
從討論串得到一些啟發,或許可以從 locality 的角度改進,使得程式執行上對 cache 更加友善。
突然有些好奇,會不會有同樣一份程式碼跑測試,在 cache 比較好的電腦測試通過,而在 cache 比較差勁的電腦卻 fail 的情形。
目前的想法卡在,若策略為使相鄰的 list 兩兩合併,例如 lists[i]
併 lists[i+1]
,那麼該如何處置奇數個 list 的問題,若一味地將落單的 list 併到最後,可能使得最後一條 list 出現越來越長的不平衡現象影響合併速度。
我發現自己看錯錯誤訊息,一直誤以為是 sort
的速度太慢才沒有通過 trace-15 。剛才發現我錯的是 trace-14 ,之後照著命令內容操作出現以下結果:
然而測試使用較小的 size 是可以通過的:
推測可能是因為此宣告 struct list_head *lists[size]
佔用了太大的空間。
接著觀摩 bakudr18
同學的實驗,讓我學習到可以針對自己的電腦硬體做測試。
我電腦的 cache 資訊如下
我想測試看看是否輸入資料的 size 塞得進某一層 cache 就能通過,首先因為 struct list_head*
佔用的空間為 8 bytes ,而 6 MiB 大約等於 6000000 bytes ,計算 6000000 除以 8 約為 750000 ,因此我重複使用 trace-14 的測試,並選擇兩個 cases 分別為
得出結果
再多測一個 :
於是我猜想 L3 cache 即是函式是否超時的關鍵。
我無法解釋為何這些 "dolphin" 和 "gerbil" 字串沒有佔用到 cache 空間
使用 time 命令測試後發現我撰寫的 q_sort()
執行速度真的非常緩慢。
有了這樣的認識後,我想將處理方式改成
然而問題在於,即使一開始可以分成每個 sublist 500000 個元素來裝進 L3 cache ,但是終究需要將它們合併到 1000000 ,此時又會遭遇 Time limit exceeded ,不曉得該如何解決。
測試使用快慢指標分割再合併的方法,竟然就通過測試了。我原先一直以為這會更慢,也有閱覽 Merge Sort 與它的變化 效能測試節 ,得此結果使我非常納悶。
與方法 1 使用相同的 merge_sorted_singly()
在 q_sort()
我們主要試了兩個方式:
我認為非常反直覺的原因在於,在其它運算都相同的情況下,既然方法 1 省去了開始時一步一步分割的步驟,為何方法 1 的速度會慢於方法 2 呢?真是神奇。
補:其實它們的運算並不相同,方法 1 是頭尾合併,而方法 2 是相鄰合併
嘗試學習 bakudr18
同學 使用 valgrind 的 cachegrind 進行實驗。我直接使用 qtest 進行實驗,並挑選一些重要的項目展現於此。
試驗中對方法 1, 2 輸入相同的 command :
方法 1 結果:
若我們僅觀察 last-level cache data read misses (DLmr) 之結果, cache miss 熱點主要分佈於:
strcmp()
1,139,711merge_sorted_singly()
1,198,364show_queue()
468,209malloc_consolidate()
272,502雖然是這樣做,但是我不懂為什麼 last level cache 是關鍵
方法 2 結果:
cache miss 熱點主要分佈於:
show_queue()
468,655malloc_consolidate()
237,735可以觀察到在方法 2 之中,雖然 merge_sorted_singly()
, strcmp()
及 split_list()
也有著蠻高的 D1mr ,但是 DLmr 卻能相對於方法 1 降到非常低 (約 0.1 million) 。
我希望能整合兩者的優點,寫出一支從單一節點開始相鄰合併的程式。
打開 qtest.c ,於 console_init()
中加入新命令:
並在 qtest.c 中加入實作函式 do_shuffle()
:
主要依循 Fisher–Yates shuffle 算法,完成上方的 12 至 25 行。
shuffle 所做的事情簡單來說就是改變節點的順序,我認為 swap 的方式比較適合於 array ,因此對於 linked list ,我選用的方法是隨機從原 list 取出節點放入新 list ,直到全數節點都被取出。
思考了之後發現 old
這個變數根本非必要,因為第 19 和 20 行的作用會限制 struck out 的作用範圍,因此直接將洗出來的節點串到 list 的最後即可,被插入尾端的節點不會再次被選中。
程式碼可縮短為:
依序輸入命令 make clean
>>make SANITIZER=1
>>./qtest
>>help
卻未遇見任何錯誤。
而當我嘗試使用 Valgrind 去檢測 qtest 的執行時,會出現錯誤訊息
先輸入
之後再試一次
就順利開始執行了,原因不明。
測試幾個指令後都沒遇到 valgrind memcheck 回報錯誤。
而輸入命令 make valgrind
會得到落落長的分析結果
因為實在執行太久了,所以我在看到 trace-16 的結果後就先按下 ctrl+c
。
可以發現回報的都是重複的內容,沿著訊息的指示尋找,可以發現錯誤訊息的路徑是 qtest.c 的 main() => linenoiseHistoryLoad() => linenoiseHistoryAdd() => malloc(), strdup() , log 指出錯誤主要來自這兩行程式碼
不過因為出現的都是 still reachable
,根據本次作業說明,我們可以去查看 global variable ,我推測問題出在 history
變數。
我懷疑是因為 qtest.c 的 main()
在結束前沒有 free history 相關的操作才出現這個問題。於是我去下載了 linenoise 提供的 example.c ,觀察其結束前也沒有 free history 一類的呼叫。但我同樣用 valgrind 的 memcheck 工具去測試 example.c ,卻沒有出現任何報錯。回頭發覺
並照著 trace-xx 的內容輸入也沒有狀況發生,因此應該是我根本沒搞懂 make valgrind
做了什麼事情。
我在 run_console()
中找到了 linenoiseFree()
呼叫,不過它跟 history 也沒有關係。
我去查詢了 memcheck manual ,得到這段解釋
"Still reachable". This covers cases 1 and 2 (for the BBB blocks) above. A start-pointer or chain of start-pointers to the block is found. Since the block is still pointed at, the programmer could, at least in principle, have freed it before program exit. "Still reachable" blocks are very common and arguably not a problem. So, by default, Memcheck won't report such blocks individually.
但我還是沒有解決問題,為了了解 make valgrind
到底在做什麼,引入 make VERBOSE=1
,並再次執行得到
不知道為什麼手動輸入命令沒有訊息,而透過 driver.py 就會偵測到 still reachable
。
我發現 linenoise.c 試圖透過 linenoiseAtExit()
來清除 history
:
並且發現其註冊寫在 enableRawMode()
中,並使用atexit_registered
讓 linenoiseAtExit()
只會註冊一次:
我在第 9 行處加了 print 來查看它是否有被註冊,得到:
表示 linenoiseAtExit()
應該有被正確註冊才對。
再使用 algrind ./test
了解到直接執行並不會記憶體洩漏的訊息,因此需要去觀察 drive.py 到底是怎麼做的。
嘗試執行
也會得到錯誤訊息:
且其中也沒有看見我安插的 I am registering linenoiseAtExit.
訊息,代表 linenoiseAtExit()
沒有被正確註冊。
由於 -v
只是在設定 VERBOSE level ,因此推測是跟 -f
有關,嘗試只用 -f
執行確實也有跳出 valgrind 訊息。
探討 qtest.c 裡的 main()
:
我發現我在整個 program 中遍尋不著 optarg
變數的宣告。
藉著 man optarg
查到了:
optstring is a string containing the legitimate option characters. If such a character is followed by a colon, the option requires an argument, so getopt() places a pointer to the following text in the same argv-element, or the text of the following argv-element, in optarg.
因此在這裡 optarg 應該會存下字串 "traces/trace-01-ops.cmd" 。
繼續追查 file descriptor 如何使用,在 console.c 中發現:
於是我們發現 stdin 跟外來的 file 會得到不同 has_infile
值,最終會在 run_console()
中走進不同的分支。
cmd_select()
程式碼
由於 cmd_select()
中並沒有用到 linenoise 相關的功能,因此也沒有地方可以註冊 linenoiseAtExit()
,而既然不會用到,我就直接改成讓 main()
不去 trigger linenoise :
修改後可見 make valgrind
的錯誤訊息大幅縮減,對於 history
變數不再報錯。
剩餘的都是 cmd 或 param 在配置空間後沒有釋放,並且集中發生在 trace-17-complexity.cmd
為 input file 時。由於其它測資都不會出現這種 leak 的情況,我們需要先釐清命令:
到底會發生什麼事。
立起 simulation 之後,會去執行 dudect/ 目錄下的時間測試程式,而其中也沒看到有什麼和 cmd, param 有關的地方。
接著我們發現 cmd 和 param 的清除都 console.c 的 do_quit()
進行:
即使是沒有下 quit
命令也會因為 quit_flag
尚未立起而自動在 finish_cmd()
呼叫。
而手動測試 rt 發現並不會看到 valgrind 報錯:
因為手動測試一定要靠 quit
離開,所以可能不太準確,這次將 trace-17-complexity.cmd
中的 it, ih 及 rh 註解掉,只留下 rt 測試:
未完全解決
嘗試透過 massif 工具觀察 simulation 過程的使用量,先嘗試對 ih
進行操作,得
可以看出過程中的 memory consumption 不斷在波動,也可以得知最高點就在第 1 個 snapshot 。
這篇論文提出使用統計學方法來測試給定的程式是否為 constant time
嘗試從 qtest.c 中對 is_insert_head_const()
的呼叫開始追查,可以發現大部分的相關程式都在 dudect/
路徑下,並且測試流程主要由 fixture.c 的 doit()
所掌控。
prepare_inputs()
函式大致的工作是產生一些隨機的資料、字串等等,主要透過 random.c 來實現,其中比較有趣的部份是用到了 /dev/urandom 這個東西。根據我在維基百科找到的說明,它可以藉由對 entropy 的控管來產生真正的亂數。
接著 measure()
會根據不同的操作 (ih
, it
, rh
, rt
) 進行不同的測量,例如要測量 insert head 時會執行以下程式碼:
其中 dut_xxx()
模樣的函式都是為了測試而對原本的 q_xxx()
函式進行重新包裝的 macro 。
get_random_string()
則是取出一在 prepare_inputs()
時生成的隨機字串,每個字串長度 7 ,
cpucycles()
依據不同平台而有不同定義,在 i386 及 x86_64 架構下定義為
不知道中間那一行是什麼,只覺得怎麼看都不像 C 語言,查詢 GNU C doc ,了解到這是內嵌組合語言的語法,並且它的 qualifier 是要放在後面,所以這個 volatile
才會長成這樣子。
這篇文章提到了:
在intel pentium以上級別的cpu中,有一個稱為“時間戳(time stamp)”的部件,它以64位無符號整型數的格式,記錄了自cpu上電以來所經過的時鐘週期數。
在pentium以上的cpu中,提供了一條機器指令rdtsc(read time stamp counter)來讀取這個時間戳的數字,並將其儲存在edx:eax暫存器對中。
所以 cpucycles()
使用兩個變數儲存從 register 取得的值,再用 bitwise 的方式合成並以 int64_t
的格式回傳。
不過我也有看到文章表示這樣的用法已經不合時宜:
至此 cpucycles()
的功能已大致理解,接著繼續探討 measure()
留下的兩個問題:
dut_insert_head()
且不量測 cpu cycle ,之後再獨立執行 1 次 dut_insert_head()
來進行測量呢?drop_size
?針對 1. ,我發現不管是要 measure 哪一種操作,前面都固定是 dut_insert_head()
,內容如上方的 4 至 6 行。不同操作間改變的僅有兩個 cpucycles()
間的部份。
measure()
函式先往後看,在執行完 measure()
之後要執行的是 differentiate()
,它會把 after_ticks
和 before_ticks
相減儲存至 exec_time()
,就是數學上差分的概念。
接著是 update_statistics()
根據註解的提點, Welford's online algorithm