# fibdrv 2 contributed by < [`tinyynoob`](https://github.com/tinyynoob) > > [作業要求](https://hackmd.io/@sysprog/linux2022-fibdrv) 因為一篇筆記放不下了,所以開第二 > 前篇:[link](https://hackmd.io/@tinyynoob/linux2022q1-fibdrv) ## 繼續改進 fibdrv > [code](https://github.com/tinyynoob/fibdrv) ### 架構調整 觀摩同學們的 code 後,我首先將 architecture dependent 的東西集中到 **base.h**,盡量讓 **ubignum.h** 單純做大數的事情。 分割完之後進行 `make` 馬上出現一堆 function undefined 的訊息,目前需要研究一下該如何編譯才能讓 **fibdrv.c** 使用 **ubignum.c** 中的函式。 根據 [doc](https://docs.kernel.org/kbuild/modules.html),需要在 Makefile 將 source object 列出: ``` <module_name>-y := <src1>.o <src2>.o ... ``` 不過我嘗試編譯後顯示: ``` ERROR: modpost: missing MODULE_LICENSE() in /home/tinyynoob/code/fibdrv/fibdrv.o ``` 但是我的程式碼中確定是有 `MODULE_LICENSE()`,接著我從 [stack overflow](https://stackoverflow.com/questions/56662176/linux-kernel-driver-modpost-missing-module-license) 找到,原來是 source objects 跟最後的 object 產生了命名衝突,於是 **Makefile** 內被我改成: ``` TARGET_MODULE := fibdrv_main obj-m := $(TARGET_MODULE).o $(TARGET_MODULE)-y := fibdrv.o ubignum.o ``` 成功編譯並掛載進核心。 執行時持續導致 kernel crash,重開機數次之後回到 user space 進行 debug。 1. 首先直接執行 fib 計算,從 stdout 未見得任何問題與錯誤 2. 使用 Valgrind 監測,發現固定在 $n=98$ 會出現 invalid read ``` 98 is 135301852344706746049 ==11554== Invalid read of size 8 ==11554== at 0x10999F: ubignum_left_shift (ubignum.c:168) ==11554== by 0x10A0CA: ubignum_divby_ten (ubignum.c:277) ==11554== by 0x10AAF6: ubignum_2decimal (ubignum.c:467) ==11554== by 0x10AC36: ubignum_show (bignum_debug.c:12) ==11554== by 0x10AD5F: main (bignum_debug.c:33) ==11554== Address 0x4c9b9b8 is 0 bytes after a block of size 8 alloc'd ==11554== at 0x483DD99: calloc (in /usr/lib/x86_64-linux-gnu/valgrind/vgpreload_memcheck-amd64-linux.so) ==11554== by 0x109455: ubignum_init (ubignum.c:70) ==11554== by 0x10A00F: ubignum_divby_ten (ubignum.c:268) ==11554== by 0x10AAF6: ubignum_2decimal (ubignum.c:467) ==11554== by 0x10AC36: ubignum_show (bignum_debug.c:12) ==11554== by 0x10AD5F: main (bignum_debug.c:33) ==11554== ``` $n=98$ 的意義在於它是一個超過一個 chunk 的 size 之後十倍的值,因此我的 `divby_ten()` 可能在 `a->data[1]` 和 `a->data[0]` 的邊界發生錯誤了。再安插一些 `printf()` 發現具體位置是發生在 $n=99$ 的 `shift = 64` 時,仔細一看發現上面的情況 $n=98$ 的答案已印出,所以錯誤確實應是發生在計算 $n=99$ 的過程中。 ```c= if (shift) { // merge the lower in [ai] and the higher in [ai - 1] (*out)->data[oi--] = a->data[ai - 1] >> (UBN_UNIT_BIT - shift); for (ai--; ai > 0; ai--) (*out)->data[oi--] = a->data[ai] << shift | a->data[ai - 1] >> (UBN_UNIT_BIT - shift); (*out)->data[oi--] = a->data[ai] << shift; // ai is now 0 } else { while (ai >= 0) (*out)->data[oi--] = a->data[ai--]; } ``` 以上是 `divby_ten()` 當中的程式碼,這個第 9、10 行的地方不應該直接從 `ai` 開始存取,因為 `ai` 的初值設為 `a->size`,顯然在 if branch 沒出錯是因為從 `ai - 1` 開始取,然而在 else branch 並未注意到這點,因此存取超過了 `a->data` 的範圍。 過去的版本都沒發生錯誤是因為 `ten` 的 default capacity 為 2,因此 `ten->data[1]` 在 C 語言中仍是合法取值,即使在我的設計中這是個錯誤。現在 `ten` 的 capacity 改成 1 之後就顯現了這個問題。 由於重新看 code 的時候覺得由高到低的資料複製並不直覺,因此重寫為由低到高複製的版本: ```c memset((*out)->data, 0, chunk_shift * sizeof(ubn_unit_t)); int ai = 0, oi = chunk_shift; /* copy data from a to (*out) */ if (shift) { // merge the lower part from [ai] and the higher part from [ai - 1] (*out)->data[oi++] = a->data[ai++] << shift; for (; ai < a->size; ai++) (*out)->data[oi++] = a->data[ai] << shift | a->data[ai - 1] >> (UBN_UNIT_BIT - shift); (*out)->data[oi++] = a->data[ai - 1] >> (UBN_UNIT_BIT - shift); } else { while (ai < a->size) (*out)->data[oi++] = a->data[ai++]; } /* end copy */ ``` 改寫後的邏輯較為直覺,且不容易再發生存取越界的問題,運算到 $n=1000$ 都沒發生 memory 問題: ``` ==16474== HEAP SUMMARY: ==16474== in use at exit: 0 bytes in 0 blocks ==16474== total heap usage: 25,076,159 allocs, 25,076,159 frees, 530,825,025 bytes allocated ==16474== ==16474== All heap blocks were freed -- no leaks are possible ==16474== ==16474== For lists of detected and suppressed errors, rerun with: -s ==16474== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0) ``` 再來捨棄原本 over allocation 的方式,直接計算出精準的 `new_size`: ```c const uint16_t new_size = a->size + chunk_shift + (shift > ubignum_clz(a)); ``` 於是可以將後續微調 size 的部份移除,並將資料搬移的部份再小小改進: ```diff= memset((*out)->data, 0, chunk_shift * sizeof(ubn_unit_t)); - int ai = 0, oi = chunk_shift; /* copy data from a to (*out) */ if (shift) { + int ai = 0, oi = chunk_shift; // merge the lower part from [ai] and the higher part from [ai - 1] (*out)->data[oi++] = a->data[ai++] << shift; for (; ai < a->size; ai++) (*out)->data[oi++] = a->data[ai] << shift | a->data[ai - 1] >> (UBN_UNIT_BIT - shift); - (*out)->data[oi++] = a->data[ai - 1] >> (UBN_UNIT_BIT - shift); + if (oi < new_size) + (*out)->data[oi] = a->data[ai - 1] >> (UBN_UNIT_BIT - shift); } else { - while (ai < a->size) - (*out)->data[oi++] = a->data[ai++]; + memmove((*out)->data + chunk_shift, a->data, a->size * sizeof(ubn_unit_t)); } /* end copy */ ``` 由於 `(*out)->data` 不再 over allocation,所以 `(*out)->data` 不一定有 `[chunk_shift + a->size + 1]` 這項,因此原先的第 12 行可能發生存取越界,所以此處必須加一個 if 做判斷。 `memmove()` 的部份原先是使用 `memcpy()`,但從 man 當中讀到: > The memory areas must not overlap. Use memmove(3) if the memory areas do overlap. 舉例來說左移時可能會發生: ```graphviz digraph { rankdir = "TB"; node [shape="record"]; ori [label="<l>1|<r>0"]; post [label="<l>2|<m>1|<r>0"]; ori:l -> post:l; ori:r -> post:m; } ``` 若又是 aliasing 的情況,那麼 `data[1]` 的部份就發生 overlap 了,因此需要使用 `memmove()`。 ### 時間量測 fibdrv 執行的耗時大概分 3 個部份: 1. 計算 Fibonacci number 時間 2. 2 進位轉 10 進位時間 3. 運算結果由 kernel space 傳送到 user space 時間 在開發一個段落之後想使用 **exp.c** 量看看 1.,但又遇到之前有碰過的「強制結束」問題,上次並沒有解決,這次再來嘗試看看。 強制結束只在呼叫 `fib_fast()` 時出現,並且時而發生時而不發生,但若發生則總是在 $n=360$ 時。 使用 gdb 工具,發現會收到 ==SIGKILL==: ``` Breakpoint 1, main (argc=2, argv=0x7fffffffe608) at exp.c:62 62 data[j] = write(fd, NULL, select); (gdb) n Program terminated with signal SIGKILL, Killed. The program no longer exists. ``` 為了解決這個問題,我藉著 git 回到該錯誤最開始發生的版本 [199976](https://github.com/tinyynoob/fibdrv/commit/199976792eddb61d579f696a258b4bee39a7cc5b),發現主要是從 `mult()` 過度到 `square()` 時出了問題。 一樣使用 Valgrind 的 memcheck 工具在 user space 跑程式,抓到: ``` 359 is 475420437734698220747368027166749383608266150858434120589017931240471899153 ==7358== Use of uninitialised value of size 8 ==7358== at 0x10A7C2: ubignum_mult_add (ubignum.c:315) ==7358== by 0x10ADB2: ubignum_square (ubignum.c:424) ==7358== by 0x109448: fib_fast (bignum_debug.c:44) ==7358== by 0x109669: main (bignum_debug.c:96) ==7358== ==7358== Use of uninitialised value of size 8 ==7358== at 0x109700: ubn_unit_add (ubignum.h:76) ==7358== by 0x10A7D4: ubignum_mult_add (ubignum.c:315) ==7358== by 0x10ADB2: ubignum_square (ubignum.c:424) ==7358== by 0x109448: fib_fast (bignum_debug.c:44) ==7358== by 0x109669: main (bignum_debug.c:96) ==7358== ==7358== Use of uninitialised value of size 8 ==7358== at 0x10972B: ubn_unit_add (ubignum.h:77) ==7358== by 0x10A7D4: ubignum_mult_add (ubignum.c:315) ==7358== by 0x10ADB2: ubignum_square (ubignum.c:424) ==7358== by 0x109448: fib_fast (bignum_debug.c:44) ==7358== by 0x109669: main (bignum_debug.c:96) ==7358== ==7358== Use of uninitialised value of size 8 ==7358== at 0x109748: ubn_unit_add (ubignum.h:77) ==7358== by 0x10A7D4: ubignum_mult_add (ubignum.c:315) ==7358== by 0x10ADB2: ubignum_square (ubignum.c:424) ==7358== by 0x109448: fib_fast (bignum_debug.c:44) ==7358== by 0x109669: main (bignum_debug.c:96) ==7358== ``` 找到 **ubignum.c** 的第 315 行: ```c for (; carry; oi++) carry = ubn_unit_add((*out)->data[oi], 0, carry, &(*out)->data[oi]); ``` 看來是因為存取到超過 `(*out)->size` 的 `(*out)->data` 才出現 uninitialized variable 的問題。 回頭去審視 `ubignum_square()` 中的 `ans`,在平方的計算中計算乘法的兩個部份分為 - 同 chunk 相乘 - 異 chunk 相乘 ```c= /* a b c d * * a b c d * ------------------------------ * ad bd cd dd * ac bc cc cd * ab bb bc bd * aa ab ac ad */ // compute aa, bb, cc, dd parts for (int i = 0; i < a->size; i++) ubn_unit_mult(a->data[i], a->data[i], ans->data[2 * i + 1], ans->data[2 * i]); ans->size = ans->data[a->size * 2 - 1] ? a->size * 2 : a->size * 2 - 1; // compute multiplications of different chunks for (int i = 0; i < a->size - 1; i++) { int carry = 0; ubn_unit_t overlap = 0; ubignum_set_zero(group); for (int j = i + 1; j < a->size; j++) { ubn_unit_t low, high; ubn_unit_mult(a->data[j], a->data[i], high, low); carry = ubn_unit_add(low, overlap, carry, &group->data[j]); overlap = high; // update overlap } group->data[a->size] = overlap + carry; // no carry out would be generated group->size = a->size + 1; ubignum_left_shift(group, 1, &group); if (group->data[a->size + 1]) group->size++; ubignum_mult_add(group, i, &ans); } ``` 找來找去 `ans->size` 似乎沒有錯誤,反倒是 `group->size` 有些奇怪便順手修了,並在程式碼中加了一些註解。 根據 [Valgrind manual](https://valgrind.org/docs/manual/mc-manual.html),多加一項參數 `--track-origins=yes` 去執行: ``` ==15084== Use of uninitialised value of size 8 ==15084== at 0x10A7C2: ubignum_mult_add (ubignum.c:315) ==15084== by 0x10ADA9: ubignum_square (ubignum.c:427) ==15084== by 0x109448: fib_fast (bignum_debug.c:44) ==15084== by 0x109669: main (bignum_debug.c:96) ==15084== Uninitialised value was created by a stack allocation ==15084== at 0x10A6DD: ubignum_mult_add (ubignum.c:306) ``` 發現此變數的來源竟然是 `ubignum_mult_add()` 中的 stack allocation 而不是 heap 區塊的問題。在程式中插入 `printf()` 來找出錯誤的確切位置,發現關鍵在這段: ```c= int carry = 0, oi; for (int ai = 0, oi = offset; ai < a->size; ai++, oi++) carry = ubn_unit_add((*out)->data[oi], a->data[ai], carry, &(*out)->data[oi]); for (; carry; oi++) carry = ubn_unit_add((*out)->data[oi], 0, carry, &(*out)->data[oi]); ``` 由於第 3 行的 `oi` 及第 5 行的 `oi` 並非同一個 `oi`,導致第 5 行的 `oi` 其實並未被初始化。我當初在寫這段程式時誤認為第 2 行的 `oi` 只做了指派而沒有宣告,所以導致此 bug 的產生,這個小小 bug 使我度過了無數次的 kernel crash... 現在總算可以將 fast doubling 的部份納入實驗量測 ![](https://i.imgur.com/lvXjMRq.png) 修改之後的運算效率相較前版略有提昇: **每次迭代的複雜度** 控制變因 $n$ 為 $0$ 到 $500$。 ||sequencial|fast doubling| |:-:|:-:|:-:| |舊|16 ns|834 ns| |新|==14== ns|==671== ns| > 實驗版本:[882938](https://github.com/tinyynoob/fibdrv/tree/8829388ec345cedfd6866c4d629aeb10b06598fe) ### 字串複製時間 修改 `fib_read()` 以測量字串複製時間 ```c static ssize_t fib_read(struct file *file, char *buf, size_t size, loff_t *offset) { if (!buf) return -1; char *s = (char *) kmalloc(sizeof(char) * *offset, GFP_KERNEL); for (int i = 0; i < *offset; i++) s[i] = (i & 127) + 1; s[*offset - 1] = '\0'; ktime_t kt = ktime_get(); if (copy_to_user(buf, s, *offset)) return -EFAULT; kt = ktime_sub(ktime_get(), kt); kfree(s); return (ssize_t) ktime_to_ns(kt); } ``` ![](https://i.imgur.com/3otr6F2.png) 取平均計算,算出 `copy_to_user()` 每將 1 個 byte 從 kernel space copy 到 user space 大約需要 $28$ $\mu$s。 > 實驗版本:[d10571](https://github.com/tinyynoob/fibdrv/tree/d10571905374b205ba4c96948e38a62d59910b07) --- 再來稍微算一下每個 Fibonacci number 有幾個 decimal digits: ![](https://i.imgur.com/ttCJsOv.png) 呈現非常完美的線性上升,條列幾個數字如下: | n | Length of fib(n) | |--:|:-:| |100 | 21 | |500 | 111 | |1000 | 209 | |3000 | 627 | |5000 | 1045 | |7500 | 1568 | |10000 | 2090 | 綜合以上兩組實驗,可以推估即使是計算 fib(100,000),data copy 的成本大約也才 500 ns,因此在我的實作中,資料複製並不是主要的開銷所在,應把心力花在其它更關鍵的改善上。 ### 2 進位轉 10 進位時間 其實實驗的過程中就有感覺到最耗時的應該是轉進制的部份,因為每次當 terminal 被 blocking 時打開 perf top 觀察總是會發現 CPU 一直努力在執行 `ubignum_sub()`,而此函式僅於 `ubignum_divby_ten()`,也就是轉進制時才會呼叫。 以量化的方式測量看看: ![](https://i.imgur.com/Z2LCikx.png) 測量 運算+轉進制的時間 和 純轉進制的時間,結果兩條線幾乎疊合,表示所有的開銷幾乎都是在轉進制上。 我是使用 fast doubling 算法做測試。根據之前的量測結果 $n=1000$ 的運算耗時約為 8000 ns,而加上轉進制之後的耗時竟直逼 $6\times 10^6$ ns。 為了看出每一條曲線的差異,將視角縮小到 $n=100$。 ![](https://i.imgur.com/YSSptPr.png) 可以看出在多做了 converting 的情況下,computing 的成本可以說是無足輕重,且相對而言幾乎是個常數。 > 實驗版本:4b1b454b5ec17ee8e3ba638c0fe8414ff51266a0 ### in-place `ubignum_sub()` 全力降低轉進制的時間顯然是在工程上效益最高的方針。 首先我們知道在計算轉進制是藉由 `ubignum_divby_ten()` 來達成,而其中會頻繁地呼叫 `ubignum_sub()`。 在原先計算 `a - b` 的大數運算中,每次做減法會額外使用一個變數 `cmt` 來存放 `b` 的 ones' complement,然而此額外配置空間的舉動對無論空間或時間都是個負擔。 考量到減法其實也是加法,是由低往高逐 chunk 運算的,且由於 `a` 或 `b` 的每個 chunk 都只會被算一次,因此做減法時 `b` 的每個 chunk 的 one's complement 只要在相加前一刻進行即可往後也不會再用到,所以沒有必要保存整個 `b` 的 ones' complement。於是 `ubignum_sub()` 可以做到 in place 的方式,就如同我們在 `ubignum_add()` 所做的。 註:這裡用 ones' complement 是因為我們用 `a + ~b + 1` 的方式來實作 `a - b`,該 `1` 會在加法時由最低區塊的 carry in 加入運算。 #### 原版本 給定: ```graphviz graph { node [shape="record"]; a [label="a:|110...101|100...011|111...110"]; b [label="b:|100|110...001|101...010"]; a -- b [style=invis]; } ``` 額外配置: ```graphviz graph { node [shape="record"]; cmt [label="cmt:|011|001...110|010...101"]; } ``` 接著計算 `a + cmt + 1` 完成減法 :::spoiler 原 `ubignum_sub()` 程式碼 ```c bool ubignum_sub(const ubn_t *a, const ubn_t *b, ubn_t **out) { if (unlikely(ubignum_compare(a, b) < 0)) return false; /* ones' complement of b */ ubn_t *cmt = ubignum_init(a->size); // maybe there is way without memory allocation? if (unlikely(!cmt)) return false; for (int i = 0; i < b->size; i++) cmt->data[i] = ~b->data[i]; for (int i = b->size; i < a->size; i++) cmt->data[i] = UBN_UNIT_MAX; int carry = 1; // to become two's complement for (int i = 0; i < a->size; i++) // compute result and store in cmt carry = ubn_unit_add(a->data[i], cmt->data[i], carry, &cmt->data[i]); // the final carry is discarded cmt->size = a->size; for (int i = a->size - 1; i >= 0; i--) if (!cmt->data[i]) cmt->size--; ubignum_free(*out); *out = cmt; return true; } ``` ::: #### 新版本 不額外配置 `cmt` 空間,嘗試移除 memory allocation 的成本 ```graphviz graph { node [shape="record"]; b [label="b:|100|110...001|101...010"]; a [label="a:|110...101|100...011|<n>111...110"]; c [label="010...101"]; b -- a [style=invis]; a:n -- c [color=blue]; } ``` ```graphviz graph { node [shape="record"]; a [label="a:|110...101|<n>100...011|res0"]; c [label="001...110"]; a:n -- c [color=blue]; } ``` ```graphviz graph { node [shape="record"]; a [label="a:|<n>110...101|res1|res0"]; c [label="011"]; a:n -- c [color=blue]; } ``` 每次 chunk 加法要加 carry in,其中 least significant chunk 計算的 carry in 為 1,來自一補數 to 二補數的 conversion。 :::spoiler 改進後的 `ubignum_sub()` 程式碼 ```c /* *out = a - b * Since the system is unsigned, a >= b should be guaranteed to obtain a * positive result. */ bool ubignum_sub(const ubn_t *a, const ubn_t *b, ubn_t **out) { if (unlikely(ubignum_compare(a, b) < 0)) { // invalid return false; } else if (unlikely(ubignum_compare(a, b) == 0)) { ubignum_set_zero(*out); return true; } if ((*out)->capacity < a->size) { if (unlikely(!ubignum_recap(*out, a->size))) return false; } else if ((*out)->size > a->size) { memset((*out)->data + a->size, 0, sizeof(ubn_unit_t) * ((*out)->size - a->size)); } // compute subtraction with adding the two's complement of @b int carry = 1; for (int i = 0; i < b->size; i++) carry = ubn_unit_add(a->data[i], ~b->data[i], carry, &(*out)->data[i]); for (int i = b->size; i < a->size; i++) carry = ubn_unit_add(a->data[i], UBN_UNIT_MAX, carry, &(*out)->data[i]); // the final carry is discarded (*out)->size = a->size; while ((*out)->data[(*out)->size - 1] == 0) (*out)->size--; return true; } ``` ::: ![](https://i.imgur.com/4OXj2if.png) 實驗可以發現轉進制速度大約有一倍的改善,這表示原本光是在 memory allocation 就佔了超過一半的時間,對 kernel 而言是非常冗贅的工作。 ### Memory pool for `ubignum_divby_ten()` 接著我發現在 `ubignum_divby_ten()` 中,`dvd`, `suber`, `ans`, `ten` 在每次進入函式都需要重新配置,而一個滿的 chunk (在 64-bit 情況下) 轉進制時大約都會呼叫 20 次 `ubignum_divby_ten()`,因此我認為消除這個重複 allocation 也將巨幅地降低運算時間。 在這裡我想到的方法是:專門為 divby_ten 工作打造一個 struct,並且於 `ubignum_2decimal()` 函式中初始化,那麼就可以在每次呼叫 `ubignum_divby_ten()` 時傳入供其使用。 結構定義如下: ```c /* The struct that is used for ubignum_divby_ten(). * dvd \div 10 = quo ... rmd */ typedef struct { ubn_t *dvd; // dividend ubn_t *quo; // quotient ubn_t *subed; // subtrahend ubn_t *ten; // constant 10 int rmd; // remainder, \in [0,9] } ubn_dbten_t; ``` ![](https://i.imgur.com/WUKuWIG.png) 原先預想經過這個改進之後計算時間會大幅下降,但實驗起來僅掉了 10 ~ 20% 而已。嘗試於更大的 $n$ 也沒有更大的差距,也就是到 $n$ 再往上時,改進的幅度可能已不到 1%,實驗的結論是此方法帶來的改進非常有限。 --- 過程中發現並修掉了 `ubignum_left_shift()` 的錯誤,由於可能有 pointer aliasing 的問題,因此若要做到 in-place 執行,我們不能為了可讀性而從低位開始複製,還是得從高位開始。 ```graphviz digraph { rankdir = "TB"; node [shape="record"]; ori [label="||<2>2|<1>1|<0>0"]; post [label="<4>4|<3>3|<2>2|<1>1|<0>0"]; edge [weight=1]; ori:2 -> post:4 [color = purple]; ori:1 -> post:3 [color = blue]; ori:0 -> post:2 [color = red]; edge [weight=3]; edge [style=invis]; ori:2 ->post:2; } ``` 上面表示從低位開複製的 `ubignum_left_shift()`,執行順序是 紅 $\to$ 藍 $\to$ 紫。假設這兩個區塊是 aliasing 也就是上下的 2 是同一個地址,依此類推。 上圖當紫色要去取 2 放到位置 4 的時候,取到的其實是 0,因為紅色已經執行,所以沒拿到預期的 2,導致結果錯誤。 ```graphviz digraph { rankdir = "TB"; node [shape="record"]; ori [label="||<2>2|<1>1|<0>0"]; post [label="<4>4|<3>3|<2>2|<1>1|<0>0"]; edge [weight=1]; ori:2 -> post:4 [color = red]; ori:1 -> post:3 [color = blue]; ori:0 -> post:2 [color = purple]; edge [weight=3]; edge [style=invis]; ori:2 ->post:2; } ``` 反過來若是從高位開始複製,則不會有取到髒資料的問題。 總結達成 in-place 的關鍵在於,不能改動到未來要用的變數,否則就會引用到錯誤的數值。所以在加法的情況我們必須從低位開始做,而在左移的情況我們就必須從高位開始做了。另外像是除法這類全盤修改的運算,就無法做出 in-place 的算法。 --- 參考 [KYG 大大的筆記](https://hackmd.io/@KYWeng/rkGdultSU#perf),嘗試用 perf 來觀測 fibdrv 的熱點分佈。 ```shell $ sudo perf record -F 4000 -e cycles,cache-misses,cache-references -g taskset -c 1 ./client > /dev/null ``` ```shell $ sudo perf report -g --no-children ``` ```shell + 39.93% client [kernel.kallsyms] [k] ubignum_left_shift + 31.47% client [kernel.kallsyms] [k] ubignum_sub + 16.51% client [kernel.kallsyms] [k] ubignum_compare + 11.55% client [kernel.kallsyms] [k] ubignum_divby_ten # Samples: 75K of event 'cycles' # Event count (approx.): 63590520380 # # Overhead Command Shared Object Symbol # ........ ....... ................. .................................... # 39.93% client [kernel.kallsyms] [k] ubignum_left_shift | ---__libc_start_main read entry_SYSCALL_64_after_hwframe do_syscall_64 __x64_sys_read ksys_read vfs_read fib_read | --39.92%--ubignum_2decimal | |--38.62%--ubignum_divby_ten | ubignum_left_shift | --1.30%--ubignum_left_shift 31.47% client [kernel.kallsyms] [k] ubignum_sub | ---__libc_start_main read entry_SYSCALL_64_after_hwframe do_syscall_64 __x64_sys_read ksys_read vfs_read fib_read ubignum_2decimal | |--30.96%--ubignum_divby_ten | ubignum_sub | --0.52%--ubignum_sub ``` 顯然 CPU 絕大部分的工作還是在轉進制上,尤其是 `left_shift()` 跟 `sub()` 都很值得再繼續改善。 ### 遭遇 undefined behaviour 在我的大數實作中常常出現這樣的 prototype: ```c bool ubignum_left_shift(const ubn_t *a, uint16_t d, ubn_t **out) ``` 我在開發期間一直都很狐疑在 `a == *out` 的情況下,編譯器到底會不會認為該空間是 constant 而在取值時做最佳化。 C99 規格書 6.7.3 的第 5 點提到: > If an attempt is made to modify an object defined with a const-qualified type through use of an lvalue with non-const-qualified type, the behavior is undefined. If an attempt is made to refer to an object defined with a volatile-qualified type through use of an lvalue with non-volatile-qualified type, the behavior is undefined. 然而我在 [stackoverflow](https://stackoverflow.com/questions/32706977/c-modify-const-through-aliased-non-const-pointer) 上也看到有人說這樣的操作是 well-defined。 於是我試著在自己的電腦上寫一小段程式進行測試: ```c int main() { const int i = 1; printf("%d\n", i); *(int *) &i = 3; printf("%d\n", i); return 0; } ``` ```shell $ gcc const.c -o const $ ./const 1 3 $ gcc const.c -o const -O2 $ ./const 1 1 ``` 結果這樣的寫法確實會因為最佳化等級而改變,這樣看來這種操作確實是 undefined behavior,因此我所有 ubignum 函式可能都需要再修改了。雖然目前執行起來的正確性都沒有出問題,但難保會在哪個編譯器版本下出錯。 在 user mode 下將 **ubignum.c** 以 -O2 最佳化編譯成 .s 檔,再刪除 const qualifier 編譯一次,結果比較兩份 assembly code 完全相同,看來 gcc 在此完全沒有引入對於 pointer to const-qualified object 的最佳化。 > gcc 版本:gcc (Ubuntu 9.4.0-1ubuntu1~20.04.1) 9.4.0 我希望寫出來的 C code 在 non-aliasing 的情況下可以適當地被 compiler 最佳化,而在 aliasing 的情況下又能正確地執行,目前在想要如何達成這樣的目標。目前暫且先刪除所有在 `ubn_t` 上的 const 修飾字。 ## 改善 2 進制到 10 進制的轉換時間 ### 重寫 `ubignum_divby_ten()` 想來想去要做轉進制還是擺脫不了除法,不過我突然意識到在對 decimal 10 操作的過程中,其實它的 binary representation 就只有兩個 set bit 而已 (`0b1010`),即使怎麼左移也還是只有兩個 set bit。因此或許我根本不需要用上大數版的 `left_shift()` 和 `sub()` 來處理它,應該是可以換成一個較為簡化的版本。 #### 原版本做 long division 時每次減法的步驟: Step 0:`dvd` ```graphviz graph { node [shape="record"]; dvd [label="被減數 (被除數):|11000|100...101|...|110...001|101...111"]; } ``` Step 1:Create an `ubn_t` that represents the decimal ten ```graphviz graph { node [shape="record"]; subed [label="減數:|0|...|0|1010"]; } ``` Step 2:Left shift to the proper position ```graphviz graph { node [shape="record"]; subed [label="減數:|10100|0|...|0|0"]; } ``` Step 3:Do subtraction of `ubn_t` ```graphviz graph { node [shape="record"]; dvd [label="被減數 (被除數):|<4>11000|<3>100...101|<2>...|<1>110...001|<0>101...111"]; subed [label="減數:|<4>10100|<3>0|<2>...|<1>0|<0>0"]; edge [color=blue]; dvd:4 -- subed:4; dvd:3 -- subed:3; dvd:2 -- subed:2; dvd:1 -- subed:1; dvd:0 -- subed:0; } ``` 得 ```graphviz graph { node [shape="record"]; dvd [label="迭代後的新被除數:|100|100...101|...|110...001|101...111"]; } ``` 程式碼: ```c while (likely(ubignum_compare(dbt->dvd, dbt->ten) >= 0)) { // if dvd >= 10 uint16_t shift = dbt->dvd->size * UBN_UNIT_BIT - ubignum_clz(dbt->dvd) - 4; ubignum_left_shift(dbt->ten, shift, &dbt->subed); if (ubignum_compare(dbt->dvd, dbt->subed) < 0) ubignum_left_shift(dbt->ten, --shift, &dbt->subed); dbt->quo->data[shift / UBN_UNIT_BIT] |= (ubn_unit_t) 1 << (shift % UBN_UNIT_BIT); ubignum_sub(dbt->dvd, dbt->subed, &dbt->dvd); } ``` #### 新版本做 long division 時每次減法的步驟: Step 0:`dvd` ```graphviz graph { node [shape="record"]; dvd [label="被減數 (被除數):|11000|100...101|...|110...001|101...111"]; } ``` Step 1:Assign a double-length unsigned integer the value of the two most-significant chunks $$ 11000100...101 $$ Step 2:Create a subtrahend with appropriate value $$ 10100000...000 $$ Step 3:Do integer subtraction $$ 00100100...101 $$ Step 4:Modify the two most-significant chunks in `dvd` ```graphviz graph { node [shape="record"]; dvd [label="迭代後的新被除數:|100|100...101|...|110...001|101...111"]; } ``` 要拿兩個 chunks 的原因在於,減數 `0b1010` 的位置有可能跨越兩個 chunks 之間的 boundary,為了簡化判斷式,乾脆每次都去拿兩個 chunks。 整體來說依然是在做 long division,但實作程式碼大幅度修改: ```c= /* Do division by subtraction. * In each iteration, obtain one set bit in quotient. * For @dvd with the leading: 00000001xxxxxxxxxxxxxxxxx * ## * We find the bits at ## as @midbits, in relation to ten, 0b1010. * If @midbits is not 00, then ten can subtract @dvd at this aligned place. * Otherwise, the ten have to be right-shifted to subtract @dvd. */ while (likely(dbt->dvd->size >= 2)) { const uint16_t clz = ubignum_clz(dbt->dvd); ubn_extunit_t m = (ubn_extunit_t) dbt->dvd->data[dbt->dvd->size - 1] << UBN_UNIT_BIT | dbt->dvd->data[dbt->dvd->size - 2]; const uint16_t midbits = (m >> (2 * UBN_UNIT_BIT - 3 - clz)) & 0x3u; m -= (ubn_extunit_t) 10 << (2 * UBN_UNIT_BIT - 4 - clz - !midbits); // 4 is the length of 0b1010 const uint16_t quo_shift = dbt->dvd->size * UBN_UNIT_BIT - 4 - clz - !midbits; dbt->dvd->data[dbt->dvd->size - 1] = m >> UBN_UNIT_BIT; dbt->dvd->data[dbt->dvd->size - 2] = (ubn_unit_t) m; while (likely(dbt->dvd->size) && dbt->dvd->data[dbt->dvd->size - 1] == 0) dbt->dvd->size--; dbt->quo->data[quo_shift / UBN_UNIT_BIT] |= (ubn_unit_t) 1 << (quo_shift % UBN_UNIT_BIT); } while (likely(dbt->dvd->size) && likely(dbt->dvd->data[0] >= 10)) { // if dvd->size == 1 && dvd >= 10 const uint16_t clz = ubignum_clz(dbt->dvd); const uint16_t midbits = (dbt->dvd->data[0] >> (UBN_UNIT_BIT - 3 - clz)) & 0x3u; dbt->dvd->data[0] -= (ubn_unit_t) 10 << (UBN_UNIT_BIT - 4 - clz - !midbits); if (unlikely(dbt->dvd->data[0] == 0)) dbt->dvd->size = 0; dbt->quo->data[0] |= (ubn_unit_t) 1 << (UBN_UNIT_BIT - 4 - clz - !midbits); } ``` 由於這些操作針對 10 進行特化,因此大抵也不會用在其它函式用上,於是就直接整個寫在 `ubignum_divby_ten()` 裡了,順道少掉了 function call 的開銷。 在新的版本中: 1. 減法不再走過所有的 chunk,每次只在最高的兩個 chunk 做減法,因為 `0b1010` 最多只會跨足兩個 chunk 2. 操作時不再涉及各種大數運算包括左移、比較、減法,僅使用了整數運算,大幅提昇速度 5. 承 2,因此也無須為 10 維護一個 `ubn_t` 結構 #### 量化改進情況 ![](https://i.imgur.com/OrN17UJ.png) > 圖表誤將 ten 誤植為 teb 改善的幅度非常驚人,轉進制時間降到了原先的 20% 左右。 直接進行減法而不透過 `ubn_t` 機制進行各種操作,並做了前述的 3 點改善,在進行除法的效率上改善非常多。 這時我們可以再來看看轉進制佔了多少比例的時間: ![](https://i.imgur.com/Piw8XzC.png) 跟之前一樣測試到 $n=100$, converting 的開銷經過改進後已經降到比 computing 還低。 然而若 $n$ 再變大一些,還是難以擺脫轉進制的負擔: ![](https://i.imgur.com/KUidAVV.png) ![](https://i.imgur.com/6eth8fL.png) $n=1000$ 的轉進制耗時相比之前降低了一個數量級 (10 base),但這樣的開銷仍然不膚使用。 --- 再度搬出 perf 來了解新版本的熱點何在: ```shell + 98.22% client [kernel.kallsyms] [k] ubignum_divby_ten 0.49% client [kernel.kallsyms] [k] memset_erms 0.16% client [kernel.kallsyms] [k] kfree 0.13% client [kernel.kallsyms] [k] ubignum_2decimal # Samples: 11K of event 'cycles' # Event count (approx.): 9467681324 # # Overhead Command Shared Object Symbol # ........ ....... ................. ...................................... # 98.22% client [kernel.kallsyms] [k] ubignum_divby_ten | ---__libc_start_main read entry_SYSCALL_64_after_hwframe do_syscall_64 __x64_sys_read ksys_read vfs_read fib_read | --98.21%--ubignum_2decimal ubignum_divby_ten ``` 拿掉 function call 之後所有成本全數掉到了 `ubignum_divby_ten()` 本身,所有時間都被除以 10 花光了,這個數字大致跟上方貼合的曲線圖呼應。 看來我們還是必須再想想辦法... ### 放大除數 我想目前最大的問題就在於,當 `ubn_t` 的 size 逐漸變長時,呼叫 `ubignum_divby_ten()` 的次數也將多得不可勝數。 於是我想到的方式是,與其每次除以 10 慢慢除,我們是否可以一次就除以例如 $10^4$,等於一次轉出 4 個 digits。在一般的 CPU 架構下,同樣長度的減法成本並不會因數值大小而改變,在這個情況下,降低需要的迭代次數應可帶來改善。 為了符合在不使用大數的情況下正確進行減法,我在 64 位元的架構下選擇 $10^{16}$ 做為新除數。`UINT64_MAX` 的值約為 $10^{19}$,原則上當然是越靠近越好,不過我稍微退了一些選擇 16,讓次方項保有 power of 2 的特性,或許有機會引入一些小技巧。 假設除以 10 的 iteration 次數是 $x$ 次 (求 $x$ 個 digits),那麼改用 $10^{16}$ 其需要的 iteration 次數約會降到 $\lceil x/16\rceil$。 > 實作版本:[eb0536](https://github.com/tinyynoob/fibdrv/blob/eb0536c02ad396ebd996eb369824a27d6c1f5822/ubignum.c) 雖然知道運算次數減少了很多,但改善效果仍舊超乎我的預期: ![](https://i.imgur.com/ZVUyQbz.png) 這一次的改進又帶來了一次數量級的提昇,嘗試將兩者的 ratio 製圖: ![](https://i.imgur.com/TKw4PmY.png) 在 $n=1000$ 時,耗時比值收在 $16$ 左右,跟方才推測的 iteration 的次數變化有所呼應。 放大觀察短碼的情形: ![](https://i.imgur.com/vvvYrNw.png) 可以發現只要 $n$ 超過 $20$,新版本就有比較好的效率,到 40 時已經有兩倍之差。於是我決定完全捨棄 `divby_ten()`,全部都倚靠 `divby_superten()` 來進行轉換。 到了此版已可以輕易在自己的電腦上運算出 fib(100,000) 的答案,不像之前同樣的結果可能需要等待超過半小時,甚至現在 fib(1,000,000) 的結果也能在 1 分鐘內算出。 > 注:此處的 `superten` 在後面被改名成 `Lten`,因為之後 `superten` 另有它用 ![](https://i.imgur.com/SmHKO0A.png) 量測 computing 及 converting 的耗時比例,發現即使 $n$ 到達 $1000$ 的規模,converting 的時間也不至於有壓倒性的佔比。目前的情況大致是: - 短碼情況 computing 為效能瓶頸 - 中長碼情況 converting 為效能瓶頸 從曲線的走向理所當然可以預估到更大的 $n$ 值時,轉進制的時間依舊會是效能瓶頸。而在長碼的情況下若想再改善轉進制的效率,可能就需要引入大數運算,一次就除掉 $10^{100}$ 之類的,不過大數運算的成本很高,需要再細心設計。 --- 由於這麼大的數值不知道該如何確認正確性,所以我上網查了 fibonacci numbers 的數值,結果竟然發現我的 fib(100,000) 有錯誤。 而找尋錯誤的過程中發現 fib(10,000), fib(50,000) 等數值皆正確,但到了 fib(99,999), fib(100,000) 等數值就不合了。 嘗試使用 Valgrind 等工具都沒發現記憶體問題。 找了一陣子後發現原來是我過去將一些 size, shift 等變數的 type 設成 `uint16_t`,而到了 100,000 時已經超過這個型態的表示範圍了,導致其運算出了非預期的結果,替換成 `uint32_t` 之後就都正確了。 --- 我的目標是希望可以讓我的 fibdrv 輕鬆算出 fib($10^{6}$) = fib(1,000,000),具體而言大致希望可以降到 5 秒以內。 統計一下以二進位表示一些數值需要的 `uint64_t` chunk 數量,也就是我們大數界面中的 size: | value | `uint_64_t` chunks | |:-:|:-:| |fib(1000)| 11 | |fib(10,000)| 109 | |fib(100,000)| 1085 | |fib(1,000,000)| 10848 | |$10^{16}$| 1 | |$10^{64}$| 4 | |$10^{256}$| 14 | |$10^{1024}$| 54 | |$10^{65536}$| 3402 | ### 更大的除數 從之前的結果預期放大除數可以帶來效能改善,於是我的策略為:依據 $n$ 的大小選擇不同 level 的除數,目前大概的構想是用 $10^{16}$ (replaced by $10^8$ if 32-bit), $10^{1024}$ 與 $10^{65536}$ 此三個 level。 $$\begin{align} y &= a_2\cdot (10^{1024})^2 + a_1\cdot 10^{1024} + a_0 \\ &= \left(a_{2,63}\cdot (10^{16})^{63} +\cdots+ a_{2,0}\right)\cdot (10^{1024})^2 + \left(a_{1,63}\cdot (10^{16})^{63} +\cdots+ a_{1,0}\right)\cdot 10^{1024} + \left(a_{0,63}\cdot (10^{16})^{63} +\cdots+ a_{0,0}\right) \end{align} $$ ```graphviz digraph { rankdir = "TB"; node [shape="record"]; Y; edge [weight=2]; Y -> a2; Y -> a1; Y -> a0; a2 -> a2_63; in2 [shape="none", label="。。。"]; a2 -> in2; a2 -> a2_0; a1 -> a1_63; in1 [shape="none", label="。。。"]; a1 -> in1; a1 -> a1_0; a0 -> a0_63; in0 [shape="none", label="。。。"]; a0 -> in0; a0 -> a0_0; } ``` 就像是這樣 multi-level 的架構,最末端的每個 block 都會得到 16 個 digits。 圖解看起來可能誤導的地方在於 $a_2$, $a_1$, $a_0$ 不是平行求得的,我們做一次除法會先得 $a_0$,再除一次才會得到 $a_1$,依此類推。使用藍色的 edge 標示運算路徑後實際上會像這樣: ```graphviz digraph { rankdir = "TB"; node [shape="record"]; Y; edge [weight=2, style="dotted"]; Y -> a2; Y -> a1; a2 -> a2_63; in2 [shape="none", label="。。。"]; a2 -> in2; a1 -> a1_63; in1 [shape="none", label="。。。"]; a1 -> in1; a0 -> a0_63; in0 [shape="none", label="。。。"]; a0 -> in0; edge [weight=1, color=blue, style="solid"]; Y -> a0 -> a1 -> a2; a0 -> a0_0 -> in0 -> a0_63; a1 -> a1_0 -> in1 -> a1_63; a2 -> a2_0 -> in2 -> a2_63; } ``` 若是完全沒有引入 multi-level 的策略,運算路徑將會是一條很長的串列一路從 $a_{0,0}$ 串到 $a_{2,63}$。雖然改成這種 list of lists 之後運算量看似沒有變少,但其實可以讓 dividend 更快地降下來,此外也帶來了並行運算的可能。 :::success 注意到不同 edge 的運算開銷不同,例如 $a_0 \to a_1$ 需要使用大數除法,其開銷遠比 $a_{0,0}\to a_{0,1}$ 來得大;此外 $a_{0,0}\to a_{0,1}$ 的開銷也會比 $a_{0,62}\to a_{0,63}$ 來得大,因為越到後面 dividend 已經越來越短,迅速就可以計算完畢。 ::: 而具體對於在多大的數要引入 $10^{1024}$ 或 $10^{65536}$,之後應該會依照測試的數據去選擇一個良好的 threshold。 --- 首先大數除法的邏輯使用過去曾在 `ubignum_divby_ten()` 使用過的方式: ```c while (likely(ubignum_compare(dit->dvd, dvs) >= 0)) { // if dvd >= dvs uint32_t shift = (dit->dvd->size * UBN_UNIT_BIT - ubignum_clz(dit->dvd)) - (dvs->size * UBN_UNIT_BIT - ubignum_clz(dvs)); ubignum_left_shift((ubn_t *) dvs, shift, &dit->subed); if (ubignum_compare(dit->dvd, dit->subed) < 0) // if dvd < subed ubignum_left_shift((ubn_t *) dvs, --shift, &dit->subed); ubignum_sub(dit->dvd, dit->subed, &dit->dvd); dit->quo->data[shift / UBN_UNIT_BIT] |= (ubn_unit_t) 1 << (shift % UBN_UNIT_BIT); } ``` 大略寫完後使用 `time` command 進行初步測試的結果很糟,原先 fib($10^6$) 約須耗時 1 分鐘,但引入的 $10^{1024}$ 為 level 2 之後竟變成 15 分鐘,時間長到令人受不了。 ``` real 15m31.178s user 15m28.178s sys 0m0.492s ``` 因為目前也是先大致寫出一個初步版本,不確定研究、改進之後是否有機會比原先更好,不過我認為這個 performance 掉 15 倍有些太多了,應該不是透過細緻優化能逆轉的,此方向暫以失敗告終。 :::info TODO: - 研讀其它 division algorithm - 更換為更適當的 allocator,如 [`kvmalloc()`](https://lwn.net/Articles/711653/) ::: ## 短碼優化 假定 $n=200$ 以下為短碼 ## 其它議題 ### mutex 的作用 為了理解 mutex 相關操作在 fibdrv 中的功能,我們嘗試撰寫幾個簡單的 user space program 來測試。