contributed by < yanjiew1
>
原本的 fibdrv 是使用 Dynamic Programming 來計算,時間複雜度為 ,且使用可變長度陣列來存放第 0 ~ n 的費氏數,空間複雜度為 。
參考作業說明和 Calculating Fibonacci Numbers by Fast Doubling,修改 fibdrv 用 Fast Doubling 來計算:
實作方式是先用 Linux kernel 提供的 fls64
來得到最高位元是在哪一位,並且把它移到整個 64 bits 整數的最左邊。另外也順便得到 len
就是整數二進位有效位數。例: 0010001
, len
就會是 5 ,前面二個 0 沒作用。
接下來跟據老師題供的作業說明公式,計算 fast doubling ,並且遇到左邊 bit 為 1 時,再額外計算下一個費氏數。公式如下:
編譯載入核心模組後,確認可以正確計算到第 92 個費氏數:
上述程式改進後,時間複雜度變為 ,空間複雜度為 。
TODO: 提交 pull request,讓 GitHub Actions 得以自動測試。
已提交自動測試 GitHub Actions Pull Request
作業要求中,需要針對每一個實作分析效能。為了及早分析,故在開始實作前,先建立效能分析的架構。
我們需要精確計時器來分析效能。在作業說明中提到使用 ktime_t
來做,此外此作業 fibdrv
中的 write
剛好沒作用,可以用來量測和回傳量測結果。另外參考 KYG-yaya573142
的實作來開發。
ktime_t
測試宣告 static
變數:
修改 fib_read
進行量測:
修改 fib_write
用來查詢量測結果:
新增 measure.c
測試程式,測試程式中,使用 write(fd, write_buf, 0)
來取得上次執行的時間。
執行 ./measure
後,成功輸出計算費氏數時間,輸出如下:
因為要測量運算完成後,把資料從核心搬到使用者模式,及使用者模式收到資料的時間。故在使用者模式取得到時間要能跟核心模式進行對應。
在使用者模式可以用 clock_gettime
函式來取得時間,其中又有不同的 clock id 。跟據 Linux 核心 ktime API 文件,在 Linux 核心內,以 ktime_get
函式所取得的時間可以對應到 CLOCK_MONOTONIC
的時間。
跟據 Linux 原始碼 include/linux/ktime.h
, ktime_t
的單位為柰秒,並且為 64-bit 有號數型態。
struct timespec
為使用者模式透過 clock_gettime
取得的時間。要轉成 ktime_t
也很簡單。 struct timespec
定義如下:
把 tv_sec
乘上 再加上 tv_nsec
即可,可直接在使用者模式實作。因此我們就可利用 write
的回傳值來傳遞在核心模式量測到的時間。而使用者模式量測到時間可以轉成 ktime_t
後再比較。
Linux 支援的最大日期 ktime_t
以柰秒來存放時間。故能設定的最大時間落在 2262 年左右。以 date -s "2270-01-01"
指令來設定時間果然不能運作。看來解決了 Y2038 問題,二世紀後的人們要面對 Y2262 的問題。
這個作業會需要比較多種演算法的效能。觀察目前 read
和 write
用到的參數,可以發現 size
未被使用。故就拿它來選擇演算法用。
read
會回傳計算完成的費氏數,並把花費時間記錄在一個全域變數內。而 write
中,若 size
傳入 0 ,則回傳全域變數內上次 read
所花費的時間。若傳入大於 0 的數字,則會選定演算法跑一次費氏數運算,但不回傳結果,而是回傳執行時間。
故我修改 fib_sequence
讓它可以選擇演算法
然後再修改 read
和 write
函式:
先排除效能干擾因素
這是在作業說明沒寫,但我覺得也會影響測試結果的部份。因為在 SMT 開啟的狀態下,會有二個硬體的 Thread 跑在同一個 CPU 核心上,會互相競爭,故把它關閉。
按照作業說明,把以下內容複製到一個獨立檔案 performance.sh
之後再執行 $ sudo sh performance.sh
把某一核心空出來給待測程式跑。原本的作業說明是在 GRUB 加上 isolcpus=...
。但我希望能夠在 Linux 執行時,能直接空出 CPU Core 不必改 boot loader 。
我查到一篇說明文件說明如何隔離 CPU core ,使用的是 cpuset 。
先安裝 cpuset 套件:
用以下指令來建立 cpuset 。這裡假設要隔離第 0 個 core ,而 1-3 這四個 core 不要隔離。
建立二個 cpuset 分別是 others
和 isolated
。我們把要放在第 0 個 core 的行程放在 isolated
,其他的行程都放在 others
。
確保 isolated
這個 cpuset 不執行 task scheduling :
把所有行程放到 others
內
至此就建立好量測環境了。可以用下列指令來讓行程放在 isolated
內執行
最近再次使用 cset
時,一直出現 cset
無法去掛載 cpuset
相關檔案系統到 /cpusets
錯誤訊息。後來是發現它跟 cgroup v2
有衝突,當使用 cgroup v2
的 cpuset
機制時,需要透過 cgroup v2
機制來設定 cpuset
,故 cset
就無法用傳統方式操作 cpuset
。
原本系統沒有這個問題,不確定是哪次更新系統或安裝某個套件後開始有這個問題。
故後來的我先把 cgroup v2
關閉,改用 cgroup v1
。 修改 /etc/default/grub
,在 GRUB_CMDLINE_LINUX_DEFAULT=
後面,加上 systemd.unified_cgroup_hierarchy=false
,例如:
再執行 update-grub
更新 GRUB 設定。
在作業說明有提供二種方式進行第 93 費氏數後的運算,一個是用 __int128
,另一個方式是用十進位字串的方式。但因為十進位字串的方式感覺很浪費空間,故我決定實作一個使用 64-bit 無號整數組成陣列的方式來提供大數運算。
Linux 內建的大數運算函式 include/linux/mpi.h
,可作為效能比較標的之一。
為何不直接使用 Linux 核心 MPI 相關 API 呢?
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
我一開始打算按照作業說明提供的材料,自己練習寫一個大數運算的程式,應用作業說明提到的方式來試試看。後來看到 Linux 核心有 MPI 相關 API 後,打算也用 MPI 相關 API 來實作比較效能,當下沒有想到就直接使用它來做。
今天下午我嘗試用 MPI 相關 API 來開發,但很關鍵的函式
mpi_mul
(乘法) 沒有被 export 出來,編譯後沒辦法連結,無法使用。我用 lxr 查,發現它是在 v6.0 以後才被 export 出來可以用在核心模組。之後我會安裝 v6.0 以後版本的核心再來試看看。
按照作業說明來計算。
透過這個公式可直接計算第 個費氏數:
當 n 足夠大時,可得到約略值:
取得以 2 為底的對數
但因為 Linux 核心不能適合使用浮點數,故乘上 轉成整數運算
寫成 C 語言函式。因為 k
小時誤差大,故多一個比較式確保至少有 2 個 bit 被使用。
定義一個資料結構,可以用來放大數。其中 capacity
代表 digits
有幾個 unsigned long
,而 size
代表用到幾個 unsigned long
來存放目前數字。 另外在計算費氏數時,不會出現負數,故不針對負數做處理。此外一開始就把所需的空間開好,故也不考慮溢位要增加空間的處理。
定義所需大數運算的操作。由 fast-doubling 公式可知,需要有左移一位、加法、減法和乘法運算。
用小學減法來實作 bn_add
:
用小學減法來實作 bn_sub
:
用小學乘法來做:因為二個 64-bit 整數相乘會得到 128-bit ,故此函式有用到 GCC Extension __uint128_t
。
從最高位的左邊 1 位開始計算,每次左移 1 位元時,跟目前處理右邊一位的最高位元做 AND 運算。
buf
目前的計算結果是直接把數字回傳,但因為回傳值型態是 size_t
為 64-bit 無號整數,在大的費氏數無法用。改把計算結果用 buf
傳回。
但是把結果放進 buf
會需要使用 size
參數才能知道 buf
大小,但又顧及到需動態選擇演算法,因為改成用 write
介面來選擇演算法。
我把計算結果暫存至 struct file
內的 private_data
。 我新增了一個結構 struct fibdrv_priv
,這個結構會在 open
時被建立,然後讓 private_data
指向它,並在 release
時被釋放。
這個資料結構可以針對每一個獨立開啟 file descriptor 保存一份狀態,這樣子就可以讓 fibdrv 被同時使用,此外原本放在全域的 mutex 就可以移除。
新增 fib_init_priv
和 fib_free_priv
二個函式來初始化和釋放 struct fibdrv_priv
:
另外修改 open
和 release
函式。open
會配置空間給 struct fibdrv_priv
並初始化它,而 release
則會釋放 struct fibdrv_priv
空間:
另外原本計算費氏數的函式會回傳計算結果,現在改成把計算結果寫進 struct fibdrv_priv
:
read
的行為修改成:
size
個位元組,複製到使用者傳入的 buf
中。修改 lseek
,在操作時要鎖住 mutex ,並且把先前的結果清除掉: (中間程式跟原來一樣,故省略)
write
可以用來選擇計算費氏數的實作和取得上次計算費氏數的時間:
我分別實作了 Dynamic Programming 和 Fast doubling 版本如下:
Dynamic Programming 版本
Fast Doubling 版本
TODO: 引入 hash table 來保存已經運算過的數值,再利用已知 Fibonacci 數列的原理來加速後續運算。
要在使用者模式印出由核心模式傳來由 64-bit 無號整數組成的大數字串,需要先轉成 十進位由 0 ~ 9 組成的字串。
為了避免時常重新配置空間,字串所需大小可以用這個公式計算:
其中 是原來數字是由幾個位元組成,計算出來的結果就是字串所需的長度。
因為直接對大數做除法很花時間,故先找到最大可以存進 64-bit 無號整數,以 1 為開頭後面都是 0 的十進位數字。可以找到 。
每次都先對大數除以 取得商數和餘數。把餘數轉成 10 進位數並串接到字串上(先以反方向串接)。再對商數重複上述步驟,直到商數為 0 。最後再把字串反轉就是答案了。
圖中的 kernel 代表在 kernel 量測計算費氏數時間的結果, user 則代表在 user space 量測時間的結果, kernel to user 則是把 user 時間減去 kernel 時間,代表 system call 和在 user space 呼叫 clock_gettime
取得時間的成本。
我使用作業說明提到的 Python 程式來進行分析。我把它引進到我的程式中。
64-bit 整數 DP 版本
可以看到花費時間,線性往上增加。從演算法也可知,DP 版本的時間複雜度為 ,圖也符合這個趨勢。另外從 kernel to user 時間,可以知道 syscall overhead 相當多。 kernel time 的數據大約是從一開始的 138ns 到最後變成 471ns 。而 kernel to user 大約是 210ns。而 kernel to user
除了最前面的高峰外,大約在 1800ns 左右。
一開始在 kernel to user 有一個小高峰,目前推測是 cache 的問題。故我修改量測程式,讓量測是先把待測的程式都跑過一次,確保它們在 cache 內,解決了這個高峰的問題。
Commit 7e7355f
對比未排除 cache miss 因素的量測結果:
圖中的數據比前面的圖還快很多,是因為我忘了關 Turbo Boost 。後面的圖都會重新量測做調整。
64-bit 整數 Fast Doubling 版本
Fast Doubling 的時間複雜度為 ,圖中的線大致上是平的,但有時會上下坡動,量測可能還是有一些干擾在。kernel 的時間介於 123ns 到 212ns 之間 ,基本上是平的。而 kernel to user 大約是 1800ns 左右,開銷還是很大。
大數 DP 版本
相較於前面花費不到 100ns 就可以算完,用大數運算成本增加非常多。最高 kernel 的時間就到 53788ns 。另外有點意外的是它在 93 以內,也是呈現階梯狀。因為在 93 以後,才會需要第 2 個 64-bit 無號整數來存,在第 93 費氏數以前的數字應該都固定用 1 個 64-bit 無號整數存就夠了。
圖中可知我寫的大數運算成本相當高,之後參考 sysprog21/bignum
來重新實作一個。
大數 Fast Doubling 版本
大數的 Fast Doubling 版本又比 DP 版本花的時間更長。目前估計是因為乘法沒做優化,乘法的時間複雜度是 除了時間複雜度高外,針對相同的二數相乘,應該可以把重複計算的部份省下來,但我的程式沒有這麼做。這都是未來可以優化的點。之後參考 sysprog21/bignum
並搭配快速乘法演算法,來重新實作。