iT邦幫忙

2026 iThome 鐵人賽

DAY 6
1
Software Development

1+1+1>3 ~ Spark 與 DataFusion、Comet 效能煉金術 ~系列 第 6

Day 06 效能底層煉金:從 Clang 組合語言拆解 Arrow 記憶體與向量化

  • 分享至 

  • xImage
  •  

經過前五天理論的洗禮,今天來做點實驗~~~~
規格沒寫編譯器怎麼想,只能編譯成組合語言,看它輸出什麼指令啦

實驗環境與設備

  • 晶片 Apple M5
  • SIMD: NEON 128-bit,一條暫存器裝4 個 float32

不是 D2 圖裡 AVX2 的 8 lane,也沒有 AVX-512 的 k0 mask register。D2 舉的 vmulps ymm、broadcast 進 ymm 再 AND,在這台要翻譯成 fmul.4sdup / mov.band.16b 所以不能拿 x86 的圖去對這份組合語言,機制一樣(lane 必須 lockstep),但指令名字不一樣

編譯器是 Apple clang 21(終端機打 gcc 也是它),不是 GNU GCC 所以不會輸出 control flow in loop,而是輸出 vectorized loopcost-model indicates that vectorization is not beneficial

kernel null 怎麼放 今天要看什麼
k1 沒有,每個 float 都當有值 基準線
k2 每列一個 byte,迴圈裡 if D2 說的branch 擋向量化
k3 bitmap,每列抽 1 bit 再乘 展開能不能 SIMD
k3r 跟 k3 一樣,參數加 restrict 失敗是不是其實 alias,不是 bitmap
k3b 值迴圈不碰 validity;validity 另外 AND Arrow 比較像的拆法
k4 跟 k1 一樣,加 restrict 少掉的是重疊檢查,不是 SIMD 本身

資料範例

六個 kernel 算的是同一個式子,result = price * (1 - discount),NULL 位置填 0,差別只在 validity 怎麼存、怎麼合併。用 8 列當範例,第 0、2、5 列是 NULL:

price    = [10, 20, 30, 40, 50, 60, 70, 80]
discount = [.1, .1, .1, .1, .1, .1, .1, .1]

# 存法一:每列一個 byte,k2 用這個
flag     = [0,  1,  0,  1,  1,  0,  1,  1]

# 存法二:bitmap,一列一個 bit,LSB 是 row 0,k3 / k3r / k3b 用這個
bitmap   = 0b11011010

# 兩種存法算出來都一樣
out      = [0, 18,  0, 36, 45,  0, 63, 72]

k1、k4 完全不看 validity,等於把 flag / bitmap 都當成全 1,輸出會是 [9, 18, 27, 36, 45, 54, 63, 72]

編譯時加上 -Rpass=loop-vectorize,clang 會對每個迴圈印輸出,說明是否有向量化、為什麼
接下來一個一個看 kernel 怎麼做

1. 沒有 null

void k1(const float *price, const float *disc, float *out, int n) {
    for (int i = 0; i < n; i++) {
        out[i] = price[i] * (1.0f - disc[i]);
    }
}

i++ clang 輸出:

vectorized loop (width: 4, interleaved count: 4)

width: 4 是 NEON 一次算 4 個 float,interleaved count: 4 是編譯器把這套動作連做 4 回再跳回迴圈開頭,所以一輪走完 4 × 4 = 16 列
組合語言語輸出:

ldp     q1, q2, [x10, #-32]
ldp     q3, q4, [x10], #64
fsub.4s v5, v0, v5
fmul.4s v1, v1, v5
subs    x13, x13, #16

最後那個 subs #16 就是一輪 16 列的證據,fmul.4s 一條乘 4 個 float,出現四次剛好對上 interleaved 4

2. byte flag:迴圈裡有 if

void k2(const float *price, const float *disc, const u8 *flag,
        float *out, int n) {
    for (int i = 0; i < n; i++) {
        if (flag[i]) {
            out[i] = price[i] * (1.0f - disc[i]);
        } else {
            out[i] = 0.0f;
        }
    }
}

flag[i] == 0 當 null,不是去 float 裡找 NaN,迴圈是:

ldrb  w15, [x11, #-3]
cbz   w15, LBB1_7
ldur  s2, [x14, #-16]
fmul  s2, s2, s3

讀一個 byte,是 0 就跳過乘法
一次一列,還展開成 8 組,每組自己一個 cbz,沒有 fmul.4s

3. bitmap

k3 把 bitmap 展開寫進同一條值迴圈,沒有 if,用三元運算選 0 或乘積:

void k3(const float *price, const float *disc, const u8 *valid,
        float *out, int n) {
    for (int i = 0; i < n; i++) {
        float v = price[i] * (1.0f - disc[i]);
        unsigned bit = (valid[i >> 3] >> (i & 7)) & 1u;
        out[i] = bit ? v : 0.0f;
    }
}

但 clang 仍會輸出 cannot identify array bounds

ldr    s2, [x0, x8, lsl #2]     ; price[i]
fmul   s2, s2, s3
lsr    w10, w8, #3               ; i / 8
ldrb   w10, [x2, x10]            ; valid[i/8]
lsr    w10, w10, w11             ; >> (i % 8)
fcsel  s2, s1, s2, eq            ; 用比較選值,沒 branch

同一份迴圈,參數加上 restrict(k3r),變成 vectorized width: 8

ldrb    w15, [x12], #1           ; 讀一個 bitmap byte(8 列)
mov.b   v6[0], w15               ; 廣播到向量的四個位置
mov.b   v6[4], w15
ushl.4s v17, v4, v20             ; 位移,把想要的 bit 拉到最低位
cmeq.4s v6, v6, #0               ; 展成 0 或全 1 的 mask
bic.16b v6, v7, v6               ; 用 mask 把 null 位置歸零
fmul.4s v16, v16, v17            ; 值的乘法

Arrow 比較像的是 k3b:值迴圈完全不看 validity
validity 另外做一條 AND:

void k3b_values(const float *price, const float *disc, float *out, int n) {
    for (int i = 0; i < n; i++) {
        out[i] = price[i] * (1.0f - disc[i]);
    }
}

void k3b_validity(const u64 *a, const u64 *b, u64 *out, int nwords) {
    for (int i = 0; i < nwords; i++) {
        out[i] = a[i] & b[i];
    }
}

乘法那個函式編譯指令跟 k1 一樣,都是 fmul.4s(一次乘 4 個 float)
合併 null 標記的那個,真正反覆跑、吃掉最多時間的那一圈裡有四條 and.16b
一條 and.16b 一次處理 16 byte,四條就是 64 byte,bitmap 一列只佔 1 bit,64 byte 等於一次把 512 列的「有值 / null」AND 完

ldp     q0, q1, [x10, #-32]
ldp     q2, q3, [x10], #64
and.16b v0, v4, v0              ; 一條指令合併 128 個 validity bit
and.16b v1, v5, v1
and.16b v2, v6, v2
and.16b v3, v7, v3

null 不進乘法,合併是另一條 AND,不是在值迴圈裡逐列抽 bit。

4. restrict:少掉的是檢查,不是更多 SIMD

k4 跟 k1 算同一個式子,只是指標標了 restrict

void k4_restrict(const float *restrict price, const float *restrict disc,
                 float *restrict out, int n) {
    for (int i = 0; i < n; i++) {
        out[i] = price[i] * (1.0f - disc[i]);
    }
}

等於告訴編譯器三塊記憶體不重疊,clang 一樣輸出 width 4、interleaved 4

差在開頭,k1 有記憶體不重疊的檢查,失敗就走純量,但 k4 標restrict 意思是保證不重疊,直接進向量迴圈
(Rust 的 &[f32] 在 LLVM IR 會帶 noalias,效果跟這裡的 restrict 同類)好餓QAQ

clang 對每個 kernel 的輸出

六個 kernel :

kernel clang 輸出
k1 vectorized loop (width: 4, interleaved count: 4)
k2 vectorization is not beneficial
k3 cannot identify array bounds,沒向量化
k3r vectorized loop (width: 8, interleaved count: 1)
k3b 值 vectorized loop (width: 4, interleaved count: 4)
k3b validity vectorized loop (width: 2, interleaved count: 4)
k4 vectorized loop (width: 4, interleaved count: 4)

明天預告

編譯器這層結束,但認真說乘法好解決除法比較麻煩XXD
明天拿 DataFusion 的 EXPLAIN ANALYZE 來玩玩

那就明天見~


上一篇
Day 05 向量化 codegen 哪時候比較划算?
系列文
1+1+1>3 ~ Spark 與 DataFusion、Comet 效能煉金術 ~6
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言