# 2022q1 Homework3 (quiz3) contributed by < [`tinyynoob`](https://github.com/tinyynoob) > > [作業要求](https://hackmd.io/@sysprog/BJJMuNRlq) ## 測驗 3 ### 解釋程式碼 ```c= #include <stdint.h> uint8_t rev8(uint8_t x) { x = (x >> 4) | (x << 4); x = ((x & 0xCC) >> 2) | (EXP2); x = ((x & 0xAA) >> 1) | (EXP3); return x; } ``` 為了對 8 bit 數字進行翻轉,這裡的作法以類似 top-down 的方式往下處理,每次交換左右半部,直到每個半部的 size 都為 1 即完成所有翻轉,圖解如下: ```graphviz graph { node[shape=record]; parti8[label="7|6|5|4|3|2|1|0"]; } ``` ```graphviz graph { node[shape=record]; parti4a[label="3|2|1|0"]; parti4b[label="7|6|5|4"]; } ``` ```graphviz graph { node[shape=record]; parti2a[label="1|0"]; parti2b[label="3|2"]; parti2c[label="5|4"]; parti2d[label="7|6"]; } ``` ```graphviz graph { node[shape=record]; 0; 1; 2; 3; 4; 5; 6; 7; } ``` 因為 `0xcc` = `11001100` ,所以第 5 行需要補上的就是另一半的操作,因此 `EXP2` 為 `(x & 0x33) << 2` 。 同理 `EXP1` 為 `(x & 0x55) << 1` 。 ## 測驗 5 ### 解釋程式碼 ```c= #include <limits.h> int divide(int dividend, int divisor) { int signal = 1; unsigned int dvd = dividend; if (dividend < 0) { signal *= -1; dvd = ~dvd + 1; } unsigned int dvs = divisor; if (divisor < 0) { signal *= -1; dvs = ~dvs + 1; } int shift = 0; while (dvd > (EXP6)) shift++; unsigned int res = 0; while (dvd >= dvs) { while (dvd < (dvs << shift)) shift--; res |= (unsigned int) 1 << shift; EXP7; } if (signal == 1 && res >= INT_MAX) return INT_MAX; return res * signal; } ``` 可以看出第 4 至 15 行在把 `dvd` 跟 `dvs` 都轉成非負的值,並把 sign 值存放到 `signal` 。另外也可以看出缺乏對 divisor 為 0 的檢查。 這裡由於是 int 轉成 unsigned int ,所以取二補數並不會因 INT_MIN 導致 undefined behaviour ,以 4 bit 舉例說明: ``` 1000 # INT_MIN of 4-bit signed integer, which is -8 1000 # two's complement of 1000, it is unsigned 8 ``` 因此第 8 及 14 行會得出正確的結果。 接著後面就是要想辦法用 shift 和 subtract 來做出除法。因為除法是從最高位開始,所以這裡的第 17~19 行即是要推出最高位並以 `shift` 紀錄,大概會想到兩個可能的答案 1. `dvs << shift` 2. `dvs << shift + 1` 有例子會比較容易推測,所以我們先假定 `dvd` = 16 和 `dvs` = 3 ,則相除結果應為 0101 。除了第 26 行未知以外,唯一在更新答案的就是第 25 行,其功能為將特定位元設為 1 ,因此可以推斷第一次執行到第 25 行時 `shift` 應該要為 2 。而因為第 23 ~ 24 行結束後會保證 `dvd >= (dvs << shift)` ,所以第 17~19 行把 `shift` 起始值稍微設超過也沒關係,因此 `EXP6` 可以選成較為簡潔的 `dvs << shift` 。 ``` 1 -------- 11 / 10000 1100 # (11) << 2 ------ ``` 在繼續除之前,我們需要更新 `dvd` ,所以 `EXP7` 就是 `dvd -= (dvs << shift)` ,得: ``` 1 -------- 11 / 10000 1100 ------ 100 ``` 接著繼續想,會發現 23~24 行幫助我們找到哪一位可以繼續除,全部做完就會像這樣: ``` 101 -------- 11 / 10000 1100 ------ 100 11 ---- 1 ``` 不過因為我們是用 unsigned int 在除,而商的回傳型態是 int ,所以前面所提及的 INT_MIN 還是可能造成問題。一樣以 4-bit 為例 , 1000 除以 1 得 1000 ,但因為 1000 原本代表的是負數所以 `signal` 會被標記為 -1 ,那麼到第 31 行就會發生 1000 * (-1) 的狀況 :::warning `abs(INT_MIN)` 是未定義行為,那 `(INT_MIN) * (-1)` 呢? 測試似乎會得到原值 ::: :::spoiler 補齊後程式碼 ```c= int divide(int dividend, int divisor) { int signal = 1; unsigned int dvd = dividend; if (dividend < 0) { signal *= -1; dvd = ~dvd + 1; } unsigned int dvs = divisor; if (divisor < 0) { signal *= -1; dvs = ~dvs + 1; } int shift = 0; while (dvd > (dvs << shift)) shift++; unsigned int res = 0; while (dvd >= dvs) { while (dvd < (dvs << shift)) shift--; res |= (unsigned int) 1 << shift; dvd -= (dvs << shift); } if (signal == 1 && res >= INT_MAX) return INT_MAX; return res * signal; } ``` ::: :::warning 不理解 28, 29 行的功能 ::: ### 改進並實作 #### 錯誤處理 我發現它沒有對 `divisor` 為 0 的檢查,結果會使程式陷入無窮迴圈。 使用一些 corner cases 測試可以發現: ```shell 2147483647 / 1 = 2147483647 2147483647 / -1 = -2147483647 -2147483648 / 1 = -2147483648 -2147483648 / -1 = 2147483647 ``` 在 `INT_MIN / -1` 的情境中,也因為結果出現 32 位有號數無法表示的 $2^{32}$ 導致答案不如預期。 為了了解在慣例上這兩種情況該如何處置,我去測試了 standard library 中的 `div()` 。 呼叫 `div(7, 0)` 以及 `div(INT_MIN, -1)` 皆得到相同結果: ```shell 浮點數例外 (核心已傾印) ``` 看來它們都是直接用 exception 的方式處理,查詢 `man signal` 找到這段敘述: > Integer division by zero has undefined result. On some architectures it will generate a SIGFPE signal. (Also dividing the most negative integer by -1 may generate SIGFPE.) Ignoring this signal might lead to an endless loop. 我想仿造使用相同的方式,因此在 `divide()` 函式的最前方加入: ```c if (divisor == 0) raise(SIGFPE); else if (dividend == INT_MIN && divisor == -1) raise(SIGFPE); ``` 測試後 signal 可被正確地觸發。 #### 效能改善 我認為這裡 while 迴圈判斷 `shift` 的方式,可以使用 `clz()` 來取而代之,程式碼更動如下: ```diff + int shift = __builtin_clz(dvs) - __builtin_clz(dvd); - int shift = 0; - while (dvd > (dvs << shift)) - shift++; unsigned int res = 0; while (dvd >= dvs) { + if (dvd < (dvs << shift)) + shift--; - while (dvd < (dvs << shift)) - shift--; res |= (unsigned int) 1 << shift; dvd -= (dvs << shift); } ``` 嘗試測量 10000 組隨機數進行除法所耗費的時間,得: ```shell number of cases: 10000 origial consumption: 966675 v1 consumption: 148825867 ``` 結果顯示改進版程式碼的耗費時間竟為原版的 ${10}^2$ 倍,與我的預期截然不同。 :::info working on... ::: ## 測驗 7 ### 解釋程式碼 給定 32 位的 unsigned `v` ,這題想計算出需要幾個 bit 才能表示 `v` ,顯然 `ret`$\in [0, 32]$ ,且 `ret` 是由 `v` 的 most significant set bit 所決定。 根據二進制的原理,我們知道任意 `v` 必定能表示為 $$ \sum_{i=0}^{31}b_i\cdot 2^i \quad \text{where } b_i\in\{0,1\} $$ $i$ 值最大且為 1 的 $b_i$ 就是我們要的 `ret` ,這裡想透過類似二分搜尋的方式來進行查找。 首先把 32 個 bit 分為兩半,數字代表 most significant set bit 在由下往上的第幾位。 ```graphviz digraph { whole [label="32 ~ 1"]; left [label="32 ~ 17"]; right [label="16 ~ 1"]; whole -> right; whole -> left; } ``` `v` 至少要是 $$ \text{binary } 1 \underbrace{00\cdots00}_{16個0} $$ 才能落在左邊,因此當 `v` $\geq2^{16}$ 時 `v` 屬於左半,否則 `v` 屬於右半。 在屬於左半的情況若是我們先把數字 16 存到某個變數,那麼左右兩個 branch 又變成一樣的問題,那就是求「最大的 1 在 16 位中的何處?」,於是繼續: ```graphviz digraph { whole [label="16 ~ 1"]; left [label="16 ~ 9"]; right [label="8 ~ 1"]; whole -> right; whole -> left; } ``` 完成後一樣把結果加到 `m` 並繼續 4 位的搜尋。 ```c= int ilog32(uint32_t v) { int ret = v > 0; int m = (v > 0xFFFFU) << 4; v >>= m; ret |= m; m = (v > 0xFFU) << 3; v >>= m; ret |= m; m = (v > 0xFU) << 2; v >>= m; ret |= m; m = EXP10; v >>= m; ret |= m; EXP11; return ret; } ``` 在表示上,我們將前面的 `v` $\geq2^{16}$ 更改為等價的 `v` $>2^{16}-1 = \mathtt{0xFFFFU}$ 以利程式看起來的對稱性和二分關係。 在每一輪中我們藉由比較和左移把結果暫存到 `m` ,並用 `|` 來立起 `ret` 中對應的 bit ,接著右移 `v` 來讓兩種結果回歸同一個新問題。 繼續二分的結果, `EXP10` 顯然為 `(v > 0x3U) << 1` 。 最後第 16 行的 `EXP11` 就比較特別了,在程式走到這裡的時候僅剩下 2 個 bit ,所以要判斷是否 $>1$ ,我們可以列出 `v` 為 `00`, `01`, `10`, `11` 的情況來思考,結果應該要分別對應到 0, 1, 2, 2。 然而我們目前不能產生兩位數 (已於 13 至 15 行佔用第 2 位),此外仔細觀察可以發現第 1 位也早在第 3 行就已經被佔用,因此 `0` 和 `1` 的 case 其實已經判斷完畢。 那麼 `EXP11` 就應該以加法更新,並只在最後是 `10` 或 `11` 時更新,即為 `ret += (v > 1)` 。 ### 探索 kernel 中的應用 ### 研讀《[Using de Bruijn Sequences to Index a 1 in a Computer Word](http://supertech.csail.mit.edu/papers/debruijn.pdf)》 雖然目前 architecture 上大都有提供 fls 或相似的 instruction,但為了 cross-platform 和 cross-compiler 的情境,我們有時仍需要 software 的 fls 處理。上方的 `ilog32()` 程式碼仍可能有 branch 產生,因此我們想找到更好的 branch-free 版本。 考慮 $n$-bit 無號整數,此篇文章以 $n=8$、求 ffs 為例。 首先,我們需要一組長度為 $n$ 的 [de Bruijn sequence](https://en.wikipedia.org/wiki/De_Bruijn_sequence),這類特殊 binary sequence 所有 $\log n$ 長度的 cyclic subsequence 完全不重複,也就是每個 $\log n$ 長度的整數都會剛好出現一次。 例如這裡給定為 `debru` = `00011101`,它所有長 $\log n$ 的 cyclic subsequence 為:`000`, `001`, `011`, `111`, `110`, `101`, `010`, `100` (由左而右),我們可以建立 look-up table 以快速查詢位置: |subsequence|index| |:-:|:-:| |`000`|0| |`001`|1| |`010`|6| |`011`|2| |`100`|7| |`101`|5| |`110`|4| |`111`|3| 接著要如何實現 ffs 呢? 我們先針對輸入值 weight 為 1 的 case,做法如下: 1. 輸入值 `x` 乘以 `debru` 2. `>> 5` 3. `& 7` 4. 查表 #### 原理 以 `x` = `00010000` 為例,首先因為 `x` 的 weight 為 1,將其乘以 `debru` 等同於對 `debru` 左移 ffs(x) ``` 00010000 * 00011101 ----------------- 0000000111010000 @@@ ``` 步驟 2 和 3 固定取出 `@@@` 位置的 bits 可以發現這些操作就等同取出 `debru` 的第 ffs(x) 個 cyclic subsequence,於是反查就可以輕易找到 ffs(x)。 --- 以上僅適用於 weight 為 1 的 `x`,那要如何擴充到任意 `x` 呢?只要先對 `x` 使用 isolate first set 操作就行了,一個經典的方法是 `x = x & (-x)`。 :::success 需要注意到我們這裡的 ffs 並未考量 `x` = 0 ::: 解釋至此, fls() 也可以用相似的技巧配合 isolate last set 進行快速求解。 我們取 [stackoverflow 討論串](https://stackoverflow.com/questions/11376288/fast-computing-of-log2-for-64-bit-integers) 裡的 `debru`,並嘗試自己撰寫出 fls() 。 以 32-bit 為目標,首先撰寫輔助程式建出 look-up table ```c static const int debruijn_fls32_table[32] = { 32, 31, 22, 30, 21, 18, 10, 29, 2, 20, 17, 15, 13, 9, 6, 28, 1, 23, 19, 11, 3, 16, 14, 7, 24, 12, 4, 8, 25, 5, 26, 27}; ``` 於是可以寫出: ```c /* most significant = 1 * least significant = 32 * 0 = 0 */ int fls32(uint32_t x) { return (0 - !!x) & debruijn_fls32_table[(ils(x) * 0x07C4ACDDu) >> 27 & 31]; } ``` :::spoiler `ils()` function ```c // isolate last set uint32_t ils(uint32_t n) { n |= n >> 1; n |= n >> 2; n |= n >> 4; n |= n >> 8; n |= n >> 16; return n ^ (n >> 1); } ``` ::: \ 依據之前 branch 版本的慣例,我將 MSB 視為 1、LSB 為 32,並補上 fls(0) = 0。 簡單用亂數進行測試,將 `fls32()` 的結果與 `__builtin_clz() + 1` 比對,完全沒出現錯誤回報,唯兩者對 0 會產生不同結果。 :::spoiler test code ```c #define TESTNUM 10000000 int main() { srand(time(NULL)); puts("start test:"); for (int i = 0; i < TESTNUM; i++) { uint32_t x = rand() << 16 | rand(); if (__builtin_clz(x) + 1 != fls32(x)) printf("different answer when x = %d\n", x); } for (int i = 0; i < TESTNUM; i++) { uint32_t x = rand(); if (__builtin_clz(x) + 1 != fls32(x)) printf("different answer when x = %d\n", x); } puts("end test."); return 0; } ``` ::: ### 編譯時期的 `ilog2()` 實作 ## 測驗 10 ### 解釋程式碼 ```c DIVIDE_ROUND_CLOSEST(x, divisor) ``` 此題想在整數除法中選擇較接近的整數作為答案,而非原本的無條件捨去。 先考慮正數的例子,從數學上來思考,假設除法結果落在 `@` 會得到 1,而落在 `#` 會得 2,從數線看起來如: ``` 0 1 2 3 |------|------|------| @@@@@@@####### traditional computer result @@@@@@@####### our goal ``` 假設除出來位在 ``` 0 1 2 3 |------|------|------| x ``` 如果我們能夠把它右移 0.5 ,那麼 `x` 就會落入傳統 `@` 的範圍並被電腦映射到 1。 然而在整數中根本沒有 0.5 的概念,而追究 0.5 的來源即是整數除法中無法整除的部份,因此我們應想辦法去調整被除數。 假設正整數 $a, b$ 在電腦上做除法得 $a/b=c$ ,我們希望在算出 $c$ 之後可以將它右移 $0.5$ ,那麼可以將原算式改造成 $(a+b*0.5)/b$ 達成目的, $*0.5$ 在電腦上可以使用 `>> 1` 。 --- 接著我們需要將負整數除法也納入討論,在本機使用 gcc 編譯測試顯示 `/` 運算的結果會對稱於原點 0 : ``` -6 / 3 = -2 -5 / 3 = -1 -4 / 3 = -1 -3 / 3 = -1 -2 / 3 = 0 -1 / 3 = 0 0 / 3 = 0 1 / 3 = 0 2 / 3 = 0 3 / 3 = 1 4 / 3 = 1 5 / 3 = 1 6 / 3 = 2 ``` 注意負數部份,例如 `-4/3` 得 -1 而非 -2 。 :::warning 嘗試搜尋 C99 規格書,看到除法運算寫在 6.5.5 章節,但沒有查到對於商或餘數選擇的敘述 ::: 由於這種對稱性,因此前述對於正數右移 0.5 的工作,到了負數情況便改成要左移 0.5 。 回頭來看題目: ```c= #define DIVIDE_ROUND_CLOSEST(x, divisor) \ ({ \ typeof(x) __x = x; \ typeof(divisor) __d = divisor; \ (((typeof(x)) -1) > 0 || ((typeof(divisor)) -1) > 0 || \ (((__x) > 0) == ((__d) > 0))) \ ? ((RRR) / (__d)) \ : ((SSS) / (__d)); \ }) ``` 因為 `-1 = UINT_MAX > 0` ,所以表達式 `((typeof(x)) -1) > 0` 可用於判斷該變數是否為 unsigned ;而 `(((__x) > 0) == ((__d) > 0)))` 檢驗兩者是否同號。 因為 C 語言會將 unsigned 和 signed 混合運算的結果轉為 unsigned ,因此只要被除數或除數中有 unsigned number ,那結果必定非負。若兩者同號那相除結果也會非負。 於是乎我們可以推測 `RRR` 處理 $\geq 0$ 的結果, `SSS` 處理 $<0$ 的結果。根據之前的討論,填入: - `RRR` : `__x + (__d >> 1)` - `SSS` : `__x - (__d >> 1)` ### 探索 kernel 中的應用 :::info 待補 ::: ## 測驗 11 ### 解釋程式碼 這裡我跳過 `fls()` 的部份,直接針對開平方根的部份進行解釋,其功能等同 $\lfloor\sqrt{x}\rfloor$ 。 ```c= unsigned long i_sqrt(unsigned long x) { unsigned long b, m, y = 0; if (x <= 1) return x; m = 1UL << (fls(x) & ~1UL); while (m) { b = y + m; XXX; if (x >= b) { YYY; y += m; } ZZZ; } return y; } ``` 對於 $x$ 滿足 $\mathrm{fls}(x) = n$ ,我可以猜到 $\sqrt{x}\geq \lfloor n/2\rfloor$ ,但是我不知道要怎麼算出更細節的結果。 我在 [wiki](https://en.wikipedia.org/wiki/Methods_of_computing_square_roots#Digit-by-digit_calculation) 找到相關的說明,它的概念是利用 $(a+b)^2 = a^2+2ab+b^2$ 公式,想辦法在已知 $a$ 的情況下去湊出 $b$ 。 這題的變數和要填空的格子有點多,很難依可見的資訊判斷作答,我研究了好一陣子都很茫然,所以我直接去看答案。 ```c= unsigned long i_sqrt(unsigned long x) { unsigned long b, m, y = 0; if (x <= 1) return x; m = 1UL << (fls(x) & ~1UL); while (m) { b = y + m; y >>= 1; if (x >= b) { x -= b; y += m; } m >>= 2; } return y; } ``` 以 $x = (120)_{10} = (1111000)_2$ 為例來說明: 首先`fls(x)` 會算出 6 ,接著 `& ~1` 的作用是把最低的位元改成 0 ,也就是取出小於等於 `fls(x)` 的最大偶數,以我們的例子會輸出 6 。最後把 `m` 初始化為 $2^6$ ,稍候就會發現 `m` 變數是用來指示目前運算到第幾位。 因為 return `y` 所以 `y` 應是存開根號結果的地方。 ``` y = 1 ----------------- \/ 1 11 10 00 = x b = 1 ----------- 0 11 ``` 每次它會算出 `b` 之後檢查 `x >= b` ,如果成立那 `x` 便可以減 `b` 並得該位的 `y` 是 1 ,否則該位 `y` 是 0 。 繼續 loop 之前執行第 16 行,目的是把 `m` 對齊下一個要運算的位。 ``` y = 1 ----------------- \/ 1 11 10 00 1 ----------- 0 11 = x b = 1 01 ```