# 2023q1 Homework2 (quiz2)
contributed by <[`Shiritai`](https://github.com/Shiritai)>
## 測驗一
### 測驗一題目
將以下程式:
```c
#include <stdint.h>
static inline uint64_t pow2(uint8_t e) { return ((uint64_t)1) << e; }
uint64_t next_pow2(uint64_t x)
{
uint8_t lo = 0, hi = 63;
while (lo < hi) {
uint8_t test = (lo + hi)/2;
if (x < pow2(test)) { hi = test; }
else if (pow2(test) < x) { lo = test+1; }
else { return pow2(test); }
}
return pow2(lo);
}
```
改為位元運算風格如下,應填入何?
```c
uint64_t next_pow2(uint64_t x)
{
x |= x >> 1;
x |= x >> 1;
x |= x >> 1;
x |= x >> 1;
x |= x >> 1;
x |= x >> 1;
x |= x >> 1;
x |= x >> AAAA;
x |= x >> 16;
x |= x >> BBBB;
return CCCC;
}
```
### 測驗一分析
「填補」的説明提示為
> 然而上述程式碼存在分支,於是我們可考慮建構以下「填補」位元表示中的 1:
> ```
> x = 0010000000000000
> x = 0011000000000000
> x = 0011110000000000
> x = 0011111111000000
> x = 0011111111111111
> ```
可見往左每次補左移 1, 2, 4, 8, ... 皆為二的冪,以此我們重新整理題目的程式碼,並加上適當的空行註解:
```c
// shift 1 then or
x |= x >> 1;
// shift 2 then or
x |= x >> 1;
x |= x >> 1;
// shift 4 then or
x |= x >> 1;
x |= x >> 1;
x |= x >> 1;
x |= x >> 1;
// shift -> 8 then or
x |= x >> AAAA;
// shift 16 then or
x |= x >> 16;
// shift -> 32 then or
x |= x >> BBBB;
// 00...011...11 + 1 = result twos power
return CCCC;
```
:::success
答案呼之欲出:
`AAAA` 為 `8`,`BBBB` 為 `32`,`CCCC` 為 `x + 1`。
:::
本程式碼透過以 `1` 填滿最高位 `1` 位元以降的所有位元,來獲得大於原值之二的冪減一,最後加一即獲得所求。
不過實際上本算法有個問題:當原本 x 即為二的冪時反而求得比其大的二的冪...
### 使用 `__builtin_clzl` 改寫
有 count leading zero,尋找目標二的冪變的更加簡單:取得 leading zero 中最高位的 `0` 的位移量,產生一個以零為基礎,將該為設為 `1` 後的結果:
```c
uint64_t next_pow2(uint64_t x)
{
return 1 << (64 - __builtin_clzl(x));
}
```
極其簡單,不過如我於作業三的開發紀錄中 [Count leading zero 實作](https://hackmd.io/qwJmNVgBTnyvz-z8swc6Jg#Count-leading-zeros-%E5%AF%A6%E4%BD%9C)一章所述,`__builtin_clzl` 有未定義行為,可能需要使用 branching 排除例外:
```c
if (x)
return 1 << (64 - __builtin_clzl(x));
else // zero
return 1;
```
前面提到過 x 本身即二的冪的可能性,這可以透過 `__builtin_ctzl` 來判斷:當 `lead + tail + 1` 為 `64` 時即表示 `x` 為二的冪:
```c
int lead = __builtin_clzl(x);
int tail = __builtin_ctzl(x);
if (lead + tail == 63)
return x;
else if (x)
return 1 << (64 - lead);
else // zero
return 1;
```
此時不仿利用邏輯與位元運算離規避上述 branching:
```c=
int lead = __builtin_clzl(x);
int tail = __builtin_ctzl(x);
_Bool is2power = lead + tail == 63;
int res;
(res = (-is2power & x)) || (res = ((-!!x) & (1u << (64 - lead))) + !x);
return res;
```
注意到利用邏輯運算子之 short circuit (短路求值) 的特性,第 12 行故意使用 `||` 使當前者若非零就短路求值,這導致 `res` 停留在捕獲若 `x` 為二的冪時的結果,否則捕獲後者的結果。`||` 中後者的說明如下:
```c
/**
* !!x -> whether x is non-zero
* -!!x -> if x is non-zero, got all ones (-1 = 0xff..ff)
* otherwise, still zero
*
* !x -> if x is zero, got 1
*/
((-!!x) & (1u << (64 - lead))) // for x != 0
+ !x; // for x == 0
```
如此便可實現看似無 branching 版的 `next_pow2`。
### x86 指令層級的效能分析
前述我們討論了兩種以 `__builtin_clzl` 為基礎的實現,差別在一個使用純 branching,,另一個極端的使用邏輯與位元運算規避 branching。一個重要的問題是:後面這樣折騰,是否真的比較高效?看看產生的指令也許可以略知一二:
:::info
以下所有組語使用 `cc -O2 -std=c99 -S next_pow2.c` 編譯。
前幾行求 `lead` 和 `tail` 皆相同,出現 `__builtin_clzl` 對應之組語 `bsrq` 和 `__builtin_ctzl` 對應之 `bsfq`:
```python
bsrq %rdi, %rax
xorl %edx, %edx
xorq $63, %rax
rep bsfq %rdi, %rdx
addl %eax, %edx
...
```
:::
> 為了比較極端使用非分支與極端使用分支,我額外寫一個分支判斷二的冪後即不使用分支的中間版本作為比較...
1. 分支版
```python=
...
movl %eax, %esi
movq %rdi, %rax
cmpl $63, %edx
je .L4
movl $1, %eax
testq %rdi, %rdi
je .L4
movl $64, %ecx
subl %esi, %ecx
sall %cl, %eax
cltq
.L4:
ret
```
2. 去除判斷二的冪後的無分支版
```python=
...
cmpl $63, %ecx
je .L4
negq %rdi
movl $64, %ecx
sbbl %esi, %esi
subl %edx, %ecx
movl $1, %edx
salq %cl, %rdx
movslq %esi, %rsi
andq %rdx, %rsi
cmpq $1, %rax
movq %rsi, %rax
adcq $0, %rax
.L4:
ret
```
3. 全無分支版
```python=
...
cmpl $63, %eax
sete %al
movzbl %al, %eax
negl %eax
andl %edi, %eax
jne .L2
movq %rdi, %rax
movl $64, %ecx
negq %rax
sbbl %eax, %eax
subl %edx, %ecx
movl $1, %edx
salq %cl, %rdx
andl %edx, %eax
cmpq $1, %rdi
adcl $0, %eax
.L2:
cltq
ret
```
可以發現全無分支版的竟然出現 `jne` (jump if not equals) 指令,不過對於它的出現也不應該感到意外,這就是 short circuit 的實作。考慮到條件轉跳於預測錯誤時 CPU flush 的性能損耗,分支版有兩條件轉跳,其餘兩者皆為單條件轉跳,單轉跳的可能表現比較好。不過極端版組合語言明顯長非常多。綜合來看,道取中庸可能是比較好的選擇,其對應的 C 語言如下:
```c
int lead = __builtin_clzl(x);
int tail = __builtin_ctzl(x);
if (lead + tail == 63)
return x;
else
return ((-!!x) & (1llu << (64 - lead))) + !x;
```
## 測驗二
### 測驗二題目
串接 $1$ 至 $n$ 間所有二進制數的值。
```c=
int concatenatedBinary(int n)
{
const int M = 1e9 + 7;
int len = 0; /* the bit length to be shifted */
/* use long here as it potentially could overflow for int */
long ans = 0;
for (int i = 1; i <= n; i++) {
/* removing the rightmost set bit
* e.g. 100100 -> 100000
* 000001 -> 000000
* 000000 -> 000000
* after removal, if it is 0, then it means it is power of 2
* as all power of 2 only contains 1 set bit
* if it is power of 2, we increase the bit length
*/
if (!(DDDD))
len++;
ans = (i | (EEEE)) % M;
}
return ans;
}
```
## 測驗二分析
考慮判斷去除最低位的 `1` 後是否為零的邏輯,便是一個使用 `__builtin_ctz` 的好時機:將 tailing zero 位加一之位元去除,故 `DDDD` 為 `x & ~(1 << __builtin_ctz(x))`。由於暫時不確定是針對哪個變數操作,故先以 `x` 代替。
之後我們看到第 18 行需要一個與 `ans` 於之前迴圈舊的值位移後有關的運算結果,推斷 `DDDD` 的邏輯應該是為了複製貼上 `i` 的位元所做的準備,故 `DDDD` 的變數應該與 `i` 有關,也就是 `i`。這樣思考的話 `EEEE` 的答案也呼之欲出:即將 `ans` 位移 `len`。
:::success
故答案為:
* `DDDD`: `i & ~(1 << __builtin_ctz(i))`
* `EEEE`: `ans << len`
:::
由於直覺想到使用 `__builtin_ctz`,與延伸不謀而合,於是我去觀賞其他學員們的答案,發現許多人 `DDDD` 都使用 `i & (i - 1)` 作答,其所代表的意義為直接判斷是否為二的冪,也十分精妙。
## 測驗三
### 測驗三題目
> 當初確診時看這題題目敘述就昏頭,現在看一次就懂了,可惡 qq
以 [SWAR](https://en.wikipedia.org/wiki/SWAR) 實作計算 UTF8 字數的函式。
```c
size_t swar_count_utf8(const char *buf, size_t len)
{
const uint64_t *qword = (const uint64_t *) buf;
const uint64_t *end = qword + len >> 3;
size_t count = 0;
for (; qword != end; qword++) {
const uint64_t t0 = *qword;
const uint64_t t1 = ~t0;
const uint64_t t2 = t1 & 0x04040404040404040llu;
const uint64_t t3 = t2 + t2;
const uint64_t t4 = t0 & t3;
count += __builtin_popcountll(t4);
}
count = (1 << AAAA) * (len / 8) - count;
count += (len & BBBB) ? count_utf8((const char *) end, len & CCCC) : DDDD;
return count;
}
```
當中出現的 `count_utf8` 為以單字元為基礎掃描的版本:
```c
#include <stddef.h>
#include <stdint.h>
size_t count_utf8(const char *buf, size_t len)
{
const int8_t *p = (const int8_t *) buf;
size_t counter = 0;
for (size_t i = 0; i < len; i++) {
/* -65 is 0b10111111, anything larger in two-complement's should start
* new code point.
*/
if (p[i] > -65)
counter++;
}
return counter;
}
```
### 測驗三分析
首先觀察 `swar_count_utf8` 中初始化的兩指標,`qword` 表四重 words (2 bytes),也就是 $64$ 位元,而 `end` 指向 `qword` 遍歷結束的位址,除以 $8$ (右移 $3$) 處理了 `char *` 至`uint64_t *` 位移量的型別轉換。
遍歷完迴圈後,可以預期 `count` 已經紀錄 continuation bytes 數,以總位元組數減去之後便是真正的 UTF8 字數。考量如此,`(1 << AAAA) * (len / 8)` 應該為總位元組數,也就是假設 `len` 整除於 $8$ ($64$ bits) 情況下的總位元組數:整數除 $8$ 後乘 $8$ `== 1 << 3`,故 `AAAA = 3`。
但 `buf` 未必能對齊於 `uint64_t`,需額外計算未對其的部分。由此推斷 `BBBB` 為判斷對齊與否的邏輯:`len & 0b111`,故 `BBBB = 0b111 = 7`,同時判斷 `DDDD` 即 `0`,表已經對齊不需補加。
至於 `CCCC`,由於希望 `count_utf8` 幫我們處理未對齊的部分,故應該略去已經對齊的量,透過 `& 0b111` 即可。
:::success
故答案為:
* `AAAA = 3`
* `BBBB = 7`
* `CCCC = 7`
* `DDDD = 0`
:::
由於單次尋找量為非 SWAR 的八倍,但單次迴圈內的計算量增加,故推測效能會有顯著而小於八倍的成長。
:::info
TODO: 更具體的效能比較...
> [name=Shiritai]
:::
## 測驗四
### 測驗四題目
```c
#include <stdint.h>
#include <stdbool.h>
bool is_pattern(uint16_t x)
{
if (!x)
return 0;
for (; x > 0; x <<= 1) {
if (!(x & 0x8000))
return false;
}
return true;
}
```
以上述函式確認 $16$ bits 無號數是否符合以下樣式:
```txt
8000, c000, e000, f000, f800, fc00, fe00, ff00, ff80, ffc0, ffe0, fff0, fff8, fffc, fffe, ffff,
```
改由以下函式更加有效:
```c
bool is_pattern(uint16_t x)
{
const uint16_t n = EEEE;
return (n ^ x) < FFFF;
}
```
### 測驗四分析
眼尖便能發現特定樣式的為最高位必是一個以上(含)連續的一。如果不使用題目的框架,我可能會這樣寫:
```c
return x && __builtin_ctzs(x) + __builtin_clzs(~x) == 16;
```
不過題目框架已經給好了,這還真腦筋急轉不過來...只好參考其他學員們的答案 (`EEEE = ~x + 1`, `FFFF = x`),但還是覺得沒有學到什麼,就不額外說明了。