contributed by < vax-r
>
測驗中程式碼利用 digit-by-digit calculation 做為基礎來運算 x
的平方根。
基本原理很單純,透過
其中
接著我們逐步思考。
在這裡我們可以假設
不斷推展的話可以得到
此處我們再假設
到這裡其實就可以利用每次利用
透過
在每次迴圈更新
計算到最後得到的
函式中 z
即可對應到 m
即可對應 b
即是 x
則是代表前一輪的差值也就是
ffs()
, fls()
進行改寫ffs()
從 lsb 開始找第一個 set bit 的位置並回傳 index , index 由 1 開始算。利用 ffs()
和 shift 找出 msb 的位置並減一。
int i_sqrt_ffs(int x)
{
if (x <= 1)
return x;
int tmp = x, msb = 0;
while (tmp) {
int i = ffs(tmp);
msb += i;
tmp >>= i;
}
msb--;
int z = 0;
for (int m = 1UL << (msb & ~1UL); m; m >>= 2) {
int b = z + m;
z >>= 1;
if (x >= b) {
x -= b;
z += m;
}
}
return z;
}
block/blk-wbt.c
此檔案的程式碼是在處理 bottle writeback throttling ,透過一個 time window 來監控延遲時間,如果在該 time window 中的最小延遲時間超過給定 target ,則增加 scaling step 並把 queue depth 除上某個二的倍數,且 monitoring window 會被縮到 100 / sqrt(scaling step + 1)
。
對應到程式碼當中即是
rwb->cur_win_nsec = div_u64(rwb->win_nsec << 4, int_sqrt((rqd->scale_step + 1) << 8));
但為何 100 是 rwb->win_nsec << 4
? 可以理解 shift 是為了盡量用到 64 位元 ,分母 shift 8 位元是為了開平方根後就剩 shift 4 。
static void rwb_arm_timer(struct rq_wb *rwb)
{
struct rq_depth *rqd = &rwb->rq_depth;
if (rqd->scale_step > 0) {
/*
* We should speed this up, using some variant of a fast
* integer inverse square root calculation. Since we only do
* this for every window expiration, it's not a huge deal,
* though.
*/
rwb->cur_win_nsec = div_u64(rwb->win_nsec << 4,
int_sqrt((rqd->scale_step + 1) << 8));
} else {
/*
* For step < 0, we don't want to increase/decrease the
* window size.
*/
rwb->cur_win_nsec = rwb->win_nsec;
}
blk_stat_activate_nsecs(rwb->cb, rwb->cur_win_nsec);
}
注意書名號 (即 〈
和 〉
) 的使用,不是「小於」和「大於」符號。
除了上述題目使用的 digit recurrence 以外還可用查表方式來計算開平方根,閱讀〈Analysis of the lookup table size for square-rooting〉以分析查表方式的大小最小需要多少。 查表方式的開平方根運算主要牽涉到利用原本的 radicand table[z]
,接著利用 table[z]
和 talbe[z+1]
進行線性插值法或二分搜尋。所以一開始的初始估算值非常重要,造成初始估算值的誤差兩大來源分別是
首先思考如何準確決定
〈Timing Square Root〉則是量測不同開根號實作之間的效能差異,共比較了六種方法
sqrt()
實作 (編譯為 x87 FSQRT opcode)sqrtss
rsqrtss
取得 實驗結果是方法四 SSE rsqrtss
乘上 x
的運算時間最短,但平均誤差是 0.0094% ,如果利用方法 5 或 6 會讓運算時間變長,但平均誤差卻可以降到 0% ,並且編譯器內建的 sqrt()
是所有方法當中最慢的。
作者提到他沒想到最快的方法居然是先計算開平方根的倒數再乘上原本的數字,利用 SSE rsqrtss
乘上 x
後再利用牛頓法增加精度,能夠擊敗原先 SSE 的 opcode sqrtss
。
閱讀完以上素材我有兩個實作的想法
x
進行 int_sqrt()
得出 rsqrtss
取得 實作 fast inverse square root 演算法需要用到一個 magic number 0x5f3759df
,對於 IEEE 754 規格的浮點數才能直接使用,轉換成二進位會變成 01011111001101110101100111011111
,如果採用定點數則 fractional bits 只能用 1 bit ,我認為不是把 magic number 直接轉成二進位,應探討為何 IEEE 754 規格可以推出這個浮點數,我的定點數是否能導出類似的數。
利用
int ilog2(unsigned n)
{
return (31 - __builtin_clz(n | 1));
}
unsigned i_fastsqrt(unsigned x)
{
int y = (ilog2(x) >> 1);
unsigned z = 1 << y;
z = (z + (x / z)) >> 1;
z = (z + (x / z)) >> 1;
z = (z + (x / z)) >> 1;
return z;
}
從 1 到 1000000 和題目的 i_sqrt()
整數開平方根做比較,可以發現非常接近,做圖結果如下
利用以下腳本計算兩種不同方法的平均執行時間
#define bench(statement) \
({ \
struct timespec _tt1, _tt2; \
clock_gettime(CLOCK_MONOTONIC, &_tt1); \
statement; \
clock_gettime(CLOCK_MONOTONIC, &_tt2); \
long long time = (long long) (_tt2.tv_sec * 1e9 + _tt2.tv_nsec) - \
(long long) (_tt1.tv_sec * 1e9 + _tt1.tv_nsec); \
time; \
})
int main(void) {
long long isqrt_time = 0;
long long fastsqrt_time = 0;
for (int i = 1; i < 1000000; i++) {
isqrt_time += bench(i_sqrt(i));
fastsqrt_time += bench(i_fastsqrt(i));
}
printf("i_sqrt avg time : %lf\n", (double) isqrt_time / 1000000);
printf("fast sqrt avg time : %lf\n", (double) fastsqrt_time / 1000000);
return 0;
}
結果如下
$ ./out
i_sqrt avg time : 55.248122
fast sqrt avg time : 51.479055
如果利用 $ sudo perf stat --repeat 100 ./out
分別量測兩種不同實作的 cpu cycle 與 cpu clock utilized 分別可以得到以下結果
i_sqrt()
Performance counter stats for './out' (100 runs):
79.11 msec task-clock # 0.996 CPUs utilized ( +- 0.26% )
0 context-switches # 0.000 /sec
0 cpu-migrations # 0.000 /sec
52 page-faults # 657.317 /sec ( +- 0.13% )
2,7698,5996 cycles # 3.501 GHz ( +- 0.09% ) (83.56%)
1,5255,7505 stalled-cycles-frontend # 55.08% frontend cycles idle ( +- 0.09% ) (83.88%)
5742,1358 stalled-cycles-backend # 20.73% backend cycles idle ( +- 0.20% ) (67.79%)
3,4771,1663 指令 # 1.26 insn per cycle
# 0.44 stalled cycles per insn ( +- 0.02% ) (83.91%)
5555,5190 branches # 702.257 M/sec ( +- 0.03% ) (83.88%)
6,2454 branch-misses # 0.11% of all branches ( +- 16.45% ) (80.90%)
0.079415 +- 0.000209 seconds time elapsed ( +- 0.26% )
i_fastsqrt()
Performance counter stats for './out' (100 runs):
74.05 msec task-clock # 0.996 CPUs utilized ( +- 0.31% )
0 context-switches # 0.000 /sec
0 cpu-migrations # 0.000 /sec
51 page-faults # 688.735 /sec ( +- 0.13% )
2,5955,4261 cycles # 3.505 GHz ( +- 0.06% ) (83.70%)
1,5273,2557 stalled-cycles-frontend # 58.84% frontend cycles idle ( +- 0.06% ) (83.69%)
9279,8175 stalled-cycles-backend # 35.75% backend cycles idle ( +- 0.12% ) (67.39%)
2,5141,9842 指令 # 0.97 insn per cycle
# 0.61 stalled cycles per insn ( +- 0.03% ) (83.69%)
3513,1310 branches # 474.434 M/sec ( +- 0.02% ) (83.69%)
2264 branch-misses # 0.01% of all branches ( +- 6.87% ) (81.53%)
0.074353 +- 0.000227 seconds time elapsed ( +- 0.31% )
觀察結果可以發現, i_fastsqrt()
用了 259554261 個 cycles , cpu clock time 是 74.05 msec ,而 i_sqrt()
則用了 276985996 個 cycles , cpu clock time 是 79.11 msec ,是 fast_sqrt()
更快,比較指令數目的話, i_fastsqrt()
只使用了 251419842 道指令 ,明顯少於 i_sqrt()
使用的 347711663 道指令 ,少了幾乎 100000000 道指令。不管從平均執行時間,或者是 cpu cycles 數量甚至到指令數量,我實作的 i_fastsqrt()
都少於 i_sqrt()
的實作,證明我的實作更快速,而且我的實作還是 branchless 的實作,可以觀察使用到的 branches 數量跟 branch-misses 都是遠小於 i_sqrt()
。
我希望嘗試貢獻這段程式碼實作到 Linux 核心,但不知道該如何將實驗結果表示在 commit message 當中比較妥當。
做圖到 100000000 的結果,依然幾乎貼合
直接與 Linux 核心的 int_sqrt
比較,測試程式碼如下
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <math.h>
#include <time.h>
unsigned long int_sqrt(unsigned long x)
{
unsigned long b, m, y = 0;
if (x <= 1)
return x;
m = 1UL << ((31 - __builtin_clz(x)) & ~1UL);
while (m != 0) {
b = y + m;
y >>= 1;
if (x >= b) {
x -= b;
y += m;
}
m >>= 2;
}
return y;
}
int main() {
for (int i = 1; i < 1000000; i++)
int_sqrt(i);
return 0;
}
利用 perf stat
測試,重複次數為 100
$ sudo perf stat --repeat 100 ./out
Performance counter stats for './out' (100 runs):
35.37 msec task-clock # 0.992 CPUs utilized ( +- 0.62% )
0 context-switches # 0.000 /sec
0 cpu-migrations # 0.000 /sec
49 page-faults # 1.385 K/sec ( +- 0.14% )
1,2181,3348 cycles # 3.444 GHz ( +- 0.08% ) (76.56%)
6997,1269 stalled-cycles-frontend # 57.44% frontend cycles idle ( +- 0.09% ) (76.69%)
559,1291 stalled-cycles-backend # 4.59% backend cycles idle ( +- 0.20% ) (76.56%)
1,6095,3665 指令 # 1.32 insn per cycle
# 0.43 stalled cycles per insn ( +- 0.06% ) (88.28%)
2551,2990 branches # 721.380 M/sec ( +- 0.04% ) (88.28%)
1,0616 branch-misses # 0.04% of all branches ( +- 2.25% ) (81.91%)
0.035667 +- 0.000220 seconds time elapsed ( +- 0.62% )
然後測試我寫的版本
unsigned long i_fastsqrt(unsigned long x)
{
unsigned long y = ((31 - __builtin_clzll(x | 1)) >> 1);
unsigned long z = ((1 << y) + (x >> y)) >> 1;
z = (z + (x / z)) >> 1;
z = (z + (x / z)) >> 1;
return z;
}
int main() {
for (int i = 1; i < 1000000; i++)
i_fastsqrt(i);
return 0;
}
再用 perf stat
重複測試 100 次
$ sudo perf stat --repeat 100 ./out
Performance counter stats for './out' (100 runs):
33.96 msec task-clock # 0.991 CPUs utilized ( +- 0.67% )
0 context-switches # 0.000 /sec
0 cpu-migrations # 0.000 /sec
49 page-faults # 1.443 K/sec ( +- 0.14% )
1,1645,7487 cycles # 3.430 GHz ( +- 0.10% ) (78.43%)
7323,1953 stalled-cycles-frontend # 62.88% frontend cycles idle ( +- 0.10% ) (78.40%)
2464,6153 stalled-cycles-backend # 21.16% backend cycles idle ( +- 0.14% ) (69.80%)
5621,0086 指令 # 0.48 insn per cycle
# 1.30 stalled cycles per insn ( +- 0.05% ) (89.21%)
321,0409 branches # 94.543 M/sec ( +- 0.03% ) (89.20%)
2407 branch-misses # 0.07% of all branches ( +- 7.27% ) (84.16%)
0.034267 +- 0.000229 seconds time elapsed ( +- 0.67% )
可以發現不管是 task-clock 、 cycles 、指令、 branches 與 branch-misses 都是我撰寫的版本優於 int_sqrt()
。
改寫 x / z
的操作我參閱了數種方法、不同的逼近方式等尚未找到能取代 division 操作的演算法,由於使用牛頓法至少必須逼近三次,每次 z
未必會是 2 的指數次方因此無法直接用 shift 替代。
提交 patch 後發現我的實作 precision 上也有問題,原本的 int_sqrt()
和我實作的 i_fastsqrt()
利用 gnuplot 做圖看似幾乎貼合,但實際上如果逐個答案和 sqrt()
的整數部分進行誤差比較,我的實作誤差比 int_sqrt()
大很多,並且我的實作有用到除法這對於某些沒有除法指令的架構是不可行的,應該重新思考一個方法。
不運用 /
和 %
來求得除以 10 的商與餘數的方法,參考我在第二週做的筆記 Integer Division by Constant 和 Hacker's Delight ,除以 10 相同於乘以 x/10
相當於 x * 1/10
且近似於 x * 13 / 128
,可以進一步拆解成 x * 13 / 8 / 16
。
x * 13 / 8
則可以透過 (x >> 3) + (x >> 1) + x
得到,而進行 right shift 的時候會遺失最低 3 個位元,所以預先保存最低 3 個位元最後才加回去,得到 x * 13
後再除以 128 ,如下
unsigned d0 = x & 0b1;
unsigned d1 = x & 0b11;
unsigned d2 = x & 0b111;
unsigned q = ((x >> 3) + (x >> 1) + x) << 3;
q = q + d0 + d1 + d2; /* restore last 3 bits */
q >>= 7; /* divide by 128 */
最後餘數再由餘數定理
unsigned r = x - (((q << 2) + q) << 1);
完整函式如下
void divmod_10(uint32_t in, uint32_t *div, uint32_t *mod)
{
unsigned d0 = x & 0b1;
unsigned d1 = x & 0b11;
unsigned d2 = x & 0b111;
*div = ((((in >> 3) + (in >> 1) + in) << 3) + d0 + d1 + d2) >> 7;
*mod = in - (((*div << 2) + *div) << 1);
}
最後包裝成題目的程式碼如下
void divmod_10(uint32_t in, uint32_t *div, uint32_t *mod)
{
uint32_t x = (in | 1) - (in >> 2); /* div = in/10 ==> div = 0.75*in/8 */
uint32_t q = (x >> 4) + x;
x = q;
q = (q >> 8) + x;
q = (q >> 8) + x;
q = (q >> 8) + x;
q = (q >> 8) + x;
*div = (q >> 3);
*mod = in - ((q & ~0x7) + (*div << 1));
}
至於是怎麼包裝的呢?原本的程式碼實作跟最後題目給出的函式實作看似毫無關聯,事實上關聯性確實很有限,一開始的 x = (in | 1) - (in >> 2)
是在計算 x * 3/4
,並且若 x
為偶數則加一(不明白原因,尚需探討),接著 q = (x >> 4) + x
相同於計算 q = (q >> 8) + x
來進行逼近。
最後 q >> 3
相當於 q & ~0x7
是在保留 q
除了最後三個 bits 以外的位元值,用意和 *div << 3
相同,因此 (q & ~0x7) + (*div << 1)
就是在計算 *div * 10
。
透過 lscpu
命令確認運行電腦的處理器版本,我所使用的是 Intel i7 在 Instruction tables 當中對應到第 206 頁的 Intel Nehalem 。 首先實作使用 /, %
的實作
static void divmod10(int in, int *div, int *mod)
{
*div = in / 10;
*mod = tmp % 10;
}
透過以下命令編譯並透過 objdump
觀察對應 assembly instruction (僅列出 divmod10()
函式對應指令)
$ gcc -O0 -o divmod divmod.c -lm -lc
$ objdump -Slz divmod
...
0000000000001149 <divmod10>:
divmod10():
1149: f3 0f 1e fa endbr64
114d: 55 push %rbp
114e: 48 89 e5 mov %rsp,%rbp
1151: 89 7d fc mov %edi,-0x4(%rbp)
1154: 48 89 75 f0 mov %rsi,-0x10(%rbp)
1158: 48 89 55 e8 mov %rdx,-0x18(%rbp)
115c: 8b 45 fc mov -0x4(%rbp),%eax
115f: 48 63 d0 movslq %eax,%rdx
1162: 48 69 d2 67 66 66 66 imul $0x66666667,%rdx,%rdx
1169: 48 c1 ea 20 shr $0x20,%rdx
116d: c1 fa 02 sar $0x2,%edx
1170: c1 f8 1f sar $0x1f,%eax
1173: 29 c2 sub %eax,%edx
1175: 48 8b 45 f0 mov -0x10(%rbp),%rax
1179: 89 10 mov %edx,(%rax)
117b: 8b 4d fc mov -0x4(%rbp),%ecx
117e: 48 63 c1 movslq %ecx,%rax
1181: 48 69 c0 67 66 66 66 imul $0x66666667,%rax,%rax
1188: 48 c1 e8 20 shr $0x20,%rax
118c: c1 f8 02 sar $0x2,%eax
118f: 89 ce mov %ecx,%esi
1191: c1 fe 1f sar $0x1f,%esi
1194: 29 f0 sub %esi,%eax
1196: 89 c2 mov %eax,%edx
1198: 89 d0 mov %edx,%eax
119a: c1 e0 02 shl $0x2,%eax
119d: 01 d0 add %edx,%eax
119f: 01 c0 add %eax,%eax
11a1: 29 c1 sub %eax,%ecx
11a3: 89 ca mov %ecx,%edx
11a5: 48 8b 45 e8 mov -0x18(%rbp),%rax
11a9: 89 10 mov %edx,(%rax)
11ab: 90 nop
11ac: 5d pop %rbp
11ad: c3 ret
...
總共有 14 個 mov
(register to register) 共用了 14 個 cpu cycles , push
(register) 一個用了 3 個 cpu cycles ,兩個 imul
用了 6 個 cpu cycles ,兩個 shr
使用 2 個 cpu cycles , 4 個 sar
用了 4 個 cycles , 三個 sub
共用 3 個 cycles , 一個 shl
用了 1 個 cycles ,總共用了 33 個 cycles 。
改為題目的實作並以相同方法測試
...
0000000000001149 <divmod10>:
divmod10():
1149: f3 0f 1e fa endbr64
114d: 55 push %rbp
114e: 48 89 e5 mov %rsp,%rbp
1151: 89 7d ec mov %edi,-0x14(%rbp)
1154: 48 89 75 e0 mov %rsi,-0x20(%rbp)
1158: 48 89 55 d8 mov %rdx,-0x28(%rbp)
115c: 8b 45 ec mov -0x14(%rbp),%eax
115f: 83 c8 01 or $0x1,%eax
1162: 89 c2 mov %eax,%edx
1164: 8b 45 ec mov -0x14(%rbp),%eax
1167: c1 e8 02 shr $0x2,%eax
116a: 89 c1 mov %eax,%ecx
116c: 89 d0 mov %edx,%eax
116e: 29 c8 sub %ecx,%eax
1170: 89 45 f8 mov %eax,-0x8(%rbp)
1173: 8b 45 f8 mov -0x8(%rbp),%eax
1176: c1 e8 04 shr $0x4,%eax
1179: 89 c2 mov %eax,%edx
117b: 8b 45 f8 mov -0x8(%rbp),%eax
117e: 01 d0 add %edx,%eax
1180: 89 45 fc mov %eax,-0x4(%rbp)
1183: 8b 45 fc mov -0x4(%rbp),%eax
1186: 89 45 f8 mov %eax,-0x8(%rbp)
1189: 8b 45 fc mov -0x4(%rbp),%eax
118c: c1 e8 08 shr $0x8,%eax
118f: 89 c2 mov %eax,%edx
1191: 8b 45 f8 mov -0x8(%rbp),%eax
1194: 01 d0 add %edx,%eax
1196: 89 45 fc mov %eax,-0x4(%rbp)
1199: 8b 45 fc mov -0x4(%rbp),%eax
119c: c1 e8 08 shr $0x8,%eax
119f: 89 c2 mov %eax,%edx
11a1: 8b 45 f8 mov -0x8(%rbp),%eax
11a4: 01 d0 add %edx,%eax
11a6: 89 45 fc mov %eax,-0x4(%rbp)
11a9: 8b 45 fc mov -0x4(%rbp),%eax
11ac: c1 e8 08 shr $0x8,%eax
11af: 89 c2 mov %eax,%edx
11b1: 8b 45 f8 mov -0x8(%rbp),%eax
11b4: 01 d0 add %edx,%eax
11b6: 89 45 fc mov %eax,-0x4(%rbp)
11b9: 8b 45 fc mov -0x4(%rbp),%eax
11bc: c1 e8 08 shr $0x8,%eax
11bf: 89 c2 mov %eax,%edx
11c1: 8b 45 f8 mov -0x8(%rbp),%eax
11c4: 01 d0 add %edx,%eax
11c6: 89 45 fc mov %eax,-0x4(%rbp)
11c9: 8b 45 fc mov -0x4(%rbp),%eax
11cc: c1 e8 03 shr $0x3,%eax
11cf: 89 c2 mov %eax,%edx
11d1: 48 8b 45 e0 mov -0x20(%rbp),%rax
11d5: 89 10 mov %edx,(%rax)
11d7: 8b 45 fc mov -0x4(%rbp),%eax
11da: 83 e0 f8 and $0xfffffff8,%eax
11dd: 89 c2 mov %eax,%edx
11df: 48 8b 45 e0 mov -0x20(%rbp),%rax
11e3: 8b 00 mov (%rax),%eax
11e5: 01 c0 add %eax,%eax
11e7: 8d 0c 02 lea (%rdx,%rax,1),%ecx
11ea: 8b 45 ec mov -0x14(%rbp),%eax
11ed: 29 c8 sub %ecx,%eax
11ef: 89 c2 mov %eax,%edx
11f1: 48 8b 45 d8 mov -0x28(%rbp),%rax
11f5: 89 10 mov %edx,(%rax)
11f7: 90 nop
11f8: 5d pop %rbp
11f9: c3 ret
...
共使用了 44 個 mov
與其他若干指令,佔用的 cpu cycles 明顯多於原本使用 /
和 %
的版本。
用 perf stat
分析,使用 /, %
的實作共用了 122,3235 cpu cycles ,不使用的版本用了 122,3024 個 cpu cycles ,沒有明顯差異。
CPU 週期數量「沒有明顯差異」,就說明此舉的效益,當指令集受限時,勢必要調整實作手法,而且看似指令數量增加,但避開較長週期的特定指令,有機會在亂序 (out-of-order) 執行的處理器上,爭取到更高的 IPC。
jservImage Not Showing Possible ReasonsLearn More →
- The image file may be corrupted
- The server hosting the image is unavailable
- The image path is incorrect
- The image format is not supported
此處讓我很好奇,如果使用使用 /, %
的實作反而更好或沒有明顯差異,為何我們要採取 shifting 的策略而避免使用 /, %
?編譯器是否對 /, %
等指令做了優化的行為?又或者硬體架構上除法指令集本來就設計為使用 shift ,而我撰寫的程式反而讓它多了幾道指令?尚需了解編譯器優化行為還有檢討我的實驗方法。
考慮到 RISC-V 一類的處理器,其主體的 ISA (如 RV32I 和 RV64) 甚至沒有乘法指令,上述的調整就是必然。
jservImage Not Showing Possible ReasonsLearn More →
- The image file may be corrupted
- The server hosting the image is unavailable
- The image path is incorrect
- The image format is not supported
/, %
參照自 Hacker's Delight 。
原本的方法是利用二分搜尋,輸入是 32 位元長的資料型態最大可能到 i
是否大於 i
是否大於
可以發現 ilog2(n)
就是在計算 n
從 msb 數來第一個 set bit 的位置 __builtin_clz(n)
先計算 n
的 leading zeroes 接著用 31 減掉它也就是從 msb 數來第一個 set bit 的位置。
在 include/linux/log2.h 可以找到數個和題目程式碼類似的運算,包含 __ilog2_u32(u32 n), const_ilog2(n), ilog2(n)
等巨集與函式。
Moving average 是對一系列資料,有數種變形。最簡單的便是 simple moving average ,把指定時間區間內的資料加總並取平均如下
如果希望減少時間較久以前的資料,可以利用 Exponential smoothing 也就是題目中提到的方法,他利用 exponential window function 來對時間序列的資料做 smoothing 。
基本形式如下,假設時間 t 的資料是
其中
對應到題目的程式碼, avg
是用來存取平均數的結構體, internal
是目前的 ewma ,值得注意的是 internal
是 unsigned long
的資料型態,但平均數應該會是浮點數,因此我們可以確認此程式碼利用定點數運算, factor
正是這裡定點數設計的 scaling factor ,我們可以從讀取也就是 ewma_read
來確認,每次要讀取 avg->internal
也就是目前資料的 ewma 值時都會先右移 avg->factor
個位元,因此讀取到的是 ewma 的整數部分。
另外寫入時使用 ewma_add
,若目前 avg
結構當中還沒有資料,則將新加入的 val
左移 avg->factor
個位元再存入 avg->internal
中,和 avg->internal
不為 0 時,更新 avg->internal
的方式居然是 (((avg->internal << avg->weight) - avg->internal) + (val << avg->factor)) >> avg->weight
,如果把 avg->weight
理解為 avg->internal - avg->weight * avg->internal
這樣才符合
原來此結構中的 ewma_add
當中的運算是先將 avg->weight
個位元的原因。
在 include/linux/average.h 定義了 DECLARE_EWMA(name, _precision, _weight_rcp)
此巨集,會依據給定的 name
給開發者一個 struct ewma_name
結構體和 init
, read
, add
三個函式。此處利用定點數運算 fractional bits 有 _precision
個, 而 ewma 中的 1/_weight_rcp
表示, _weight_rcp
必須是二的指數次方。此巨集當中的函式實作方式和我們題目當中非常類似,不過此巨集的結構體只儲存 ewma 值, smoothing factor 會在每次呼叫 ewma_name_add
時利用 ilog2()
重新計算,我們可以評估是否在 ewma_name_init
處就把 weight_rcp
當成一個成員儲存起來,如此一來就省去每次呼叫 ilog2()
的成本。
而 drivers/net/wireless/ralink/rt2x00/rt2x00.h 當中則是透過 DECLARE_EWMA(rssi, 10, 8)
定義一個結構體 struct ewma_rssi
,定點數的 fractional bits 是 10,smoothing factor 則是 struct link_ant
結構體中的成員 struct ewma_rssi rssi_ant
就利用 ewma 來算 rssi 的 walking average。
drivers/net/wireless/ralink/rt2x00/rt2x00.c 當中定義的函式 rt2x00link_get_avg_rssi
就是 ewma_rssi_read
的包裝。
rt2x00 核心模組當中多處皆運用到 ewma 的運算來更新無線網路的 RSSI。
int ceil_ilog2(uint32_t x)
{
uint32_t r, shift;
x--;
r = (x > 0xFFFF) << 4;
x >>= r;
shift = (x > 0xFF) << 3;
x >>= shift;
r |= shift;
shift = (x > 0xF) << 2;
x >>= shift;
r |= shift;
shift = (x > 0x3) << 1;
x >>= shift;
return (r | shift | x > 1) + 1;
}
r
用來記錄 x
從 msb 數來第一個 set bit 由右數來的位置,和 ilog2()
記錄一樣的數值,但 ilog2
是計算 floor of log2 ,同時因為 r
和 shift
一定是 2 的指數因此加法可以利用 bitwise or |
進行 , r | shift
等效於 r + shift
,最後還要判斷 x > 1
是因為判斷 x > 0x3
時若 x == 0x2, x == 0x3
則 shift 依舊會是 0 ,會少算 1 ,因此才要多判斷 x > 1
。
一開始進行 x--
是為了處理 x
剛好為 2 的指數次方時的狀況,如果 x
剛好為 2 的指數,沒有在一開始減一的話最後輸出會因為最後的 +1
而比正確答案多一。
由於一開始 x--
,使得 x=0
輸入函式的話經過 x--
後會變成 0xFFFFFFFF
,最簡單的改進便是在將 x--
變為 x -= !!x
,也就是在 x > 0
才進行減法。
int ceil_ilog2(uint32_t x)
{
uint32_t r, shift;
- x--;
+ x -= !!x;
如此一來便能在 branchless 情況下處理 x = 0
的狀況。
TODO
int totalHammingDistance(int* nums, int numsSize)
{
int total = 0;;
for (int i = 0;i < numsSize;i++)
for (int j = 0; j < numsSize;j++)
total += __builtin_popcount(nums[i] ^ nums[j]);
return total >> 1;
}
閱讀 Population Count 和 Hacker's Delight 。
首先假設以二進位表示的話數字
重新排列後可以推得以下公式
題目的程式實作利用
n = (v >> 1) & 0x77777777
即是在計算
因此程式碼前六行即是在計算
此時 v
當中每四個位元儲存的數字大小即是原本 v
當中每四個位元的 population count ,目前的 v
可以表達為 v
當中每四個 bits 的 population count 。 v = (v + (v >> 4)) & 0x0F0F0F0F
即是將
0 (B7+B6) 0 (B5+B4) 0 (B3+B2) 0 (B1+B0)
最後 v *= 0x01010101
即是在第四個區塊做 v >> 24
。
最後是 Hamming Distance 的定義與運用 population count 計算 Hamming Distance 的方法。
在針對 Leetcode 477. Total Hamming Distance 的程式碼當中,因為給定程式碼的做法會將每兩個數字的 hamming distance 重複算兩遍,因此加總的最後要除以 2 ,所以向右位移 1 位元即可。
如果直接把題目程式碼丟進 leetcode ,結果會是 TLE ,要將迴圈順序稍微改變如下
int totalHammingDistance(int* nums, int numsSize) {
int total = 0;;
for (int i = 0;i < numsSize;i++)
- for (int j = 0; j < numsSize;j++)
+ for (int j = i+1; j < numsSize;j++)
total += popcount_branchless(nums[i] ^ nums[j]);
return total;
}
我透過每兩位元 (nibble) 相加可得該二位元的 population count 原理重複計算,程式如下
unsigned popcount_v2(unsigned v)
{
v = v - ((v >> 1) & 0x55555555);
v = (v & 0x33333333) + ((v >> 2) & 0x33333333);
v = (v + (v >> 4)) & 0x0f0f0f0f;
v = (v + (v >> 8)) & 0x00ff00ff;
v = (v + (v >> 16)) & 0x0000ffff;
return v;
}
透過 perf stat --repeat 100
重複量測 100 次,和題目原先提供的 popcount_branchless
相比, task-clock 從 487.32 msec 降至 468.83 msec , 耗費的 cycles 數量也從 18,2015,8606 降至 17,5005,2307 。
在 solution 區域看到別人的解法,速度快過 100% submissions ,但原理我尚未理解
int totalHammingDistance(int* nums, int numsSize){
int total_dist = 0;
int bits = 0;
for (int i = 0; i < 32; i++) {
for (int j = 0; j < numsSize; j++) {
bits += (nums[j] >> i) &1;
}
total_dist += bits*(numsSize-bits);
bits = 0;
}
return total_dist;
}
模擬一百萬次棋局,棋盤大小是 3x3 所以最多有 9 種可能的選擇,相較於把下棋看成在九宮格中選擇一格並標記,可以把棋局看成是 8 種可能的路線(三條橫線、三條縱線、兩條對角線),分別對應到 uint32_t
以 16 進位表示的不同位元,第一條橫線為 msb ,第二條橫線為 msb - 1 ,依此類推。若滿足其中一個路線大小為 7 則獲勝,因為化為二進位來看 0x7 即是 0111 ,加 1 後會變成 8 ,因此判斷獲勝與否的函式即是
static inline uint32_t is_win(uint32_t player_board)
{
return (player_board + 0x11111111) & 0x88888888;
}
每一個下棋的選擇都會影響多條路線的狀態,把選擇第 0 格到第 8 格分別會影響的狀態紀錄在 move_masks
當中,例如選擇第 0 格也就是棋盤最左上方的格子,對應到更新的路線會是第一條橫線、第一條縱線、第一條對角線,而第 0 格同時是這些路線最左邊的格子,因此會是 0100 ,對應到該三條路線狀態為 0x400400400
,其他元素也是相同概念。
每一局棋局當中一開始的 available moves
都是九個,透過 xorshift()
產生隨機亂數後再除以 n_moves
以隨機找一個選項下棋,其中找餘數的函式是利用 fastmod()
。函式當中若除以 2 的指數例如 2、4 或 8 ,可以直接和該數減一進行 bitwise AND ,除以三和七則利用函式 mod3(), mod7()
來處理。
觀察 mod7()
函式,其中 0x24924925
是
int remu7(unsigned n) {
static char table [75] = {0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4};
n = (n >> 15) + (n & 0x7FFF);
n = (n >> 9) + (n & 0x001FF);
n = (n >> 6) + (n & 0x0003F);
return table[n];
}
原理是利用 0x24924925
,乘上這個數後許多 high order bits 會被遺棄,最後右移 29 位元得到的數字即是想要的答案。
treeint.c
是一支測試程式,當中利用 struct treeint_ops
此結構體建立 function hook 並呼叫 treeint_xt
當中的函式。treeint_xt.[ch]
包含了五個開放給使用者使用的 API ,它們都是對應 xtree 操作的包裝,會透過呼叫 xtree 的函式來進行對應操作。Xtree,AVL tree 和 red-black tree 皆為 self-balanced binary tree ,它使用一個變數 hint
來判斷是否有子樹不平衡,特別注意當我們建立一個 struct xt_tree
後,該結構實際包含兩個 pointer to function 成員,讓我們自行實作用來建立節點和刪除節點的函式。
xt_destroy()
__xt_destroy()
來將整顆樹刪除xt_first(n)
n
代表的子樹中 preorder traversal 的第一個節點xt_last(n)
n
代表的子樹中 preorder traversal 的最後一個節點xt_rotate_left(n)
n
執行以下操作xt_rotate_right(n)
n
執行以下操作xt_update()
xt_balance()
計算節點 n
左右子節點的 hint
差值,若小於負一則進行 xt_rotate_right
,若大於一則進行 xt_rotate_left
,最後檢查 n
的 hint
值是否改變還有 n
是否變成 leaf node ,若任一者成立則繼續對 n
的親代節點進行 xt_update()
xt_insert()
key
,首先利用 __xt_find()
尋找當前的樹中是否已經存在鍵值為 key
的節點,若存在則不進行插入,若不存在, __xt_find()
的過程也會順便將節點應該擺放的位置給找好 (由 p
和 d
共同紀錄) ,之後利用 __xt_insert()
進行節點的插入,最後透過 xt_update()
檢查樹的平衡狀態並更新。xt_delete()
xt_find()
找出要刪除的節點,注意到 xt_find()
並非呼叫 __xt_find()
而是呼叫 __xt_find2()
,過程當中省去了紀錄親代節點和尋找方向的紀錄。之後利用 __xt_remove()
來進行移除操作,如果要移除的節點 del
具有右子節點,則使用在右子節點代表的子樹中 inorder traversal 第一個找到的節點來替代 del
的位置,若無則找左子節點代表的子樹中 inorder traversal 最後一個找到的節點來替代(此處原本程式碼的註解有誤,在第 327 行應該是 "the last node found in the left subtree." 而非 "the first node found in the left subtree."),如果 del
是 leaf node 則直接移除並對親代節點進行 xt_update()
。修正註解的 commit
所有 BST 不管是 AVL tree, Red-Black tree 還是一般的 binary search tree ,都可以定義出一個遞迴結構也就是說當 root
代表的樹是 BST 時, root->left, root->right
也都會是 BST ,同時可能還有數項定義需要遵守, Xtree 是否也有同樣的遞迴結構?有的話我應該要歸納出完整定義。再者,進行插入、刪除等操作的時候,都應該保持幾項 loop invariant , Xtree 操作的 loop invariant 是什麼?
了解 Xtree 各項操作與定義的同時,我發現幾個可以改進的地方。
進行 xt_insert()
後會嘗試進行 xt_update()
,由於 binary search tree 的特性,新插入的節點在 xt_update()
之前一定是 leaf node ,也就是呼叫 __xt_insert()
一定會導向 n->hint == 0
接著對其親代節點進行 xt_update()
,我們可以直接對親代節點做 xt_update()
以省去多餘函式呼叫與檢查時間 ( leaf node 檢查 balance factor 一定平衡) 。
xt_parent(n) = p;
- xt_update(root, n);
+ xt_update(root, p);
再來發現一個問題,每當插入的節點 n
使得某個子樹結構如下時,會進行旋轉但旋轉後的高度差其實並沒有改變。
此時只要用以下方式新增節點 f
和 g
就能不斷重構上述的子樹結構
在 g
插入的之後會進行旋轉,旋轉後的 xt_update
會停在節點 e
於是這個 Xtree 結構會一直傾斜下去。舉個例子,如果插入節點的順序是 [1000, 500, 300, 0, 400, 350, 450, 425, 475] ,最後產生的 Xtree 會長的像下圖
n->hint
代表的是該節點到最遠 leaf node 的邊數, Xtree 希望保持的是左右子節點的 hint
差距不超過 1 ,藉此保證左右子數高度的平衡,但在上述例子當中,由於進行旋轉之後 500 的 hint
依然保持 2 ,所以不會繼續往上更新親代節點的 hint
也就無從得知旋轉後的樹依然是不平衡。事實上解決方法也不該繼續往上更新,如此一來會造成無窮次數的旋轉因為怎麼左右轉都是不平衡的,原因出在造成不平衡的來源是 n, g
節點的子樹,但旋轉後 n, g
被放入另一邊子樹下面,所以整棵樹依舊不平衡,必須先檢查造成不平衡的原因何在。
解決方法像 AVL tree 一樣將不平衡的類型分類成 RR, RL, LR, LL 四種,如果是 RL 或 LR 則要進行兩次旋轉,例如 RL 要先轉成 RR 然後再轉一次。此處只需要在發現不平衡時再子節點得兩個子節點的 hint
值何者較大即可判斷。將程式碼修改為以下
if (b < -1) {
/* leaning to the right */
+ if (xt_balance(xt_right(n)) > 0)
+ xt_rotate_left(xt_right(n));
if (n == *root)
*root = xt_right(n);
xt_rotate_right(n);
}
else if (b > 1) {
/* leaning to the left */
+ if (xt_balance(xt_left(n)) < 0)
+ xt_rotate_right(xt_left(n));
if (n == *root)
*root = xt_left(n);
xt_rotate_left(n);
}
如此一來即可判斷傾斜狀況是何種,例如若 b < -1
代表右傾,則進一步檢查 xt_right(n)
是左子樹較高還是右子樹,若是左子樹則先對 xt_right(n)
進行左旋使得右子樹較高然後對 n
進行右旋,左傾也是同樣的邏輯。
進行以上變更後,比較變更前後插入 [1000, 500, 300, 0, 400, 350, 450, 425, 475, 460] 建立的 Xtree 搜尋節點 460 的平均時間(各執行十次取平均)。
變更前 : 99.4
變更後 : 69.2
可以看到改動前後對於距離 root 最遠節點的搜尋時間大幅下降。
不過比較循序輸入與亂序輸入時, root
節點的 hint
是否正確記錄樹高時,可以發現循序輸入的 hint
可以正確反映樹高,亂序卻沒辦法。此處代表實作尚有需要改進的地方,又或者這不是錯誤,而是我該明確定義 Xtree 的平衡性質。
$ ./build/treeint 100 0
root hint : 6
xtree height 6
$ ./build/treeint 100 3
root hint : 5
xtree height 7
和 AVL tree 或 Red-Black Tree 進行比較之前,我們需要先搞清楚這些二元搜尋樹跟一般的搜尋樹有何不同,二元搜尋樹重要的是搜尋時間,如何最小化搜索時間會是關鍵,我們知道按照 General BST 的建構方式,有可能建構出一棵非常傾斜的樹而使搜尋時間跟在一個一維陣列當中搜索一樣,最好的情況則是建立一棵 complete binary tree ,在 complete binary tree 當中第
如果給定的元素集合為
同時我們需要考慮到搜索失敗的可能,假設每個 leaf node 都另外延伸出兩個子節點當作 failure node 也就是搜索失敗會碰到的節點,則我們會有
OBST 的定義是能使得
證明 external node 數量是 internal node + 1
假設二元樹當中總共有
Balanced BST 則是保證此 BST 的 worst case search time complexity 與 average case search time complexity 都保持在
AVL tree 的定義是樹
保證左右子樹的高度差距不超過 1 ,這點和 Xtree 相同,不同的是 AVL tree 會在每個節點當中維護一個成員 bf
稱為 balanced factor ,用來記錄當前左右子樹的高度差, Xtree 紀錄的 hint
理論上是節點距離最遠 leaf node 的邊數,只要能保證紀錄的值正確(思考如何證明,數學歸納法或許可行),函式 xt_balance
就能計算出如同 AVL tree 當中的 bf
值。 AVL tree 保證每次 insertion 所做的旋轉次數頂多 2 次, Xtree 能否證明類似性質?
實作 AVL tree 的 insertion 並比較循序與亂序輸入情況下, AVL tree 和 Xtree 的樹高差異。 AVL tree 實作使用 AVL Tree Program in C 當中的程式,不過他的實作在節點數量多於 1000 時會因為過多遞迴呼叫造成 segmentation fault ,應改為非遞迴實作。以下測試 100 個節點循序插入與亂序插入
$ ./build/treeint 100 0
xtree height 6
avl tree height 6
$ ./build/treeint 100 5
xtree height 8
avl tree height 6
可以觀察到 Xtree 的實作在亂序輸入的時候樹高會達到 8 , AVL tree 樹高僅有 6 符合平衡樹樹高
Red-black tree
紅黑樹遵守五個基本定義
參考 Red-Black Trees 內容所述,若把紅黑樹當中所有紅色節點抽離,我們可以得到一棵所有 leaf node 都在同一層的樹,間接證明了紅黑樹的 black-height 是
Xtree 能否推導證明修正後能保照 Xtree 性質被維護?
除了上述性質外,針對 Linux 核心的場景,要特別考慮到 Augmenting Red Black Trees,你常聽到的 AR/VR 其全銜的 Augmenting,同一個字。
jservImage Not Showing Possible ReasonsLearn More →
- The image file may be corrupted
- The server hosting the image is unavailable
- The image path is incorrect
- The image format is not supported
閱讀 Augmenting Red Black Trees , Augmenting Data Structure 是在闡述如何擴增原本的資料結構,例如增加成員或函式,使得該資料結構能夠在不改變原本操作的時間複雜度情況下,解決原本解決不了的問題。
以紅黑樹為例,如果要在紅黑樹中找到第 k 大的元素,原本的紅黑樹必須使用 in-order traversal 找到第 k 個存取的節點, worst case time complexity 會是 k == left.size + 1
則代表該節點即是第 k 大的元素,如果小於該數值則往左子樹尋找第 k 大元素,否則往右子樹尋找第 k - left.size + 1
大的元素。
Linux 核心 紅黑樹實作可以參考以上四份文件,雖然我覺得程式碼十分混亂例如 rbtree_augmented.h 重複定義了數個在 rbtree.h 就定義的巨集。嘗試引入此紅黑樹實作並和 Xtree 進行比較。
首先定義自己的結構體並把 struct rb_node
嵌入其中
struct rbtree_node {
struct rb_node node;
int value;
};
struct rbtree_head {
struct rb_root root;
};
利用 /include/linux/rbtree.h 開放的函式 rb_find_add()
與 rb_find()
分別實作 insertion 和 find 函式。
較為困難的地方在於實作 remove 函式,需要利用到 rb_erase()
,此函式事實上包含了 __rb_erase_augmented()
,可以注意到在使用 __rb_insert(), __rb_erase_augmented(), __rb_erase_color()
利用到了 dummy_callbacks
,這是因為在一般紅黑樹 (非 augmented red-black tree) 當中不需要自行實作 propagate, copy, rotate
等函式。
比較 Linux 核心 紅黑樹在亂序輸入與循序輸入的插入、搜尋與移除時間,對比 Xtree 如以下
$ ./build/treeint 1000 0
Red-Black Tree
Average insertion time : 122.658000
Average find time : 46.012000
Average remove time : 43.772000
XTree
Average insertion time : 247.713000
Average find time : 124.979000
Average remove time : 144.251000
$ ./build/treeint 1000 10
Red-Black Tree
Average insertion time : 132.087000
Average find time : 56.848000
Average remove time : 52.963000
XTree
Average insertion time : 242.538000
Average find time : 134.666000
Average remove time : 176.729000
可以發現不管是循序輸入還是亂序輸入, Linux 核心版本的紅黑樹平均執行不同操作的時間都遠小於 Xtree 。這裡讓我很好奇, Xtree 的實作應該是比較貼近 AVL tree 的,在插入時間上比紅黑樹長我認為不足為奇,但在搜尋與移除的操作上都是紅黑樹表現更為優秀,這令我思考是 Xtree 維護樹高的操作不正確亦或者是 Linux 核心的紅黑樹設計上有何特別之處? (此處討論的都是沒有 augmented 的紅黑樹)。