--- tags: linux2022 --- # 2022q1 Homework2 (quiz2) contributed by < `sternacht` > > [作業要求](https://hackmd.io/@sysprog/BybKYf5xc) ### 測驗 1 #### 答案 EXP1 = `a & b & 1` EXP2 = `a & b` EXP3 = `a ^ b` #### 延伸 - 解釋程式碼運作的原理 本題描述一個取 a,b 兩值平均值的方式,若是直接相加再除以 2 ,可能面臨 overflow 的風險,而寫成 a + (b - a) / 2 則是比較安全的方式之一,要求將此方式以 bitwise 操作改寫 ```c uint32_t average(uint32_t a, uint32_t b) { return (a >> 1) + (b >> 1) + (EXP1); } ``` 在 bitwise 操作中, `a >> 1` 相當於把 a 除二並捨去小數,於是就可以想到 EXP1 所指的就是要對捨去的小數做進位的動作,因為當 a,b 都是奇數時 (LSB == 1) , LSB 相加的進位會變成各自的小數被捨棄。因此, EXP1 的表示式要在 a,b 的 LSB 都為 1 的時候為 1 ,其餘為 0 ,答案就是 `a & b & 1` 接著再進一步改寫成 ```c uint32_t average(uint32_t a, uint32_t b) { return (EXP2) + ((EXP3) >> 1); } ``` 乍看之下看不出個所以然,所以先來看看 & , ^ 等 bistwise 操作在兩個數上的另一個意義。 & 會令兩數同為 1 的 bit 為 1 ,而以加法來看,兩個同為 1 的 bit 會有進位存在,因此可以把 & 想作為 '取得相加後會進位的 bit 並除以 2 '。而 ^ 則是在兩 bit 不相同時會為 1 ,因此可以看成是 '相加後會變成 1 的 bit'。 綜合以上兩個操作,可以把 a + b 寫成 `((a & b) << 1) + (a ^ b)` ,分別表示了有進位與沒有進位的 bit ,接著在把 a + b 除以二取平均,也就是 right shift 一個 bit , 就變成 `(a & b) + ((a ^ b) >> 1)` ,於是就得到了答案。 #### 延伸 - 比較上述實作在編譯器最佳化開啟的狀況,對應的組合語言輸出,並嘗試解讀 利用 `gcc -S -O2 average.c` 命令取得最佳化的組合語言的程式碼如下 ```asm= average: .LFB0: .cfi_startproc endbr64 movl %edi, %eax movl %esi, %edx andl %esi, %edi shrl %eax shrl %edx andl $1, %edi addl %edx, %eax addl %edi, %eax ret .cfi_endpro ``` 從第 5 行開始,前兩行是將傳入值 a, b 搬到暫存器上 第 7 行將 a, b 做 & 操作,此時取得 `a & b` 8,9 行對暫存器中的 a , b 做 shift ,得到 `(a << 1)` 和 `(b << 1)` 第 10 行再回頭做第二次 & 操作,取得 `a & b & 1` 11, 12 行則是將得到的三個值相加 用第二種程式取得的最佳化組合語言如下 ```asm= average: .LFB0: .cfi_startproc endbr64 movl %edi, %eax andl %esi, %edi orl %esi, %eax shrl %eax addl %edi, %eax ret .cfi_endproc ``` 第 5 行將傳入值 b 放到暫存器上 第 6 行做 `a & b` 的操作 第 7, 8 做 `(a | b) << 1` 第 9 行把兩個相加 ### 測驗 2 #### 答案 EXP4 = `a ^ b` EXP5 = `a < b` #### 延伸 - 解釋程式碼運作的原理 本題要求利用 bitwise 操作求得 a, b 兩者中較大的值 ```c #include <stdint.h> uint32_t max(uint32_t a, uint32_t b) { return a ^ ((EXP4) & -(EXP5)); } ``` 這題的解法要先從 ^ 的幾個特性說起, ^ 也就是 XOR,在 `0 ^ 1` 時為 1 , `0 ^ 0` 或 `1 ^ 1` 時為 0 ,而且這個操作具有交換率,也就是 (a ^ b) ^ c == a ^ (b ^ c),延伸可得一個特性是 x ^ (x ^ y) == y,這就可以用交換率解釋 : (x ^ x) = 0 然後得到 (0 ^ y) = y。 接下來先看題目中 EXP5 的部分,因為我們希望當 a 是較大的值時,整個函式會得到 a ^ 0,因此 -EXP5 就要在 a > b 的時候為 0 ,可得到答案是 `a < b`。至於為何要用負號,是因為當 a < b 時,EXP5 會得到 1 ,而我們要把內容轉換成 -1 ,也就是 0xFFFFFFFF 讓 EXP4 與其做 & 操作,得到 EXP4 內的值,最後結合前面提過的 a ^ (a ^ b) == b , 可得知 EXP4 答案為 `a ^ b` #### 延伸 - 針對 32 位元無號/有號整數,撰寫同樣 branchless 的實作 這題運用到的方法不管在有號或是無號數上,只要 (a < b) 的結果正確,那就是通用的。因此有號數的版本只要使讀進來的兩個值型態為有號數即可 ```c #include <stdint.h> uint32_t max(int32_t a, int32_t b) { return a ^ ((a ^ b) & -(a < b)); } ``` #### 延伸 - 請在 Linux 核心原始程式碼中,找出更多類似的實作手法 [實作 1](https://github.com/torvalds/linux/commit/6c786bcb29dd684533ec165057f437d5bb34a4b2) [實作 2](https://github.com/torvalds/linux/commit/747f49ba67b8895a5831ab539de551b916f3738c) ### 測驗 3 #### 答案 COND = `v` RET = `u << shift` #### 延伸 - 解釋程式運作原理 題目改寫自 GCD 演算法,對兩數求最大公因數 ([來源](https://hackmd.io/@sysprog/linux2022-quiz2#測驗-3)) 1. If both x and y are 0, gcd is zero $gcd(0, 0) = 0$. 2. $gcd(x, 0) = x$ and $gcd(0, y) = y$ because everything divides $0$. 3. If x and y are both even, $gcd(x, y) = 2*gcd(\frac{x}{2}, \frac{y}{2})$ because $2$ is a common divisor. Multiplication with $2$ can be done with bitwise shift operator. 3. If x is even and y is odd, $gcd(x, y) = gcd(\frac{x}{2}, y)$. 4. On similar lines, if x is odd and y is even, then $gcd(x, y) = gcd(x, \frac{y}{2})$. It is because $2$ is not a common divisor. 原本的函式 ```c #include <stdint.h> uint64_t gcd64(uint64_t u, uint64_t v) { if (!u || !v) return u | v; while (v) { uint64_t t = v; v = u % v; u = t; } return u; } ``` 改寫成以下 ```c #include <stdint.h> uint64_t gcd64(uint64_t u, uint64_t v) { if (!u || !v) return u | v; int shift; for (shift = 0; !((u | v) & 1); shift++) { u /= 2, v /= 2; } while (!(u & 1)) u /= 2; do { while (!(v & 1)) v /= 2; if (u < v) { v -= u; } else { uint64_t t = u - v; u = v; v = t; } } while (COND); return RET; } ``` 雖然改寫後複雜了許多,但本質上還是 GCD 的演算法。多的是 shift 這個變數,這個變數儲存的是 u, v 兩數對 2 的冪的公因數,也就是在除以 $2^{shift}$ 後,必至少有一個數會是奇數,並且可以確定不會再有更多 2 出現在最大公因數裡,於是接下來的操作就是把 u 中多餘的 2 先除掉,這一步我的理解是為了要盡可能減少計算的量,連同後續在 do while 中對 v 做除 2 也是相同的原因。 ```c while (!(u & 1)) u /= 2; ``` 接著 do while 的部分,在原函式中是用 % (mod) 的方式來取的值,若一開始 u < v,則會變成 u, v 交換,接著在後續的過程中保證 u >= v,直到 v 為 0 ,換句話說,v 是用來判斷函式結束的關鍵。 而改寫後更像是輾轉相除法,用相減來逼近答案,因此要判斷 u 是否大於 v ,以保證相減後獲得的值是大於 0 ,最後結束條件同樣是 v 等於 0, COND 部分可簡寫成 while (v) 來檢查 v 是否為 0。 RET 要回傳的就是結果,因此除了從 GCD 中得到的 u 之外,還要把最前面除掉的 $2^{shift}$ 乘回去,因此 RET 為 `u << shift` #### 延伸 - 在 x86_64 上透過 __builtin_ctz 改寫 GCD,分析對效能的提升 透過 __builtin_ctz 改寫後的程式碼如下,主要將除 2 的部份透過 __builting_ctz 來取代迴圈執行 ```c uint64_t gcd64(uint64_t u, uint64_t v) { if (!u || !v) return u | v; int shift = __builtin_ctz(u) < __builtin_ctz(v)? \ __builtin_ctz(u): __builtin_ctz(v); u >>= __builtin_ctz(u); do { v >>= __builtin_ctz(v); if (u < v) { v -= u; } else { uint64_t t = u - v; u = v; v = t; } } while (v); return u << shift; } ``` 撰寫一個簡單的測試,對兩個數求最大公因數並輸出兩個函式分別執行的時間,結果如下圖,使用了 __builtin_ctz 的函式明顯比未使用得來的快。 ![](https://i.imgur.com/YZ72PPJ.png) ### 測驗 4 #### 答案 EXP6 = `bitset & -bitset` #### 延伸 - 解釋程式運作原理,並舉出這樣的程式碼用在哪些真實案例中 本題要求在 EXP6 表示式中取得 bitset 中最低位元的 1 ,而這裡運用的就是 - (負號)的特性。對一個值加負號,就相當於翻轉全部的位元並 +1,也就是 -a = ~a + 1 ,最低的 1 位元之前的所有 0 都會被反轉成 1 ,並在 + 1 之後一路進位,直到最低位元的 1 的位置(被反轉成 0 ,進位後又是 1 ),最後再透過 & 操作來得到答案。 ```c #include <stddef.h> size_t improved(uint64_t *bitmap, size_t bitmapsize, uint32_t *out) { size_t pos = 0; uint64_t bitset; for (size_t k = 0; k < bitmapsize; ++k) { bitset = bitmap[k]; while (bitset != 0) { uint64_t t = EXP6; int r = __builtin_ctzll(bitset); out[pos++] = k * 64 + r; bitset ^= t; } } return pos; } ``` 測驗 3 的延伸題中,[lib/math/gcd.c](https://github.com/torvalds/linux/blob/master/lib/math/gcd.c) 即用到這個方法來計算 lsbit ### 測驗 5 #### 答案 PPP = `pos--` MMM = `list_add` EEE = `&heads[remainder % size]` #### 延伸 - 解釋上述程式碼運作原理,指出其中不足,並予以改進 這題題目是分數轉成十進位小數的字串的實作,而計算方式是先記錄出現過的餘數,當該餘數再次出現時就表示進入循環小數的部分了,而記錄的方式就是熟悉的 linked-list 結合 hash table,接著看看題目問題出現的地方 ```c char *fractionToDecimal(int numerator, int denominator) { int size = 1024; char *result = malloc(size); char *p = result; if (denominator == 0) { result[0] = '\0'; return result; } if (numerator == 0) { result[0] = '0'; result[1] = '\0'; return result; } /* using long long type make sure there has no integer overflow */ long long n = numerator; long long d = denominator; /* deal with negtive cases */ if (n < 0) n = -n; if (d < 0) d = -d; bool sign = (float) numerator / denominator >= 0; if (!sign) *p++ = '-'; long long remainder = n % d; long long division = n / d; sprintf(p, "%ld", division > 0 ? (long) division : (long) -division); if (remainder == 0) return result; p = result + strlen(result); *p++ = '.'; /* Using a map to record all of reminders and their position. * if the reminder appeared before, which means the repeated loop begin, */ char *decimal = malloc(size); memset(decimal, 0, size); char *q = decimal; size = 1333; struct list_head *heads = malloc(size * sizeof(*heads)); for (int i = 0; i < size; i++) INIT_LIST_HEAD(&heads[i]); for (int i = 0; remainder; i++) { int pos = find(heads, size, remainder); if (pos >= 0) { while (PPP > 0) *p++ = *decimal++; *p++ = '('; while (*decimal != '\0') *p++ = *decimal++; *p++ = ')'; *p = '\0'; return result; } struct rem_node *node = malloc(sizeof(*node)); node->key = remainder; node->index = i; MMM(&node->link, EEE); *q++ = (remainder * 10) / d + '0'; remainder = (remainder * 10) % d; } strcpy(p, decimal); return result; } ``` 最後一個 for 迴圈就是用於判斷循環小數, pos 表示循環小數出現的位置,也可以理解從第幾位之後是循環小數,還沒有循環時, pos 得到的回傳值是 -1, 當 pos 大於 0 之後,程式要將剩餘的十進位小數補滿,補滿的形式是 `整數.非循環小數(循環小數)` ,因此首先 PPP 要補齊的的是非小數部分,以 pos 遞減的方式計算,答案就是 `pos--`。 當還沒循環時,程式要記錄每一個餘數以及對應的小數十進位值,並放進 hash table 中,MMM 根據提示及題意可以判斷出是 `list_add` 或 `list_add_tail` 的其中一種,符合最精簡的原則,前者是較好的答案,再來 EEE 就是具體要放在哪個 heads 的位置,根據另一個函式 `find` 的內容,我們可以得知 hash 值的計算方式就是`remainder % size`,因此得到 EEE 的答案就是 `&heads[remainder % size]` 除了提示中提到計算正負號可以寫作 `bool isNegative = numerator < 0 ^ denominator < 0;` 之外,我在閱讀的過程中,對於 size 值的設定感到有些困惑,考慮到 hash table 要達到盡可能分散進來的值,將 size 值設定的大一些是可以理解的。但對於這樣找循環小數的程式來說,其實只要讓 size = denominator 就夠了,因為我們可以確定的是 remainder 不會大於 denominator,,也就是說 remainder 的範圍就是從 0 到 denominator ,只要遇到重複就表示循環小數發生,因此在最糟的情況下,也只會是每一個 heads 都被放入一次 remainder ,接著就一定會循環了,第二個 size 可以改寫成 `size = (denominator > n)? n: denominator;` , n 是我們設定的 hash 範圍,範例中是 1333。 同時,如果輸入的 denominator 很大的時候,原先設定的字串長度 1024 也會不夠用導致程式錯誤,將第一個 size 改成以下的條件式 `int size = (denominator > 0)? denominator: -denominator;` 即可。 ### 測驗 6 #### 答案 M = `_h` X = `0` #### 延伸 - 解釋上述程式碼運作原理 ```c #define ALIGNOF(t) \ ((char *)(&((struct { char c; t _h; } *)0)->M) - (char *)X) ``` 先將這提拆分成減號前後兩部份,先細看前面的部份。 在 `(char *)(&((struct { char c; t _h; } *)0)->M)` 中有一個 `struct { char c; t _h; }` 的結構,建立一個此結構的指標,並給與此指標值為 0 ,也就是將這個結構的指標放到記憶體 0 的位址。接著因為 alignment 的緣故, `_h` 會根據型態 t 的不同,與 char `c` 的距離有所改變,而 `_h` 的記憶體起始位址減去 `c`的記憶體起始位址,就是 alignment 的距離,至此我們可以理解前面的部份就是 `_h` 的位址, M = `_h` ,而後面的部份就是 char `c` 的起始位址,本題題目將整個結構放在 0 的位址上, `c` 的起始位置在 0 , X = 0 。 #### 延伸 - 在 Linux 核心原始程式碼中找出 __alignof__ 的使用案例 2 則,並針對其場景進行解說 - [include/uapi/linux/netfilter_bridge/ebtables.h](https://github.com/torvalds/linux/blob/5bfc75d92efd494db37f5c4c173d3639d4772966/include/uapi/linux/netfilter_bridge/ebtables.h) line 90 ```c char data[0] __attribute__ ((aligned (__alignof__(struct ebt_replace)))); ``` 利用 `__alignof__` 取得某個 struct alignment,接著用 `__attribute__ (aligned)` 使變數對齊。 - [include/linux/kstrtox.h](https://github.com/torvalds/linux/blob/35e43538af8fd2cb39d58caca1134a87db173f75/include/linux/kstrtox.h) line 36-37 ```c if (sizeof(unsigned long) == sizeof(unsigned long long) && __alignof__(unsigned long) == __alignof__(unsigned long long)) ``` 這一段看起來很有趣,以我的電腦來說 long long 相當於 64 bits , long 也是 64 bits,但是在某些不同的架構底下 long 可能是 32 bits,因此這一段程式碼主要就是用在分辨不同的長度,以確保回傳值的長度固定。 ### 測驗 7 #### 答案 KK1 = `div3` KK2 = `div5` KK3 = `div3 << 2` #### 延伸 - 解釋上述程式運作原理並評估 naive.c 和 bitwise.c 效能落差 FizzBuzz 的題目要求題目: - 從 1 數到 n,如果是 3的倍數,印出 “Fizz” - 如果是 5 的倍數,印出 “Buzz” - 如果是 15 的倍數,印出 “FizzBuzz” - 如果都不是,就印出數字本身 而觀察輸出則可以發現輸出的文字就是 "Fizz", "Buzz" 兩個的排列組合,因此給定一個字串 `FizzBuzz`,並透過調整輸出字串的 "起始位置" 及 "長度",就可以滿足三種不同的輸出形式。 題目的程式碼如下 ```c static inline bool is_divisible(uint32_t n, uint64_t M) { return n * M <= M - 1; } static uint64_t M3 = UINT64_C(0xFFFFFFFFFFFFFFFF) / 3 + 1; static uint64_t M5 = UINT64_C(0xFFFFFFFFFFFFFFFF) / 5 + 1; int main(int argc, char **argv) { for (size_t i = 1; i <= 100; i++) { uint8_t div3 = is_divisible(i, M3); uint8_t div5 = is_divisible(i, M5); unsigned int length = (2 << KK1) << KK2; char fmt[9]; strncpy(fmt, &"FizzBuzz%u"[(9 >> div5) >> (KK3)], length); fmt[length] = '\0'; printf(fmt, i); printf("\n"); } return 0; } ``` 首先在決定長度的部分,當 n 是 3 或 5 的倍數時為 2,同時是 3, 5 的倍數時為 8 ,因此 KK1 及 KK2 就分別代表 n 是否為 3, 5 的倍數,以 0, 1 表示,而在其他狀況下,長度為 2 ,也就是字串中用來輸出整數的 `"%d"` 。回頭看前兩行的 div3 跟 div5,從變數名稱大概可以猜得出來其所代表的意涵,實際上的運作方式是這樣的。 當有一個 3 的倍數 n 與 M3 一起傳入, n * M3 會相當於 `(n / 3) * 0xFFFFFFFFFFFFFFFF + n` ,而在溢位之後剩下的就只有 `2n / 3` ,因此在一定範圍內會比 M3 這樣的數字來的小,當 n 不是 3 的倍數時,溢位後仍會剩下 `0xFFFFFFFFFFFFFFFF` 加上一個大於等於 1 的整數,使條件不成立。 於是 KK1 及 KK2 的答案就分別是 div3 及 div5 根據上述獲得的字串長度,接著要知道起始位置,在`(8 >> div5) >> (KK3)`這個部分,可以考慮以下情況時起始位置的不同 > 原題目是寫`(9 >> div5) >> (KK3)`,但應該將 9 改成 8,亦即開頭應該設定在 `%` 的位置,否則其他狀況下的輸出會剩下一個 `u`,不符合題目要求 - 3 的倍數,應該為 0 ,8 要右移至少 4 個 bits - 5 的倍數,應該為 4 ,8 要右移 1 個 bit - 15 的倍數,應該為 0 ,要右移 4 個 bits(1 + 至少 3) - 以上皆非,不須位移 因此我們可以得知 KK3 至少要是 4 ,在符合最簡潔程式碼的條件下 KK3 = `div3 << 2` 是最好的答案