昨天看完 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。
vsetvl 決定 vl。vl 接回 K1 的 Vector-256bit。同一個 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++ 型別與函式介面,讓程式直接表達:
vl 個 active element。可以把三種方法放在一起看:
| 寫法 | 程式明確指定什麼 | 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 是 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 個 float32。out[i] = a[i] + b[i] 一輪最多可處理 8 個元素。
這裡先講最多,實際每輪處理幾個元素,要看 vsetvl 回傳的 vl。
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 會把最後一輪的元素數控制好。
#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;
}
}
這段程式做的事情很單純:把 a 和 b 的每個 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。先能把 vle32、vfadd、vse32 讀出來,就能跟著很多簡單範例走。
現在回頭介紹一下常見名詞,前面介紹一直噴很多名詞出來。
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,例如 mf2、mf4、mf8。
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 v1.0 把介面分成兩類
本篇先用 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 產生 vsetvli 或 vsetivli,vreinterpret 可能不需要任何 machine instruction。
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。
i32m1、u8m4、f32m1一般 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 決定 |
mf2、mf4、mf8 是 fractional LMUL。並非每個 element width 都能搭配所有 LMUL;合法組合要查 type system,widening / narrowing 還要同時檢查來源與目的 EMUL。
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 時,不應只看其中一個數字。
看這個官方範例:
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 核對每個參數型別。
整數相等比較的 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 的來源 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 保留舊值。
vfmacc 是常見的累加形式
vfloat32m1_t acc = __riscv_vfmacc_vv_f32m1(
acc, va, vb, vl);
它表達:
acc = acc + va * vb
vfmadd、vfnmacc、vfmsac 等名稱也屬於 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 代替格式轉換。
同一個整數 add 也可以使用 overloaded 介面:
vint32m1_t r0 = __riscv_vadd(a32, b32, vl);
vint16m4_t r1 = __riscv_vadd(a16, b16, vl);
__riscv_vadd 沒有 vv、i32 或 m1,compiler 從參數型別選擇介面。Policy suffix 仍會保留,例如 __riscv_vadd_tu。
Overloaded 名稱也有例外。Widening operation 為了區分 vv、vx、wv、wx,仍會保留 operand mnemonic;conversion、vreinterpret 等 family 也可能保留回傳型別。閱讀別人的程式時,先確認它採用 explicit 或 overloaded API,再套用對應規則。
看到一個沒讀過的名稱,可以照這六步:
vv、vx、vf、vs 分別說明哪些 operand 形狀?_rm、_tu、_m、_tum、_mu、_tumu。<riscv_vector.h> 對應文件或官方 generated prototype,再用 assembly 驗證 compiler 最後選了什麼。K1 的 Vector-256bit 讓我們知道 e32m1 一輪最多 8 個 float32。寫 RVV kernel 時,LMUL 只是其中一個調整點;資料型別、載入/儲存、暫存器壓力、執行緒切分也會影響結果。可以從幾個方向看。
對今天例子這類 same-width operation,SEW 要跟資料型別對上。widening / narrowing 指令和某些 load / store 還會有 EEW / EMUL 與當前 SEW / LMUL 不同的情況,這裡先不展開。
float32 用 e32 / f32。float16 會使用 e16 的 FP16 向量操作。BF16 雖然也是 16-bit 儲存格式,但指數與尾數佈局不同;Zvfbfmin 提供 BF16 與 FP32 的向量轉換,Zvfbfwma 提供 BF16 乘法並 widening 累加到 FP32,e16 只表示 SEW = 16,不代表 FP16 與 BF16 具有相同運算語意。e8 相關載入/ convert / widening 操作。SEW 變小時,一個向量暫存器可以放更多元素。K1 的 256-bit 向量在 e8m1 下最多可以放 32 個 8-bit 元素,所以量化 kernel 會很在意資料 unpack、縮放係數、widening、累加型別怎麼安排。
LMUL 變大時,一輪可以處理更多元素,也會用掉更多向量暫存器。e32m2 一輪最多 16 個 float32,看起來比 e32m1 多,但可用的向量暫存器 group 會變少。
對簡單 array add,m2 可能看起來合理;對比較複雜的 kernel,例如同時要保留累加器、縮放係數、mask、temporary buffer,LMUL 太大會讓暫存器壓力變高,編譯器可能產生更多溢出,被迫將多餘的變數暫時存放到速度較慢的記憶體,最後效能反而下降。
所以選 LMUL 時可以先問:
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 指令數看起來變少,實際效能仍可能被記憶體存取拖住。
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 和記憶體頻寬爆掉。
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 等價於 I、M、A、F、D、Zicsr、Zifencei;B 簡寫包含 Zba、Zbb、Zbs,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 指令。
先複習一下今天的重點
vsetvl 決定這輪的 vl,再做向量載入、計算、儲存。e32m1 一輪最多 8 個 float32。SEW 跟資料型別有關,LMUL 跟一輪處理量和暫存器壓力有關。_tu、_m、_tum、_mu、_tumu 說明 tail 與 masked-off element 是否需要保留舊值。明天會把 RVV 迴圈放進真實推論服務系統裡看。我們會從 SGLang 的 attention 後端出發,從 Python 執行環境到 C++ 自訂運算子的路徑,理解手寫 RVV kernel 要怎麼變成模型前向傳播會呼叫到的後端。
e6bc0ae