--- tags: Linux, linux2022 --- # 2022q1 Homework3 (fibdrv) contributed by < [`NOVBobLee`](https://github.com/NOVBobLee/fibdrv) > > [作業要求](https://hackmd.io/@sysprog/linux2022-fibdrv) ## 環境設置 * Linux 核心版本(Ubuntu): ```shell $ uname -r 5.13.0-35-generic ``` * 編譯器版本: ```shell $ gcc --version gcc (Ubuntu 9.4.0-1ubuntu1~20.04) 9.4.0 ``` * CPU 架構和規格: ```shell $ lscpu Architecture: x86_64 CPU op-mode(s): 32-bit, 64-bit Byte Order: Little Endian Address sizes: 39 bits physical, 48 bits virtual CPU(s): 8 On-line CPU(s) list: 0-7 Thread(s) per core: 2 Core(s) per socket: 4 Socket(s): 1 NUMA node(s): 1 Vendor ID: GenuineIntel CPU family: 6 Model: 94 Model name: Intel(R) Xeon(R) CPU E3-1230 v5 @ 3.40GHz Stepping: 3 CPU MHz: 3400.000 CPU max MHz: 3800.0000 CPU min MHz: 800.0000 BogoMIPS: 6799.81 Virtualization: VT-x L1d cache: 128 KiB L1i cache: 128 KiB L2 cache: 1 MiB L3 cache: 8 MiB NUMA node0 CPU(s): 0-7 ... ``` * 確認使用者身份不是 `root` : ```shell $ whoami zz4t $ sudo whoami root ``` * 確認 UEFI secure boot 已關閉: ```shell $ dmesg | grep -i secure [ 0.000000] secureboot: Secure boot disabled ``` * 確認 `linux-headers` 套件已安裝: ```shell $ dpkg -L linux-headers-5.13.0-35-generic | grep /lib/modules /lib/modules /lib/modules/5.13.0-35-generic /lib/modules/5.13.0-35-generic/build ``` * 檢查專案,確認可以執行(出現綠色 `Passed [-]` ): ```shell $ git clone git@github.com:NOVBobLee/fibdrv.git $ cd fibdrv $ make check cc -o client client.c ... Passed [-] f(93) fail input: 7540113804746346429 expected: 12200160415121876738 ``` ### 獨立實驗環境,排除干擾 參照這次 [K04: fibdrv](https://hackmd.io/@sysprog/linux2022-fibdrv#-Linux-%E6%95%88%E8%83%BD%E5%88%86%E6%9E%90%E7%9A%84%E6%8F%90%E7%A4%BA) 實驗說明和 [KYG](https://hackmd.io/@KYWeng/rkGdultSU#%E6%8E%92%E9%99%A4%E5%B9%B2%E6%93%BE%E6%95%88%E8%83%BD%E5%88%86%E6%9E%90%E7%9A%84%E5%9B%A0%E7%B4%A0) 學長的共筆,將干擾實驗的因素排除。 * 保留一顆 CPU 核心不被排其他行程 ```shell ## Add isolcpus=7 in /etc/default/grub (0-based index) ## GRUB_CMDLINE_LINUX_DEFAULT="quiet splash ipv6.disable=1 isolcpus=7" $ sudo update-grub $ shutdown -r now $ cat /sys/devices/system/cpu/isolated 7 ## CPU 7 is not in pid 1 init's affinity list $ taskset -cp 1 pid 1's current affinity list: 0-6 ``` * 使用 [`taskset`](https://man7.org/linux/man-pages/man1/taskset.1.html) 指定行程執行在保留的 CPU 上 ```shell $ sudo taskset -c 7 ./client ``` * 關閉 ASLR (Address Space Layout Randomization) :::spoiler 關於 `sudo sh -c` 參考 [sh(1)](https://man7.org/linux/man-pages/man1/sh.1p.html#OPTIONS) 和 [What does `sudo sh -c` means in linux?](https://www.reddit.com/r/learnprogramming/comments/3bsct5/what_does_sudo_sh_c_means_in_linux/) ```shell $ sudo echo 3 > test.plain $ sudo sh -c "echo 3 > test.sudoshc" $ ls -l test.* -rw-rw-r-- 1 zz4t zz4t 2 Mar 22 14:30 test.plain -rw-r--r-- 1 root root 2 Mar 22 14:30 test.sudoshc ``` ::: ```shell $ sudo sh -c "echo 0 > /proc/sys/kernel/randomize_va_space" ``` * 關閉 Intel 處理器的 Turbo mode ```shell $ sudo sh -c "echo 1 > /sys/devices/system/cpu/intel_pstate/no_turbo" ``` * 使用 CPU 的 performace mode ```shell $ sudo sh -c "echo performace > /sys/devices/system/cpu/cpu7/cpufreq/scaling_governor" ``` * 修改 [SMP IRQ Affinity](https://www.kernel.org/doc/html/latest/core-api/irq/irq-affinity.html) ,避免保留的 CPU 去處理 IRQ ```shell #!/bin/sh for file in `find /proc/irq -name "smp_affinity"` do mask=0x`cat ${file}` new_mask=$((${mask} & ~0x80)) new_mask=`printf '%02x' ${new_mask}` sudo sh -c "echo ${new_mask} > ${file}" done mask=0x`cat /proc/irq/default_smp_affinity` new_mask=$((${mask} & ~0x80)) new_mask=`printf '%02x' ${new_mask}` sudo sh -c "echo ${new_mask} > /proc/irq/default_smp_affinity" ``` 有些 SMP IRQ Affinity 是無法從 User Space 更改的,比方說 IRQ0 - timer interrupt ,不過從 `/proc/interrupts` 觀察,剛好保留的 CPU 沒處理過這個 IRQ ,發生次數也不多。 ```shell $ cat /proc/interrupts CPU0 CPU1 CPU2 CPU3 CPU4 CPU5 CPU6 CPU7 0: 7 0 0 0 0 0 0 0 IO-APIC 2-edge timer 1: 0 4 0 0 0 0 0 0 IO-APIC 1-edge i8042 ... ``` 除了 IRQ0 外,另外還有 IRQ2 - cascaded interrupt 的也無法更改。不過 IRQ2 尚未發生過,在更改過 `default_smp_affinity` 後,若 IRQ2 發生,也不會使用保留的 CPU 。 ```shell ## error msg from modify irq0 affinity $ sudo sh -c "echo 7f > /proc/irq/0/smp_affinity" sh: 1: echo: echo: I/O error ## some affinities cannot be changed $ cat /proc/irq/0/effective_affinity 00 ``` 相關連結: [Is it possible to change which core timer interrupts happen on?](https://stackoverflow.com/a/45544810/16257547) [[patch 38/55] genirq: Introduce effective affinity mask](https://lore.kernel.org/lkml/20170619235446.247834245@linutronix.de/) [The irq_domain interrupt number mapping library](https://www.kernel.org/doc/html/latest/core-api/irq/irq-domain.html) :::warning 本共筆測試結果皆參照 [用統計手法去除極端值](https://hackmd.io/@sysprog/linux2022-fibdrv#%E7%94%A8%E7%B5%B1%E8%A8%88%E6%89%8B%E6%B3%95%E5%8E%BB%E9%99%A4%E6%A5%B5%E7%AB%AF%E5%80%BC) 取樣 1000 次,取 95% 信賴區間之後再計算其平均,可參考程式碼 [`expt01_userkernel.c`](https://github.com/NOVBobLee/fibdrv/blob/master/expt01_userkernel.c) 。 ::: ### 測試效果 在原環境下: ![before](https://i.imgur.com/DEPSe0R.png) ![before-3](https://i.imgur.com/wM7pjxW.png) 在原環境下,每次測試變化大,不易觀察趨勢。 排除以上干擾後: ![after](https://i.imgur.com/csgaDla.png) ![after-3](https://i.imgur.com/v5E9EmD.png) 排除以上干擾後,每次測試的變化變小,趨勢比較容易觀察,穩定度真的上升很多,不過還是會有突波出現。 ## 概觀 `fibdrv` 模組 * 編譯 `fibdrv` 模組: ```shell $ make $ ls -l fibdrv.* -rw-rw-r-- 1 zz4t zz4t 4046 Mar 9 23:26 fibdrv.c -rw-rw-r-- 1 zz4t zz4t 327920 Mar 21 14:09 fibdrv.ko -rw-rw-r-- 1 zz4t zz4t 42 Mar 21 14:09 fibdrv.mod -rw-rw-r-- 1 zz4t zz4t 1265 Mar 21 14:09 fibdrv.mod.c -rw-rw-r-- 1 zz4t zz4t 106488 Mar 21 14:09 fibdrv.mod.o -rw-rw-r-- 1 zz4t zz4t 223024 Mar 21 14:09 fibdrv.o ``` * `fibdrv` 模組資訊: ```shell $ modinfo fibdrv.ko filename: /home/zz4t/study/sysprog/fibdrv/fibdrv.ko version: 0.1 description: Fibonacci engine driver author: National Cheng Kung University, Taiwan license: Dual MIT/GPL srcversion: 9A01E3671A116ADA9F2BB0A depends: retpoline: Y name: fibdrv vermagic: 5.13.0-35-generic SMP mod_unload modversions ``` * 裝載 `fibdrv` 模組後,可以看到他以 character deivce 的形式出現: ```shell $ sudo insmod fibdrv.ko ## Module Size Used by $ lsmod | grep fibdrv fibdrv 16384 0 $ ls -l /dev/fibonacci crw------- 1 root root 507, 0 Mar 21 14:11 /dev/fibonacci ## (Device Major Number):(Minor number) $ cat /sys/class/fibonacci/fibonacci/dev 507:0 $ cat /sys/module/fibdrv/version 0.1 ## No one uses it $ cat /sys/module/fibdrv/refcnt 0 ``` ### 模組主程式 `fibdrv.c` : 由 `fib_sequence` 計算費氏數列。 ```c static long long fib_sequence(long long k) { /* FIXME: C99 variable-length array (VLA) is not allowed in Linux kernel. */ long long f[k + 2]; f[0] = 0; f[1] = 1; for (int i = 2; i <= k; i++) { f[i] = f[i - 1] + f[i - 2]; } return f[k]; } ``` 由於此模組於 [VFS](https://www.kernel.org/doc/html/latest/filesystems/vfs.html) 中有產生 character device file ,所以可以藉由檔案操作介面進行操作,有定義的方法如下: ```c const struct file_operations fib_fops = { .owner = THIS_MODULE, .read = fib_read, .write = fib_write, .open = fib_open, .release = fib_release, .llseek = fib_device_lseek, }; ``` 使用 `read` 讀取費氏數列的計算結果。 ```c /* calculate the fibonacci number at given offset */ static ssize_t fib_read(struct file *file, char *buf, size_t size, loff_t *offset) { return (ssize_t) fib_sequence(*offset); } ``` 計算費氏數列到第幾位是由 `offset` 控制,可由 `lseek` 更改 `offset` 。 ```c static loff_t fib_device_lseek(struct file *file, loff_t offset, int orig) { loff_t new_pos = 0; switch (orig) { case 0: /* SEEK_SET: */ new_pos = offset; break; case 1: /* SEEK_CUR: */ new_pos = file->f_pos + offset; break; case 2: /* SEEK_END: */ new_pos = MAX_LENGTH - offset; break; } if (new_pos > MAX_LENGTH) new_pos = MAX_LENGTH; // max case if (new_pos < 0) new_pos = 0; // min case file->f_pos = new_pos; // This is what we'll use now return new_pos; } ``` 藉由 `mutex` 控制使用此模組的行程數量,從 `fib_open` 可以看到一次只允許一個行程使用。 ```c static DEFINE_MUTEX(fib_mutex); static int fib_open(struct inode *inode, struct file *file) { if (!mutex_trylock(&fib_mutex)) { printk(KERN_ALERT "fibdrv is in use"); return -EBUSY; } return 0; } static int fib_release(struct inode *inode, struct file *file) { mutex_unlock(&fib_mutex); return 0; } ``` ::: spoiler `write` 目前沒有實際功能,之後會拿來改寫成讀取計算時間用。 ```c /* write operation is skipped */ static ssize_t fib_write(struct file *file, const char *buf, size_t size, loff_t *offset) { return 1; } ``` ::: ## 加入計時器 這裡在 kernel space 使用 [`hrtimer`](https://www.kernel.org/doc/html/latest/timers/hrtimers.html) 計時,而 user space 使用的是 [`clock_gettime`](https://man7.org/linux/man-pages/man3/clock_gettime.3.html) ,最後可以用兩者的差計算出 system call 所佔用的時間。 * 改寫 `write` ,加入計時器,測量在 kernel space 裡計算費氏數列的時間。 ```c static ssize_t fib_write(struct file *file, const char *buf, size_t size, loff_t *offset) { ktime_t kt; kt = ktime_get(); fib_sequence(*offset); kt = ktime_sub(ktime_get(), kt); return (ssize_t) ktime_to_ns(kt); } ``` * 使用 `clock_gettime` 計算在 user space 裡,計算費氏數列的總耗費時間。 ```c #include <time.h> int main(void) { ... struct timespec t1, t2; clock_gettime(CLOCK_MONOTONIC, &t1); times[KERNEL][n] = write(fd_fib, buf, 1); clock_gettime(CLOCK_MONOTONIC, &t2); times[USER][n] = (double) (t2.tv_sec * 1e9 + t2.tv_nsec - t1.tv_sec * 1e9 - t1.tv_nsec); ... } ``` [完整程式碼 `expt01_userkernel.c` ](https://github.com/NOVBobLee/fibdrv/blob/5d8b94d48c7cd2c2ffaeebdf812e7ba0307d64e1/expt01_userkernel.c#L49) 下圖為測試結果,只有計算到第 92 位費氏數,使用的方法為[方法一(費氏數列定義)](https://hackmd.io/@at0mCe11/linux2022-fibdrv#%E6%96%B9%E6%B3%95%E4%B8%80%EF%BC%88%E8%B2%BB%E6%B0%8F%E6%95%B8%E5%88%97%E5%AE%9A%E7%BE%A9%EF%BC%89),可以看到計算時間正比於費氏數列的位數,符合預期的 $O(n)$ 時間複雜度。 ![kernel space vs. user space](https://i.imgur.com/csgaDla.png) 中間的藍色線為 kernel space 與 user space 的差,幾乎為水平線,為固定值。再做進一步測試,固定計算第零位費氏數,只改變輸入的 `size` 大小。在實作上是沒有使用到 `copy_from_user` ,所以應該不會因 `size` 大小而變動,藍色線應該還會是水平線。 ```diff diff --git a/expt_userkernel01.c b/expt_userkernel01.c index 90a3f61..0d054a6 100644 --- a/expt_userkernel01.c +++ b/expt_userkernel01.c @@ -26,7 +26,7 @@ enum { KERNEL, USER }; int main(void) { - char buf[1] = {'\0'}; + char buf[93] = { [0 ... 91] = 'a', [92] = '\0' }; int fd_fib = open(FIB_DEV, O_RDWR); if (fd_fib < 0) { @@ -42,7 +42,7 @@ int main(void) /* start testing time */ for (int i = 0; i <= NFIB; ++i) { - lseek(fd_fib, i, SEEK_SET); + int bufsize = i + 1; double times[NSPACE][NSAMPLE] = {0.}; double mean[NSPACE] = {0.}, sd[NSPACE] = {0.}; @@ -50,7 +50,7 @@ int main(void) for (int n = 0; n < NSAMPLE; ++n) { struct timespec t1, t2; clock_gettime(CLOCK_MONOTONIC, &t1); - times[KERNEL][n] = write(fd_fib, buf, 1); + times[KERNEL][n] = write(fd_fib, buf, bufsize); clock_gettime(CLOCK_MONOTONIC, &t2); times[USER][n] = (double) (t2.tv_sec * 1e9 + t2.tv_nsec - t1.tv_sec * 1e9 - t1.tv_nsec); ``` ![fix fib number, change buf size](https://i.imgur.com/MJ4Z83M.png) 結果符合推測,這結果在之後修改 `write` 時,引數 `size` 可以拿來傳遞其他參數。不過可以看到 kernel space 與 user space 之間的差距非常大,差不多是 500 納秒,與在 kernel space 計算所需的時間相比,成本是很高的。其原因是使用 `write` 這個 system call ,在 kernel space 與 user space 之間做轉換,以下為 `perf` 的執行結果,程式是執行 `write` 計算第 92 位費氏數 2,000,000 次,所以總佔時間為 95.84% ,這邊我們只看 `write` 裡面的成份。 ```shell ---_start __libc_start_main main | --95.84%--__GI___libc_write | |--69.43%--entry_SYSCALL_64_after_hwframe | | | --67.97%--do_syscall_64 | | | |--36.37%--syscall_exit_to_user_mode | | | | | --1.62%--exit_to_user_mode_prepare | | | --30.32%--__x64_sys_write | | | --30.15%--ksys_write | | | |--25.62%--vfs_write | | | | | |--4.03%--0xffffffffc302c23a | | | | | | | |--2.71%--read_tsc | | | | | | | --1.23%--ktime_get | | | | | |--2.87%--rw_verify_area | | | | | | | --2.58%--security_file_permission | | | | | | | |--1.51%--apparmor_file_permission | | | | | | | | | --1.49%--common_file_perm | | | | | | | --0.72%--common_file_perm | | | | | |--2.49%--0xffffffffc302c2af | | | | | | | |--1.64%--read_tsc | | | | | | | --0.66%--ktime_get | | | | | |--2.30%--0xffffffffc302c295 | | | | | |--2.13%--0xffffffffc302c290 | | | | | |--2.13%--0xffffffffc302c28a | | | | | |--2.00%--0xffffffffc302c29c | | | | | |--1.82%--0xffffffffc302c2a2 | | | | | |--0.68%--__fsnotify_parent | | | | | |--0.53%--0xffffffffc302c298 | | | | | --0.51%--0xffffffffc302c28c | | | |--1.91%--__fdget_pos | | | | | --1.70%--__fget_light | | | --0.53%--0xffffffffc302c155 | |--12.22%--__entry_text_start | --11.97%--syscall_return_via_sysret ``` ## 費氏數列實作 ### 方法一(費氏數列定義) 費氏數列的定義為 $F(n+1)=F(n)+F(n-1)$ ,初始值不只一種,這裡使用的是 $F(0)=0,\, F(1)=1$ 。 最初在模組裡所用的方法即為費氏數列的定義,先配置一個陣列,大小可容納我們指定的費氏數列所有數量,依費氏數列定義開始計算從小到大的費氏數,最終的計算即為所求。 ```c static long long fib_sequence(long long k) { /* FIXME: C99 variable-length array (VLA) is not allowed in Linux kernel. */ long long f[k + 2]; f[0] = 0; f[1] = 1; for (int i = 2; i <= k; i++) { f[i] = f[i - 1] + f[i - 2]; } return f[k]; } ``` `fibdrv.c` 在編譯時會跳出 `-Wvla` 的警告 `warning: ISO C90 forbids variable length array ‘f’ [-Wvla]` ,而程式碼裡也有寫 FIXME ,指出 VLA 是不允許在 Linux kernel 中使用的,想到可直接替換的方法包括改用 [`kmalloc`](https://www.kernel.org/doc/html/latest/core-api/mm-api.html#c.kmalloc) 等動態記憶體配置或是只儲存必要的兩個數。 下面程式碼使用 `kmalloc_array` 動態配置記憶體,跟原本的程式相比,差異只有 VLA 的部份換成使用動態記憶體配置,計算的部份沒有改變。 ```c #include <linux/slab.h> static long long fib_seq_kmalloc(long long k) { long long result, *f = kmalloc_array(k + 2, sizeof(long long), GFP_KERNEL); if (unlikely(!f)) return -1; f[0] = 0; f[1] = 1; for (int i = 2; i <= k; ++i) f[i] = f[i - 1] + f[i - 2]; result = f[k]; kfree(f); return result; } ``` 這個程式碼只使用必要的兩個數,每次做讀取和寫入的位置剛好與奇偶相關,所以每次陣列位置都需要做判斷奇偶的運算,不過由於陣列只有兩個數,足夠放在 cache 裡,甚至是 register 裡,減少讀取記憶體的時間。 ```c static long long fib_seq_fixedla(long long k) { long long f[2] = {0, 1}; for (int i = 2; i <= k; ++i) f[i & 0x1] += f[(i - 1) & 0x1]; return f[k & 0x1]; } ``` 下圖為 kernel space 裡的各方法所需計算時間,可以看出長度為 2 的固定長度陣列(以下用圖中代號 `fixed-la` 稱呼)所需時間最少,再來是 VLA ,最後是差距很大的 `kmalloc` ,猜測 `fixed-la` 比較快是因為不用反覆去記憶體讀寫,而剩下兩個算法相同,都是用連續記憶體,那差距只在動態配置記憶體呼叫上。 ![vla free 3 methods](https://i.imgur.com/XTbJ9bH.png) 相關連結: [The Linux Kernel Is Now VLA-Free: A Win For Security, Less Overhead & Better For Clang](https://www.phoronix.com/scan.php?page=news_item&px=Linux-Kills-The-VLA) [Linux 核心設計: 記憶體管理](https://hackmd.io/@sysprog/linux-memory) ### 方法二( Exact solution method ) 由費氏數列的定義 $F_{n+1}=F_n+F_{n-1}$ 與 $F_n=F_n$ ,可以得出一個矩陣關係式: $\begin{pmatrix} F_{n+1} \\ F_n \end{pmatrix} =\begin{pmatrix} 1 & 1 \\ 1 & 0 \end{pmatrix} \begin{pmatrix} F_n \\ F_{n-1} \end{pmatrix}$ 而這個關係式可以進一步推得 $\begin{pmatrix} F_{n+k+1} \\ F_{n+k} \end{pmatrix} =\begin{pmatrix} 1 & 1 \\ 1 & 0 \end{pmatrix}^k \begin{pmatrix} F_{n+1} \\ F_{n} \end{pmatrix} \text{ 和 } \begin{pmatrix} F_{n+1} \\ F_n \end{pmatrix} =\begin{pmatrix} 1 & 1 \\ 1 & 0 \end{pmatrix}^n \begin{pmatrix} F_1 \\ F_0 \end{pmatrix}$ 這樣可以看出若要算出 $F_n$ ,需要計算出中間矩陣的 $n$ 次方,由於該矩陣是對稱矩陣,這裡可以使用對角化( $\phi_+$ 為黃金比例, $\phi_-$ 為其另一個特徵值)。 $\begin{split} \begin{pmatrix} F_{n+1} \\ F_n \end{pmatrix} &= \begin{pmatrix} 1 & 1 \\ 1 & 0 \end{pmatrix}^n \begin{pmatrix} 1 \\ 0 \end{pmatrix} \\ &= \dfrac{1}{\phi_+ - \phi_-} \begin{pmatrix} \phi_+ & \phi_-\\ 1 & 1 \end{pmatrix} \begin{pmatrix} \phi_+ & \\ & \phi_- \end{pmatrix}^n \begin{pmatrix} 1 & -\phi_- \\ -1 & \phi_+ \end{pmatrix} \begin{pmatrix} 1 \\ 0 \end{pmatrix} \\ &= \dfrac{1}{\sqrt{5}} \begin{pmatrix} \phi_+ & \phi_-\\ 1 & 1 \end{pmatrix} \begin{pmatrix} \phi_+^n & \\ & \phi_-^n \end{pmatrix} \begin{pmatrix} 1 \\ -1 \end{pmatrix} \\ &= \dfrac{1}{\sqrt{5}} \begin{pmatrix} \phi_+ & \phi_-\\ 1 & 1 \end{pmatrix} \begin{pmatrix} \phi_+^n \\ -\phi_-^n \end{pmatrix} \\ &= \dfrac{1}{\sqrt{5}} \begin{pmatrix} \phi_+^{n+1} - \phi_-^{n+1} \\ \phi_+^n - \phi_-^n \end{pmatrix} \end{split}$ 所以最後得到 $F_n=\dfrac{1}{\sqrt{5}}(\phi_+^n - \phi_-^n)$ ,只需要計算冪即可,比較有趣的是 $\phi_-$ 是個負數,針對他的次方 $n$ 的奇偶性,會對前面的 $\phi_+^n$ 一下往上修正,一下往下修正。不過可惜的是 $\sqrt{5}$ 是無理數( $\phi_+$ 和 $\phi_-$ 都有 $\sqrt{5}$ ),在資料型態和記憶體大小的限制下,計算一定會產生誤差,而且 $n$ 越大越明顯。 ```c #include <math.h> #define phi_p 1.6180339887498948L #define phi_n -0.6180339887498949L #define inv_sqrt5 0.4472135954999579L long long fib_exact(int k) { double result = inv_sqrt5 * (pow(phi_p, k) - pow(phi_n, k)); return (long long) result; } ``` ![error from exact-solution method](https://i.imgur.com/NXNIeCe.png) 這裡使用 `double` 型態,直到 $F_{71}$ 都沒有問題,然而從下一個結果開始,誤差產生,然後擴大,可能的對應方式是增加計算精準度,針對不同位數的費氏數改變精準度。 | | $F_{72}$ | $F_{92}$ | |:---------------------:|:---------------:|:-------------------:| | Fibonacci number | 498454011879264 | 7540113804746346429 | | Exact-solution method | 498454011879265 | 7540113804746369024 | | Error | +1 | +22595 | 相關連結: [The Golden Ratio](https://www2.cs.arizona.edu/icon/oddsends/phi.htm) [Double precision - decimal places](https://stackoverflow.com/questions/9999221/double-precision-decimal-places) [IEEE Arithmetic](https://docs.oracle.com/cd/E19957-01/806-3568/ncg_math.html) [Why aren't the FPU registers saved and recovered in a “context switch”?](https://stackoverflow.com/q/50104289/16257547) [Why am I able to perform floating point operations inside a Linux kernel module?](https://stackoverflow.com/q/15883947/16257547) 既然 $\sqrt{5}$ 是問題根源,是否可以繞過他作計算,觀察 $\phi_+ = \dfrac{1+\sqrt{5}}{2}$ 還有 $\phi_- = \dfrac{1-\sqrt{5}}{2}$ ,每個費氏數都是整數,所以計算後的分子應該可以跟前面的分母 $\sqrt{5}$ 相消,由此可知 $F_n$ 即 $\phi_+^n$ 的 $\sqrt{5}$ 前面係數。 $\begin{split} F_1 &= \dfrac{1}{\sqrt{5}}(\dfrac{1+\sqrt{5}}{2} - \dfrac{1-\sqrt{5}}{2}) \\ &= \dfrac{1}{\sqrt{5}}\cdot\dfrac{2\sqrt{5}}{2} \\ &= 1 \end{split}$ 從上面推導可以知道,實際上可以不用計算 $\sqrt{5}$ 。又觀察 $\dfrac{1+\sqrt{5}}{2}$ 的冪可以發現,最終都可以變成 $\dfrac{a+b\sqrt{5}}{2}$ 的形式,所以可以得到: $\begin{split} (\dfrac{1+\sqrt{5}}{2})^{k-1}(\dfrac{1+\sqrt{5}}{2}) &= \dfrac{a+b\sqrt{5}}{2}\cdot\dfrac{1+\sqrt{5}}{2} \\ &= \dfrac{(a+5b)+(a+b)\sqrt{5}}{4} \\ &= \dfrac{(\dfrac{a+5b}{2})+(\dfrac{a+b}{2})\sqrt{5}}{2} \end{split}$ 以這個方式重複計算到 $n$ 次方,然後最終 $\dfrac{a+b}{2}$ 即為解。 ```c static long long fib_seq_exactsol2(long long k) { if (unlikely(k < 2)) return k; long long a = 1, b = 1; for (int i = 2; i <= k; ++i) { long long tmp_a = (a + 5 * b) >> 1; b = (a + b) >> 1; a = tmp_a; } return b; } ``` 因為在運算中用到 `(a + 5 * b)` ,結果比之前的方法更早發生 overflow ,在計算 $F_{91}$ 時發生溢位(前面的方法溢位在 $F_{93}$ ),那麼就試著避免先相加或相乘的情況,利用在 [quiz2](ttps://hackmd.io/@at0mCe11/linux2022-quiz2#%E6%B8%AC%E9%A9%97-1) 裡的測驗一學到的技巧。 $\big(a,\, b\big) \rightarrow \big(\dfrac{a+5b}{2},\, \dfrac{a+b}{2}\big) = \big(\dfrac{a+b}{2}+2b,\, \dfrac{a+b}{2}\big)$ ```c static long long fib_seq_exactsol3(long long k) { if (unlikely(k < 2)) return k; long long a = 1, b = 1; for (int i = 2; i <= k; ++i) { long long old_b = b; b = (a & b) + ((a ^ b) >> 1); a = (old_b << 1) + b; } return b; } ``` 這樣溢位延後到 $F_{92}$ 發生,而在計算時間上,第一改寫結果的計算時間比 `fixed-la` 高,比 `vla` 少;而第二次的改寫結果降到跟 `fixed-la` 一樣,猜測第一個改寫結果所需時間較高,可能跟[乘法運算](http://ithare.com/infographics-operation-costs-in-cpu-clock-cycles/)有關係。 ![compare 5 different methods execution time](https://i.imgur.com/aipHN9d.png) ### 方法三( Fast doubling method ) 以上的時間複雜度都是 $O(n)$ ,而接下來的第三個方法 Fast doubling method 可以降到 $O(log\,n)$ 。 由 $\begin{pmatrix} F_{n+k+1} \\ F_{n+k} \end{pmatrix} = \begin{pmatrix} 1 & 1 \\ 1 & 0 \end{pmatrix}^{k} \begin{pmatrix} F_{n+1} \\ F_{n} \end{pmatrix}$ 可以推導出: $\begin{split} \begin{pmatrix} F_{2n+1} \\ F_{2n} \end{pmatrix} &= \begin{pmatrix} 1 & 1 \\ 1 & 0 \end{pmatrix}^{2n} \begin{pmatrix} 1 \\ 0 \end{pmatrix} \\ &=\begin{pmatrix} 1 & 1 \\ 1 & 0 \end{pmatrix}^{n-1} \begin{pmatrix} 1 & 1 \\ 1 & 0 \end{pmatrix} \begin{pmatrix} 1 & 1 \\ 1 & 0 \end{pmatrix}^{n-1} \begin{pmatrix} 1 & 1 \\ 1 & 0 \end{pmatrix} \begin{pmatrix} 1 \\ 0 \end{pmatrix} \\ &=\begin{pmatrix} 1 & 1 \\ 1 & 0 \end{pmatrix}^{n-1} \begin{pmatrix} F_2 & F_1 \\ F_1 & F_0 \end{pmatrix} \begin{pmatrix} 1 & 1 \\ 1 & 0 \end{pmatrix}^{n-1} \begin{pmatrix} F_2 & F_1 \\ F_1 & F_0 \end{pmatrix} \begin{pmatrix} 1 \\ 0 \end{pmatrix} \\ &=\begin{pmatrix} F_{n+1} & F_n \\ F_n & F_{n-1} \end{pmatrix} \begin{pmatrix} F_{n+1} & F_n \\ F_n & F_{n-1} \end{pmatrix} \begin{pmatrix} 1 \\ 0 \end{pmatrix} \\ &=\begin{pmatrix} F_{n+1} & F_n \\ F_n & F_{n-1} \end{pmatrix} \begin{pmatrix} F_{n+1} \\ F_n \end{pmatrix} \\ &= \begin{pmatrix} F_{n+1}^2 + F_n^2 \\ F_n(F_{n+1} + F_{n-1}) \end{pmatrix} \\ &=\begin{pmatrix} F_{n+1}^2 + F_n^2 \\ F_n(2F_{n+1} - F_{n}) \end{pmatrix} \end{split}$ 從推導出的關係式可以看出位數 $n$ 可以成兩倍跳,但是單只用此關係式只能算出位數 $n$ 為 $2$ 的冪左右的費氏數,還需要作一些應變。現在觀察一個數的二進位表示,其組成跟 $2$ 的冪有關,這裡用 $13$ 舉例。 | 最高位數 | 十進位表示 | 二進位表示 | 目標的二進位餘下部份 | 動作需求 | |:--------:|:----------:| ----------:|:-------------------- |:-----------:| | 目標 | 13 | 1101 | | | | 0 | 0 | 0 | 1101 | | | $2^0$ | 1 | 1 | 101 | $\times2+1$ | | $2^1$ | 3 | 11 | 01 | $\times2+1$ | | $2^2$ | 6 | 110 | 1 | $\times2$ | | $2^3$ | 13 | 1101 | | $\times2+1$ | 由上表看到 $13$ 的二進位組成方式,剛開始由 $0$ 開始,若下一個位數是 1 ,則需要 $\times2+1$ ,所以 $0$ 才會變成 $1$ ;反之,下一個位數為 0 ,則只需要 $\times 2$ ,所以 $3$ 會變為 $6$ ,原理跟短除法一樣。 $\begin{matrix} F_0 \\ F_1 \end{matrix} \xrightarrow[]{\times2+1} \begin{matrix} F_1 \\ F_2 \end{matrix} \xrightarrow[]{\times2+1} \begin{matrix} F_3 \\ F_4 \end{matrix} \xrightarrow[]{\times2} \begin{matrix} F_6 \\ F_7 \end{matrix} \xrightarrow[]{\times2+1} \begin{matrix} F_{13} \\ F_{14} \end{matrix}$ 可以看出每一步都會用到 $2$ 倍的關係式,而加 1 的部份可以用定義達成,由以上的說明可以寫出一個 bottom-up 的 fast doubling method : ```c static long long fibseq_fastdoubling(long long k) { if (unlikely(k < 2)) return k; /* find the left-most bit */ unsigned long long mask = 1ULL << 62; while (!(k & mask)) mask >>= 1; /* fast doubling */ long long a = 0, b = 1; while (mask) { /* times 2 */ long long tmp = a; a = a * ((b << 1) - a); b = tmp * tmp + b * b; /* plus 1 */ if (k & mask) { tmp = b; b += a; a = tmp; } mask >>= 1; } return a; } ``` 從測試結果可以看到 fast doubling method 的時間變化疑似水平線,跟之前的方法相比,在計算較大位數的費氏數時,都是 fast doubling method 以更少的時間完成計算。 ![fast doubling method v1](https://i.imgur.com/9Pl5QPw.png) fast doubling method 迴圈有二,第一個要找最高位數的 bit ,此法是從 `long long` 的第 62 位開始往右尋找;第二的迴圈則是 fast doubling method 的本體,迭代次數是看總共由幾個位數組成,舉例 13 是由 4 個位數組成,所以會迭代四次。 另外, `mask` 目前不需要使用到 `unsigned long long` 的長度,使用 `unsigned int` 長度即可應付到 $F_{4294967295}$ (如果記憶體大到足夠使用的話)。 從上面的實驗結果可以看到,深藍色線從 $F_3$ 開始會進入 fast doubling method 計算,包括第一個迴圈和第二個迴圈。而在第二個迴圈的迭代次數是看組成位數, 3 的組成位數為 2 所以會迭代兩次,然後到最後計算的 $F_{92}$ , 92 的組成位數是 6 ,可以明顯看出 fast doubling method 的結果大部分都是在跑第一個迴圈,所以現在針對第一個迴圈去做最佳化。 ![fast doubling method with different starts](https://i.imgur.com/vuw7kRG.png) 上面是測試最高位數 bit 的起始點,括號中為起始位數,除了 62 使用 `unsigned long long` ,其餘都使用 `unsigned int` 長度。現在是算到 $F_{92}$ ,所以就測到起始位數 6 ,而這個改變至少可以使計算時間減少一半。 ```c /* find the left-most bit */ unsigned mask = 1U << (fls((unsigned) k) - 1); ``` 而找尋最高位數 bit 的方法還有其他方式,接下來測試 [`clz`/`ffs`](https://en.wikipedia.org/wiki/Find_first_set) (count leading zeros/find first set) ,這裡使用 GCC builting-function [`__builting_clz`](https://gcc.gnu.org/onlinedocs/gcc/Other-Builtins.html) 、 Linux 核心使用的 `fls` (方向跟 wiki 使用的相反,方向是從 LSB 往 MSB ,回傳的是位置 (1-based) ),其實作方式有三種(見相關連結),可以用 `objdump -dS fibdrv.ko` 看是使用哪一種,這裡使用的是 x86 硬體的 [`bsr`](https://www.felixcloutier.com/x86/bsr) 指令;同樣從反組譯的輸出看, `__builtin_clz` 也是用硬體的 `bsr` 指令。 ```c ; fast doubling method using fls (bsr) 0000000000000150 <fibseq_fastdoubling_fls>: { 150: e8 00 00 00 00 callq 155 <fibseq_fastdoubling_fls+0x5> 155: 55 push %rbp 156: 48 89 e5 mov %rsp,%rbp if (unlikely(k < 2)) 159: 48 83 ff 01 cmp $0x1,%rdi 15d: 7e 4b jle 1aa <fibseq_fastdoubling_fls+0x5a> unsigned mask = 1U << (fls((unsigned) k) - 1); 15f: ba 01 00 00 00 mov $0x1,%edx * top 32 bits will be cleared. * * We cannot do this on 32 bits because at the very least some * 486 CPUs did not behave this way. */ asm("bsrl %1,%0" 164: b9 ff ff ff ff mov $0xffffffff,%ecx 169: 0f bd cf bsr %edi,%ecx 16c: d3 e2 shl %cl,%edx while (mask) { ; fast doubling method using __builtin_clz (bsr) 00000000000001c0 <fibseq_fastdoubling_clz>: { 1c0: e8 00 00 00 00 callq 1c5 <fibseq_fastdoubling_clz+0x5> 1c5: 55 push %rbp 1c6: 48 89 e5 mov %rsp,%rbp if (unlikely(k < 2)) 1c9: 48 83 ff 01 cmp $0x1,%rdi 1cd: 7e 49 jle 218 <fibseq_fastdoubling_clz+0x58> unsigned mask = 1U << (31 - __builtin_clz((unsigned) k)); 1cf: 0f bd cf bsr %edi,%ecx 1d2: ba 00 00 00 80 mov $0x80000000,%edx 1d7: 83 f1 1f xor $0x1f,%ecx 1da: d3 ea shr %cl,%edx while (mask) { ``` :::spoiler Linux 核心中的 `__ffs` Linux kernel 裡 (x86) 的 `__ffs` 雖然名稱跟 wiki 的一樣,不過他的 first 指的是從 LSB 開始往 MSB 的方向,換句話說是在做 `ctz` (count tailing zeros) ,而他的實作有三種,一種是使用 GCC 的 builtin-function [`__builtin_ctzl`](https://gcc.gnu.org/onlinedocs/gcc/Other-Builtins.html) ,一種是使用 x86 指令 [`bsf`](https://web.itu.edu.tr/kesgin/mul06/intel/instr/bsf.html) ,而最後一種使用二元搜尋法。 在第一次測試中,直接呼叫 `__ffs` ,結果導致 soft lockup ,最終是用 [Magic SysRq key](https://en.wikipedia.org/wiki/Magic_SysRq_key) 強制關機,跟核心有關的東西還是小心為上。 相關連結: [Softlockup detector and hardlockup detector (aka nmi_watchdog)](https://www.kernel.org/doc/Documentation/lockup-watchdogs.txt) `__ffs` 使用 `bsf` 位置: [arch/x86/include/asm/bitiops.h](https://github.com/torvalds/linux/blob/1930a6e739c4b4a654a69164dbe39e554d228915/arch/x86/include/asm/bitops.h#L235) `__ffs` 使用二元搜尋法位置: [include/asm-generic/bitops/__ffs.h](https://github.com/torvalds/linux/blob/1930a6e739c4b4a654a69164dbe39e554d228915/include/asm-generic/bitops/__ffs.h#L13) `__ffs` 使用 `__builtin_ctzl` 位置: [include/asm-generic/bitops/builtin-__ffs.h](https://github.com/torvalds/linux/blob/1930a6e739c4b4a654a69164dbe39e554d228915/include/asm-generic/bitops/builtin-__ffs.h#L13) ::: ![fast-doubling with clz, fls](https://i.imgur.com/KX9vFvq.png) 從測試可以看到 `__builtin_clz` 和 `fls` 跟起始位數 6 的差不多快,剛開始在 $F_{20}$ 前是 `__builtin_clz` 與 `fls` 較快,然後到 $F_{31}$ 的過程中三者不相上下,之後變成起始位數 6 較快。原因是使用迴圈的次數減少,當在找 $F_{32}$ 最高位數時只需要右移一次,甚至在 $F_{64}$ 之後不需要右移。但是我們不會每次都知道該使用什麼起始位數,在計算多種位數的費氏數時,使用 `__builtin_clz` 或 `fls` 會更佳。 相關連結: [Calculating Fibonacci Numbers by Fast Doubling](https://chunminchang.github.io/blog/post/calculating-fibonacci-numbers-by-fast-doubling) `fls` 使用 `bsr` 的位置: [arch/x86/include/asm/bitops.h](https://github.com/torvalds/linux/blob/1930a6e739c4b4a654a69164dbe39e554d228915/arch/x86/include/asm/bitops.h#L324) `fls` 使用二元搜尋法的位置: [include/asm-generic/bitops/fls.h](https://github.com/torvalds/linux/blob/1930a6e739c4b4a654a69164dbe39e554d228915/include/asm-generic/bitops/fls.h#L13) `fls` 使用 `__builtin_clz` 的位置: [include/asm-generic/bitops/builtin-fls.h](https://github.com/torvalds/linux/blob/1930a6e739c4b4a654a69164dbe39e554d228915/include/asm-generic/bitops/builtin-fls.h#L12) ![6 methods comparison](https://i.imgur.com/j5LDr5X.png) 在 $F_{92}$ 之前就用上面這個測試當一個小結尾,下一個目標是計算 $F_{93}$ 以上的費氏數。 ## 計算 $F_{93}$ 之後的費氏數列 - 大數計算 在計算 $F_{93}$ 之前要先把 `fibdrv.c` 的限制提高,這是由於之前 `long long` 的最高上限只放得下 $F_{92}$ ,所以在 `lseek` 的時候會檢查 `offset` ,如果輸入的參數 `offset` 大於 92 就會被限制成 92 。 ```diff diff --git a/fibdrv.c b/fibdrv.c index d838dff..c6db028 100644 --- a/fibdrv.c +++ b/fibdrv.c @@ -18,7 +18,8 @@ MODULE_VERSION("0.1"); /* MAX_LENGTH is set to 92 because * ssize_t can't fit the number > 92 */ -#define MAX_LENGTH 92 +//#define MAX_LENGTH 92 +#define MAX_LENGTH 100 ``` 在之前的測試都只計算到 $F_{92}$ ,是因為計算 $F_{93}$ 會發生溢位,所以就不放在測試範圍內了。而溢位的原因是型態的限制, Linux 是使用 [LP64](https://en.wikipedia.org/wiki/64-bit_computing#64-bit_data_models) ,所以 `long long` 跟 `long` 位元是一樣長的,都是 64 bits ,從下表可以看到 `long long` 跟 `unsigned long long` 可以存下費氏數的極限,分別是 $F_{92}$ 跟 $F_{93}$ ,再多就存不下了,之後就必須靠 GCC 擴充或是大數計算了。 | | $F_{92}$ | $F_{93}$ | $F_{94}$ | |:------------------------:| --------------------:| --------------------:| --------------------:| | Fibonacci | 7540113804746346429 | 12200160415121876738 | 19740274219868223167 | | `LONG_MAX` | 9223372036854775807 | 9223372036854775807 | 9223372036854775807 | | `ULONG_MAX` | 18446744073709551615 | 18446744073709551615 | 18446744073709551615 | 相關連結: [The first 300 Fibonacci numbers, factored](https://r-knott.surrey.ac.uk/Fibonacci/fibtable.html) [The first 500 Fibonacci numbers](https://blog.abelotech.com/posts/first-500-fibonacci-numbers/) 參考 [`KYG-yaya573142`](https://hackmd.io/@KYWeng/rkGdultSU#%E5%A4%A7%E6%95%B8%E9%81%8B%E7%AE%97) 學長的報告,這裡大數計算使用類似的策略,用 `uint32_t` 儲存資料,在使用加法、乘法、左移運算等會溢位的運算時,轉換成 `uint64_t` 來計算,最後在依溢位的情況(第 32 個至第 63 個位元)決定是否改變 `uint32_t` 的數量和儲存方式。 ### 結構 `fbn` 在 `KYG-yaya573142` 的實作中,很多方法都是適用於 general case 的,比方說可以計算負數,所以在結構裡會有 `sign` 這個成員,但在費氏數列中,以我們列舉的方法裡是不會遇到負數的(除了 exact solution method ),所以說有些部份我們可以針對費氏數的計算作特化。因此,這裡使用的結構為 ```c /* * [num] points to an array, every elements are 4-byte, * so storing a big number larger than 4 bytes will * be like bellow: * * 0xfedc'ba98'7654'3210 => | 76543210 | fedcba98 | ... | * ^ ^ * num[0] num[1] * * [len] is the length of the array */ typedef struct { u32 *num; int len; } fbn; ``` 其儲存方式可以用下圖表示,這裡左側為以十六進位表示的目標數值,總共需要 6 bytes 來儲存,可見只用單個 `uint32_t` 或 `uint64_t` 都不夠,所以使用多個 `uint32_t` 來儲存,一個 `uint32_t` 可以儲存 4 bytes ,即圖右邊每個格子儲存 8 個十六進位的數字。而陣列方向則是順著 x86 的 [little endian](https://en.wikipedia.org/wiki/Endianness) ,每次新的元素是產生在右邊。 ```graphviz digraph { node[shape=record;]; rankdir=LR; e1 [label="0xba98'7654'3210"; shape=plaintext;]; e2 [label="{num[0]\n76543210|num[1]\n0000ba98|...}"]; e1 -> e2; } ``` 其數學式可以由 $\text{Big Number} = a_0 + a_1 2^{32} + a_2 2^{2\cdot32}+ a_3 2^{3\cdot32} + \dots + a_n 2^{n\cdot32}\text{ , }\forall\,a_k\in[\,0,\,2^{32} - 1]$ 來表示, $a_0$ 即陣列第零個元素,以此類推。 ### 加法 `fbn_add` 而實際計算方式可以從加法開始,而加法也是費氏數列的主要運算之一,先介紹一些用到的函式: * `fbn_swap` 是交換兩個 `fbn` 指標 ```c /* Swap two fbn pointers */ #define fbn_swap(a, b) \ ({ \ fbn *tmp = a; \ a = b; \ b = tmp; \ }) ``` * `fbn_resize` 是調整 `fbn` 裡的 `num` 陣列長度 ```c /* * Resize fbn. * @obj: fbn object * @len: new length to @obj's num * Return 0 on success and -1 on failure. */ static int fbn_resize(fbn *obj, int len) { if (obj->len == len) return 0; obj->num = krealloc_array(obj->num, len, sizeof(u32), GFP_KERNEL); if (unlikely(!obj->num)) return -1; /* fail to realloc */ if (len > obj->len) memset(obj->num + obj->len, 0, sizeof(u32) * (len - obj->len)); obj->len = len; return 0; } ``` * `fbn_swap_content` 是交換兩 `fbn` 所持有的內容,目的是取代比較費工的 `fbn_copy` 。在許多大數運算上會指定回傳的 `fbn` ,在運算過程中計算的結果可能會在 `fbn` 的暫時變數(運算完即被釋放)中,此時使用 `fbn_copy` 比較浪費,因為不需要兩個內容都相同,只需要將正確結果換進指定回傳的 `fbn` 內即可。 ```c /* Swap two fbn contents. */ static void fbn_swap_content(fbn *a, fbn *b) { u32 *num = a->num; a->num = b->num; b->num = num; int len = a->len; a->len = b->len; b->len = len; } /* * Copy fbn to another fbn. * @des: the copy destination of fbn * @src: the copy source of fbn * Return 0 on success and -1 on failure. */ int fbn_copy(fbn *des, fbn *src) { int res = fbn_resize(des, src->len); if (unlikely(res < 0)) return -1; memcpy(des->num, src->num, sizeof(u32) * src->len); return 0; } ``` 再來解釋加法 `fbn_add` ,剛開始挑選讓 `a` 為擁有陣列比較長的 `fbn` ,以利之後的程式碼簡潔,然後調整回傳結果的 `c` 的陣列長度,保持跟 `a` 是一樣長的。 ```c /* fbn last element */ #define fbn_lastelmt(x) ((x)->num[(x)->len - 1]) /* c = a + b, addition assignment (a += b) is also acceptable */ void fbn_add(fbn *c, fbn *a, fbn *b) { /* a->num is always the longest one */ if (a->len < b->len) fbn_swap(a, b); fbn_resize(c, a->len); /* addition operation (same length part) */ int i; u64 bcabinet = 0; for (i = 0; i < b->len; ++i) { bcabinet += (u64) a->num[i] + b->num[i]; c->num[i] = bcabinet; bcabinet >>= 32; } /* addition operation (remaining part) */ for (; i < a->len; ++i) { bcabinet += (u64) a->num[i]; c->num[i] = bcabinet; bcabinet >>= 32; } /* if the carry is still remained */ if (unlikely(bcabinet)) { fbn_resize(c, a->len + 1); fbn_lastelmt(c) = bcabinet; /* bcabinet = 1 */ } } ``` 第二步開始進行加法,可以對照下面的數學表示,第一階段是 `a`, `b` 都擁有相對應的元素,所以在 `bcabinet` 的加法裡兩者都會參與,而計算上因為要保留溢位的部份,所以提昇成 `u64` 的型態來相加,加完以後 `bcabinet` 可以分為高位元和低位元兩個部份,低位元的部份可以直接存進 `c` 裡,而 `bcabinet` 的高位元部份代表現在陣列元素 `u32` 裡相加溢位的部份,換句話說是進位,影響的部份在下一個陣列元素,所以將之右移 32 位,準備跟下一次迴圈裡的陣列元素相加。 $A = a_0 + a_1 2^{32} + a_2 2^{2\cdot32} + \dots \\ B = b_0 + b_1 2^{32} + b_2 2^{2\cdot32} + \dots \\ \text{If } a_0 + b_0 > (2^{32}-1)\text{ , then } a_0 + b_0 = (2^{32}-1) + K\text{ , where }K\in[\,1,\,2^{32}-1] \\ \text{i.e. }a_0 + b_0 = 2^{32} + (K-1) \\ \text{So, there's a carry bit from }2^{32}. \\ (a_1 + b_1 + 1)\cdot2^{32} = \dots$ 加法的第二階段跟第一階段相同,不過 `b` 已經沒有相對應的元素,所以 `bcabinet` 只需要跟 `a` 做相加。在最後 `a` 陣列對應的元素都相加完畢,如果 `bcabinet` 還有餘數,代表說還需要進位,但 `c` 的陣列已經到盡頭了,所以還要再加長 `c` 的陣列長度,將剩餘的 `bcabinet` 存入,這樣加法就完成了。 在加法的過程中,若 `a` 跟 `c` 為同一個指標也是沒有問題的,換句話說可以拿來做 addition assignment 運算( `a += b` ),這是由於寫進 `a` 的某陣列元素後,代表該位數的相加已經結束,不會再回去使用該元素。順代一提, `bcabinet` 是來自 [Biosafety Cabinet](https://en.wikipedia.org/wiki/Biosafety_cabinet) 給的印象,由於操作上會有生化危險,所以必須在一個櫃子裡操作。現在生活中充斥著新冠肺炎病毒 Covid-19 ,這裡變數名稱就使用跟病毒有關的 P4 實驗室器具。 ### 大數計算版本的費氏數列 (1) `fbn_fib_defi` 完成加法後就可以寫出基本的費氏數列計算(使用定義的迭代方式): ```c /* * Calculate the nth Fibonacci number with definition. * @des: fbn object to store @n-th Fibonacci number * @n: @n-th Fibonacci number */ void fbn_fib_defi(fbn *des, int n) { /* trivial case */ if (unlikely(n < 2)) { fbn_resize(des, 1); des->num[0] = n; return; } /* Fibonacci definition */ fbn *arr[2]; arr[0] = fbn_alloc(1); /* initial value is 0 */ arr[1] = fbn_alloc(1); arr[1]->num[0] = 1; for (int i = 2; i <= n; ++i) fbn_add(arr[i & 1], arr[i & 1], arr[(i - 1) & 1]); fbn_swap_content(des, arr[n & 1]); fbn_free(arr[0]); fbn_free(arr[1]); } ``` ### 十進位字串輸出 `fbn_print` 此函式引入 `KYG-yaya573142` 的實作,原理跟上面 [fast doubling method](https://hackmd.io/@at0mCe11/linux2022-fibdrv#%E6%96%B9%E6%B3%95%E4%B8%89%EF%BC%88-Fast-doubling-method-%EF%BC%89) 說明 13 的二進位組成一樣。 ```c /* Print fbn into a string (decimal), need kfree to free this string */ char *fbn_print(const fbn *obj) { size_t slen = (sizeof(int) * 8 * obj->len) / 3 + 2; char *str = kmalloc(slen, GFP_KERNEL), *p = str; memset(str, '0', slen - 1); str[slen - 1] = '\0'; for (int i = obj->len - 1; i >= 0; --i) { for (unsigned mask = 1U << 31; mask; mask >>= 1) { int carry = !!(mask & obj->num[i]); for (int j = slen - 2; j >= 0; --j) { /* bit * 2 + 1 */ str[j] += str[j] - '0' + carry; carry = (str[j] > '9'); if (carry) str[j] -= 10; } } } while (p[0] == '0' && p[1] != '\0') ++p; memmove(str, p, strlen(p) + 1); return str; } ``` 改寫 `read` ,測試大數計算版本的費氏數列計算結果: ```c /* calculate the fibonacci number at given offset */ static ssize_t fib_read(struct file *file, char *buf, size_t size, loff_t *offset) { fbn *fib = fbn_alloc(1); fbn_fib_defi(fib, *offset); char *str = fbn_print(fib); size_t left = copy_to_user(buf, str, strlen(str) + 1); kfree(str); fbn_free(fib); return left; } ``` 與 [The first 500 Fibonacci numbers](https://blog.abelotech.com/posts/first-500-fibonacci-numbers/) 對照 $F_{92}$ 之後的結果,確認無誤。 ```shell ... 92 7540113804746346429 93 12200160415121876738 94 19740274219868223167 95 31940434634990099905 96 51680708854858323072 97 83621143489848422977 98 135301852344706746049 99 218922995834555169026 100 354224848179261915075 ``` ### 左移運算 `fbn_lshift` 再來是準備寫 fast doubling method 的大數計算版本,這需要實作左移運算、減法和乘法,那麼先從左移運算開始。 左移運算有實作兩個版本,一個是移動 32 位元以內(超過會取 modulus )的版本,另一個是通用版本,不過在 fast doubling method 中只會用到左移一位的運算,所以這邊就不放上通用版本了。 ```c /* Modulo 32 */ #define MOD32(x) ((x) & ((1U << 5) - 1)) /* Round-Up 32 bits */ #define ROUNDUP32(x) (((x) + (1U << 5) - 1) & ~((1U << 5) - 1)) /* Check fbn number is zero or not */ #define fbn_iszero(x) (((x)->len == 1) && (!(x)->num[0])) /* * Left-shift under 32 bits: b = a << k. a <<= k is also acceptable. * @b: fbn object to store the result * @a: fbn object to be shifted * @k: shift @k bits, k <= 32, take modulus 32 */ void fbn_lshift32(fbn *b, fbn *a, int k) { /* shift 0 bit or a is zero fbn */ if (unlikely(!k || fbn_iszero(a))) { fbn_copy(b, a); return; } /* take modulus (1 <= k <= 32) and resize b */ u32 kmod32 = MOD32(k); k = kmod32 + (!kmod32 << 5); int new_len = a->len - 1 + (ROUNDUP32(fls(fbn_lastelmt(a)) + k) >> 5); fbn_resize(b, new_len); /* shift and combine carry bits */ u64 bcabinet = 0; for (int i = 0; i < a->len; ++i) { bcabinet = (u64) a->num[i] << k | bcabinet; b->num[i] = bcabinet; bcabinet >>= 32; } /* remaining part */ if (bcabinet) fbn_lastelmt(b) = bcabinet; } ``` ### 減法 `fbn_sub` 減法運算跟加法 `fbn_add` 原理一樣,由於沒有使用 `sign` 這個成員,所以不能使用轉換正負號的方法。最後會將 `num` 的高位數元素為零的部份截掉,如果最終運算結果是零,則 `fbn` 的長度還是需要維持為 1 ,不過這在計算費氏數列時不會遇到。 ```c /* c = a - b, where a >= b. a -= b is also acceptable */ void fbn_sub(fbn *c, fbn *a, fbn *b) { fbn_resize(c, a->len); int i; u32 borrow = 0; u64 subtrahend; for (i = 0; i < b->len; ++i) { subtrahend = (u64) b->num[i] + borrow; borrow = subtrahend > a->num[i]; c->num[i] = a->num[i] - (u32) subtrahend; } for (; i < a->len; ++i) { subtrahend = (u64) borrow; borrow = subtrahend > a->num[i]; c->num[i] = a->num[i] - (u32) subtrahend; } /* truncate the leading zero elements */ for (i = c->len - 1; i >= 0 && !c->num[i]; --i) ; fbn_resize(c, i + 1 + (i == -1)); /* avoid i == -1, happens when num is 0, although it won't happen in Fibonacci computing*/ } ``` ### 乘法 `fbn_mul` 目前使用比較直觀的 [long multiplication](https://en.wikipedia.org/wiki/Multiplication_algorithm#Long_multiplication) ,跟 `fbn_lshift` 一樣,因為有使用 `fls` ,所以要避免遇到 `num` 高位數元素為零的情況。 ```c /* c = a * b (long multiplication). a *= b is also acceptable */ void fbn_mul(fbn *c, fbn *a, fbn *b) { if (unlikely(fbn_iszero(a) || fbn_iszero(b))) { fbn_resize(c, 1); c->num[0] = 0; return; } /* Be careful, num[obj->len - 1] cannot be zero. If met, it means * there's zero elements in the high position of num didn't truncated */ int new_len = a->len + b->len - 2 + (ROUNDUP32(fls(fbn_lastelmt(a)) + fls(fbn_lastelmt(b))) >> 5); fbn *pseudo_c = fbn_alloc(new_len); /* need an all zero array */ /* long multiplication */ for (int offset = 0; offset < b->len; ++offset) { int pc_idx = 0; /* 0 for suppressing cppcheck */ u64 bcabinet = 0; /* c += a * (b->num[offset]) */ for (int i = 0; i < a->len; ++i) { pc_idx = i + offset; bcabinet += (u64) a->num[i] * b->num[offset] + pseudo_c->num[pc_idx]; pseudo_c->num[pc_idx] = bcabinet; bcabinet >>= 32; } pseudo_c->num[pc_idx + 1] = bcabinet; /* maybe it's 0 */ } /* truncate the leading zero element */ if (!fbn_lastelmt(pseudo_c)) fbn_resize(pseudo_c, new_len - 1); /* pass the content to c */ fbn_swap_content(pseudo_c, c); fbn_free(pseudo_c); } ``` 過程中 `bcabinet` 使用 `u64` 型態解決溢位問題,當每個值處於 `u32` 最大極限的狀態下, `u64` 依然可以容納其結果,請見下面數學關係式: $\text{Suppose that }a = b = c = bcabinet = 2^{32}-1 \\ \begin{split} \text{Then, } a\cdot b + c + bcabinet &= (2^{64} - 2\cdot 2^{32} + 1) + 2\cdot(2^{32} - 1) \\ &= 2^{64} - 1 \leq (2^{64} - 1) \end{split}$ ### 大數計算版本的費氏數列 (2) fbn_fib_fastdoubling 有了以上的運算,現在可以實作 fast doubling method 的大數計算版本。 ```c /* * Calculate the nth Fibonacci number with fast doubling method. * @des: fbn object to store @n-th Fibonacci number * @n: @n-th Fibonacci number */ void fbn_fib_fastdoubling(fbn *des, int n) { fbn_resize(des, 1); /* trivial case */ if (unlikely(n < 2)) { des->num[0] = n; return; } /* fast doubling method */ u32 mask = 1U << (fls((u32) n) - 1); fbn *a = des; /* a will be the result */ fbn *b = fbn_alloc(1); fbn *tmp = fbn_alloc(1); a->num[0] = 0; /* a = 0 */ b->num[0] = 1; /* b = 1 */ while (mask) { /* times 2 */ fbn_lshift32(tmp, b, 1); /* tmp = ((b << 1) */ fbn_sub(tmp, tmp, a); /* - a) */ fbn_mul(tmp, tmp, a); /* * a */ fbn_mul(a, a, a); /* a^2 */ fbn_mul(b, b, b); /* b^2 */ fbn_add(b, b, a); /* b = a^2 + b^2 */ fbn_swap_content(a, tmp); /* a <-> tmp */ /* plus 1 */ if (mask & n) { fbn_swap_content(a, b); /* a <-> b */ fbn_add(b, b, a); /* b += a */ } mask >>= 1; } fbn_free(b); fbn_free(tmp); } ``` ### 效能測試 下圖為測試兩種方法 `fbn_fib_defi` (左)和 `fbn_fib_fastdoubling` (右),分別在 user space (綠線)與 kernel space (紫線)中所花費的時間,而靠在水平線上的是 `copy_to_user` 加上 kernel/user 空間轉換的時間(藍線)。 ![bn compare: defi vs. fastdbl](https://i.imgur.com/mGCx4lH.png) 兩種方法相比, fast doubling method 稍快( kernel 是包括 `fbn_fib_fastdoubling` 和 `fbn_print` ),但看不出 $O(log\,n)$ 的趨勢,反而跟左圖趨勢相似,推測主要時間都被 `fbn_print` 給主導,以下測試將 `fbn_print` 移到藍線上,從測試結果可以看出推測是正確的。 ![bn compare2: move out fbn_print](https://i.imgur.com/aJ3sYnG.png) 從以上測試中得知, `fbn_print` 影響過大。然後在計算 $F_{1000}$ 以下的費氏數時,大部分時間可以由 kernel space 主導,在之後的測試中,將以測試 kernel space 時間為主。下圖為三個函式在 kernel space 中的執行時間。 ![defi vs. fastdbl vs. fbnprint](https://i.imgur.com/rgwcyCl.png) 從以上測試中,計畫之後的效能改善分為兩個部份: 1. `fbn_print` :此函式目的為輸出 `fbn` 結構持有數字所代表的十進位表示,影響總體花費時間最大。 2. 以 `fbn_fib_fastdoubling` 為主的大數運算:在初步實作大數運算的過程中,有想到不少可以改善的空間,之後將以此函式相關部份最佳化。 ## 改善效能 ### Radix conversion by performing short division 在 `fbn_print` 中主要計算在裡面的雙迴圈,分別對 bits 和 digits (十進位)走迴圈,其目的是 radix conversion ,從二進位轉換成十進位,原理是二進位的組成方式(見 [13 的二進位組成方式](https://hackmd.io/@at0mCe11/linux2022-fibdrv#%E6%96%B9%E6%B3%95%E4%B8%89%EF%BC%88-Fast-doubling-method-%EF%BC%89))。而另外還有其他種轉換方式,比方說使用短除法,藉由不斷從除法得到的餘數,剛好會是轉換目標十進位表示的每一位數字(順序由低位到高位),所以首先需要一個有效率的大數除法。 舉個例子,將 0x80 除以 10 。一個十六進位數字可以存 4 個位元,所以上限為 0xf ,那麼其最高可以容納一個 10 (dec) 在裡面,如此我們可以以一個十六進位數字為單位進行除法,如下面數學所表示: $\begin{split} 0\text{x}80 &= 8\cdot 2^4 + 0\cdot 2^0 \\ &= (0\cdot 10 + 8)\cdot 2^4 + 0\cdot 2^0 \\ &= 0\cdot 10\cdot 2^4 + (8\cdot 2^4 + 0)\cdot 2^0 \\ &= 0\cdot 10\cdot 2^4 + (12\cdot 10 + 8)\cdot 2^0 \\ &= 0\cdot 10\cdot 2^4 + 12\cdot 10\cdot 2^0 + 8\cdot 2^0 \\ &= (0\cdot 2^4 + 12\cdot 2^0)\cdot 10 + 8 \\ &= 0\text{xc}\cdot 10 + 8\ \end{split}$ 剛開始先對高位數的 $8$ 進行除法,得到商 $0$ 餘數 $8$ ,但由於是高位數,所以該餘數的實際值還要乘上他的底 $2^4$ ,即實際值為 $8\cdot 2^4$ ,也就是說還有 10 在裡面,所以接下來將他加進比較低的位數(此時兩者基底相同,皆為 $2^0$ ),變成 $(8\cdot 2^4+0)$ , 繼續進行除法,得到商 $12$ 餘數 $8$ ,此時沒有更低的位數,餘數也比 10 小,所以除法已經完成。如此一來,最終可以算出商 0xc (依舊可以由十六進位表示)與餘數 8 。 此除法可以套用在大數計算上,不過除數有個限制,就是必須可以被 `u32` 給容納( `num` 元素使用 `u32` 型態)。另外,大數除法需要走過陣列每一個元素,將會耗費不少時間,所以能做越少次越好,參考 [sysprog21/bignum](https://github.com/sysprog21/bignum/blob/abbbdf75d045342bf517fb7c2775aed81d3aa7a6/format.c#L183) 的作法,可以將除數拉高到 $10^9$ ,這樣總體流程變為 1. 大數除法除以 $10^9$ 2. 得到的餘數再做短除法,輸出 9 個十進位數字 3. 回到 1. ```c #define divl(high, low, d, q, r) \ __asm__("divl %4" : "=a"(q), "=d"(r) : "0"(low), "1"(high), "rm"(d)) ``` 而高位加低位的除法剛好在 x86-64 裡有指令 [`div`](https://www.felixcloutier.com/x86/div) 可以使用,其中 `l` 是代表 `doub(l)eword` (4 bytes) ,每個變數皆為 32 bits , `d` 是除數, `q` 為商, `r` 為餘數,比較特別的是被除數是 64 bits ,由 `high`, `low` 組成,所做的事情以下面數學表示: $high\cdot2^{32}+low = q\cdot d + r\text{ , where }0\leq q\leq(2^{32}-1)\text{ and }0\leq r<d$ 從上面數學式可以看出這個指令有 integer overflow 的可能,比方說 `high = 1`, `d = 1` 的情況,此時 `q` 會存不下超過 32 bits 的商,不過在這使用的演算法中不會遇到除法溢位,由於 `high` 是高位數的餘數,上限為 $10^9-1$ ,此時除法不會遇到溢位問題,請見下面數學: $\text{Suppose that }high = 10^9-1,\,low = 2^{32}-1,\,d = 10^9 \\ \begin{split} q &= \lfloor\dfrac{high\cdot2^{32}+low}{d}\rfloor \\ &= \lfloor\dfrac{(10^9-1)\cdot2^{32}+(2^{32}-1)}{10^9}\rfloor \\ &= \lfloor\dfrac{2^{32}\cdot10^9-1}{10^9}\rfloor \\ &= \lfloor\dfrac{(2^{32}-1)\cdot10^9+(10^9-1)}{10^9}\rfloor \\ &= 2^{32}-1 \leq (2^{32}-1) \end{split}$ 以下為步驟 1. 的程式碼,由於想減少呼叫 `krealloc` ,所以沒有使用 `fbn_resize` , `obj` 不會因為出現零元素就調整長度,這樣的話,需要另外使用 `nonzero_len` 紀錄現在實際值所需要的長度: ```c /* * Divide obj by 10^9. * @obj: fbn object which is dividend in the beginning and quotient in the end * @nonzero_len: obj->len - #(leading zero elements) * Return the remainder. */ static u32 fbn_divten9(fbn *obj, int nonzero_len) { u32 high_r = 0, divisor = 1000000000U; /* start from the leading non-zero element */ u32 *nump = obj->num + nonzero_len - 1; for (int i = nonzero_len - 1; i >= 0; --i) { u32 cur = *nump, q, r; divl(high_r, cur, divisor, q, r); *nump = q; /* store the quotient */ high_r = r; /* update the remainder */ --nump; /* move to the lower num */ } return high_r; } ``` 而在 Linux 核心中當然也有 radix conversion 的需要,比方說 `printf` ,方法也是使用短除法取餘數,不過並不會使用除法和模,取而代之使用[定點數](https://en.wikipedia.org/wiki/Fixed-point_arithmetic),舉個例子, r 除以 10 的商可以轉換成: $\begin{split} \dfrac{r}{10} &= \dfrac{r}{10}\cdot\dfrac{2^{15}}{2^{15}} \\ &= (r\cdot\dfrac{2^{15}}{10})\cdot\dfrac{1}{2^{15}} \\ &\approx (r\cdot0\text{xccd})\cdot\dfrac{1}{2^{15}} \end{split}$ 可以看到他將除法轉換成乘法,由於是取近似值,所以還是會有些誤差存在,不過他保證在 $r<16389$ 的情形下輸出是正確的。這邊將 Linux 核心中的 [`put_dec`](https://github.com/torvalds/linux/blob/a19944809fe9942e6a96292490717904d0690c21/drivers/firmware/efi/libstub/vsprintf.c#L38) 改寫成適合這裡的版本。 ```c static unsigned put_dec_helper4(char *end, unsigned x) { /* q = x / 10^4 * = (x * (2^43 / 10^4)) * 2^(-43) * require: x < 1,128,869,999 */ unsigned q = (x * 0x346DC5D7ULL) >> 43; unsigned r = x - q * 10000; for (int i = 0; i < 3; ++i) { /* q2 = r / 10 * = (r * (2^15 / 10)) * 2^(-15) * require: r < 16,389 */ unsigned q2 = (r * 0xccd) >> 15; *--end = '0' + (r - q2 * 10); r = q2; } *--end = '0' + r; return q; } ``` 程序大概是這樣,先除以 $10^4$ ,然後除以 $10$ 四次,分別從餘數得到 4 個 digits (基底為 $10^0$ ),然後一樣步驟再算出 4 個 digits (基底為 $10^4$ ),再來因為 `put_dec` 是輸入除以 $10^9$ 之後的餘數,所以現在只剩一個 digit (基底為 $10^8$ ),直接轉換成字元即可。 ```c /* modified from put_dec() in drivers/firmware/efi/libstub/vsprintf.c */ static char *put_dec(char *end, unsigned n) { unsigned high, q; char *p = end; high = n >> 16; /* low = (n & 0xffff) */ /* n = high * 2^16 + low * = 65536 * high + low * = (6 * 10^4 + 5536) * high + low * = (6 * high) * 10^4 + (5536 * high + low) */ /* 10^0 part */ q = 5536 * high + (n & 0xffff); q = put_dec_helper4(p, q); p -= 4; /* 4 digits */ /* 10^4 part */ q += 6 * high; q = put_dec_helper4(p, q); p -= 4; /* 4 digits */ /* 10^8 part, q < 10 */ *--p = '0' + q; /* the 9-th digit */ return p; } ``` 再來將兩個函式組合起來,先是用 `fbn_divten9` 做 $10^9$ 的大數除法,然後使用 `put_dec` 從餘數算出 9 個 digits ,輸出字串,重複這兩個步驟直到整個的短除法結束。 ```c /* Print fbn into string (version 1), need kfree to free the string */ char *fbn_printv1(const fbn *obj) { if (unlikely(fbn_iszero(obj))) { char *str = kmalloc(2, GFP_KERNEL); str[0] = '0'; str[1] = '\0'; return str; } ... int res = fbn_copy(obj2, obj); /* copy fbn */ ... char *str_end = str + str_len - 1, *head = str_end - 1; /* short division, print decimal string */ int nonzero_len = obj2->len; do { /* divided by 10^9, obj2 will become the quotient */ u32 r_ten9 = fbn_divten9(obj2, nonzero_len); /* print r_ten9 in str (9 digits) */ head = put_dec(head, r_ten9); /* decrease when a new leading zero element appears */ nonzero_len -= !obj2->num[nonzero_len - 1]; } while (nonzero_len); /* strip off the leading 0's */ while (head < str_end && *head == '0') ++head; memmove(str, head, strlen(head) + 1); fbn_free(obj2); return str; ... } ``` 測試結果真的非常令人滿意,原因是 v0 是疊加二進位的 bit ,每次計算簡單,一個加法和一個 `if` 裡的減法,但總共計算次數要 bits 數量乘上 digits (十進位)數量(準確說是 `kmalloc` 的字串長度),這在費氏數越大時,其計算量非常可觀。而 v1 是使用短除法直接計算 digit ,每次計算雖然是比較複雜,除法、乘法和加法( `fbn_divten9` 接 `put_dec` ),不過次數較少, `fbn` 長度乘上 digits 數量,總共計算量是少上許多。現在使用 v1 執行下面的實驗( $F_0$ 算到 $F_{1000}$ 每次 1000 次),大概 5 秒內可以結束,這在之後開發與測試的效率將可以大幅提昇。 ![print v0 vs. v1](https://i.imgur.com/tkr1SwL.png) 在 kernel space 中三個函式執行時間比較,現在 `fbn_printv1` 時間比 `fbn_fib_fastdoubling` 少,不過從趨勢看,之後 `fbn_printv1` 將會超過 `fbn_fib_fastdoubling` ,然後 `copy_to_user` 則是相較之下比較微小的漸增趨勢。 ![fastdbl vs. print_v1 vs. copy_to_user](https://i.imgur.com/nARvqIQ.png) 相關連結: 大數除法: [sysprog21/bignum](https://github.com/sysprog21/bignum) 使用定點數做 radix conversion : [`put_dec`](https://github.com/torvalds/linux/blob/a19944809fe9942e6a96292490717904d0690c21/drivers/firmware/efi/libstub/vsprintf.c#L38) [Reciprocal Multiplication, a tutorial](http://homepage.divms.uiowa.edu/~jones/bcd/divide.html) [Ratio of Bits to Decimal Digits](https://www.exploringbinary.com/ratio-of-bits-to-decimal-digits/) ### Fast doubling method without subtraction (v0 -> v1) 原本的 fast doubling method 是 $\begin{pmatrix}F_{n+1} \\ F_n\end{pmatrix}\rightarrow\begin{pmatrix}F_{2n+1} \\F_{2n}\end{pmatrix}$ ,這其中還有幾個地方還能改進,第一個:由於是從 $\begin{pmatrix}F_1 \\ F_0\end{pmatrix}$ 開始,這在原本的演算法中,在第一次迴圈乘以二的部份會是做白工( $\begin{pmatrix}F_1 \\ F_0\end{pmatrix}\xrightarrow[\times2]{}\begin{pmatrix}F_1 \\F_0\end{pmatrix}$ ),只有加一的部份是有效的( $\begin{pmatrix}F_1 \\ F_0\end{pmatrix}\xrightarrow[+1]{}\begin{pmatrix}F_2 \\F_1\end{pmatrix}$ )。第二個:有使用減法,在大數運算的減法中,會將沒有必要的零元素給去除,持續修正 `fbn` 的陣列長度,這在演算法中會增加呼叫 `krealloc` 的機會。第三個:若目標是 $F_k$ ,則每次最終都會多計算一個附加的 $F_{k+1}$ 。 針對這幾點,這裡使用稍微不同的數學等式,一樣利用 $\begin{pmatrix} F_{n+2} \\ F_{n+1} \end{pmatrix} = \begin{pmatrix} 1 & 1 \\ 1 & 0 \end{pmatrix} \begin{pmatrix} F_{n+1} \\ F_{n} \end{pmatrix}$ 推導,不同在於這次是使用 $2n-1$ 次方: $\begin{split} \begin{pmatrix} F_{2n} \\ F_{2n-1} \end{pmatrix} &= \begin{pmatrix} 1 & 1 \\ 1 & 0 \end{pmatrix}^{2n-1} \begin{pmatrix} F_1 \\ F_0 \end{pmatrix} \\ &=\begin{pmatrix} 1 & 1 \\ 1 & 0 \end{pmatrix}^{n-1} \begin{pmatrix} 1 & 1 \\ 1 & 0 \end{pmatrix} \begin{pmatrix} 1 & 1 \\ 1 & 0 \end{pmatrix}^{n-1} \begin{pmatrix} F_1 \\ F_0 \end{pmatrix} \\ &=\begin{pmatrix} 1 & 1 \\ 1 & 0 \end{pmatrix}^{n-1} \begin{pmatrix} F_2 & F_1 \\ F_1 & F_0 \end{pmatrix} \begin{pmatrix} F_n \\ F_{n-1} \end{pmatrix} \\ &=\begin{pmatrix} F_{n+1} & F_n \\ F_n & F_{n-1} \end{pmatrix} \begin{pmatrix} F_n \\ F_{n-1} \end{pmatrix} \\ &=\begin{pmatrix} F_n(F_{n+1}+F_{n-1}) \\ F_n^2 + F_{n-1}^2 \end{pmatrix} \\ &= \begin{pmatrix} F_n(2F_{n-1}+F_n) \\ F_n^2 + F_{n-1}^2 \end{pmatrix} \end{split}$ 最終變成 $\begin{pmatrix}F_n \\ F_{n-1}\end{pmatrix}\rightarrow\begin{pmatrix}F_{2n} \\F_{2n-1}\end{pmatrix}$ ,來比較一下兩種等式的不同之處,以目標 $F_{13}$ 為例: 原:$\begin{matrix} F_1 \\ F_0 \end{matrix} \xrightarrow[\times2+1]{} \begin{matrix} F_2 \\ F_1 \end{matrix} \xrightarrow[\times2+1]{} \begin{matrix} F_4 \\ F_3 \end{matrix} \xrightarrow[\times2]{} \begin{matrix} F_7 \\ F_6 \end{matrix} \xrightarrow[\times2+1]{} \begin{matrix} F_{14} \\ F_{13} \end{matrix}$ 新:$\begin{matrix} F_1 \\ F_0 \end{matrix} \xrightarrow[]{\times2+1} \begin{matrix} F_3 \\ F_2 \end{matrix} \xrightarrow[]{\times2} \begin{matrix} F_6 \\ F_5 \end{matrix} \xrightarrow[]{\times2+1} \begin{matrix} F_{13} \\ F_{12} \end{matrix}$ 可以看出使用新的等式,總體少了一次計算,是第一次迴圈的部份,之後的計算則是跟原本的相同。第二,新的等式最終會計算到 $F_{13}$ ,不會計算出多餘的 $F_{14}$ 。第三,減法的部份變成使用加法。 ```c void fbn_fib_fastdoublingv1(fbn *des, int n) { fbn_resize(des, 1); /* trivial case */ if (unlikely(n < 2)) { des->num[0] = n; return; } /* fast doubling method */ u32 mask = 1U << (fls((u32) n) - 1 - 1); fbn *a = fbn_alloc(1); fbn *b = des; /* b will be the result */ fbn *tmp = fbn_alloc(1); a->num[0] = 0; /* a = 0 */ b->num[0] = 1; /* b = 1 */ while (mask) { /* times 2 */ fbn_lshift32(tmp, a, 1); /* tmp = ((a << 1) */ fbn_add(tmp, tmp, b); /* + b) */ fbn_mul(tmp, tmp, b); /* * b */ fbn_mul(a, a, a); /* a^2 */ fbn_mul(b, b, b); /* b^2 */ fbn_add(a, a, b); /* b = a^2 + b^2 */ fbn_swap_content(b, tmp); /* a <-> tmp */ /* plus 1 */ if (mask & n) { fbn_swap_content(a, b); /* a <-> b */ fbn_add(b, b, a); /* b += a */ } mask >>= 1; } fbn_free(a); fbn_free(tmp); } ``` 兩種方法進行比較測試,平均減少 260 ns 左右,計算 $F_{1000}$ 時間從 4112.2986 (v0) 減至 3853.0461 ns (v1) ,減少幅度約為 6% 。 ![fast doubling v0 vs. v1](https://i.imgur.com/KahDBrB.png) ### Reduce unnecessary computations (v1 -> v2) 1. `DIV_ROUNDUP32` :結合 `ROUNDUP32` 巨集與除以 32 的計算,減少不必要的計算,影響的函數有 `fbn_lshift32` 和 `fbn_mul` 。 ```diff /* Modulo 32 */ #define MOD32(x) ((x) & ((1U << 5) - 1)) +/* Divide 32 */ +#define DIV32(x) ((x) >> 5) /* Round up 32 bits */ #define ROUNDUP32(x) (((x) + (1U << 5) - 1) & ~((1U << 5) - 1)) +/* Round up 32 bits and then divide 32 */ +#define DIV_ROUNDUP32(x) DIV32((x) + (1U << 5) - 1) ``` 2. 修改 `fbn_lshift32` :原函式可以左移 32 位元(含)以下,改為 31 位元(含)以下。 ```diff -void fbn_lshift32(fbn *b, fbn *a, int k) +void fbn_lshift31(fbn *b, fbn *a, int k) { /* shift 0 bit or a is zero fbn */ if (unlikely(!k || fbn_iszero(a))) { fbn_copy(b, a); return; } - /* take modulus (1 <= k <= 32) and resize b */ - u32 kmod32 = MOD32(k); - k = kmod32 + (!kmod32 << 5); - int new_len = a->len - 1 + (ROUNDUP32(fls(fbn_lastelmt(a)) + k) >> 5); + /* take modulus 32 and resize b */ + int new_len = a->len - 1 + DIV_ROUNDUP32(fls(fbn_lastelmt(a)) + MOD32(k)); fbn_resize(b, new_len); ``` 測試結果如下,由於計算費氏數越到後面,使用 `fbn_lshift` 與 `fbn_mul` 的次數就越多,所以時間的差距會拉大。計算 $F_{1000}$ 時間從 3853.0461 (v1) 減少至 3653.0772 ns (v2 - divroundup32) ,減少幅度約 5% 。 ![v1 vs. v2 - div roundup 32](https://i.imgur.com/gJ7TdRT.png) ### Lazy `krealloc` ([memory pool](https://en.wikipedia.org/wiki/Memory_pool)) 類似 [Lazy allocation](https://pdos.csail.mit.edu/6.828/2019/labs/lazy.html) ,只有在需要的時候才會呼叫配置記憶體的函式,目的是減少呼叫 `krealloc` 的次數。這裡將配置陣列元素的單位增加至 4 個 `u32` 元素,只有當配置的陣列元素不足時才會呼叫 `krealloc` ,如此配置的陣列元素只增不減,可以減少呼叫 `krealloc` 的需求。 首先要修改 `fbn` 結構,新的成員 `cap` 是配置的總陣列長度;而新的 `len` 成員表示有效元素長度,指的是在該長度內元素儲存的值是有效的,因為將配置的單位加大成 4 ,有些元素是沒有意義的零,而 `len` 就是區分元素是否有效的一個成員,不過要注意若 `fbn` 要表示零,是使用 `len` 長度為零來表示: ```c /* * [num] points to an array, every elements are 4-byte, * so storing a big number larger than 4 bytes will * be like bellow: * * 0xba98'7654'3210 => | 76543210 | 0000ba98 | ... | * ^ ^ * num[0] num[1] * * [len] is the length of array with valid value elements * i.e. allocated array length - #(leading zero elements) * [cap] is the allocated array length */ typedef struct { u32 *num; int len; int cap; } fbn; /* Check fbn number is zero or not */ #define fbn_iszero(x) (!(x)->len) ``` 而 lazy `krealloc` 的主角 `fbn_resize` ,將配置的單位加大至 4 個元素,且只在配置的陣列長度不足時才會呼叫 `krealloc` 。 ```c /* * Resize fbn, realloc if needed (lazy alloc). * @obj: fbn object * @len: new length to @obj's num * Return 0 on success and -1 on failure. */ static int fbn_resize(fbn *obj, int len) { obj->len = len; if (likely(len <= obj->cap)) return 0; int new_cap = ROUNDUP4(len); obj->num = krealloc_array(obj->num, new_cap, sizeof(u32), GFP_KERNEL); if (unlikely(!obj->num)) return -1; /* fail to realloc */ if (new_cap > obj->cap) memset(obj->num + obj->cap, 0, sizeof(u32) * (new_cap - obj->cap)); obj->cap = new_cap; return 0; } ``` 一樣是測試 fast doubling method ,比較的對象是上一次的 v2 ,計算的費氏數越大,效益也越大。在計算 $F_{1000}$ 時,時間從 3653.0772 (v2) 減少至 3603.3485 ns (v3) ,幅度約為 1% 。(若有突起,下降比紫線快,綠線總體相較比較平滑, why? ) ![v2 vs. v3 lazy realloc](https://i.imgur.com/CxR9Vt8.png) 另一個方案是先計算最終費氏數需要多少陣列元素,只在最初的時候配置所有陣列元素,所以途中完全不需要使用 `krealloc` ,而如何計算多少陣列元素,可以參考此式 binary digits = $\lfloor log_2(F_n)\rfloor + 1$ ,利用總二進位位數換算成 `u32` 的元素個數,但有兩個問題,一是需要浮點數計算,而 $n$ 越大,誤差也越大,二是需要先計算出 $F_n$ ,亦即我們的最終計算目標。 ### 使用 `perf` 分析 使用 `perf` 觀察 `fbn_fib_fastdoubling` 的執行效能,將 `fib_read` 改成以下樣子,重複計算 `fbn_fib_fastdoubling` 200000 次,讓該函式變成時間主導項。由於有 lazy realloc 的關係,所以每次迴圈必須重新初始化 `fib` 這個變數,這裡可以用 caller-callee 關係知道 `fbn_alloc` 和 `fbn_free` 是哪一部份的程式,進一步排除該因素。 ```c static ssize_t fib_read(struct file *file, char *buf, size_t method, loff_t *offset) { ... #elif defined(_PERF_EVT) #define REPEAT (200 * 1000) for (int i = 0; i < REPEAT; ++i) { fbn *fib = fbn_alloc(1); bn_fibonacci_seq[method](fib, *offset); fbn_free(fib); } return 0; ... } ``` 由 `perf` 輸出可以知道 `fbn_fib_fastdoubling` 每部份計算的時間佔比,其中 `fbn_mul` 佔最多,佔了 84.77% / 95.41% ,其他依序為 `fbn_add`, `fbn_alloc`, `fbn_free`, `fbn_lshift31` ,佔比都相對低很多,很清楚可以知道,對 `fbn_mul` 最佳化效果會是最明顯的。 ```shell $ sudo perf record -g ./expt07bn_perf ## load kernel module symbols $ sudo mkdir -p /lib/modules/$(uname -r)/extra $ sudo cp fibdrv_bn.ko /lib/modules/$(uname -r)/extra $ sudo depmod -a $ sudo perf report --stdio --children -g graph,.3,caller ... 96.83% 1.42% expt07bn_perf [fibdrv_bn] [k] fbn_fib_fastdoublingv1 | |--95.41%--fbn_fib_fastdoublingv1 | | | |--84.77%--fbn_mul | | | | | |--33.16%--fbn_alloc | | | | | | | |--13.21%--__kmalloc | | | | | | | | | |--2.13%--kmalloc_slab | | | | | | | | | --0.49%--__cond_resched | | | | | | | |--9.14%--memset_erms | | | | | | | |--7.51%--kmem_cache_alloc_trace | | | | | | | | | --0.45%--rcu_all_qs | | | | | | | |--0.41%--__cond_resched | | | | | | | --0.37%--kmalloc_slab | | | | | |--22.27%--fbn_free | | | | | | | |--20.62%--kfree | | | | | | | | | --3.97%--memcg_slab_free_hook | | | | | | | --1.27%--memcg_slab_free_hook | | | | | --0.56%--kfree | | | |--4.05%--fbn_add | | | |--2.80%--fbn_alloc | | | | | |--0.82%--__kmalloc | | | | | |--0.82%--kmem_cache_alloc_trace | | | | | --0.79%--memset_erms | | | |--1.91%--fbn_free | | | | | --1.50%--kfree | | | | | --0.37%--memcg_slab_free_hook | | | --1.39%--fbn_lshift31 ... ``` ```shell $ sudo perf report -g graph,.3,caller + 99.91% 0.00% expt07bn_perf [kernel.kallsyms] [k] entry_SYSCALL_64_after_hwframe + 99.91% 0.00% expt07bn_perf [kernel.kallsyms] [k] do_syscall_64 + 99.89% 0.00% expt07bn_perf libc-2.31.so [.] __libc_start_main + 99.89% 0.00% expt07bn_perf libc-2.31.so [.] read + 99.89% 0.00% expt07bn_perf [kernel.kallsyms] [k] __x64_sys_read + 99.89% 0.00% expt07bn_perf [kernel.kallsyms] [k] ksys_read + 99.89% 0.00% expt07bn_perf [kernel.kallsyms] [k] vfs_read + 99.63% 0.07% expt07bn_perf [fibdrv_bn] [k] fib_read + 96.83% 1.42% expt07bn_perf [fibdrv_bn] [k] fbn_fib_fastdoublingv1 + 85.26% 28.79% expt07bn_perf [fibdrv_bn] [k] fbn_mul + 37.68% 2.32% expt07bn_perf [fibdrv_bn] [k] fbn_alloc + 25.40% 0.90% expt07bn_perf [fibdrv_bn] [k] fbn_free + 24.32% 19.42% expt07bn_perf [kernel.kallsyms] [k] kfree + 15.83% 12.09% expt07bn_perf [kernel.kallsyms] [k] __kmalloc + 10.41% 9.85% expt07bn_perf [kernel.kallsyms] [k] memset_erms + 9.53% 8.22% expt07bn_perf [kernel.kallsyms] [k] kmem_cache_alloc_trace + 6.13% 5.34% expt07bn_perf [kernel.kallsyms] [k] memcg_slab_free_hook + 4.39% 3.94% expt07bn_perf [fibdrv_bn] [k] fbn_add ... ``` ![](https://i.imgur.com/WWSg4dP.png) ### Karatsuba multiplication $U = U_0 + U_1 2^N \\ V = V_0 + V_1 2^N \\ \text{Let }z_0 = U_0V_0,\,z_1 = (U_0 + U_1)(V_0 + V_1),\,z_2 = U_1V_1 \\ \text{Then, }UV = z_0 + (z_1 - z_0 - z_2)2^N + z_1 2^{2N} \\ \text{Let }z_1' = (U_0 - U_1)(V_1 - V_0) = (U_1 - U_0)(V_0 - V_1) \\ \text{Then, }UV = z_0 + (z_1' + z_0 + z_2)2^N + z_1 2^{2N}$ [Karatsuba algorithm](https://en.wikipedia.org/wiki/Karatsuba_algorithm) ### square instead of mul (save almost half of multiplications) ``` a b c d x) a b c d ---------------------------------- ad bd cd dd ac bc cc cd ab bb bc bd aa ab ac ad ``` ### use gcc extension `unsigned __int128` [128-bit intergers](http://gcc.gnu.org/onlinedocs/gcc-4.8.1/gcc/_005f_005fint128.html#_005f_005fint128)