iT邦幫忙

2026 iThome 鐵人賽

DAY 3
1
Software Development

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

Day02:用 RVV Intrinsic 寫第一個 Vector Add 運算子

  • 分享至 

  • xImage
  •  

昨天看完 SpacemiT K1 的規格,記下了 RV64GCVB、RVA22、RVV 1.0、Vector-256bit、8 cores、cache、LPDDR、DVFS。今天開始寫 RVV intrinsic。

RVV intrinsic 是 C/C++ 裡表達 RVV 語意的一組函式介面。寫 intrinsic 時,我們可以明確指定向量載入、加法、儲存與 vl。編譯器仍可依據資料流做合併、移動或刪除,但 intrinsic 與最後的機器指令不保證一對一,還是要用組合語言或反組譯驗證。

今天的例子是一個 float32 array add:

out[i] = a[i] + b[i];

我們會先回答三個基礎問題
為什麼 C/C++ 已經有一般迴圈,還需要 intrinsic?
SIMD 的「一條指令處理多筆資料」實際指什麼?
__riscv_vfadd_vv_f32m1 這種長名稱要怎麼拆?
接著再把 array add 寫成 RVV intrinsic,並用 K1 的 Vector-256bit 估算:在 e32m1 設定下,一輪最多可以處理 8 個 float32

本篇大綱

  • 先說明自動向量化、組合語言與 intrinsic 各自提供什麼控制。
  • 用 array add 拆清楚 SIMD、lane、向量暫存器與多核心平行化的差別。
  • 看 RVV strip-mining 迴圈:每一輪用 vsetvl 決定 vl
  • 讀完整 RVV intrinsic 程式。
  • 把 VLEN、ELEN、SEW、LMUL、vl 接回 K1 的 Vector-256bit。
  • 建立 intrinsic 命名規則,並用 load/store、整數、浮點、mask、widening、reduction 與 policy 範例練習。
  • 整理寫 intrinsic 時要怎麼跟著硬體規格做優化判斷。

為什麼要有 RVV intrinsic

同一個 array add 至少有三種寫法。

第一種是一般 C 迴圈:

for (size_t i = 0; i < n; ++i) {
    out[i] = a[i] + b[i];
}

這種寫法把向量化決定交給 compiler。當 alias、alignment、資料相依性與 target flag 都符合條件時,GCC 或 Clang 可能自動產生 RVV,條件不夠清楚時,也可能保留純量迴圈。原始碼本身沒有要求一定使用哪一組 RVV operation。

第二種是直接寫組合語言。程式可以精確控制 vsetvli、向量指令和暫存器,但要自己處理 register allocation、ABI、clobber、instruction constraint 與不同 compiler 的 inline assembly 規則。kernel 一變大,維護成本會很快增加。

第三種是 RVV intrinsic。它提供 C/C++ 型別與函式介面,讓程式直接表達:

  • 這裡要做 unit-stride vector load。
  • 這裡要做 vector-vector floating-point add。
  • 目的向量使用 32-bit float、LMUL = 1。
  • 這一輪只有 vl 個 active element。
  • tail 或 masked-off element 要使用哪個 policy。

可以把三種方法放在一起看:

寫法 程式明確指定什麼 Compiler 負責什麼 適合情境
一般 C 迴圈 scalar 語意 判斷能否向量化、選指令、配置暫存器 先求可讀性,或 compiler 已能穩定自動向量化
RVV intrinsic 向量 operation、型別、vl、mask / policy 暫存器配置、排程、合併與實際指令選擇 寫 kernel、處理 compiler 不易辨認的模式、需要明確控制 RVV 語意
Assembly 接近最終指令的操作 少量編碼與連結工作 極低階控制、compiler 尚無對應 intrinsic、或需要驗證特定指令序列

Intrinsic 位在一般 C 和 assembly 之間。它比一般 C 更直接地表達向量語意,也保留 compiler 做 register allocation 與 instruction scheduling 的空間。

intrinsic 表達的是語意,不保證每個函式呼叫都對應一條同名 machine instruction。Compiler 可以消除沒有用到的結果、重用既有 vtype 設定,或把常數型 vector-scalar operation 選成 immediate instruction。判斷最後產生什麼,仍要看 assembly。

SIMD 到底是什麼

SIMD 是 Single Instruction, Multiple Data。一條指令同時對多個資料元素做同一種操作。

假設有兩個 float array:

a:   [1.0, 2.0, 3.0, 4.0]
b:   [0.5, 0.5, 0.5, 0.5]
out: [ ?,   ?,   ?,   ?  ]

純量程式一次處理一組元素:

out[0] = a[0] + b[0]
out[1] = a[1] + b[1]
out[2] = a[2] + b[2]
out[3] = a[3] + b[3]

向量程式先把一批元素放進向量暫存器,再對所有 active element 執行同一個 add:

va   = load a[0..vl-1]
vb   = load b[0..vl-1]
vout = va + vb       # 對每個 active element 做加法
store vout -> out[0..vl-1]

可以把 va 想成多個 lane 所承載的元素:

va:   [a0, a1, a2, a3, ...]
vb:   [b0, b1, b2, b3, ...]
       ↓   ↓   ↓   ↓
add:  [+   +   +   +   ...]
       ↓   ↓   ↓   ↓
vout: [c0, c1, c2, c3, ...]

這張圖描述資料層級平行(data-level parallelism),是說多個元素套用相同操作。它沒有保證所有 element 都在單一 cycle 完成。實際吞吐量還會受 execution unit 寬度、pipeline、load/store bandwidth、cache miss 與 clock frequency 影響。VLEN = 256 能用來計算向量暫存器可容納多少 bit,不能直接當成每 cycle 的算術吞吐量。

SIMD 也要和多核心分開:

多核心 / 多執行緒:
core 0 處理一段 array
core 1 處理另一段 array

單一 core 內的 SIMD:
一條 vector operation 處理該 core 當前的一批元素

兩者可以疊加,外層用 thread 切分資料,每個 thread 內再跑 RVV strip-mining loop,什麼是 strip-mining 等一下會介紹。

array add 適合 SIMD,因為不同 index 之間沒有相依性:

out[i] 只依賴 a[i] 和 b[i]
out[i] 不需要等待 out[i-1]

如果每一輪有不同控制流程、元素間有前後相依,或資料位置需要大量不規則 gather,向量化就會更複雜。SIMD 描述的是執行形狀,不代表所有演算法都能直接獲得相同加速。

在 K1 的 Vector-256bit 上,如果使用 float32 且 LMUL = 1,一個向量暫存器最多可以放 8 個 float32out[i] = a[i] + b[i] 一輪最多可處理 8 個元素。

這裡先講最多,實際每輪處理幾個元素,要看 vsetvl 回傳的 vl

RVV 迴圈的基本形狀

RVV 程式很常圍繞 vl 來寫迴圈。程式每一輪問硬體這次要處理幾個元素,然後用這個 vl 去做 載入、計算、儲存。

最小形狀會長這樣:

for (size_t i = 0; i < n; ) {
    size_t vl = __riscv_vsetvl_e32m1(n - i);
    // 這一輪處理 vl 個 float32
    i += vl;
}

n - i 是還沒處理的元素數量。__riscv_vsetvl_e32m1 會根據硬體能力、SEW、LMUL 和剩餘元素數,回傳這輪實際使用的 vl

這個寫法稱為 strip-mining。它可以處理任意長度的 array,也可以自然處理最後一輪不足 8 個 float32 的情況。

用 K1 的 256-bit 來想,e32m1 的 VLMAX 是 8。如果 n = 20,實作很可能回傳 8, 8, 4

第 1 輪:剩 20 個,vl = 8
第 2 輪:剩 12 個,vl = 8
第 3 輪:剩 4 個,vl = 4

不過 8, 8, 4 不是 ISA 規格對所有 RVV 處理器的唯一要求。當 AVL 介於 VLMAX 和 2 * VLMAX 之間時,實作可以選擇較平衡的 vl;第二輪 AVL = 12 時,vl = 6,後面再跑 6 也是合法結果。程式應該只依賴回傳的 vl,不要假設固定分段。

所以 RVV intrinsic 的迴圈 不需要自己寫一段純量尾端程式碼。vl 會把最後一輪的元素數控制好。

第一個 RVV intrinsic 程式

#include <stddef.h>
#include <riscv_vector.h>

void add_f32(float *out, const float *a, const float *b, size_t n) {
    for (size_t i = 0; i < n; ) {
        size_t vl = __riscv_vsetvl_e32m1(n - i);

        vfloat32m1_t va = __riscv_vle32_v_f32m1(&a[i], vl);
        vfloat32m1_t vb = __riscv_vle32_v_f32m1(&b[i], vl);
        vfloat32m1_t vc = __riscv_vfadd_vv_f32m1(va, vb, vl);

        __riscv_vse32_v_f32m1(&out[i], vc, vl);
        i += vl;
    }
}

這段程式做的事情很單純:把 ab 的每個 float 相加,寫到 out。重要的是每一行 intrinsic 名稱裡都有 RVV 資訊。

先看 __riscv_vsetvl_e32m1(n - i)

  • __riscv_:RISC-V intrinsic 的前綴。
  • vsetvl:設定這一輪向量 length。
  • e32:每個元素是 32-bit。
  • m1:LMUL = 1。
  • n - i:目前還沒處理的元素數量,也常叫 AVL(application vector length)。

再看載入

vfloat32m1_t va = __riscv_vle32_v_f32m1(&a[i], vl);
  • vfloat32m1_t:向量型別,代表 32-bit float、LMUL = 1。
  • vle32:向量載入元素寬度 32。
  • f32m1:元素型別是 float32,LMUL = 1。
  • vl:這次要載入幾個元素。

接著看 add

vfloat32m1_t vc = __riscv_vfadd_vv_f32m1(va, vb, vl);
  • vfadd:向量 floating-point add。
  • vv:兩個輸入都是向量。
  • f32m1:結果和輸入使用 float32、LMUL = 1 的向量型別。
  • vl:這次要計算幾個元素。

最後是儲存:

__riscv_vse32_v_f32m1(&out[i], vc, vl);
  • vse32:向量儲存元素寬度 32。
  • f32m1:儲存的向量是 float32、LMUL = 1。
  • vl:這次要寫回幾個元素。

剛開始讀 intrinsic,可以先抓幾個位置:指令做什麼、元素寬度、LMUL、vl。先能把 vle32vfaddvse32 讀出來,就能跟著很多簡單範例走。

VLEN、ELEN、SEW、LMUL、vl

現在回頭介紹一下常見名詞,前面介紹一直噴很多名詞出來。

VLEN 是每個向量暫存器的 bit 數。K1 規格寫 Vector-256bit,所以可以用 VLEN = 256 bits 來估算一個向量暫存器能放多少元素。

VLEN 是程式看得到的向量暫存器寬度,不是單週期吞吐量保證。K1 文件描述的執行資源為 128-bit × 2,因此這裡用 256-bit 算 VLMAX,不用它直接推算加速倍數。

ELEN 是硬體支援的最大元素寬度。

SEW 是 selected element width。這是這一輪向量運算選擇的元素寬度。e32 代表 SEW = 32,e16 代表 SEW = 16。

LMUL 是向量暫存器 grouping。m1 代表用 1 個向量暫存器當一組,m2 代表用 2 個,m4 代表用 4 個。RVV 也有 fractional LMUL,例如 mf2mf4mf8

vl 是這一輪實際處理的元素數量。它由 vsetvl 系列 intrinsic 設定,後面的載入、計算、儲存都會帶著同一個 vl

可以用這個公式估算一輪最多能處理幾個元素:

VLMAX = LMUL * VLEN / SEW

在 K1 的 Vector-256bit 上,如果先用 VLEN = 256 估算:

設定 計算 一輪最多元素數
e32m1 1 * 256 / 32 8 個 float32
e32m2 2 * 256 / 32 16 個 float32
e16m1 1 * 256 / 16 16 個 16-bit 元素
e8m1 1 * 256 / 8 32 個 8-bit 元素
e32mf2 1/2 * 256 / 32 4 個 float32

這張表可以拿來估算迴圈一輪做多少事,也可以拿來判斷某個 intrinsic 名稱大概會用掉多少向量暫存器。

RVV intrinsic 命名規則

RVV Intrinsic v1.0 把介面分成兩類

  • Explicit(non-overloaded):名稱完整寫出 operand form、回傳型別、rounding mode 與 policy。
  • Implicit(overloaded):讓 compiler 從參數型別推導 EEW / EMUL,名稱會省略多數型別資訊。

本篇先用 explicit 名稱,因為看到函式時就能讀出主要的向量設定。

以下命名與函式原型依官方 rvv-intrinsic-doc repository 的 v1.0 規格 2026-06-18 的版本
。規格與 compiler 支援仍會演進,實際使用時還是要核對 toolchain 所支援的 API 版本。

__riscv_{指令 mnemonic}_{operand mnemonic}_{return type}_{round mode}_{policy}

每一種 intrinsic 不一定擁有所有欄位。vsetvl 是 pseudo intrinsic,load/store 和 reduction 也有自己的命名細節。

第一段:__riscv_

__riscv_ 是 RISC-V C intrinsic 的保留前綴,後面緊接 operation 或 pseudo operation:

名稱 主要語意
vsetvl 根據 AVL、SEW、LMUL 取得本輪 vl
vle32 / vse32 32-bit element 的 unit-stride load / store
vadd 整數加法
vfadd 浮點加法
vfmacc fused multiply-accumulate
vmseq 比較是否相等,產生 mask
vwadd widening integer add
vredsum reduction sum
vreinterpret 保留 bit pattern,改用另一個向量型別解讀

Instruction mnemonic 通常能對到 RVV ISA 名稱,例如 vfadd.vv 對應 intrinsic 中的 vfadd_vv。Pseudo intrinsic 是例外;例如 vsetvl 通常會使 compiler 產生 vsetvlivsetivlivreinterpret 可能不需要任何 machine instruction。

第二段:operand mnemonic

Operand mnemonic 說明輸入的形狀。常見形式如下:

片段 意義 範例
vv vector-vector __riscv_vadd_vv_i32m1(va, vb, vl)
vx vector-integer scalar __riscv_vadd_vx_i32m1(va, x, vl)
vf vector-floating scalar __riscv_vfadd_vf_f32m1(va, x, vl)
vs reduction 類的 vector 與 scalar-vector-group 形式 __riscv_vredsum_vs_i32m1_i32m1
v 單一 vector operand,或 load/store 的 vector data __riscv_vle32_v_f32m1

vx 裡的 x 指一般整數暫存器或整數 scalar;浮點 scalar 使用 f。常數傳給 vx intrinsic 時,compiler 仍可能選擇 ISA 的 immediate form。Intrinsic 名稱描述 C 介面,不應用名稱直接斷言最終一定是 .vx

第三段:型別 i32m1u8m4f32m1

一般 non-mask 回傳型別會把 element kind、element width 與 LMUL 寫在一起:

i32m1
│ │ └─ LMUL = 1
│ └─── element width = 32 bit
└───── signed integer

常見的 element kind:

  • i:signed integer,例如 i32m1
  • u:unsigned integer,例如 u8m4
  • f:IEEE floating point,例如 f32m1
  • bf:BFloat16 extension 使用的縮寫,例如 bf16m1
  • b:mask ratio,例如 b32,對應 vbool32_t

Intrinsic 名稱和 C vector type 會互相對應:

Intrinsic suffix C vector type 意義
i32m1 vint32m1_t signed int32、LMUL = 1
u8m4 vuint8m4_t unsigned int8、LMUL = 4
f32mf2 vfloat32mf2_t float32、LMUL = 1/2
bf16m1 vbfloat16m1_t BFloat16、LMUL = 1;需要對應 extension 與 toolchain
b32 vbool32_t mask type;ratio 由 EEW / EMUL 決定

mf2mf4mf8 是 fractional LMUL。並非每個 element width 都能搭配所有 LMUL;合法組合要查 type system,widening / narrowing 還要同時檢查來源與目的 EMUL。

第四段:load/store 名稱要同時看 EEW 和 vector type

Array add 使用:

vfloat32m1_t va = __riscv_vle32_v_f32m1(&a[i], vl);
__riscv_vse32_v_f32m1(&out[i], va, vl);

可以拆成:

__riscv_vle32_v_f32m1
          │   │  └─ float32, LMUL = 1
          │   └──── vector result
          └──────── 每個 memory element 載入 32 bit

__riscv_vse32_v_f32m1
          │   │  └─ 寫入的 vector type
          │   └──── vector source
          └──────── 每個 memory element 儲存 32 bit

vle32 中的 32 是 memory element width。對本篇 same-width load,它和 f32m1 的 32 一致;遇到 widening load、narrowing store、index load/store 或 segment load/store 時,不應只看其中一個數字。

第五段:widening 名稱寫的是目的型別

看這個官方範例:

vint32m1_t __riscv_vwadd_vv_i32m1(
    vint16mf2_t vs2,
    vint16mf2_t vs1,
    size_t vl);

vwadd 表示 widening add,名稱尾端的 i32m1 是回傳型別。輸入是 i16mf2,結果是 i32m1。來源使用 LMUL = 1/2、目的使用 LMUL = 1,兩邊具有相同 VLMAX。

所以不能看到 i32m1 就假設所有 operand 都是 int32。Widening、narrowing、conversion 與 reduction 都應該打開 prototype 核對每個參數型別。

第六段:mask 結果會再編碼 mask type

整數相等比較的 explicit 名稱是:

vbool32_t __riscv_vmseq_vv_i32m1_b32(
    vint32m1_t vs2,
    vint32m1_t vs1,
    size_t vl);

i32m1 描述輸入向量,b32 描述回傳的 mask type。對 e32m1 而言,mask ratio 是 32,因此 C type 是 vbool32_t

這個 mask 可以交給 masked intrinsic,讓 operation 只更新 mask 為 true 的 active element。

第七段:reduction 會同時寫輸入與輸出型別

只編碼回傳型別不足以區分 reduction 的來源 LMUL,所以 explicit reduction 會寫兩組型別:

vint32m1_t __riscv_vredsum_vs_i32m4_i32m1(
    vint32m4_t vs2,
    vint32m1_t vs1,
    size_t vl);
vredsum_vs_i32m4_i32m1
           │      └─ reduction result / seed group:i32m1
           └──────── input vector:i32m4

Reduction 的結果仍放在向量 register group;通常再用 scalar-move intrinsic 取出第 0 個元素。

第八段:_rm 與 policy suffix

浮點 intrinsic 如果要明確指定 rounding mode,名稱會加入 _rm,函式也會多一個 frm 參數:

vfloat32m1_t vc = __riscv_vfadd_vv_f32m1_rm(
    va, vb, __RISCV_FRM_RNE, vl);

一般 intrinsic 的尾端還可能出現 policy suffix。RVV Intrinsic v1.0 的命名如下:

Suffix Mask Tail policy Masked-off policy 是否需要 passthrough vd
無 suffix unmasked agnostic 不適用
_tu unmasked undisturbed 不適用
_m masked agnostic agnostic
_tum masked undisturbed agnostic
_mu masked agnostic undisturbed
_tumu masked undisturbed undisturbed

vadd 為例:

// tail-agnostic
vint32m1_t r0 =
    __riscv_vadd_vv_i32m1(a, b, vl);

// tail-undisturbed:超過 vl 的 tail element 保留 passthrough vd
vint32m1_t r1 =
    __riscv_vadd_vv_i32m1_tu(old, a, b, vl);

// masked、tail-agnostic、mask-agnostic
vint32m1_t r2 =
    __riscv_vadd_vv_i32m1_m(mask, a, b, vl);

// masked,tail 與 masked-off element 都保留 passthrough vd
vint32m1_t r3 =
    __riscv_vadd_vv_i32m1_tumu(mask, old, a, b, vl);

Agnostic 表示程式不能依賴 tail 或 masked-off element 的內容。如果 accumulator 分多輪更新,最後卻用較大的 vl reduction,先前未啟用的 tail 可能在後一輪變成 active;這類寫法需要仔細檢查是否應使用 _tu 保留舊值。

第九段:FMA 名稱相近,operand role 仍要看 prototype

vfmacc 是常見的累加形式

vfloat32m1_t acc = __riscv_vfmacc_vv_f32m1(
    acc, va, vb, vl);

它表達:

acc = acc + va * vb

vfmaddvfnmaccvfmsac 等名稱也屬於 fused multiply-add family,但 destination 與 source 的角色不同。寫多項式、dot product 或 GEMM accumulator 時,不能只看到「fma」就互換。先確認 ISA semantics,再核對 intrinsic prototype 和實際 assembly。

複習一下要怎麼樣讀

Intrinsic 怎麼讀
__riscv_vsetvl_e32m1(avl) 以 SEW=32、LMUL=1 取得本輪 vl
__riscv_vle32_v_f32m1(ptr, vl) unit-stride 載入 32-bit element,回傳 float32 m1 vector
__riscv_vse32_v_f32m1(ptr, v, vl) 把 float32 m1 vector 以 32-bit element 儲存
__riscv_vadd_vv_i32m1(a, b, vl) int32 m1 vector + vector
__riscv_vadd_vx_i32m1(a, x, vl) int32 m1 vector + integer scalar
__riscv_vfadd_vf_f32m1(a, x, vl) float32 m1 vector + floating scalar
__riscv_vfmacc_vv_f32m1(acc, a, b, vl) float32 m1 fused multiply-accumulate
__riscv_vmseq_vv_i32m1_b32(a, b, vl) 比較兩個 int32 m1 vector,回傳 b32 mask
__riscv_vwadd_vv_i32m1(a16, b16, vl) 兩個 int16 mf2 vector widening add,回傳 int32 m1
__riscv_vredsum_vs_i32m4_i32m1(v, seed, vl) 將 int32 m4 vector reduction 到 i32m1 group
__riscv_vreinterpret_v_i32m1_f32m1(v) 保留 bits,將 i32m1 register group 重新解讀成 f32m1
__riscv_vfadd_vv_f32m1_rm(a, b, frm, vl) 明確指定 floating-point rounding mode 的 float32 add
__riscv_vadd_vv_i32m1_tumu(mask, old, a, b, vl) masked add,tail 與 masked-off element 都保留 old

vreinterpret 只改型別觀點,不做數值轉換。如果要把 int32 數值轉成 float32,應查 vfcvt family;BF16 bit pattern 也不能用一般 narrowing float conversion 代替格式轉換。

Overloaded 名稱為什麼短很多

同一個整數 add 也可以使用 overloaded 介面:

vint32m1_t r0 = __riscv_vadd(a32, b32, vl);
vint16m4_t r1 = __riscv_vadd(a16, b16, vl);

__riscv_vadd 沒有 vvi32m1,compiler 從參數型別選擇介面。Policy suffix 仍會保留,例如 __riscv_vadd_tu

Overloaded 名稱也有例外。Widening operation 為了區分 vvvxwvwx,仍會保留 operand mnemonic;conversion、vreinterpret 等 family 也可能保留回傳型別。閱讀別人的程式時,先確認它採用 explicit 或 overloaded API,再套用對應規則。

遇到新 intrinsic 時的閱讀順序

看到一個沒讀過的名稱,可以照這六步:

  1. 找 instruction mnemonic:它是 load、arithmetic、conversion、reduction 還是 permutation?
  2. 找 operand mnemonic:vvvxvfvs 分別說明哪些 operand 形狀?
  3. 讀 return type:signedness、element width、LMUL 或 mask ratio 是什麼?
  4. 檢查是否為 widening、narrowing、reduction 或 reinterpret;這些 family 常要編碼多組型別。
  5. _rm_tu_m_tum_mu_tumu
  6. 打開 <riscv_vector.h> 對應文件或官方 generated prototype,再用 assembly 驗證 compiler 最後選了什麼。

要怎麼根據 K1 規格調整寫法

K1 的 Vector-256bit 讓我們知道 e32m1 一輪最多 8 個 float32。寫 RVV kernel 時,LMUL 只是其中一個調整點;資料型別、載入/儲存、暫存器壓力、執行緒切分也會影響結果。可以從幾個方向看。

1. 先選對 SEW

對今天例子這類 same-width operation,SEW 要跟資料型別對上。widening / narrowing 指令和某些 load / store 還會有 EEW / EMUL 與當前 SEW / LMUL 不同的情況,這裡先不展開。

  • float32e32 / f32
  • float16 會使用 e16 的 FP16 向量操作。BF16 雖然也是 16-bit 儲存格式,但指數與尾數佈局不同;Zvfbfmin 提供 BF16 與 FP32 的向量轉換,Zvfbfwma 提供 BF16 乘法並 widening 累加到 FP32,e16 只表示 SEW = 16,不代表 FP16 與 BF16 具有相同運算語意。
  • int8 quantized weight 或 activation 會接到 e8 相關載入/ convert / widening 操作。

SEW 變小時,一個向量暫存器可以放更多元素。K1 的 256-bit 向量在 e8m1 下最多可以放 32 個 8-bit 元素,所以量化 kernel 會很在意資料 unpack、縮放係數、widening、累加型別怎麼安排。

2. LMUL 要看暫存器壓力

LMUL 變大時,一輪可以處理更多元素,也會用掉更多向量暫存器。e32m2 一輪最多 16 個 float32,看起來比 e32m1 多,但可用的向量暫存器 group 會變少。

對簡單 array add,m2 可能看起來合理;對比較複雜的 kernel,例如同時要保留累加器、縮放係數、mask、temporary buffer,LMUL 太大會讓暫存器壓力變高,編譯器可能產生更多溢出,被迫將多餘的變數暫時存放到速度較慢的記憶體,最後效能反而下降。

所以選 LMUL 時可以先問:

  • 這個 kernel 同時需要幾個向量的值?
  • 有沒有 widening 運算,會讓 EMUL 變大?
  • 是否需要 mask 或 tail-undisturbed 版本保留舊值?
  • 載入/儲存是連續資料,還是 stride / gather?

3. 優先讓載入/儲存連續

RVV arithmetic 指令本身通常只佔一部分成本。對 K1 這種用 LPDDR 的板子,資料搬移很容易變成瓶頸。

array add 這種例子使用的是 unit-stride 載入/儲存:

__riscv_vle32_v_f32m1(&a[i], vl);
__riscv_vse32_v_f32m1(&out[i], vc, vl);

這是最容易讓硬體跑得順的型態。後面做 AI 運算子時,如果資料配置讓 kernel 需要很多不連續載入、stride 載入或 scalar gather,RVV 指令數看起來變少,實際效能仍可能被記憶體存取拖住。

4. 多核心優化要交給執行緒層級平行化

RVV intrinsic 描述的是單個硬體執行緒上的向量運算。K1 有 dual-cluster 8 核心,想吃到 8 核心,需要在外層切工作。

概念上會長這樣:

執行緒 0 -> 處理 out[0..chunk0]
執行緒 1 -> 處理 out[chunk0..chunk1]
...
每個執行緒 裡面再跑 RVV strip-mining loop

這也是後面 SGLang RVV 後端會遇到的問題:運算子內部要怎麼切 batch、row、head、block,才能讓多核心和 RVV 都有事做,同時不要讓 cache 和記憶體頻寬爆掉。

5. 自訂 AI 指令要另外處理

K1 相關資料提到 AI computing power 和自訂指令。規格表將專用 AI 指令、TCM 與 2 TOPS 資源列在 Cluster 0,不是八個核心都有對稱的專用 AI 資源。這些能力也要和標準 RVV intrinsic 分開看。

__riscv_vfadd_vv_f32m1 這類 intrinsic 對應的是標準 RVV。自定義指令通常需要廠商函式庫、特定 intrinsic、inline assembly,或編譯器後端支援。寫標準 RVV intrinsic 時,不會自動把某個運算變成 K1 的自訂 AI 指令。

所以這裡會分成兩條路

標準 RVV path:
C/C++ intrinsic -> RVV 指令 -> 可用一般 GCC/LLVM 檢查

廠商自定義 path:
廠商 API / intrinsic / assembly -> 自定義指令 -> 需要廠商文件與工具鏈支援

這個系列先走標準 RVV 路徑。後面如果要碰自訂 AI 指令,需要另外補工具鏈和文件。

編譯和檢查

RVV intrinsic 要 include:

#include <riscv_vector.h>

編譯時,目標也要開 RVV。概念上會需要類似這種設定:

riscv64-linux-gnu-gcc -O3 -march=rv64gcv -mabi=lp64d add.c -c

K1 規格寫的是 RV64GCVB。實際 -march 要看你手上的 GCC / LLVM 支援哪些擴充名稱。現行 G 等價於 IMAFDZicsrZifenceiB 簡寫包含 ZbaZbbZbs,K1 文件另列 Zbc。編譯前要查 toolchain 接受的寫法,編譯後再看反組譯。

Intrinsic API 版本也要分開記錄。RVV intrinsic 1.0 API 的完整支援以 GCC 14 與 Clang 19 為較安全的 baseline;GCC 13 和 Clang 16 對應的是較早 0.11 API。編譯器能產生 RVV 指令,不等於它接受相同版本的 intrinsic 名稱與型別。

在真實專案裡,我不會只看 C 程式碼就以為它用了 RVV,比較可靠的檢查方式是看編譯後的組合語言或 object 反組譯有沒有出現 RVV 組合語言

之後做 PyTorch Inductor RVV 或 SGLang RVV 後端時,也會用同樣方法檢查產生的程式碼:看 C++ source 有沒有走到向量路徑,再看編出來的 .so 裡有沒有真的出現 RVV 指令。

今天先走到這裡

先複習一下今天的重點

  • SIMD 是在單一硬體執行緒內,對多個 active element 套用相同操作;它可以和多核心平行化疊加,但 VLEN 不等於單週期吞吐量。
  • RVV intrinsic 位在一般 C 迴圈與 assembly 之間:程式明確表達 RVV 語意,compiler 仍負責暫存器配置與排程。
  • 一個基本 RVV 迴圈會用 vsetvl 決定這輪的 vl,再做向量載入、計算、儲存。
  • K1 的 Vector-256bit 可以用來估算 VLMAX,例如 e32m1 一輪最多 8 個 float32
  • SEW 跟資料型別有關,LMUL 跟一輪處理量和暫存器壓力有關。
  • Explicit intrinsic 名稱通常依序編碼 operation、operand form、回傳型別、rounding mode 與 policy;widening、reduction、mask 和 conversion 還要打開 prototype 核對來源與目的型別。
  • _tu_m_tum_mu_tumu 說明 tail 與 masked-off element 是否需要保留舊值。
  • K1 的 8 核心、cache、LPDDR、DVFS 會影響效能測試和 kernel tuning;一段 intrinsic 只能處理單核向量迴圈,外層仍要另外安排。
  • K1 的自訂 AI 指令和標準 RVV intrinsic 要分開看。

明天會把 RVV 迴圈放進真實推論服務系統裡看。我們會從 SGLang 的 attention 後端出發,從 Python 執行環境到 C++ 自訂運算子的路徑,理解手寫 RVV kernel 要怎麼變成模型前向傳播會呼叫到的後端。

參考資料


上一篇
Day01:從 SpacemiT K1 規格讀懂 RISC-V CPU 架構
下一篇
Day03:SGLang RVV Attention 後端怎麼接進來
系列文
在 AI Compiler 工程師的路上5
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言