iT邦幫忙

2026 iThome 鐵人賽

DAY 6
0
Software Development

在 AI Compiler 工程師的路上系列 第 6

Day05:Norm、Activation、RoPE 的 RVV 寫法

  • 分享至 

  • xImage
  •  

昨天看了 linear / GEMM,今天換另一組在 LLM 前向傳播裡常出現的運算子:norm、activation、RoPE。
今天會講三個 torch.ops.sgl_kernel

torch.ops.sgl_kernel.rmsnorm_cpu
torch.ops.sgl_kernel.silu_and_mul_cpu
torch.ops.sgl_kernel.rotary_embedding_cpu

這三類運算子的資料量通常比大 GEMM 小,但它們每一層都會跑,並且常常卡在記憶體存取、reduction、型別轉換和近似函式。它們很適合拿來練習把前面寫過的 RVV 迴圈放進真實運算子。

本篇大綱

  • 先看 LLM 前向傳播裡 norm、activation、RoPE 各自出現在哪裡。
  • 再看這些運算子和 GEMM 的資料存取差異。
  • 接著讀 norm.cppactivation.cpprope.cpp 的 RVV intrinsic 迴圈。
  • 然後整理 reduction、近似函式、BF16 / FP16 轉換 的寫法。
  • 最後回到 K1 的 cache line、Vector-256bit 和 LPDDR,討論這類運算子的效能測試判讀。

這三個運算子在模型裡的位置

先把一個 decoder-only Transformer block 簡化成下面這條路徑:

https://ithelp.ithome.com.tw/upload/images/20260806/20183319YQK2TeiKQu.png

上面的示意圖以 pre-norm 架構為例,綠色虛線是 residual path,①、②、③標出本篇要看的三種運算子。

pre-normpre 是指 normalization 發生在 Attention 或 MLP 子層之前。以圖中的 Attention 子層為例,輸入 x 會分成兩條路徑:一條先進 RMSNorm 和 Attention,另一條沿 residual path 直接傳到加法節點:

attention_out = x + Attention(RMSNorm(x))
mlp_out       = attention_out + MLP(RMSNorm(attention_out))

這種排法讓 Attention 和 MLP 先接收尺度較一致的輸入,residual path 則保留一條直接傳遞 hidden states 的路徑。相對地,post-norm 會先完成子層運算和 residual add,再做 normalization,例如 LayerNorm(x + Attention(x))

但不同模型的 norm 擺放方式、activation 種類和 RoPE 設定還是會有差異。

RMSNorm 控制向量尺度

RMSNorm 想要控制 hidden states 的數值尺度。Transformer block 會不斷做線性轉換和 residual add,每個 token 的 hidden vector 大小可能隨層數改變。RMSNorm 在 attention 或 MLP 前先把這個向量調整到可控制的尺度,讓後面的 projection 接收尺度較一致的輸入。

它的功能可以概括成先測量整個向量的 Root Mean Square,再縮放每個 element。對一個 token 的 hidden vector x 來說

rms(x) = sqrt((1 / hidden_size) * Σ x_i² + eps)
out_i  = (x_i / rms(x)) * weight_i

eps 是一個很小的常數,用來避免 rms 太小而造成除法不穩定。weight 則是模型學到的參數,負責再次調整每個 element 的大小。

為了算出 rms,RMSNorm 會先把同一個 token 的所有 element 平方,再把這些平方值加總成一個數值。這種將多個值彙總成一個值的運算就叫 reduction。同一個 token 的所有 element 會共用這個 rms,所以 kernel 要先完成 reduction,才能回頭 normalize 每個 element。

Activation + mul 在 MLP 中加入非線性與 gating

Activation 的目的是讓 MLP 具有非線性的表示能力。連續堆疊 linear layer 仍可以合併成一個線性轉換,中間加入 SiLU 或 GELU 後,模型才能根據輸入產生彎曲的轉換關係。

Activation + mul 的功能還包含 gating,一條分支產生 gate,另一條分支提供要傳遞的值,最後逐元素相乘,調整每個特徵的輸出強度。

以我這裡的 silu_and_mul_cpu 為例,輸入最後一維包含兩組同樣長度的向量。這兩組值通常來自 MLP 的 gate projection 和 up projection

sigmoid(x) = 1 / (1 + exp(-x))
silu(x)    = x * sigmoid(x)
out        = silu(gate) * up

Sigmoid 會產生 0 到 1 之間的平滑係數,SiLU 用它縮放 gate,再和 up 逐元素相乘。從 kernel 角度看,這是連續的 load、exp(-x) 近似、除法和 multiply,沒有 RMSNorm 那種跨整列的 reduction。

RoPE 用旋轉把 token 位置寫進 Q/K

RoPE 的目的是把 token 的位置關係放進 attention score。Self-attention 用 Q 和 K 的內積衡量內容關聯,如果沒有額外的位置資訊,這個計算本身就無法分辨 token 在序列中的順序。

RoPE(Rotary Positional Embedding)的功能是依 token position 旋轉 Q/K head 中的成對元素,旋轉發生在 Q/K 內積之前,對一組 (x0, x1) 來說可以寫成:

x0' = x0 * cos(θ) - x1 * sin(θ)
x1' = x1 * cos(θ) + x0 * sin(θ)

高中數甲有學過的旋轉矩陣在這裡出現了,θ 由 position 和這組 hidden dimension 對應的頻率決定。同一個向量在不同 position 會被旋轉不同角度,而旋轉後 Q/K 的內積會包含兩個 token 的相對位置關係。實作時通常預先算好 cos/sin cache,kernel 依 positions 查表,再對 Q 和 K 的 rotary dimension 做逐元素運算。

和 GEMM 的差別

GEMM 的主要壓力通常來自大矩陣資料搬移和累加器重用,Norm / activation / RoPE 比較像沿著最後一維掃過去。

[num_tokens, hidden_size] 的 tensor 來看,這些運算子常見迴圈是:

for each token:
  for hidden dimension:
    load x
    compute
    store out

這種模式很適合 RVV strip-mining:

for (int64_t j = 0; j < hidden_size; j += vl) {
    vl = __riscv_vsetvl_e32m4(hidden_size - j);
    ...
}

但它們也有幾個麻煩的點要去處理,RMSNorm 需要先算整列的 sum of squares,才能做 normalize。只保留 sumscale 這類統計值,第二階段仍要再讀一次 x。想避免從原 tensor 第二次載入,必須保留整列 x;例如與產生 x 的上游運算融合,並讓該列留在可重用的 buffer,和下游運算融合可以省掉 norm output 的 store / reload,但不會消除 RMSNorm 自己的第二次 x 讀取。

Activation 需要近似函式,例如 sigmoid、tanh、erf。RVV intrinsic 沒有直接給完整高階 activation,我在 vector_math.h 有寫一些數學近似函數。

RoPE 要處理 Q/K 的資料配置。有些模型使用 NeoX style,有些使用 interleaved style,兩者的 tensor shape 可以完全一樣,差別在 head dimension 裡哪兩個 element 要組成一個旋轉對。

head_dim = 8 的向量 [x0, x1, x2, x3, x4, x5, x6, x7] 為例:

NeoX style:
(x0, x4), (x1, x5), (x2, x6), (x3, x7)

interleaved style:
(x0, x1), (x2, x3), (x4, x5), (x6, x7)

NeoX style 把 head dimension 切成前後兩半,將相同 offset 的 element 配成一組。Interleaved style 則把相鄰的偶數、奇數 index 配成一組。兩種 layout 都套用同一組 cos/sin 旋轉公式,但 kernel 計算配對 index 與載入資料的方式不同。如果 layout 判斷錯誤,程式通常仍能執行,卻會旋轉錯誤的 element,最後影響 attention score。

RMSNorm

rmsnorm_cpusgl-kernel/csrc/cpu/riscv64/norm.cpp

at::Tensor rmsnorm_cpu(at::Tensor& input, at::Tensor& weight, double eps) {
  CHECK_LAST_DIM_CONTIGUOUS_INPUT(input);
  CHECK_INPUT(weight);
  CHECK_DIM(2, input);
  CHECK_DIM(1, weight);
  CHECK_EQ(input.size(1), weight.size(0));
  int64_t batch_size = input.size(0);
  int64_t hidden_size = input.size(1);
  int64_t input_strideN = input.stride(0);
  at::Tensor output = at::empty_like(input);
  ...
  AT_DISPATCH_REDUCED_FLOATING_TYPES(input.scalar_type(), "rmsnorm_kernel", [&] {
    rmsnorm_kernel_impl<scalar_t, false>(
        output.data_ptr<scalar_t>(),
        input.data_ptr<scalar_t>(),
        weight.data_ptr<scalar_t>(),
        batch_size,
        hidden_size,
        input_strideN,
        static_cast<float>(eps));
  });
  return output;
}

這裡先要求 input 最後一維連續,因為 norm 是沿 hidden dimension 掃,如果最後一維不連續,RVV 載入會變麻煩。

RMSNorm 的核心可以拆成兩段

sum = Σ x_i^2
scale = 1 / sqrt(sum / hidden_size + eps)
out_i = x_i * scale * weight_i

第一段 reduction 需要 FP32 累加。BF16 / FP16 input 會先轉成 FP32,再做平方和。下面是 rmsnorm_kernel_impl 的 RVV 主迴圈,我把原本嵌在同一行的 reduction 結果拆成 partial 方便閱讀

float sum_sq = 0.0f;
size_t vl = 0;
vfloat32m1_t vzero = __riscv_vfmv_s_f_f32m1(0.0f, 1);

for (int64_t j = 0; j < hidden_size; j += vl) {
  vl = __riscv_vsetvl_e32m4(hidden_size - j);
  vfloat32m4_t vx = load_as_float_m4(in_ptr + j, vl, scratch);
  vfloat32m4_t vsq = __riscv_vfmul_vv_f32m4(vx, vx, vl);
  vfloat32m1_t partial =
      __riscv_vfredusum_vs_f32m4_f32m1(vsq, vzero, vl);
  sum_sq += __riscv_vfmv_f_s_f32m1_f32(partial);
}
  • __riscv_vsetvl_e32m4 根據剩餘 element 數設定 vl,同時選擇 FP32(e32)和 LMUL=4(m4)。
  • load_as_float_m4 是我在 vector_helper.h 自己定義的載入函式,會把 BF16 / FP16 輸入轉成 vfloat32m4_t
  • __riscv_vfmul_vv_f32m4 做 vector-vector 乘法,一次算出 vlx_i²
  • __riscv_vfredusum_vs_f32m4_f32m1vsq 的 active lanes 加總到 m1 vector 的第一個 lane。
  • __riscv_vfmv_f_s_f32m1_f32 取出該 lane 成為 scalar,再加進 sum_sq

算出整列共用的 rsqrt_var 後,第二趟迴圈會完成 normalize 和 weight 縮放

for (int64_t j = 0; j < hidden_size; j += vl) {
  vl = __riscv_vsetvl_e32m4(hidden_size - j);
  vfloat32m4_t vx = load_as_float_m4(in_ptr + j, vl, scratch);
  vfloat32m4_t vw = load_as_float_m4(weight + j, vl, scratch);
  vfloat32m4_t vnormalized =
      __riscv_vfmul_vf_f32m4(vx, rsqrt_var, vl);
  vfloat32m4_t vout =
      __riscv_vfmul_vv_f32m4(vnormalized, vw, vl);
  store_from_float_m4(out_ptr + j, vout, vl, scratch);
}

vfmul_vfvf 表示 vector 乘 scalar float,適合把同一個 rsqrt_var 套用到所有 lanes;vfmul_vv 再讓每個 lane 乘上各自的 weightstore_from_float_m4 會轉回輸出 dtype 並儲存。最後一段不足一個完整 vector group 時,vsetvl 會縮短 vl 處理尾端。

Activation

activation.cpp 裡的 silu_and_mul_cpu 入口是:

at::Tensor silu_and_mul_cpu(const at::Tensor& input) {
  auto input_contig = input.contiguous();
  auto sizes = input.sizes().vec();
  int64_t last_dim = input.ndimension() - 1;
  int64_t d = sizes[last_dim] / 2;
  sizes[last_dim] = d;
  int64_t num_tokens = input.numel() / input.size(-1);
  at::Tensor out = at::empty(sizes, input.options());
  ...
}

它假設最後一維是 2 * d,把前半段當 x,後半段當 y

out = silu(x) * y

這個前置條件必須是最後一維為偶數,目前 C++ 入口直接用整數除法算 d,沒有顯式拒絕 odd size。
如果上層傳入奇數,會遺漏元素並讓後續 token 的 row base 錯位,產生錯誤結果。完整實作應在入口加上偶數檢查,本篇範例只在該條件成立時有效。

每個 token 會呼叫

act_silu_inner(out_ptr + i * d, x_ptr, x_ptr + d, d);

act_silu_inner 的主迴圈有兩路 unroll,這裡先看結構較單純的 tail loop。下方把原本嵌套的表達式拆成暫存變數,觀察 SiLU-and-mul 如何對應到 RVV 運算

size_t vl;
for (; j < d; j += vl) {
  vl = __riscv_vsetvl_e32m4(d - j);
  vfloat32m4_t vx = load_as_float_m4(x_ptr + j, vl, scratch);
  vfloat32m4_t vy = load_as_float_m4(y_ptr + j, vl, scratch);

  vfloat32m4_t vneg_x = __riscv_vfneg_v_f32m4(vx, vl);
  vfloat32m4_t vexp = vfexp_f32m4(vneg_x, vl);
  vfloat32m4_t vdenom = __riscv_vfadd_vf_f32m4(vexp, 1.0f, vl);
  vfloat32m4_t vsigmoid = vrec_f32m4(vdenom, vl);
  vfloat32m4_t vsilu = __riscv_vfmul_vv_f32m4(vx, vsigmoid, vl);
  vfloat32m4_t vout = __riscv_vfmul_vv_f32m4(vsilu, vy, vl);

  store_from_float_m4(out_ptr + j, vout, vl, scratch);
}

這段對應的算式是

vneg_x    = -x
vexp      = exp(-x)
vsigmoid  = 1 / (1 + vexp)
vout      = x * vsigmoid * y

__riscv_vfneg_v_f32m4__riscv_vfadd_vf_f32m4__riscv_vfmul_vv_f32m4 是 RVV intrinsic。vfexp_f32m4vrec_f32m4 則是我在 vector_math.h 寫的向量數學輔助函式,內部還會繼續用 RVV intrinsic 實作近似運算。像是 vfexp_f32m4 會先做 range reduction,再用 Horner's method 計算多項式:

vfloat32m4_t vz = __riscv_vfmul_vf_f32m4(vx, RVV_LOG2_E, vl);
vint32m4_t vn_int = __riscv_vfcvt_x_f_v_i32m4(vz, vl);
vfloat32m4_t vn = __riscv_vfcvt_f_x_v_f32m4(vn_int, vl);
vfloat32m4_t vf = __riscv_vfsub_vv_f32m4(vz, vn, vl);

vfloat32m4_t vC4 = __riscv_vfmv_v_f_f32m4(RVV_EXP_C4, vl);
vfloat32m4_t vC3 = __riscv_vfmv_v_f_f32m4(RVV_EXP_C3, vl);
vfloat32m4_t poly = __riscv_vfmv_v_f_f32m4(RVV_EXP_C5, vl);
poly = __riscv_vfmadd_vv_f32m4(poly, vf, vC4, vl);
poly = __riscv_vfmadd_vv_f32m4(poly, vf, vC3, vl);
// ...

Horner's method(霍納法)是一種很聰明改寫多項式的方法。例如原本的四次多項式

P(x) = a4*x^4 + a3*x^3 + a2*x^2 + a1*x + a0

可以改寫成

P(x) = (((a4*x + a3)*x + a2)*x + a1)*x + a0

如果每一項都從頭計算 x^k,求值一個 n 次多項式需要 1 + 2 + ... + n 次乘法,時間複雜度是 O(n²)。Horner's method 只需要 n 次乘法與 n 次加法,因此是 O(n)。如果原本已經會重用 x^k,兩者在漸進複雜度上都是 O(n),Horner's method 仍可減少中間暫存值,也不需要先存下多組 x^k

在 RVV 裡,__riscv_vfmadd_vv_f32m4(poly, vf, coefficient, vl) 剛好可以在一步中計算 poly * vf + coefficient,直接對應 Horner's method 的每一層括號。GELU 路徑則會呼叫 vferf_f32m4vftanh_f32m4,同樣把 erf / tanh 拆成 RVV 可執行的乘加、exp 近似與 reciprocal。

這種運算子的效能常常受到兩件事影響。

1.近似函式本身的指令量,SiLU 需要 sigmoid,GELU 需要 tanh 或 erf,指令比單純 multiply 多。

2.input/output 頻寬,每個元素通常載入兩份 input,儲存一份 output。如果 hidden size 大,記憶體流量仍然可觀。

RoPE

rotary_embedding_cpurope.cpp。入口會先檢查形狀

TORCH_CHECK(
    input_dim == 2 || input_dim == 3 || input_dim == 4,
    " Query/Key must be 2D ... or 3D ... or 4D ... tensor");
CHECK_LAST_DIM_CONTIGUOUS_INPUT(query);
CHECK_LAST_DIM_CONTIGUOUS_INPUT(key);

它支援 2D、3D、4D input

2D: [num_tokens, num_heads * head_size]
3D: [num_tokens, num_heads, head_size]
4D: [batch_size, seq_len, num_heads, head_size]

這三種 shape 表達的 Q/K 內容相似,主要差在 token、batch 和 head 維度是否被展平,以及目前 RVV kernel 為各路徑提供的功能

Shape 資料如何排列 目前 RVV 路徑
2D 每列是一個 token,所有 heads 展平放在最後一維;kernel 用 head_size 推回 head 數量 支援 NeoX 與 interleaved,Q/K 可有不同 head 數,並直接在原本 Q/K 上修改(in-place)
3D token 仍是第一維,head 被拆成獨立維度,沒有單獨的 batch 維度 目前只支援 interleaved、要求 K 只有 1 個 head,並另外建立輸出 tensor(out-of-place)
4D 完整保留 batch、sequence 和 head 三個維度,batch_size * seq_len 要和 positions 的元素數一致 支援 NeoX 與 interleaved,Q/K 可有不同 head 數,並原地修改 Q/K

例如同樣是 32 個 heads、head_size = 128,2D 會把一個 token 存成長度 4096 的一列;3D/4D 則保留 [32, 128] 這兩個維度。RoPE 使用的旋轉公式沒有變,kernel 需要依 shape 算出每個 token 和 head 的起始位置。

RoPE 會根據 positionscos_sin_cache 找對應 cos/sin,然後對 Q/K 的 rotary dimension 做旋轉。

簡化公式可以寫成

x0' = x0 * cos - x1 * sin
x1' = x1 * cos + x0 * sin

麻煩在資料配置,NeoX style 和 interleaved style 的配對方式不同,kernel 需要知道哪兩個元素是一組,才能正確旋轉。

先看 interleaved style 的 RVV 主迴圈。因為要配對 (x0, x1)(x2, x3),kernel 用 stride-2 load 分別取出偶數與奇數 index:

const ptrdiff_t stride = 2 * static_cast<ptrdiff_t>(sizeof(scalar_t));
size_t vl = 0;

for (int64_t j = 0; j < embed_dim; j += vl) {
  vl = __riscv_vsetvl_e32m4(embed_dim - j);
  vfloat32m4_t v_cos = load_as_float_m4(cos_ptr + j, vl, scratch);
  vfloat32m4_t v_sin = load_as_float_m4(sin_ptr + j, vl, scratch);
  vfloat32m4_t v_x =
      load_strided_as_float_m4(head_ptr + 2 * j, stride, vl, scratch);
  vfloat32m4_t v_y =
      load_strided_as_float_m4(head_ptr + 2 * j + 1, stride, vl, scratch);

  vfloat32m4_t v_out_x = __riscv_vfmul_vv_f32m4(v_x, v_cos, vl);
  v_out_x = __riscv_vfnmsac_vv_f32m4(v_out_x, v_y, v_sin, vl);
  vfloat32m4_t v_out_y = __riscv_vfmul_vv_f32m4(v_y, v_cos, vl);
  v_out_y = __riscv_vfmacc_vv_f32m4(v_out_y, v_x, v_sin, vl);

  store_strided_from_float_m4(head_ptr + 2 * j, stride, v_out_x, vl, scratch);
  store_strided_from_float_m4(head_ptr + 2 * j + 1, stride, v_out_y, vl, scratch);
}

這裡有兩個和公式直接對應的 fused multiply-accumulate intrinsic

  • vfnmsac(v_out_x, v_y, v_sin) 計算 v_out_x - v_y * v_sin,完成 x * cos - y * sin
  • vfmacc(v_out_y, v_x, v_sin) 計算 v_out_y + v_x * v_sin,完成 y * cos + x * sin

NeoX style 的乘加 intrinsic 相同,載入方式不同。它要配對前後半,所以用 x_index = jy_index = embed_dim + j,再用連續的 load_as_float_m4 載入兩段資料。這也是 layout 會直接改變 RVV memory access 的地方。

RVV 寫 RoPE 時,要注意幾件事

  • 最後一維要連續
  • rotary_dim 必須是偶數
  • cos/sin cache 資料型別要和 query/key 一致
  • Q/K head 數可能不同,GQA/MQA 下 K head 數會比較少
  • 目前 2D/4D 路徑是 in-place,3D 路徑是 out-of-place

這些檢查看起來瑣碎,但對推論服務很重要,資料配置錯了不一定 crash,可能只是模型輸出錯。
呼叫端需要依照實際 shape 處理各路徑的前置條件與輸出語意,特別是 3D 路徑的單一 KV head 限制與 out-of-place 輸出。

K1 上怎麼判讀這些運算子

K1 的 Vector-256bit 對這些運算子的影響比較直觀

  • hidden dimension 越大,RVV strip-mining 的 amortization 越好。
  • BF16 / FP16 input 轉 FP32 累加會增加暫存器壓力。
  • 逐元素運算子的 arithmetic intensity 通常低,容易受記憶體頻寬影響。
  • reduction 運算子需要額外掃描,會放大記憶體流量。
  • RoPE 如果資料配置不連續,效能和正確性都會受影響。

Benchmark 時可以看幾個訊號

  1. tensor 最後一維是否 contiguous。
  2. batch /token 數是否太小,導致執行緒額外成本比計算還大。
  3. 效能分析器裡是否真的出現 sgl-kernel::rmsnorm_cpusgl-kernel::silu_and_mul_cpusgl-kernel::rotary_embedding_cpu
  4. kernel 時間是否在推論服務裡占比夠高。
  5. 最佳化後單批次延遲是否有變,不只看單一運算子微型效能測試。

今天先走到這裡

今天看了 linear 以外的三類 RVV 運算子

  • RMSNorm 是 reduction + normalize,重點在 FP32 累加 和最後一維 contiguous。
  • Activation 是 逐元素 + 近似函式,重點在 sigmoid / tanh / erf 輔助工具 和 記憶體流量。
  • RoPE 是 資料配置 aware 逐元素 kernel,重點在 rotary pair、cos/sin cache、Q/K 形狀。
  • 這些運算子單次看起來小,但每層都跑,推論服務裡的總成本不能忽略。

明天會講 attention kernel, attention 會把今天的 reduction、activation-like softmax、資料配置、KV cache、GEMM-like dot product 放在同一個 kernel 裡。

參考資料


上一篇
Day04:從 Linear/GEMM 看 RVV AI 運算子
系列文
在 AI Compiler 工程師的路上6
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言