Try   HackMD

2024q1 Homework4 (quiz3+4)

contributed by < vax-r >

第三週測驗

測驗一

測驗中程式碼利用 digit-by-digit calculation 做為基礎來運算 x 的平方根。
基本原理很單純,透過

(x+y)2=x2+2xy+y2 並隨著項次層層拆解,以下假設
x=N2
N
即是我們想求的答案,先透過二進位表示法將
N
表示為
N=i=0nai

其中

ai=2i 或 0 ,我們要做的就是從
an
a0
逐項找出
ai
2i
還是 0 。首先預設初始值
an=2n,  (2n)2N2

接著我們逐步思考。

N2=(i=0nai)2=an2+2an(i=0n1ai)+(i=0n1ai)2=a02+2a0(i=1nai)+(i=1nai)2

在這裡我們可以假設

Pm=i=mnai ,因此上式可以表示為
N2=P02=a02+2a0P1+P12

不斷推展的話可以得到

Pm2=am2+2amPm+1+Pm+12=Pm+12+(2Pm+1+am)am

此處我們再假設

Ym=Pm2Pm+12=(2Pm+1+am)am ,又可以把上式表示為
Pm2=Pm+12+Ym

到這裡其實就可以利用每次利用

Pm+1 推得
Pm
然後計算
Pm2N2
是否成立,來判斷
am=2m
與否,但每次都要進行
Pm2
成本太昂貴,同時我們又發現
N2Pm2
的值若表示為
Xm
,則可以發現
Xm=N2Pm2
Xm+1=N2Pm+12
,因此
XmXm+1=Pm2+Pm+12=Ym
,所以每次想要計算的
N2Pm2
也可以推得以下遞迴式
Xm=Xm+1Ym

透過

Xm<0 成立與否來判斷
Ym
同時也得知
am
的值,最再把
Ym
拆解為
cm=2Pm+1am,dm=(am)2

Ym={cm+dm  ,if  am=2m0  ,if  am=0

在每次迴圈更新

cm,dm 來計算
Ym
進而推得
am

cm1=Pm2m=(Pm+1+am)2m=Pm+12m+am2m={cm2+dm,  if  am=2mcm2dm1=dm4

計算到最後得到的

c1=P020=N 即是答案。

函式中 z 即可對應到

cm ,而 m 即可對應
dm
, b 即是
Ym
x 則是代表前一輪的差值也就是
Xm+1

使用 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;
    }

commit c08e25b

Linux 核心 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

z 做為 index 先在表格當中找到
z
的估算值,假設是 table[z] ,接著利用 table[z]talbe[z+1] 進行線性插值法或二分搜尋。所以一開始的初始估算值非常重要,造成初始估算值的誤差兩大來源分別是

  1. 只考慮 radicand
    z
    的部分位數
  2. 讀取的值大小並非完整的 word

首先思考如何準確決定

q=z 的前
h
個位元,我們希望 lookup table 找到的值介於
(qrh,q+rh)
,論文中證明了如果希望找到
q=z
r
為底的前
h
位元,則我們需要
z
r
為底的
2h
個位元,也就是至少需要讀取
r2h
個 words 。如果是希望獲得
h
digits of convergence 則至少需要
z
h
個位元,以二進位來說需要
hlg(r)1
位元。

Timing Square Root〉則是量測不同開根號實作之間的效能差異,共比較了六種方法

  1. 編譯器原本的 sqrt() 實作 (編譯為 x87 FSQRT opcode)
  2. SSE 的 opcode sqrtss
  3. magic number approximation technique
  4. 利用 SSE opcode rsqrtss 取得
    1x
    的估算值並乘上
    x
  5. 利用方法 4. 並加上 Newton-Raphson iteration 來增加精度。
  6. 方法 5. 的第 13 行展開使得每次迴圈都能處理四個浮點數。

實驗結果是方法四 SSE rsqrtss 乘上 x 的運算時間最短,但平均誤差是 0.0094% ,如果利用方法 5 或 6 會讓運算時間變長,但平均誤差卻可以降到 0% ,並且編譯器內建的 sqrt() 是所有方法當中最慢的。
作者提到他沒想到最快的方法居然是先計算開平方根的倒數再乘上原本的數字,利用 SSE rsqrtss 乘上 x 後再利用牛頓法增加精度,能夠擊敗原先 SSE 的 opcode sqrtss

閱讀完以上素材我有兩個實作的想法

  1. 先對 x 進行 int_sqrt() 得出
    y
    ,再用查表方式取得
    xy2=z
    ,兩者相乘
    yz
    即可逼近
    x
  2. 探討如何在 C 語言使用 SSE rsqrtss 取得
    1x
    ,乘上
    x
    後再使用牛頓法逼近精度。

實作 fast inverse square root 演算法需要用到一個 magic number 0x5f3759df ,對於 IEEE 754 規格的浮點數才能直接使用,轉換成二進位會變成 01011111001101110101100111011111 ,如果採用定點數則 fractional bits 只能用 1 bit ,我認為不是把 magic number 直接轉成二進位,應探討為何 IEEE 754 規格可以推出這個浮點數,我的定點數是否能導出類似的數。

利用

lg(x)=12lg(x) 的概念,搭配 Newton's method 進行逼近,實作如下

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() 整數開平方根做比較,可以發現非常接近,做圖結果如下

Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

利用以下腳本計算兩種不同方法的平均執行時間

#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 的結果,依然幾乎貼合

image

直接與 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 ConstantHacker's Delight ,除以 10 相同於乘以

110 ,因此我們要找到
110
的二進位表示法。 由於 10 不符合
2k±1
的形式因此無法二進位或 digit recurssion 來表示,我們只能盡量以一個非常接近
110
的數字
a2N
來表示 10 的倒數,題目挑選
2N=128,a=13,128139.84
來使用,因此
11013128
,計算 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 */

最後餘數再由餘數定理

r=fgQ 而得。

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 相同於計算

34in+364in=102128in
1021280.79
接近 0.8 也就是
810
,為了縮小誤差連續進行四次 q = (q >> 8) + x 來進行逼近。
最後 q >> 3 相當於
810in/8=in10
,而 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。

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →
jserv

此處讓我很好奇,如果使用使用 /, % 的實作反而更好或沒有明顯差異,為何我們要採取 shifting 的策略而避免使用 /, % ?編譯器是否對 /, % 等指令做了優化的行為?又或者硬體架構上除法指令集本來就設計為使用 shift ,而我撰寫的程式反而讓它多了幾道指令?尚需了解編譯器優化行為還有檢討我的實驗方法。

考慮到 RISC-V 一類的處理器,其主體的 ISA (如 RV32I 和 RV64) 甚至沒有乘法指令,上述的調整就是必然。

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →
jserv

  • i : immediate data
  • r : register
  • mm : 64 bit mmx register
  • xmm : 128 bit xmm register
  • mm/x : mmx or xmm register
  • sr : segment register
  • m : memory
  • m32 : 32-bit memory operand

modulo 9 and modulo 5 without /, %

commit cfb7b8e

參照自 Hacker's Delight

測驗三

原理解釋

原本的方法是利用二分搜尋,輸入是 32 位元長的資料型態最大可能到

2321 ,先判斷 i 是否大於
216
,是的話
lg(i)16
,接著左移 16 位元在判斷是否大於
28
,實際上是判斷 i 是否大於
224
,是的話則把答案再加 8 bits 。
可以發現 ilog2(n) 就是在計算 n 從 msb 數來第一個 set bit 的位置 __builtin_clz(n) 先計算 n 的 leading zeroes 接著用 31 減掉它也就是從 msb 數來第一個 set bit 的位置。

Linux 核心應用案例

include/linux/log2.h 可以找到數個和題目程式碼類似的運算,包含 __ilog2_u32(u32 n), const_ilog2(n), ilog2(n) 等巨集與函式。

測驗四

Moving average 是對一系列資料,有數種變形。最簡單的便是 simple moving average ,把指定時間區間內的資料加總並取平均如下

SMAk=1k(i=nk+1npi)

如果希望減少時間較久以前的資料,可以利用 Exponential smoothing 也就是題目中提到的方法,他利用 exponential window function 來對時間序列的資料做 smoothing 。

基本形式如下,假設時間 t 的資料是

xt ,則經過 exponential smoothing 後的輸出是
st
,初始資料
s0=x0
,公式如下
st=αxt+(1α)st1,  t>0

其中

α 稱為 smoothing factor 。

對應到題目的程式碼, avg 是用來存取平均數的結構體, internal 是目前的 ewma ,值得注意的是 internalunsigned long 的資料型態,但平均數應該會是浮點數,因此我們可以確認此程式碼利用定點數運算, factor 正是這裡定點數設計的 scaling factor ,我們可以從讀取也就是 ewma_read 來確認,每次要讀取 avg->internal 也就是目前資料的 ewma 值時都會先右移 avg->factor 個位元,因此讀取到的是 ewma 的整數部分。

另外寫入時使用 ewma_add ,若目前 avg 結構當中還沒有資料,則將新加入的 val 左移 avg->factor 個位元再存入 avg->internal 中,和

s0=x0 相同。特別的是 avg->internal 不為 0 時,更新 avg->internal 的方式居然是 (((avg->internal << avg->weight) - avg->internal) + (val << avg->factor)) >> avg->weight ,如果把 avg->weight 理解為
α
,不應該是 avg->internal - avg->weight * avg->internal 這樣才符合
(1α)xt1
嗎?
原來此結構中的
α
其實是
12avg>weight
ewma_add 當中的運算是先將
st=αxt+(1α)xt1
化為
2avg>weightst=xt+(2avg>weight1)st1
,算完之後再除上
2avg>weight
也就是程式當中最後右移 avg->weight 個位元的原因。

Linux 核心相關應用案例

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 則是

18 ,在 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。

Received signal strength indicator (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 ,同時因為 rshift 一定是 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 的狀況。

commit ae309e9

Linux 核心應用案例

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 CountHacker's Delight
首先假設以二進位表示的話數字

x=b31b2b1b0 ,以數學式來定義 population count 如下
popcount(x)=n=031bn=n=031(2ni=0n12i)bn

重新排列後可以推得以下公式
popcount(x)=xx2x4x231

題目的程式實作利用
xx2x4x8
每四個位元為單位進行計算。
n = (v >> 1) & 0x77777777 即是在計算
v2

因此程式碼前六行即是在計算
xx2x4x8

此時 v 當中每四個位元儲存的數字大小即是原本 v 當中每四個位元的 population count ,目前的 v 可以表達為
v=i=07Bi16i
Bi
原本 v 當中每四個 bits 的 population count 。 v = (v + (v >> 4)) & 0x0F0F0F0F 即是將
Bi,Bi1
相加所以可得

0 (B7+B6) 0 (B5+B4) 0 (B3+B2) 0 (B1+B0)

最後 v *= 0x01010101 即是在第四個區塊做

i=07Bi ,然後結果會為在第四個區塊中所以右移 24 位元 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

2327 ,從 Hacker's Delight 我們知道利用
8k1(mod  7)
此數學關係式,可以推導出
n  (mod  7)=popcount(n)
,例如書中有給一段使用 lookup table 的程式碼

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];
}

原理是利用

n  (mod  7)=popcount(n) ,不斷的進行 digit sum ,其實每一步都在重複求 modulo 7 ,而我們題目中的程式另外運用了
n  (mod  7)87n  (mod  8)
這個關係式, 為了求得
87n  (mod  8)
我們可以計算
2327
再右移 29 位元 ,由於取 floor 會有誤差 ,題目的程式碼取 ceil 也就是
2327
結果就如同程式碼中的 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 執行以下操作






structs



struct0

p



struct1

n



struct0->struct1





struct2

l



struct1->struct2





struct3

r



struct1->struct3





struct4

ll



struct2->struct4





struct5

lr



struct2->struct5





struct6




struct7

p



struct9

l



struct7->struct9





struct11

ll



struct8

n



struct12

lr



struct8->struct12





struct10

r



struct8->struct10





struct9->struct11





struct9->struct8





  • xt_rotate_right(n)
    對節點 n 執行以下操作






structs



struct0

p



struct1

n



struct0->struct1





struct2

l



struct1->struct2





struct3

r



struct1->struct3





struct4

rl



struct3->struct4





struct5

rr



struct3->struct5





struct6




struct7

p



struct9

r



struct7->struct9





struct11

n



struct12

l



struct11->struct12





struct10

rl



struct11->struct10





struct8

rr



struct9->struct11





struct9->struct8





  • xt_update()
    透過 xt_balance() 計算節點 n 左右子節點的 hint 差值,若小於負一則進行 xt_rotate_right ,若大於一則進行 xt_rotate_left ,最後檢查 nhint 值是否改變還有 n 是否變成 leaf node ,若任一者成立則繼續對 n 的親代節點進行 xt_update()
  • xt_insert()
    嘗試插入一個新節點並且鍵值為 key ,首先利用 __xt_find() 尋找當前的樹中是否已經存在鍵值為 key 的節點,若存在則不進行插入,若不存在, __xt_find() 的過程也會順便將節點應該擺放的位置給找好 (由 pd 共同紀錄) ,之後利用 __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

commit e1e9c68

所有 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 使得某個子樹結構如下時,會進行旋轉但旋轉後的高度差其實並沒有改變。







structs



struct1

a



struct2

b



struct1->struct2





struct3

c



struct1->struct3





struct4

d



struct2->struct4





struct5

e



struct2->struct5





struct6

n



struct5->struct6





struct7




struct8

b



struct9

d



struct8->struct9





struct10

a



struct8->struct10





struct11

e



struct10->struct11





struct12

c



struct10->struct12





struct13

n



struct11->struct13





此時只要用以下方式新增節點 fg 就能不斷重構上述的子樹結構







structs



struct8

b



struct9

d



struct8->struct9





struct10

a



struct8->struct10





struct11

e



struct10->struct11





struct12

c



struct10->struct12





struct14

f



struct11->struct14





struct13

n



struct11->struct13





struct15

g



struct13->struct15





g 插入的之後會進行旋轉,旋轉後的 xt_update 會停在節點 e 於是這個 Xtree 結構會一直傾斜下去。舉個例子,如果插入節點的順序是 [1000, 500, 300, 0, 400, 350, 450, 425, 475] ,最後產生的 Xtree 會長的像下圖







structs



struct0

300



struct1

0



struct0->struct1





struct2

400



struct0->struct2





struct3

350



struct2->struct3





struct4

500



struct2->struct4





struct5

450



struct4->struct5





struct6

1000



struct4->struct6





struct7

425



struct5->struct7





struct8

475



struct5->struct8





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 最遠節點的搜尋時間大幅下降。

commit 5fbe5e4

不過比較循序輸入與亂序輸入時, root 節點的 hint 是否正確記錄樹高時,可以發現循序輸入的 hint 可以正確反映樹高,亂序卻沒辦法。此處代表實作尚有需要改進的地方,又或者這不是錯誤,而是我該明確定義 Xtree 的平衡性質。

$ ./build/treeint 100 0
root hint : 6
xtree height 6
$ ./build/treeint 100 3
root hint : 5
xtree height 7

Compare with AVL tree and Red-Black Tree

和 AVL tree 或 Red-Black Tree 進行比較之前,我們需要先搞清楚這些二元搜尋樹跟一般的搜尋樹有何不同,二元搜尋樹重要的是搜尋時間,如何最小化搜索時間會是關鍵,我們知道按照 General BST 的建構方式,有可能建構出一棵非常傾斜的樹而使搜尋時間跟在一個一維陣列當中搜索一樣,最好的情況則是建立一棵 complete binary tree ,在 complete binary tree 當中第

i 個節點會位在第
lg(i)
層,也就是從 root 到第
i
個節點共有
lg(i)
條邊,總共會有
i=1nlg(i)=O(nlg(n))
條邊。
如果給定的元素集合為
a1,a2,a3,,an
a1<a2<<an
,則對於此集合來說可以不同的插入順序可以建構出許多不同的二元搜尋樹,所謂的 Optimal Binary Search Tree 定義如下,如果搜索元素
ai
的機率為
pi
,則搜索某顆 BST 的代價會是
i=1npilevel(ai)

同時我們需要考慮到搜索失敗的可能,假設每個 leaf node 都另外延伸出兩個子節點當作 failure node 也就是搜索失敗會碰到的節點,則我們會有
n+1
個 failure node ,假設搜索到
fi
的機率是
qi
則搜索失敗的機率是
i=0nqi(level(fi)1)

OBST 的定義是能使得
i=1npilevel(ai)+i=0nqi(level(fi)1)
最小化的二元搜尋樹,同時滿足
i=1npi+i=0nqi=1

證明 external node 數量是 internal node + 1
假設二元樹當中總共有

n 個節點, external node 有
e
個, internal node 有
i
個,則
n=i+e
且因為每個 internal node 都有兩條邊所有邊數
E=2i
E=V1
所以
2i=V1
得到
V=2i+1=i+e
所以
i+1=e
得證。

Balanced BST 則是保證此 BST 的 worst case search time complexity 與 average case search time complexity 都保持在

O(log(n))

AVL tree 的定義是樹

T 若為 height-balanced 若且唯若

  1. TL,TR
    皆為 height-balanced
  2. |hLhR|1

保證左右子樹的高度差距不超過 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 符合平衡樹樹高

lg(n),但 Xtree 不符合,雖然沒有嚴謹符合
lg(n)
的樹高,但也比 Red-Black tree 的最糟情況
2lg(n)
還少。

Red-black tree
紅黑樹遵守五個基本定義

  1. 所有節點非黑即紅
  2. root 一定是黑色
  3. 所有 leaf node 一定是紅色
  4. 紅色節點的子節點一定是黑色
  5. 對於所有節點來說,所有該節點子樹的 leaf node 所接觸到的黑色節點數量一樣多 ( black-height 固定)

參考 Red-Black Trees 內容所述,若把紅黑樹當中所有紅色節點抽離,我們可以得到一棵所有 leaf node 都在同一層的樹,間接證明了紅黑樹的 black-height 是

O(log(nb)) ,最糟的情況也就是紅黑節點相間的話,紅黑樹的高度會是
2log(nb)
也就是兩倍的 black-height 。紅黑樹的 insertion 、 deletion 進行的 fixup (變色或旋轉)有可能持續往 root 進行,此部分和 Xtree 有可能不斷往親代節點做修正類似,但紅黑樹在修正部分也有證明是為了修正哪幾項性質,完成了就不會再往上修正,例如 insertion 時還保證不是違反性質 2 就是 4 ,所有操作都是為了重新讓子樹維持這兩個性質,並且保證最多旋轉兩次。

Xtree 能否推導證明修正後能保照 Xtree 性質被維護?

除了上述性質外,針對 Linux 核心的場景,要特別考慮到 Augmenting Red Black Trees,你常聽到的 AR/VR 其全銜的 Augmenting,同一個字。

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →
jserv

閱讀 Augmenting Red Black Trees , Augmenting Data Structure 是在闡述如何擴增原本的資料結構,例如增加成員或函式,使得該資料結構能夠在不改變原本操作的時間複雜度情況下,解決原本解決不了的問題。
以紅黑樹為例,如果要在紅黑樹中找到第 k 大的元素,原本的紅黑樹必須使用 in-order traversal 找到第 k 個存取的節點, worst case time complexity 會是

O(n) ,如果在節點當中新增一個成員 Rank ,用來記錄該節點所形成的子樹的節點數量。尋找第 k 大的元素方法可更改為,若 k == left.size + 1 則代表該節點即是第 k 大的元素,如果小於該數值則往左子樹尋找第 k 大元素,否則往右子樹尋找第 k - left.size + 1 大的元素。

Compare with Linux kernel red-black tree

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 函式。

commit ade5177

較為困難的地方在於實作 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 的紅黑樹)。

commit f1fd1de