今天還是AI slob 我如果之後有寫好 改完 會附上更正是寫作版本的連結ㄉ
希望這不是ㄍflag
前面三天把三條路拆開了。Day 16 是一個 block 裡的 butterfly,Day 17 是 large-N 的兩次 pass,Day 18 是把 Hadamard 改寫成 Tensor Core GEMM。
今天要比。但 GPU kernel 很容易寫出一個看起來很快、其實沒有做同一件事的版本。Day 1 講過:沒有比較對象的 100 ms,不是快也不是慢。今天補一句:比較對象如果合約不同,那張速度表一樣不能用。
Hadamard / FWT 至少要對齊:
同一個 N、同一個 batch、同一個 dtype、同一個 input layout,以及 同一個 normalization。
有人算 H^{⊗N} x,有人算 (1 / sqrt(2^N)) * H^{⊗N} x。差一個 scale。沒對齊的話,速度和誤差都沒有意義。
output 也要講清楚比的是哪一種:
bitwise 完全相同 最強
max diff 在 tolerance 內 合理
norm 有沒有守住 只能當 sanity check
FWT 全是加減,看起來應該 bitwise 對得死死的。可是 FP32 加法不是實數加法。butterfly 順序不同,rounding 路徑就可能不同。所以下面表裡有些 diff 不是 0,不一定是算錯。
baseline 也不只一種。naive / PyTorch 用來對答案,不拿來比速度。Dao-AILab 的 fast-hadamard-transform 是外面很強的 GPU 標的,尤其 N <= 15,精神跟 FlashAttention 很像:資料留在 register,必要時才用 shared memory 換位置。我們自己的 native、TF32、Ozaki,要在同一個合約下互相比。
下面兩張表不是同一個 harness 一次跑完。第一張是跟 Dao 對齊的 FP32;第二張是 Tensor Core 路徑的對打。分開寫,就是為了不要把不同合約的數字塞進同一格。
這張表的合約是:FP32、batch=128、同一個 normalized transform。Dao 在 N >= 16 撐不住,那格就留空。
| N | native (us) | Dao (us) | 相對 Dao | max diff |
|---|---|---|---|---|
| 10 | 14.37 | 21.70 | 1.51× | 7.4e+00 |
| 12 | 11.34 | 19.65 | 1.73× | 7.2e+00 |
| 14 | 52.91 | 37.89 | 0.72× | 8.6e+00 |
| 15 | 82.98 | 98.35 | 1.19× | 0.0e+00 |
| 16 | 584.56 | — | Dao 不支援 | — |
| 20 | 14710 | — | Dao 不支援 | — |
先看 N=15。82.98 對 98.35,而且 diff 是 0。這不是「差不多對」。這是 butterfly 順序也對齊了。明天會看到,這顆是後來才切出來的 single-pass;對齊 Dao 不是巧合。
再看 N=10 / 12 / 14。我們有的比 Dao 快,N=14 卻輸了,diff 也不是 0。輸的原因和 diff 非零,很可能是同一件事:這些 N 的 kernel 還沒改成跟 Dao 同一套 ordering / 同一套 register 切法。數學上等價,bitwise 不等價,速度也不在同一條曲線上。
N=16 從 82.98 us 跳到 584.56 us。Day 17 算過:batch=128 時 state 大約 33.5 MB,兩次 pass 大約 134 MB traffic,有效頻寬大約 229 GB/s。這格比的不是加減法,是「一個 block 放不下之後,多付一次 global memory」。Dao 在這裡直接不支援,所以沒有對手,不代表我們已經最快,只代表比較對象先離開了。
這張表回答另一個問題:同一題 Hadamard,改寫成 GEMM 之後會怎樣。dtype 和 harness 跟表一不同,不要拿 40 us 去跟上面的 52.91 us 比快慢。看的是同一張表裡的倍數。
| 寫法 | 時間 | 在比什麼 |
|---|---|---|
| native FP64 | ~600 us | ALU butterfly |
| dense Ozaki GEMM | ~314,000 us | 把 H 當真的 dense 矩陣乘 |
| radix-128 Ozaki | ~900 us | H_7 ⊗ H_7 + INT8/CRT |
| native FP32 | ~40 us | ALU butterfly |
| TF32 + transpose | ~380 us | 兩次小 GEMM + 轉置 |
dense Ozaki 慢五百倍,方向錯了。radix-128 把結構用上,從「慢五百倍」拉回「慢 1.5 倍」,還是沒贏。TF32 拿掉 CRT,仍然慢大約九倍。
三行一起看,結論就收得很窄:不是 INT8 轉換獨有的問題,也不是「我們 MMA 寫得不夠好」就能翻盤。只要為了 Tensor Core 多做 layout / transpose,FWT 這種加減法就付不起。
N=14 的 NCU:不要把 62% 讀成還能再快 1.6 倍表一 N=14 的 52.91 us,kernel 本身大約 50.37 us。NCU 看到的是:
SM throughput ~62%
Memory throughput ~62%
L2 hit ~87%
Branch efficiency 100%
理論 occupancy ~16.7%(每個 SM 幾乎只能放 1 個 block)
NCU 自己的判定是 compute 和 memory 已經接近平衡。FWT 算術這麼便宜,能推到這種平衡已經不容易。那 38% 不是「再 unroll 一下就能拿回來」。
occupancy 低也要分開看。N=14 的 chunk 大約 64 KB,shared memory 加上 register 之後,一個 SM 塞不下很多 block。它用 occupancy 換的是:資料留在 chip 上,少一次 global memory 來回。如果為了 occupancy 把每個 thread 拿的資料切少,結果多出一個 pass,表一的 N=16 就是那種帳單。
還有一件事一定要查:local memory load/store。N=15 每個 thread 拿 32 個 float4,也就是 128 個 float。compiler 一旦 spill,register 設計就偷偷變回比較慢的 memory path。速度表看不出這件事,profile 才看得出。
表二的 TF32 慢九倍,不該只打開 Tensor Core utilization。MMA 可能很忙,時間卻花在 MMA 外面:
input 排成 tile
Hadamard block 準備好
第一次小 GEMM 寫出
transpose
第二次小 GEMM
再轉回 output layout
Ozaki 的話,還要 conversion + CRT
end-to-end 要從原始 input 走到最後 output。有多少時間花在 conversion 而不是 MMA,有多少 access 不連續,這才是表二那幾個倍數的來源。Day 18 已經把這條資料流拆過;今天只是把它放到比較桌上,避免只截 MMA 那一小段來說「Tensor Core 很快」。
表一說:跟外面最強的 FWT baseline 比,小 N 有贏有輸;N=15 對齊之後才穩定贏;N=16 開始付 2-pass 的 memory 錢,Dao 也不再是對手。
表二說:把同一題改寫成 Tensor Core GEMM,不管有沒有 CRT,都還沒贏過直接做 butterfly。
所以 FWT 的主戰場不是 Tensor Core TFLOPS。是資料流。速度表要先問「算的是不是同一件事」,profile 要再問「時間花在算還是花在搬」。
下一篇做最後一件事:表一裡 N=15 為什麼後來才贏。切之前是 10+5 兩個 pass,大約 278 us;切之後是一個 block 做完,82.98 us。把 before / after 攤開,重切這件事才看得到。