25 · 全注意力层设备化、整层对拍与访存粒度
一句话 :把 64 层里剩下的 16 层 full attention 也搬上设备后,两类层都完成了设备化;接着用「整层对拍 → 逐级下钻 → 单核微基准 → 端到端验收」这条链定位「单层对得上、整模型一时对不上」的原因,最后落到一刀访存粒度 ,端到端从 27.2 ms/token 走到 18.9 ms/token。 前置 :建议先读 第 24 篇(GDN 48 层设备化)。 环境 :x86-64 + NVIDIA RTX 5090(32607 MiB / sm_120);模型 Qwen3.5-27B q4;参考口径
model_q4.vqf(q4 权重 + f32 激活)。 三个数 :① 全注意力单层对拍 worst max_rel 4.697e-07 (比判据 1e-4 宽约 200 倍,第 2.3 节);② 全 64 层设备内 decode 端到端 maxrel 3.4e-06 、36.8 tok/s (27.145 ms/token,第 4.5 节);③ 访存粒度落地后端到端 27.2 → 18.9 ms/token (1.44× ,跨会话量级,第 8.6 节),单核cold口径 1.48--1.81× (第 8.4 节)。 怎么读 :结论看 一、两类层都已设备化,离全模型只差一步 、九、方法链总结:一条可复用的套路 、十、小结 (必读);机制看 二、全注意力层设备化:逐算子对齐 、三、整层对拍的前提:同口径参考与构建线 、四、全模型异构设备内 decode 、八、访存粒度:端到端 1.44× (想深挖再看);五、审计入档 、六、负向用例与 27 ms 的成本分解 、七、单核微基准:形状扫描 与文末附录属过程与原始数据(可跳)。
第 24 篇把 64 层里那 48 层 GDN 搬到了设备上。本篇把剩下的 16 层 full attention 也搬上去 , 于是两类层都已完成设备化,离「全模型串起来」只差「异构 dispatch + final norm / lm_head」这一步。
接下来这篇的走法是本系列里最完整的一次逐级下钻 :先让整层对拍 说话(单层对上了,可整模型一时对不上), 再把「对不上」拆到相、拆到层,拆到单核微基准 ,最后落到一刀访存粒度------ 端到端从 27.2 ms/token 走到 18.9 ms/token。
状态:✅ 已落地。
一、两类层都已设备化,离全模型只差一步
Qwen3.5-27B 的 64 层不是一个同构堆叠,而是两种层型交替:
ini
index % 4 == 3 → full attention(16 层)
其它 → GDN(48 层)
每一层后面都接一个 dense SwiGLU MLP。第 24 篇收掉的是那 48 层 GDN 的整套算子与递推状态; 本篇收掉的是剩下 16 层 full attention 的整套算子 + 逐层 KV cache 镜像。
写到这里,路径上的两类层都各自单测过关了,但**「每层单独对」和「64 层串起来对」是两件事**------ 残差、两处 RMSNorm、MLP 这三块,在 GDN 与 full-attn 两轮里都是故意留给调用方的, 从来没被验证过。所以这一篇真正的骨架是:
| 阶段 | 做了什么 | 拿到了什么 |
|---|---|---|
| 全注意力层设备化 | 单层对拍 | worst max_rel 4.7e-07 |
| 整层同口径参考 + 缺失的构建线 | _q4ref4 / _q4ref64 + 一条会自证的构建线 |
|
| 全模型异构设备内 decode | 端到端 maxrel 3.4e-06 / 36.8 tok/s | |
| 三条守卫的负向用例 + 27 ms 成本分解 | 守卫可证、成本可拆 | |
| 单核微基准的形状扫描 | 一条假设被证伪 + 一个量化基线 | |
| 访存粒度 | 端到端 1.44× |
一句话概括这条链:从「整层对拍」发现问题,用「逐级下钻」定位问题,用「单核微基准」量化问题, 最后在「端到端」上验收收益。
二、全注意力层设备化:逐算子对齐
2.1 逐算子对应表
CPU 参考是 src/model/vllm_q35_attn.c(已由仓库的 attn 探针验证)。设备侧要与之逐算子对齐 (内核在 src/npu/cuda/vllm_cuda_kernels.cu 与共享源 src/npu/cuda/attn_prep_twin.inc / attn_core_twin.inc):
| CPU | 内核 | 关键点 |
|---|---|---|
| ① 四个投影 | k_f32_gemv / vc_rq_launch_gemv |
大投影二选一(f32 / rq q4) |
| ② RoPE 表 | k_attn_rope_tab |
partial:只用前 rd 维;emb = cat((f, f)) |
| ③/④ q 的 gate 提取 + 归一 + 旋转 + 就地压紧 | k_attn_q_prep |
★ gate 是逐 head 交织 (第 i 个 head 的后 hd 维),不是 两段拼接;RMSNorm((1 + q_norm)) ★ 用 1 + w |
| ④ k 的归一 + 旋转 | k_attn_k_prep |
同上,(1 + k_norm) |
| ⑤ 写 KV cache | cudaMemcpyDeviceToDevice |
追加下标 = 该层已写长度 |
| ⑥ GQA 注意力 | k_attn_decode |
一个 block 一个 q-head;顺序 qk→scale→max→exp→sum→norm→加权 V,与 CPU 逐字一致 |
| ⑦ output gate + o_proj | k_attn_out_gate + gemv |
★ 逐元素乘 sigmoid(gate) 之后才进 o_proj |

图 1 :16 层 full attention 的设备化视图 (逐算子与 CPU
q35_attn_step对齐)。① 四个投影走 f32 或 rq q4 二选一 → ② RoPE 表(partial :仅前rd=64维参与、hd=256,emb = cat((f,f)))→ ③④ q 的 gate 提取 + 归一 + 旋转 + 就地压紧、k 的归一 + 旋转(RMSNorm((1+w));★ gate 是逐 head 交织 的第i个 head 后hd维,不是 两段拼接)→ ⑤ 以 D2D 写 KV cache(追加下标 = 该层已写长度)→ ⑥ GQA 注意力(一个 block 一个 q-head,顺序 qk→scale→max→exp→sum→norm→加权 V)→ ⑦ output gate:★ 先逐元素乘sigmoid(gate)再进 o_proj。单层对拍worst max_rel 4.7e-07。
这张表里最难、也最容易写错的是三处,规格把它们逐条点出来:
- q 的 gate 是「逐 head 交织」,不是两段拼接 。q_proj 的输出宽度是
2 * nh * hd, 其中 gate 部分不是 跟在 q 后面的一大段,而是每个 head 各带着自己那hd维的 gate 。 写成cat(q_all, gate_all)就错了。 - partial RoPE 只旋转每 head 的前
rd维 (本模型rd = 64,而hd = 256), 且 RoPE 的emb是cat((f, f))的形式,只有前rd维参与。 - output gate 在 o_proj 之前 :先逐元素乘
sigmoid(gate),再进 o_proj。顺序反了结果就变。
上面这三条,前两条直接写在两个预处理核心里(gate 取「每个 head 的后半」、只旋转前 rd 维):
c
/* 文件名:src/npu/cuda/attn_prep_twin.inc
* ① 把 gate 抄出来(第 i 个 head 的后 hd 维)、② RMSNorm((1+q_norm))、
* ③ partial RoPE(只旋转前 rd 维;emb = cat((f,f)))、④ 就地压紧到 qg 的前 hd 维。 */
__global__ void k_attn_q_prep(float *__restrict qg, float *__restrict gt,
const float *__restrict q_norm,
const float *__restrict cosp,
const float *__restrict sinp,
int nh, int hd, int rd, float eps) {
const int i = blockIdx.x; /* 一个 block 一个 head(M=1 时;M>1 见文件头) */
const int d = threadIdx.x;
if (i >= nh) return;
extern __shared__ float sh[];
float *red = sh, *yv = sh + hd;
const size_t base = ((size_t)i) * (size_t)2 * (size_t)hd;
const float *src = qg + base;
const float x = src[d];
gt[(size_t)i * (size_t)hd + d] = src[hd + d]; /* ★ gate = 每个 head 的后半,不是整段 */
red[d] = x * x;
__syncthreads();
for (int s = blockDim.x >> 1; s > 0; s >>= 1) { /* 块内二分归约 */
if (d < s) red[d] += red[d + s];
__syncthreads();
}
const float inv = 1.0f / sqrtf(red[0] / (float)hd + eps);
const float y = (1.0f + q_norm[d]) * (x * inv); /* ★ (1 + w) */
yv[d] = y;
__syncthreads(); /* 旋转要读**别人的** y */
float o = y;
if (d < rd) { /* ★ partial:只旋转前 rd 维 */
const int half = rd >> 1;
const float rot = (d < half) ? -yv[half + d] : yv[d - half];
o = y * cp[d] + rot * sp[d];
}
qg[base + d] = o; /* 就地压紧 */
}
K 侧同构(k_attn_k_prep),GQA 主注意力则是「一个 block 一个 q-head,按顺序 qk→scale→max→exp→sum→norm→加权 V」:
c
/* 文件名:src/npu/cuda/attn_core_twin.inc ------ GQA 注意力(单 token 口径)
* 动态 shared = [npos] scores + [blockDim] 归约暂存。顺序与 CPU 逐字一致。 */
__global__ void k_attn_decode(float *__restrict ao, const float *__restrict qg,
const float *__restrict kc, const float *__restrict vc,
const int *__restrict dyn, int nh, int nkv, int hd,
int kv_width, float scale) {
const int i = blockIdx.x; /* 一个 block 一个 q-head */
const int d = threadIdx.x;
if (i >= nh) return;
const int npos = dyn[1] + 1; /* 可见长度(设备标量,兼容 graph) */
const int rep = nh / nkv;
const int kh = i / rep;
extern __shared__ float sh[];
float *scp = sh; /* [npos] */
float *red = sh + npos; /* [blockDim] */
const float *qp = qg + ((size_t)i) * (size_t)2 * (size_t)hd;
float lmax = -1e30f;
for (int t = d; t < npos; t += blockDim.x) { /* qk → scale */
const float *kp = kc + (size_t)t * kv_width + (size_t)kh * hd;
float acc = 0.0f;
for (int u = 0; u < hd; u++) acc += qp[u] * kp[u];
acc *= scale;
scp[t] = acc;
if (acc > lmax) lmax = acc; /* max */
}
...
float acc = 0.0f; /* norm → 加权 V */
for (int t = 0; t < npos; t++)
acc += scp[t] * inv * vc[(size_t)t * kv_width + (size_t)kh * hd + d];
ao[(size_t)i * (size_t)hd + d] = acc;
}
2.2 接口与约束
接口是 vllm_cuda_attn_{init, preload_f32, use_rq, reset, step},其中 layer 是全注意力紧凑下标 (与 GDN 同一约定------两套子后端各自按 compact 编址,驱动层再映射)。约束有两条:
hd/rd必须是 2 的幂 ------k_attn_*_prep与k_attn_decode都用blockDim == hd做块内二分归约;max_seq ≤ 4096。
2.3 实测
用 attn_l3 导出(layer 3 = 第一个全注意力层;T = 5,f32 权重):
text
[CUDA] attn: nl=1 dim=5120 nh=24 nkv=4 hd=256 rd=64 max_seq=512; KV 4.0 MB
[attn] tok max_abs_err max_abs_ref max_rel verdict
[attn] 0 4.768e-07 5.477e+00 8.706e-08 OK
[attn] 1 9.537e-07 6.868e+00 1.389e-07 OK
[attn] 2 4.768e-06 1.015e+01 4.697e-07 OK
[attn] 3 3.815e-06 1.639e+01 2.327e-07 OK
[attn] 4 3.129e-07 1.245e+01 2.514e-08 OK
[attn] PASS (0/5 tokens out of tolerance), worst max_rel=4.697e-07
[attn] reset 后首 token 位级复现(同进程/同发射配置):不等元素 = 0 / 5120 [相同]
[attn] step(host-facing): 0.350 ms/token/层(npos≈5 起步,随时间增长)
- 正确性(实测) :
worst max_rel 4.7e-07,比判据宽 200 倍。上面三处易错点都是一次通过, 说明与 CPU 参考的逐算子对齐到位。 reset:重放首 token 位级相同 ⇒ KV cache 真被清空 + 核确定性。但要注意口径------ 这同样只是必要条件 检验,不是设备路径的位级证明 (口径同 第 24 篇)。- 性能(实测) :0.350 ms/token/层,且本轮是 f32 权重 (q 251 MB + k/v 42 MB + o 126 MB ≈ 420 MB) ⇒ 与 GDN 层同性质:权重读主导 。本档未测 q4(
attn_use_rq已就位但未接测试数据)。 - 纪律 :本轮是 f32 档(与 oracle 同口径),先把算子序列钉死,再换 q4。
2.4 已知边界
- 只测了 T=5、单层、
max_seq=512;长上下文(npos 大)未测 。k_attn_decode的开销随 npos 线性增长(阶段 1 的 QK 是O(npos · hd)),长上下文下它会成为新瓶颈,需要单独优化(分块 / Flash 式)。 - 只对拍最终
out,中间量(q / gate / scores)未逐项观测(GDN 那边有gdn_get同类手段,注意力还没建)。 attn_len在宿主侧维护(追加下标由宿主给)⇒ 全设备内驱动需要把它挪到设备侧。
三、整层对拍的前提:同口径参考与构建线
在动 64 层之前,先解决「单层整层能否对拍」------因为残差、两处 RMSNorm、MLP 这三块从没验过。
3.1 同口径参考其实已经有了
★ 关键侦察结论(省下大量弯路) :tools/q35_model_full.c 本身就有 --vqf <path> 与 --layers N (usage 行里没写 --vqf,容易漏看)。配合 VLLM_Q35_FUSED=0,它就变成非融合路径 : 从 model_q4.vqf 读出的权重量化解量化成 f32,配 f32 激活 ------这正好和 GPU 侧 vc_rq_launch_gemv(q4 权重 + f32 激活)同口径。
powershell
$env:VLLM_Q35_FUSED = "0"
q35_model_full <model_dir> --vqf <md>\model_q4.vqf --dump <md>\_q4ref4 --layers 4 --quiet
本轮生成了 _q4ref4\(含 h0..h4,各 5×5120,加 hfinal + logits),耗时 11.3 s 。 层 0/1/2 是 GDN、层 3 是全注意力(index % 4 == 3)⇒ 前四层就把两种层型都覆盖了 。 对拍方式:把 h_L 喂给设备侧第 L 层,与 h_{L+1} 比较(此口径下差异应只有归约序)。
3.2 还缺的三块设备实现
- RMSNorm with
(1 + w):引擎现有的k_rms_norm用的是w,而 Qwen3.5 的 input/post/final layernorm 都是Qwen3_5RMSNorm⇒ 要用(1 + w)。不能复用,要新写。 - SwiGLU MLP :
gate/up/down三张投影(已有vc_rq_launch_gemv)+silu(gate) * up的逐元素内核(引擎有k_swiglu,但激活口径 / 布局要对齐)。 - 残差累加 与层间驱动 (norm→mixer→residual→norm→MLP→residual)。引擎现有
dc_*那套是 同构 64 层的,不能直接用(27B 是异构)。
前两块落到两个专门的核上------(1 + w) 的 RMSNorm 必须另写一份 (不能改既有用的是 w 的那个), 残差原地累加也必须另写 (k_add 的两个参数都带 __restrict,原地调用形式上是 UB):
c
/* 文件名:src/npu/cuda/vllm_cuda_kernels.cu
* RMSNorm with (1 + w):与既有 k_rms_norm 同构,只换缩放因子。
* Qwen3_5RMSNorm 的 weight 初值是 0、前向是 output*(1+w),复用 k_rms_norm 会在 t>=1 才显形。 */
__global__ void k_rms_norm_unit(float *__restrict out, const float *__restrict x,
const float *__restrict w, int d, float eps) {
__shared__ float red[DC_BLK];
const int i = threadIdx.x;
float ss = 0.0f;
for (int j = i; j < d; j += DC_BLK) ss += x[j] * x[j];
red[i] = ss;
__syncthreads();
for (int off = DC_BLK >> 1; off > 0; off >>= 1) {
if (i < off) red[i] += red[i + off];
__syncthreads();
}
const float rms = rsqrtf(red[0] / (float)d + eps);
for (int j = i; j < d; j += DC_BLK) out[j] = (x[j] * rms) * (1.0f + w[j]); /* ★ (1 + w) */
}
/* 残差**原地**累加 x += b(模型驱动每层用两次)。不复用 k_add:
* k_add 的两个参数都带 __restrict(承诺不别名),而这里 x 同时是输入与输出 ------ 形式 UB。 */
__global__ void k_add_ip(float *x, const float *b, int n) {
const int i = blockIdx.x * DC_BLK + threadIdx.x;
if (i >= n) return;
x[i] += b[i];
}
3.3 缺失的构建线,以及补上它的三个坑
tools/ 下这些工具(q35_model_full / q35_fused_check / q35_gdn_probe ...)没有构建脚本 , 现有 exe 是历史上手工编出来的。本轮新增 tools/build/build_tools.ps1,一次构建 11 个工具:
powershell
powershell -ExecutionPolicy Bypass -File tools\build\build_tools.ps1 # 全部
powershell -ExecutionPolicy Bypass -File tools\build\build_tools.ps1 -List # 只看源文件表
powershell -ExecutionPolicy Bypass -File tools\build\build_tools.ps1 -Tool q35_gpu_model
产物落 tools/build/out/(刻意与手工编出的老 exe 分目录,便于对照)。三个坑都是本轮踩出来的:
src/model/vllm_safetensors.c是「把整条引擎主干拖进来」的元凶 。它引vllm_tp_parfor/vllm_l3/vllm_ep_root_*/vllm_npu_*/vqf_stream_*------ 记的那一串 undefined 全部来自它。而工具其实只需要vqf_convert/src/model_cfgio.c里的st_parse_config/st_load_tensor_slice/st_config_free。 ⇒ 最小集不含vllm_safetensors.c,链接规模一下从「整个引擎」降到 13 个文件。-DVLLM_HAVE_CUDA必须默认开启 。不定义时vllm_cuda.c整体编译成无害桩 :vllm_cuda_available()恒 0,且note是空串 ⇒ 工具只报「CUDA 后端不可用」, 连一句[CUDA]诊断都不打 ,极难查。脚本里-NoCuda才退回桩。- MinGW 的
ld吃不了 argv 里的非 ASCII 路径 。本仓库根目录含中文(E:\项目\...), 绝对路径送进子进程会被按 ANSI 解码而乱码,报cannot open output file E:\??\...: No such file or directory。 ⇒ 传给 gcc 的-o必须是从仓库根起的相对路径 (-I...本来就都是相对路径,只有-o中招)。
另注:脚本注释含中文,必须以 UTF-8 BOM 保存 ;用文本替换类工具改过之后 BOM 会丢, PS 5.1 会按 ANSI 解码中文导致语法错误(本轮也踩了一次,脚本头部已写明)。
3.4 构建线可用的判据
新 exe 逐位复现 _q4ref4(9 个文件 SHA256 全同);三条既有检查全绿 (rq_check maxrel 2.372e-06 / gdn_check 3.800e-07 + 跨层切片 0/25600 / attn_check 4.697e-07)。
⇒ 构建线可用的判据不是「编过了」,而是「编出来的二进制与归档数字一致」。
四、全模型异构设备内 decode
4.1 做到了什么
64 层(48 GDN + 16 full-attn,每层后接 dense SwiGLU MLP)整套在设备上跑完 , 每 token 只付 1 次 H2D(输入 hidden)+ 1 次 D2H(输出 hidden 与 logits)。
4.2 新增的设备侧件
| 件 | 说明 |
|---|---|
k_rms_norm_unit |
RMSNorm with (1 + w) 。与既有 k_rms_norm(用 w)并列存在,没有去改它------后者被别的模型档在用,改它属于越界 |
vc_attn_step_dev |
从 vcuda_dev_attn_step 抽取 出的设备内步进体(const float *d_h → float *d_out,无 H2D/D2H/同步),与 GDN 那边的拆分同一手法 |
| 模型驱动 | vcuda_dev_model_*:按 mdl_type[L] 异构 dispatch,mdl_gidx / mdl_aidx 把模型层号 映射到 GDN / full-attn 的 compact index |
| MLP | gate/up → k_swiglu(复用既有内核,语义 act = silu(gate) * up)→ down;三块投影都走 rq 池 |
k_add_ip |
残差原地 累加(x += b)。不复用 k_add :后者两个参数都带 __restrict(承诺不别名),而原地调用 out == a 形式上是 UB |
逐层算子与 CPU 参考 q35_model_step 逐行一致:
c
x = h
nrm = RMSNorm_unit(x, in_norm[L]) /* (1+w) */
x += sublayer(nrm) /* GDN 或 full-attn */
nrm = RMSNorm_unit(x, post_norm[L])
x += down( silu(gate(nrm)) * up(nrm) )
末尾 hfin = RMSNorm_unit(h, final_norm); logits = lm_head(hfin)
一处在抽取时顺带改的、必须单列的改动 :vc_attn_step_dev 里 KV 追加由原来的同步 cudaMemcpy 改成 cudaMemcpyAsync(..., stream),内核发射也从隐含默认流改为显式写 stream。 等价性论证 :两个调用点都传 stream = 0(默认流按发射顺序串行),且都在返回前 cudaDeviceSynchronize() 并查错;唯一语义差别是 Async 的返回值只代表「入队成功」、 真错会推迟到同步点暴露------而同步点就在函数退出前。⇒ 对调用方可见行为等价 。 ⚠ 若将来有调用点传非默认流而外层仍用默认流,顺序保证即失效;函数头注释已写明 「调用方必须保证与 s 内其它发射同流」。
4.3 ★ 三条有意加的硬约束(都是「宁可拒绝,不要静默错」)
-
全有或全无 :
model_step在跑之前 把「每层的 MLP 三键 + 每层的两处 norm 确实上载过- lm_head + final norm + 两个子后端的层数一致 + attn 层的
pos等于 KV 游标」全部检查一遍。 理由写在源码注释里:跑到第 37 层才发现缺权重时,GDN/attn 的递推状态已经被改过,回退不了。
- ★ norm 必须用「已写标记」而不是「指针非空」 :
cudaMalloc不置零 ,preload_norm只是一次cudaMemcpy;若只查指针,漏调一层会让该层 RMSNorm 拿未初始化显存 算------ 结果仍是有限数、量级也可能正常,是典型静默错 。故加了mdl_norm_ok[nl](bit0/bit1 = in/post)与mdl_final_ready。 - ★ 层数一致性 :
mdl_nlin/mdl_nfull是从layer_type统计的,而attn_len是按vllm_cuda_attn_init(nl=...)分配的。两者不等时按前者索引会越界读,所以先校验相等、不等即拒绝。
- lm_head + final norm + 两个子后端的层数一致 + attn 层的
-
pos必须等于 attn 层的 KV 游标 ,否则拒绝。两者不等 = RoPE 与注意力窗口错位, 是静默错(结果仍是有限数、量级也对);显式拒绝比事后逐层对数字便宜得多。 -
池重建时清引用(gdn + mdl + attn 三处) :
vcuda_dev_rq_init/vcuda_dev_rq_clear现在同时调vc_gdn_clear_rq_keys/vc_mdl_clear_rq_keys/vc_attn_clear_rq_keys。 原因同 第 24 篇:vc_rq_expect只比形状 , 池换了之后同一个 key 若绑到另一张同形状张量,会静默用错权重 。- ★ 审计抓到的一处真实漏洞 :本步最初只补了
vc_mdl_clear_rq_keys,attn 的rq_q/k/v/o没清 (引入时就没有,全仓也不存在vc_attn_clear_rq_keys)。当时工具恰好是 「attn_init → rq_init → use_rq」的调用序,不触发 ,所以实测全绿------那是时序侥幸,不是不变式。
- ★ 审计抓到的一处真实漏洞 :本步最初只补了
这套「跑之前先把绑定全部判掉」的预检,就落在 vcuda_dev_model_step 的开头(下为节选;这也是后来所有守卫的基座):
c
/* 文件名:src/npu/cuda/vllm_cuda_kernels.cu ------ 全有或全无的预检(节选) */
VCUDA_API int vcuda_dev_model_step(void *dev, int pos, const float *h,
float *h_out, float *logits) {
vc_dev_t *s = (vc_dev_t *)dev;
if (!s || !s->ok || !h || !s->mdl_nl) return -1;
if (!s->d_mdl_in_norm || !s->d_mdl_final_norm) return -1;
const int nl = s->mdl_nl;
if (s->mdl_nlin && s->mdl_nlin != s->gdn_nl) return -1; /* 层数口径一致性 */
if (s->mdl_nfull && s->mdl_nfull != s->attn_nl) return -1;
for (int L = 0; L < nl; L++) {
if (!s->mdl_rq_gate[L] || !s->mdl_rq_up[L] || !s->mdl_rq_down[L]) return -1;
if (!s->mdl_norm_ok || s->mdl_norm_ok[L] != 3) return -1; /* ★ 用「已写标记」,非指针非空 */
if (s->mdl_type[L]) {
const int ai = s->mdl_aidx[L];
if (!vc_attn_ready(s, ai)) return -1; /* 子层绑定也提前判 */
if (ai < 0 || !s->attn_len || s->attn_len[ai] != pos) return -1; /* pos == KV 游标 */
} else if (!vc_gdn_ready(s, s->mdl_gidx[L])) return -1;
}
...
}
4.4 rq 池的 wkey 扩展
GDN 的三张大投影在既有 VLLM_CUDA_PROJ_*(0..7)里没有编号,本轮补了 VLLM_CUDA_PROJ_GDN_QKV = 8 / GDN_Z = 9 / GDN_OUT = 10(写在 vllm_cuda.h 紧邻 0..7 处, 并注明 VLLM_NPU_PROJ_* 那边暂无对应 )。MLP 沿用 GATE/UP/DOWN, lm_head 沿用哨兵层号 + PROJ_LMHEAD。
4.5 实测(RTX 5090 / 32607 MiB / sm_120)
口径 :model_q4.vqf(q4 权重)+ f32 激活 ,与参考 q35_model_full --vqf --layers N (VLLM_Q35_FUSED=0)同源同口径 。对 q4 GEMM 投影而言,两侧逐元素乘积完全相同 (int4 ∈ [-8,7] 只有 3 位有效位 × f16 scale 11 位 = ≤14 位,f32 的 24 位尾数可精确表示) ⇒ q4 反量化零舍入。
★ 但「两侧只剩求和序」是过强的说法(审计订正) :整模型里除归约序外,还有 超越函数 / reciprocal 的设备实现差,它们不是重排:
k_rms_norm_unit用rsqrtf(近似硬件指令),CPU 参考q35_rmsnorm_unit用1.0f / sqrtf;- GDN 与 attention 用 device
expf/log1pf,参考用宿主 UCRT 的expf。
两者同为 ULP 级 ,所以:预期量级 ~1e-6 ;本档工具实际门禁取 tol = 1e-4 (约 100× 余量)。 落在 1e-2 量级则必是接线 / 语义错,而不是精度------这一点是本档最重要的读法。
逐层隔离用 --layers N:N 层的输出对参考的 h{N} (参考的 hN 就是「跑完前 N 层」)。
逐档原始读数见文末 [附录 · 表 A1](#附录 · 表 A1 "#%E9%99%84%E5%BD%95%E5%8E%9F%E5%A7%8B%E6%95%B0%E6%8D%AE%E8%A1%A8")。
argmax 一致:N=4 5/5 ;N=64 5/5 ,且序列 148398 8442 198 198 198 与参考逐 token 相同。
性能(首次实测,全 64 层端到端):
text
[CUDA] rq pool: 13732.0 MB slab ... entries=401 (全部权重命中,无回退)
[gpu] 计时:20 步 542.90 ms → 27.145 ms/token ⇒ 36.8 tok/s(全 64 层)
[gpu] 有效权重带宽 530 GB/s(14.40 GB / token)
★ 这不是 roofline 结论 :14.40 GB/token 是「每 token 必读的权重总量」,530 GB/s 是 14.40 GB ÷ 实测 ms 的反算值 ,且其中含 GDN 的非带宽受限部分(delta 递推按 v-head 串行、 conv、两处小 gemv)、约 600 次 kernel 发射、以及逐层 cudaMemset / 同步等固定项。 与单核的 964 GB/s 相比有 1.8× 差距 ,但两者的工作形状完全不同, 不可直接相减归因 。「差距落在哪儿」需要另做分解(例如按层型分别计时),本档没有做,故不写结论。
与上一节的外推对照 :曾按 16.1 GB ÷ 708--964 GB/s 外推 17--23 ms/token(44--59 tok/s)。 实测 27.1 ms/token 落在该区间之外(更慢) ,原因就是上面那 1.8× 差距------ 外推再一次偏乐观,实测为准 (与 第 24 篇 推翻往返成本同一教训)。
4.6 已知边界 / 未做
- 输入 hidden 直接吃参考的
h0.bin。embedding 行的收集没有走 GPU(那是宿主侧一行 gather + f16→f32,与「64 层快照」正交)。 attn_maxseq固定 1024;KV 随 npos 线性增长的那部分开销没有单独测(本档只跑到 T=5 / 20 步)。- 全模型档只测了已上载的 64 层 ,没有做「层窗口 / 分层驻留」下的对比(rq 池是「上传一次、 从不驱逐」的语义,14.40 GB------即 13732 MiB------全驻留,余量足够)。
- 中间量观测只到
h/nrm/mo/act/logits/attn_len(vcuda_dev_model_get),没有 GDN 那种逐算子回读。 - 逐层隔离用的 4 层参考是
_q4ref4;64 层参考_q4ref64已生成(69.2 s),两者都可复用。
4.7 一个纯粹的踩坑(记下来省下次)
写 struct 字段注释时用了 gdn_*/attn_*------其中的 */ 提前关闭了块注释 ,后面半句变成代码, 报了一堆「vc_dev_t has no member mdl_nl」这种完全误导的错误。
⇒ 在 C 注释里写通配符样式的名字(
a_ / b_)时,必须写成分隔开的两个名字。
五、审计入档:两轮 MUST / SHOULD 与订正
本系列的特色之一是审计要入档 ------每一轮改动都过一遍合规审计, 把 MUST/SHOULD 与订正结果如实写下来。本篇涉及的这两轮尤其值得记,因为它们订正的都是口径, 不是功能。
5.1 第一轮
审计判定:实测数字(构建线 SHA256 复现、端到端 ~1e-6、argmax / 序列一致、36.8 tok/s) 可信且口径标注基本到位 。必须订正的三处已全部落地:
| # | 问题 | 性质 | 订正 |
|---|---|---|---|
| A | attn 的 rq_q/k/v/o 在池重建时没清 |
真实漏洞(静默用错权重);原稿点 3 属过度声明 | 新增 vc_attn_clear_rq_keys,在 rq_init / rq_clear 与 gdn/mdl 三处并列调用 |
| B | norm 权重无「已写」标记 ⇒ 漏调 preload_norm 会静默吃未初始化显存 |
静默错面,与「宁可拒绝」立意矛盾 | 新增 mdl_norm_ok[nl] + mdl_final_ready,在 model_step 前置校验 |
| C | KV 追加由 cudaMemcpy 改 cudaMemcpyAsync 属未单列的顺手改动 |
超范围(虽等价) | 已单列并给出等价性论证(保留 Async) |
审计另外指出、也一并订正的四处:
- D :
mdl_nfull与attn_nl不一致时会越界读attn_len⇒model_step增加 「两个子后端层数必须与mdl_nlin/mdl_nfull相等」的校验;MDL_ATTN_LEN的上限改用真正分配者attn_nl。 - E :残差原地调用
k_add(out == a)与它的__restrict承诺冲突(形式 UB)⇒ 新增k_add_ip。 - F :把「差别只剩求和序」这一推断写成了事实 (漏了
rsqrtf/ deviceexpf)⇒ 已重写口径段, 并把「判据 ~1e-6」改回「预期量级 ~1e-6 / 实际门禁 1e-4」。 - G :14.40 GB 与 14.03 GB 是同一 slab 的两个数 ⇒ 统一为 14.40 GB(= 13732 MiB,十进制)。
订正后的回归(全部逐位不变) :rq_check 2.372e-06 / gdn_check 3.800e-07 + 跨层切片 0/25600 / 两档等价 0/25600 / attn_check 4.697e-07 / 模型 N=4 → h 1.420e-06、hfinal 7.886e-07、 logits 3.030e-06、argmax 5/5 / N=64 → h 1.972e-06、hfinal 2.302e-06、logits 3.389e-06、argmax 5/5; 全 64 层计时 27.214 ms/token ⇒ 36.7 tok/s。
5.2 第二轮
| # | 问题 | 性质 | 订正 |
|---|---|---|---|
| H1 | model_step 的「全有或全无」预检没查子层的 rq 绑定 |
真实隐患 :失败会发生在第 N 层,而 0..N-1 层的递推状态已被改写 ⇒ 回退不了 | 抽出 vc_gdn_ready / vc_attn_ready(只读 、与两个 step 开头同一套判据),加进预检循环 |
| H2 | 诊断开关会改变输出,而唯一护栏是一行中文警告(在 GBK 控制台是乱码) | 护栏实际失效 | 改编译期门控 :只有 -DVC_DIAG_MDL_SKIP 构建才读 env;警告改 ASCII |
| H3 | 反算带宽表混口径(三个隔离档不含 lm_head、真实档含) | 三档被系统性低估 7--19% | 四档统一 --no-lmhead 重测(691 / 312 / 338 / 517) |
| H4 | 「线性可加 ⇒ 可按 48/16 加权还原」把推断写成了结论 | 过强(0.11% 落在抖动内;且若受同一 DRAM 流支配,字节可加 ⇒ 线性平凡) | 已重写,并明确不用 0.11% 去支撑分解 |
| H5 | 循环内 goto 跳过 MLP 段 |
合法但脆弱(将来在跳段里插一个带初始化的声明就是硬编译错) | 改为 if (!(s->mdl_skip & 2)) { ... } |
| H6 | vc_mdl_free 未复位 mdl_skip |
无实际漏洞,但破坏「free 后不留可影响后续的字段」的约定 | 补 s->mdl_skip = 0; |
订正后的回归(全部逐位不变) :N=4 → h 1.4200e-06 / hfinal 7.8859e-07 / logits 3.0296e-06 , argmax 5/5;N=64 → h 1.9719e-06 / hfinal 2.3019e-06 / logits 3.3887e-06,argmax 5/5; 三条负向用例全 PASS。
⚠ 审计另指出:本轮 27.572 与上一轮 27.214/27.145 的对比是跨会话、跨构建 的, 按项目口径不宜当作「未变」的证据------数值回归的强证据是 maxrel / argmax 逐位相同(已满足), 计时差异只作噪声处理(<3%)。
六、负向用例与 27 ms 的成本分解
6.1 为什么要专门做「负向用例」
三条守卫都是「宁可拒绝,不要静默错」型的。但有一条铁律: 正常回归永远不触发这些守卫 ------所有正常的对拍、计时、argmax 检查,守卫都不会响。 ⇒ 不测就等于没有 。所以必须专门构造「应该被拒」的输入,并拿到直接证据证明 「拒绝确实来自那条守卫」,而不是碰巧因别的原因失败。
新增 q35_gpu_model --neg <name>(tool rc=0 即 PASS):
| 用例 | 构造 | 期望 | 实测拒绝点([CUDA] 诊断原文) |
|---|---|---|---|
norm-skip |
故意漏掉第 0 层的 preload_norm |
必拒 | model: 第 0 层 in/post norm 未上载,整步拒绝 |
layer-count |
gdn_init(nl = nlin-1) |
必拒 | model: mdl_nlin=4 与 gdn_nl=3 不一致,拒绝 |
attn-stale |
清池→重建→只补 MLP/GDN 的键→用层 7 的四张投影字节毒化 attn 的四个 key | 必拒 | model: 第 3 层 attn 权重未就绪,整步拒绝(H1 订正后;订正前是「attn 步进失败」=第 0..2 层已经跑过) |
★ 第一版 attn-stale 的敏感性不足(本轮自查发现并已修) :最初只毒化了 q_proj。 那样即便 vc_attn_clear_rq_keys 缺失,也会因为 k/v/o 不在新池里 而被拒------ 属于「为错误的理由通过 」,等于没测。修法两条:① 四张投影全毒化; ② GDN 的键也要补回去(否则第 0 层 GDN 先失败,掩盖 attn 路径)。 修完后该用例才真正等价于「守卫缺失 ⇒ 静默用错权重并把整步跑成功」。
6.2 27 ms/token 的成本分解(同一 harness 内,且做了可加性自检)
为此加了两个仅用于计时的开关:
--synthetic gdn|attn:用同一个真实源层的权重 铺满 64 层,只保留一种层型。 形状 / 内核 / 每层发射次数与真实档完全一致,只有层型混合比变了。--skip 1|2|3(转发VLLM_CUDA_MDL_SKIP):⚠ 诊断档,输出是错的 ; 1 = 只跑 norm+MLP,2 = 只跑 norm+子层,3 = 只有两处 norm(基线)。 两处 norm 永不跳;跳子层时仍让 attn 的 KV 游标前进------否则会绕过「pos== 游标」那个对拍守卫 。 ★ 编译期门控 :该 env 只在 DLL 以-DVC_DIAG_MDL_SKIP构建时才被读。
第一步:用「整档换层型」做一致性交叉验证
text
A = --synthetic gdn (64 × GDN+MLP+norms)= 27.105 ms
B = --synthetic attn (64 × attn+MLP+norms)= 24.582 ms
真实档(48 GDN + 16 attn) = 26.445 ms
预测 0.75·A + 0.25·B = 26.475 ms
误差 0.030 ms = 0.11%
★ 这条 0.11% 只能支持一个很弱的结论(审计订正):
- 它落在本轮跑间抖动之内 ------同一档重复测出现过 27.145 / 27.214 / 27.572 ms(≈1.5%), 所以 0.11% 不具统计分辨力;
- 而且若整档本来就被同一条 DRAM 流支配,则「耗时 ≈ 字节 / 带宽 + 固定项」而字节可加 ⇒ 线性几乎是字节可加性的平凡推论。
- ⇒ 它验证的是「每层成本由层型决定、与层号 / 数据多样性无关」这一假设 , 不足以证明「逐层分解可按 48/16 严格加权还原真实档」。
第二步:按层分解 (--skip 3 为基线;分母口径与第三步统一):
| 项 | ms/层 | 占同档整层 |
|---|---|---|
| 两处 RMSNorm(含每层固定开销) | 0.0131 | 3.1% |
| dense MLP(gate / up / swiglu / down) | 0.2048 | 48.4% |
| 子层 GDN | 0.1950 | 46.1% |
| 子层 full-attn | 0.1610 | 41.9% |
| 三项和(GDN 档) | 0.4129 | --- |
直接实测 同档(skip=0) |
0.4235 | --- |
★ 可加性残差 +2.6% (0.4129 vs 0.4235),明写出来、不归因 :隔离档少了一个子层、 少了一次残差,数据流与 DRAM 访问模式都变了。没有逐相事件计时之前, 对这条残差的任何解释都只是猜想。
第三步:各档反算带宽 (分母 = 该档每 token 真正读 的权重字节; 四档一律不含 lm_head ------计时全部加 --no-lmhead,避免出现「三个隔离档不含、真实档含」的混口径):
| 档 | 实际读权重 | ms/token | 反算带宽 |
|---|---|---|---|
| 只有 MLP + 两处 norm | 64×150.4 MB = 9.626 GB | 13.946 | 691 GB/s |
| 只有 GDN + 两处 norm | 64×64.88 MB = 4.152 GB | 13.320 | 312 GB/s |
| 只有 attn + 两处 norm | 64×58.98 MB = 3.775 GB | 11.146 | 338 GB/s |
| 真实全模型(48+16) | 13.684 GB | 26.445 | 517 GB/s |
| 基线(只有两处 norm) | 2.6 MB | 0.840 | --- |
分母的次要遗漏 (量级 <0.1%,忽略但要声明):每层 norm 权重(40 KB/层)、 每层激活 d_mdl_h / nrm / mo / act 的读写、KV 与 GDN 状态的读写。
结论只到这一步(不再外推):
- dense MLP 是最大单项(48%) ,且它的反算值(691 GB/s)是各相里最高的 。 它可能 比两个子层更接近带宽受限;但这是反算值的方向性观察,未证明。
- 两个子层都明显不带宽受限(312 / 338 GB/s):GDN 有 delta 递推(按 v-head 串行)、conv、 两处小 gemv;attn 有 1024×5120 这种只有 32 个 warp 的小投影、以及与 npos 相关的解码注意力。
- 四档反算值都低于峰值 1792 GB/s ⇒ 有空间;但堵在哪里没有证据 ,本轮不做归因。
6.3 顺带发现的一处键约定缺陷(引擎侧未改)
wkey(layer, proj) = (layer << 32) | proj,而 proj 的 Q = 0 ⇒ wkey(0, PROJ_Q) == 0 , 但 rq 接口把 key == 0 当「未绑定」哨兵 ⇒ attn_use_rq 当场拒 (--synthetic attn 把 layer 0 变成 attn 层时首次暴露)。
真实 64 层 Qwen3.5 里 layer 0 是 GDN(proj = GDN_QKV = 8)故撞不上; 任何「layer 0 就是 full-attention」的模型都会踩到 。本轮只在工具侧规避 (key 里层号 +1,key 在池内不透明),没有改引擎的键约定 ------改它会牵动 st_cuda_wkey 与 NPU/CUDA 两侧 wkey 的互换性,属于需要单独决策的改动。
6.4 另记:DLL 的中文诊断在 GBK 控制台上是乱码
vc_vlog 的中文经 nvcc/MSVC 编译后按 ANSI(本机 GBK)输出,而 MinGW 侧工具输出 UTF-8------ 同一条 stderr 上两种编码,控制台只能正确显示其中一种(本轮排障时被这条绊了一下)。 功能无影响,但拖慢排障。修法(未做):DLL 侧加 /utf-8,或 DLL 的诊断一律走 ASCII。
七、单核微基准:形状扫描与一条被证伪的假设
7.1 动机
上一节只做到「按相反算带宽 691 / 312 / 338 / 517 GB/s」,没有归因 ------而且手里那个 基线(17408×5120 达 1713.5 GMAC/s ≈ 964 GB/s)是反复读同一张 50 MB 权重 测出来的: 若该张量装得进 L2,它就是 L2 速率,不可比。
7.2 方法:三种口径
给 tools/q35_q4_gpu.cu 加 --sweep(合成权重,标度一律填 f16 1.0,避免 0 / denormal / NaN 污染 FMA 计时),每个形状给三个口径,都把工作集顶到 ≈1.5 GB(≫ L2):
| 口径 | 含义 |
|---|---|
hot |
1 份权重反复读------与基线原方法一致 |
cold |
K 份,逐份发射------与模型同形态(每层一次小发射),DRAM 流式 |
bulk |
K 份当作一张 [K*rows, cols]、一次发射------不含逐份发射成本,接近形状上限 |
构建线新增 tools/build/build_cuda_tool.ps1(nvcc 出 tools/build/out/*.exe, 自动进 VS dev shell ,省掉每次手敲 Launch-VsDevShell.ps1 这个反复踩的坑)。
⚠ 口径留痕 :本节的表是工具
-fmad=true口径的那一次构建(该旗标当时漏在脚本外)。 同形状复跑与第八节的表差 1--3% 。两张表不可并排引用 ;本节只用来说明结论的方向 (cold≪bulk、与形状基本无关),不用作数值基线。数值基线一律以第八节的表为准。
7.3 实测(RTX 5090,iters=20)
| 形状 | bpc(MB) | copies | hot | cold | bulk | cold/bulk |
|---|---|---|---|---|---|---|
| mlp gate/up 17408×5120 | 47.81 | 32 | 987.8 | 780.3 | 1034.7 | 0.75 |
| mlp down 5120×17408 | 47.81 | 32 | 872.0 | 682.4 | 950.9 | 0.72 |
| attn q 12288×5120 | 33.75 | 45 | 895.8 | 680.3 | 1031.6 | 0.66 |
| gdn in_qkv 10240×5120 | 28.12 | 54 | 793.6 | 581.4 | 1031.3 | 0.56 |
| gdn in_z 6144×5120 | 16.88 | 91 | 714.0 | 709.9 | 1035.5 | 0.69 |
| gdn out / attn o 5120×6144 | 16.88 | 91 | 802.1 | 638.9 | 1030.1 | 0.62 |
| attn k/v 1024×5120 | 2.81 | 512 | 177.4 | 128.8 | 1023.0 | 0.13 |
| lm_head 248320×5120 | 682.03 | 2 | 1044.1 | 1033.4 | 1023.7 | 1.01 |
(单位 GB/s)
稳定性与自洽 (复跑 iters=40):cold / bulk 两列稳定在 ≤2% , 而 hot 列有 ~13% 跑间抖动 ------因为 1 份权重只跑 20--40 次发射,窗口太短。 ⇒ 下面所有结论只依赖 cold / bulk 。另外原模式(_gpu dump,iters=50)复现出 maxrel 2.372e-06(与归档逐位相同)与 1722.9 GMAC/s(归档 1713.5,0.5% 跑间差) ⇒ 内核本体没被本轮的扫描改动碰到。
7.4 三条结论 + 一条被证伪的假设
- ★ 证伪:基线的 964 GB/s 不是 L2 数字。 论证用不等号方向 而不是「约等」: 在两轮测量里
hot(1 份,可能被 L2 兜住)始终 ≤ 大工作集的bulk(mlp gate/up:860.8 / 987.8 vs 1019.7 / 1034.7;lm_head:1044.1 / 1031.9 vs 1023.7 / 1007.8) ------若 L2 真有加成,hot必须显著高于bulk。它没有。 ⇒ 该内核在这几个形状上本来就跑在 ~1000 GB/s 量级;「基线被 L2 兜住」的怀疑不成立。 - 内核自身的 DRAM 上限 ≈ 1030 GB/s(峰值的 58%) ,且与形状基本无关 (除
mlp down951)。 这是单核里最大的一块钱:1.74× 的理论空间在核里,不在模型结构里。 - 逐份发射(
cold)在小形状上损失惨重 :形状越小损失越大------28--48 MB 的形状掉到 0.56--0.75 倍,而 2.81 MB 的attn k/v只剩 0.13 倍 (128.8 vs 1023 GB/s)。attn k/v每次发射 ≈ 16--22 µs,而在bulk里摊到每份只要 ~2.75 µs。
★ 关于第 3 条的机制 ,2026-10-01 审计订正 :原始写法把它解释为「行数太少 ⇒ warp 数太少 ⇒ 占用率不足以隐藏访存延迟」,这一条当作机制解释是错的 ,原始写法还有算错的数 (「1024 行 = 256 warp」应为 1024 行 = 1024 warp = 128 block × 8 warp ; 32768 / 348160 = 9.4% )。真实的对照证据是同表的 gdn in_z :6144 行 = 6144 warp, cold 也只有 709 GB/s ⇒ 「行数 / warp 数」并不能 解释 attn k/v 的 129 GB/s。 真正的区分量是它每份只有 2.81 MB :固定发射成本被摊薄到每份 2.75 µs 的差别上。 机制本身仍未用事件计时证实。
7.5 与整模型逐层对账
用 cold 速率把「模型每层的纯投影耗时」算出来,与上一节实测的层耗时相减:
| 层 | 纯投影(按 cold 算) | 实测层耗时 | 差 = 非投影部分 | 投影占比 |
|---|---|---|---|---|
| GDN(3 投影) | 0.0986 ms | 0.1950 ms | 0.096 ms | 51% |
| full-attn(4 投影) | 0.1197 ms | 0.1610 ms | 0.041 ms | 74% |
| MLP(3 投影) | 0.1926 ms | 0.2048 ms | 0.012 ms | 94% |
- MLP 几乎是纯投影(94%),非投影部分只占 6% ⇒ 上一节的「MLP 0.2048 ms/层」可信。
- GDN 有一半在投影之外(conv+SiLU、q/k L2 归一、delta 递推、gated-RMSNorm、两处小 gemv)。
- attn 有 26% 在投影之外 ;其中
attn k/v这一个 2.81 MB 的小投影就占 0.0218 ms, 是 attn 层的 13.5%------只因它以 128.8 GB/s 跑。
7.6 由此得到的方向(本轮不改)
- 提高单核 DRAM 效率 (1030 → 向峰值靠):当前每个 lane 只读 1 字节 (字节级寻址) ⇒ 访存粒度太细。原方案的「8 lane 各取
uint32再 shuffle 分发」正是为此, 现在它有了量化依据与目标(1034.7 GB/s 这条基线)。✅ 已落地为下一节的访存粒度改动。 - 小行数投影沿 K 拆并行------★ 审计订正:这条的机制错了 (见上节第 3 条的订正),
gdn in_z反例证明真正的区分量是每份权重的字节数 ,不是 warp 数 / 占用率。 该条暂不作为方向。 - 融合小投影 (把 q/k/v 或 gate/up 合成一次发射)能直接把
cold→bulk的那部分损失拿回来。 仍然成立,且下一节把这个缺口在绝对值上放大了。
★ 注意这三条都只是方向 :本轮只测了「核在什么速率上跑」,没有验证任何优化方案。
八、访存粒度:端到端 1.44×
上一节的判定是「cold 口径(逐份发射 = 模型真正的形态)只有 620--780 GB/s,且与形状基本无关 ⇒ 瓶颈不在 DRAM,而在每 lane 1 字节的访存粒度」。本节把这一刀落到引擎里。
8.1 改了什么
k_rq_q4_gemv_f32act(引擎)与工具侧的 v5 内核两份拷贝同步改(本节时点):
| 项 | 旧(v4) | 新(v5) |
|---|---|---|
| 每 lane 每次迭代读 W | 1 字节(1 个 nibble) | 1 个 uint32(8 个 nibble) |
| 每 lane 每次迭代读 x | 1 个 float,同一 tile 的 4 行各读一遍 | 2 次 float4(32 B),零冗余 |
| 完成 MAC / 迭代 | 1 | 8 |
| 每 lane 的 load : MAC | 3 : 1(x + 系数 + 标度) | 4 : 8 = 0.5 : 1 |
| 列映射 | 块内第 lane 个元素(j=lane&15, hi=lane>>4) |
8 块 × 4 组:bi=lane>>2, g=lane&3 |
算术仍是 y = Σ (int4·d)·x,不改语义 ;改的是取数的组织方式 ⇒ 与旧实现的差 只是求和序 (实测最大相对差 1.2e-7--2.4e-7)。新映射「8 块 × 4 组」就是上一节贴出的 bi = lane >> 2, g = lane & 3 那一段。
8.2 ★ 定性:这是「归约树重排 + TOKIDS 级对齐」,不是位级对齐
本系列「保序优化,不碰归约树」这条铁律在本轮被有意动到 ------但要说清是哪一层 : 它动的是一行点积内部 的累加分组(a0..a3 四链 → 按 (块,组) 分给 32 个 lane), 没有 动真正的落点(GDN 沿时间轴的 S_t ← S_{t-1} 递推、以及 k_gdn_delta 的逐 token 串行链) ------那一层一个字没改。
豁免依据只有两条实测:TOKIDS / argmax 不变 (64 层 5/5、序列逐 token 相同)与 两条同字节 entry 的 y 仍 0/17408 逐位相同 (后者证明的是「内核确定性」,不是「与旧版位级相同」)。 而本系列明确:「TOKIDS 相同」不作为位级证明。 ⇒ 任何后续引用本节的地方,都不得把这轮说成「位级一致」。
8.3 两条契约 + 一个真踩过的双射坑
新增两条契约:
- x 必须 16 B 对齐 (
float4读)。这条成立是因为本内核只被 static 的vc_rq_launch_gemv调用, 所有 x 都是cudaMalloc的 f32 缓冲,偏移都是 4 float 的整数倍。不要把它暴给外部宿主缓冲。 nb % 8 != 0的尾块 :vllm_cuda_rq_put的契约只保证cols % 32 == 0(不是 256), 所以核里补了一段「退回旧映射、加进同一个 acc」的尾块分支。本项目所有投影cols都是 256 的倍数 ⇒ 该分支恒不执行,但它是契约的一部分。
映射的双射性是一个真踩过的坑 :工具里第一版写成 q = lane&3, g = lane>>3, 只覆盖 16 个 (块,组) 组合、且每个出现两次 ⇒ 结果恰好是正确值的 2 倍 , 互校 maxrel = 1.00e+00 。这个指纹(|a-2a|/|a| = 1)已写进两份源码注释:
看到 maxrel ≈ 1.0 先想到整数倍重复覆盖。
8.4 微基准:形状扫描(q35_q4_gpu --sweep --iters 20)
单位 GB/s;cold = K 份权重逐份发射、工作集 ≈ 1.5 GB(= 模型真正的形态):
| 形状 | bpc MB | cop | hot v4 | cold v4 | cold v5 | bulk v4 | bulk v5 | maxrel | cold × |
|---|---|---|---|---|---|---|---|---|---|
| mlp gate/up | 47.81 | 32 | 976.3 | 777.8 | 1197.1 | 1023.3 | 1581.9 | 1.22e-07 | 1.54× |
| mlp down | 47.81 | 32 | 838.2 | 680.2 | 1198.4 | 950.3 | 1532.7 | 2.44e-07 | 1.76× |
| attn q (2nhhd) | 33.75 | 45 | 864.8 | 683.6 | 1157.3 | 1015.6 | 1566.9 | 1.24e-07 | 1.69× |
| gdn in_qkv | 28.12 | 54 | 791.3 | 576.8 | 1022.2 | 1019.4 | 1581.5 | 1.24e-07 | 1.77× |
| gdn in_z | 16.88 | 91 | 704.4 | 709.1 | 1052.5 | 1012.9 | 1575.9 | 1.24e-07 | 1.48× |
| gdn out / attn o | 16.88 | 91 | 774.9 | 612.3 | 1033.0 | 1018.1 | 1581.4 | 2.12e-07 | 1.69× |
| attn k/v | 2.81 | 512 | 165.6 | 128.9 | 233.0 | 1023.3 | 1594.0 | 1.27e-07 | 1.81× |
| lm_head | 682.03 | 2 | 1013.6 | 1015.5 | 1576.5 | 1018.0 | 1579.9 | 1.80e-07 | 1.55× |
| tail nb%8=1 | 14.15 | 108 | 791.6 | 598.8 | 950.6 | 1025.1 | 1520.7 | 1.25e-07 | 1.59× |
| tail nb%8=2 | 14.24 | 107 | 794.6 | 597.1 | 1005.3 | 1031.8 | 1525.5 | 1.20e-07 | 1.68× |
| pure tail (nb=1) | ~0 | 512 | 0.2 | 0.2 | 0.2 | 90.2 | 81.9 | bit-exact | 0.99× |
| rows%8=4 (warp 早退) | 2.80 | 512 | 177.2 | 128.6 | 232.8 | 1024.0 | 1596.5 | 1.27e-07 | 1.81× |
cold口径 1.48--1.81× (这就是端到端收益的来源);bulk1532--1594 GB/s = 5090 峰值 1792 的 86--89%。- 没有一条形状退化 。
attn k/v仍是最低(233 GB/s),但它是 1024 行 = 1024 个 warp (128 block × 8 warp) ,而bulk有 1594 GB/s ⇒ 推断 这一档是固定发射成本 被放大 (每份发射 16--22 µs,bulk里摊到每份只 ~2.75 µs),不是 「warp 数不够」------反例就在同表:gdn in_z有 6144 个 warp,cold也只有 709 GB/s。⚠ 机制本身未用事件计时证实。 - 最后四行是审计要求补的边角覆盖 (真实模型里全是死代码):
nb%8=1/2覆盖主循环 + 尾块混合,pure tail (nb=1)覆盖主循环一次都不跑 (实测与 v4 逐位相同 ),rows%8=4覆盖 grid 的warp >= rows早退。三者的maxrel都在 1.2e-07 量级 ⇒ 映射与早退都对。 这三行的性能列无意义 (工作集只有 KB 级,完全被发射延迟支配),只看maxrel。 - ⚠ 口径提醒 :本表与第七节的表不是同一次构建 ⇒ 两表不可并排引用。
8.5 引擎路径验证(出厂 vllm_cuda.dll,1718272 B)
| 检查 | 改前(归档) | 改后 | 判据 |
|---|---|---|---|
q35_rq_check(_gpu dump) |
maxrel 2.293e-06 / RMSrel 1.227e-06 | maxrel 2.372e-06 / RMSrel 1.203e-06 | 两条 entry 0/17408 逐位相同 |
q35_rq_check host-facing |
0.200 ms → 445.6 GMAC/s | 0.072 ms → 1237.9 GMAC/s | ★ 见下方「B」 |
q35_gdn_check(f32 档) |
3.800e-07、0/5120、0/25600 ×2 | 完全相同 | 不经 rq,理应变 |
q35_gdn_check --q4(闸门) |
OK | OK(maxrel 2.721e-02) | < 1e-1 |
q35_gdn_check --q4 设备内层耗时 |
0.156 ms/token(最终归档值) | 0.130 ms/token | ⇒ 1.20×,见下方「A」 |
q35_attn_check(f32 档) |
4.697e-07、0/5120 | 完全相同 | 不经 rq |
q35_gpu_model --layers 4 |
h 1.420e-06 / hfinal 7.886e-07 / logits 3.030e-06 | h 1.5292e-06 / hfinal 7.8859e-07 / logits 3.1484e-06,argmax 5/5 | 门禁 1e-4 |
q35_gpu_model --layers 64 |
h 1.9719e-06 / hfinal 2.302e-06 / logits 3.389e-06 | h 2.2536e-06 / hfinal 2.0334e-06 / logits 3.3887e-06,argmax 5/5 | 门禁 1e-4 |
| 三条负向用例 | PASS | PASS | 均被拒 |
精度变化全在 1e-6 量级内(求和序),与「同语义、只差求和序」的预期一致。
★ A(审计订正):上面那行 --q4 设备内层耗时的基线,第一版写错了。 初稿写的是「0.224 → 0.130 ⇒ 1.72×」,但 0.224 是中间态 (只做了「去 shuffle + 4 条累加链」两刀), 紧跟着的最终归档值是 0.156 ms (再加上硬件 __half2float 那一刀, 累计 1.72× = 0.268/0.156)。拿 0.224 当基线会把硬件 f16 转换那一刀的收益重复计入 ⇒ 正确的同口径倍率是 0.156 → 0.130 = 1.20× ,与第 8.4 节里 1.48--1.81× 的纯内核倍数不同 (这一行是「整层」:还含 8 个非 gemv 内核与 GDN 递推,那部分不随访存粒度变快)。 1.72× 就是第 24 篇那笔账,本轮不得再算一次。
★ B(审计订正):host-facing 那一行原写 0.050 ms → 1782.6 GMAC/s 是 clock() 量化产物, 不是测量值。 q35_rq_check 用宿主 clock()(Windows CLOCKS_PER_SEC=1000)、iters 默认 20 ⇒ per 只能落在 0.050 ms 的整数格点 上(审计连跑三次得 0.100/0.100/0.050)。 改用 --iters 500(总时长 ~36 ms ⇒ 格点 2 µs)后为 0.072 ms/次 → 1237.9 GMAC/s 。 两个端点都是 clock() 口径、都只当量级用。
8.6 端到端(全 64 层,--layers 64)
| 改前(上一轮) | 改后(两次独立运行) | ||
|---|---|---|---|
| ms/token | 27.2 | 18.916 / 18.935 | 1.44× |
| tok/s | 36.7 | 52.9 / 52.8 | |
| 反算权重带宽 | 529 GB/s | 761 / 760 GB/s |
⚠ 1.44× 是「跨会话、同 GPU、量级」结论 :改后两次运行同一会话内 差 0.1% (--iters 20/40),所以改后那一端可复现 ;但「改前」那一端是另一次会话, 中间还夹着工具口径修理。审计已明确要求:不得把 1.44× 对外表述为 「本轮同轮实测倍率」,正确表述是「27.2 → 18.9 ms/token(量级:1.4×)」。
8.7 为什么是 1.44× 而不是 1.64×
★ 本节已被后一篇(同日)取代 :下面 1--6 条里的推断,除 #3 之外都已换成实测。 结论:#2「逐层发射的空隙」是错的 (实测只占 0.2%),真正的分摊是 投影 64% / 非投影 36% ,结构上限 1.41×。下面的原文保留作对照(含被证伪的那条)。
按第 8.4 节的 cold 速率、以「每 token 每种投影的字节数」加权(48 个 GDN 层 + 16 个 attn 层, 各含 3 个 MLP 投影),纯投影部分的预期倍率是:
- v4:Σ(字节/速率) = 18.75(任意单位,下同)
- v5:Σ(字节/速率) = 11.46
- ⇒ 比值 ≈1.64×(逐投影倍率 1.48--1.81×)
实测整体 1.44× ⇒ 差额来自:
- 非 GEMM 的部分一点没快:两处 norm、GDN 的 conv/beta-g/递推、attn 的 rope/KV 追加/softmax 核、 embedding 行收集(本来就没走 GPU)。
- 逐层发射的空隙:每层约 10 次发射,64 层的发射间隙不随内核变快而缩小。
attn k/v仍是 233 GB/s(未拆 K)⇒ 16 层 × 12 µs 量级。
⚠ 上面三条是推断,不是实测 :要定论得做逐相 CUDA 事件计时 。 因此 1.44× 不能拆成「GEMM 1.64 × 其余 1.00」再反推。
审计另点了三条同向的可能原因(同样未证实,但会削弱「1.64 是干净的预期」这个前提):
- 1.64 这个预期本身来自合成权重 (标度恒为
f16(1.0))。真实权重里 subnormal / 零标度更多, 预期值本身可能是有偏的。 - 基线构成歧义 :27.2 ms/token 那次 DLL 与 18.9 这次是否只差这一刀 , 无法从仓库自证(本仓库不是 git 仓库,也没有旧 DLL 留档)。
- 两端都没记录 GPU 时钟 / boost 状态(会话间、加载后的持续负载差都可能进 ±几个 %)。
⇒ 在拿到事件计时之前,对外只说「≈1.44×(18.9 vs 27.2 ms/token,跨会话量级)」, 不解释成「GEMM 快了 1.64×、其余没变」。
8.8 顺带修掉的一处口径不一致(工具 vs 引擎)
tools/build/build_cuda_tool.ps1 第一版漏了 -fmad=false ,而 build_x64.ps1 编 DLL 时带着它。 后果是工具跑在 -fmad=true 口径下 、与引擎不是同一精度口径------同一份 _gpu dump、同一个 v4 内核, RMSrel 从归档的 1.208e-06 变成 1.207e-06 (只差末位,但按「跨口径数字不得并排引用」的公理, 跨口径的数字不能并排引用)。
修法 :脚本加 -fmad=false(并写明理由)。修后自证 :v4 内核在同一 dump 上恢复到 RMSrel=1.208e-06 / maxrel=2.372e-06 / 1714.1 GMAC/s ,与归档的 1.208e-06 / 2.372e-06 / 1713.5 GMAC/s 前两项完全相同 、吞吐差 0.04%(测量抖动) ⇒ 口径确实复原了。本节所有微基准数字都是修正后的口径。
8.9 已知边界 / 未做
- 微基准用合成权重 (随机 nibble + 标度恒为
f16(1.0))。该内核无数据相关分支 (无压缩、无 early-exit),所以「与数据无关」是结构性 的;但__half2float的 subnormal 路径在真实权重下触发频率更高(合成档标度恒为正常数)⇒ 这一处本次未覆盖, 只能靠整模型对拍兜底。 hot列仍有跑间抖动(976.3 vs 988.3),结论只依赖cold/bulk/cold ×。- 尾块 / 边角覆盖只有第 8.4 节末尾那四行合成形状 ;真实模型里这些分支恒不执行 。 ⚠ 这条链的强度依赖「两份拷贝永远同步」的纪律 ,不是自动检查------引擎那份的正确性目前是 传递性证明(工具那份实测正确 + 两份逐字等价)。
- 跨轮比较的局限 :
27.2 → 18.9的两端是两次不同的会话 ,中间还夹着「工具口径修理」, 且本仓库不是 git 仓库 、旧 DLL 无留档 ⇒ 「只差这一刀」这一点自证不了 。同一轮内可比的是gdn_check --q4的设备内层耗时(0.156 → 0.130,1.20× ),但它同样是单次运行、未做方差统计。 - 审计「建议 E」(给
vc_rq_launch_gemv加 16 B 对齐运行时护栏)本轮不采纳 ,理由备查: ① 该内核只有一个内部调用点 ,所有 x 都是cudaMalloc缓冲(审计已逐调用点核对); ② 万一传了非对齐指针,后果是cudaErrorMisalignedAddress------响亮失败 ,不是静默错误, 与「宁可拒绝,不要静默错」针对的场景不同;③ 真加了反而会让「16 B 对齐」这条契约退化成运行期 才知道的约束。若日后出现第二个调用点,这条要重新评估。 - 仍未做:逐相 CUDA 事件计时、沿 K 拆 / 小投影融合、RK3588 口径(从来没测过)。
8.10 审计结论与订正
审计独立复跑 了 sweep / rq_check(逐位条目)/ gdn_check --q4 / 全模型计时 (18.971 ms/token,与我的 18.916/18.935 差 0.3%),并逐调用点核对了对齐假设。 结论:功能上达标(阻断 0 项);作为对外归档,订正后达标。
| # | 级别 | 结论 | 处置 |
|---|---|---|---|
| A | 需订正 | --q4 设备内层耗时基线用错了中间态 0.224(真值应为 0.156)⇒ 1.72× 是把硬件 f16 那一刀重复计入 ,正确是 1.20× |
✅ 已改(第 8.5 节-A) |
| B | 需订正 | host-facing 0.050 ms → 1782.6 GMAC/s 是 clock()(1 ms 格点 / iters=20)的量化产物 |
✅ 已改:补测 --iters 500 = 0.072 ms / 1237.9 GMAC/s,并标注口径 |
| C | 需订正 | attn k/v 的 warp 数写成 128(把 block 当 warp);第七节同错,「行数⇒warp 数⇒占用率」这个机制解释被同表 gdn in_z 反例推翻 |
✅ 已改(第 8.4 / 7.4 节) |
| D | 需订正 | 引擎源码注释里的区间是另一个 -fmad 口径的数 |
✅ 已改为区间 + 「以第 8.4 节为准」 |
| E | 建议 | 给 vc_rq_launch_gemv 加 16 B 对齐运行时护栏 |
❌ 不采纳,理由见第 8.9 节 |
| F | 建议 | 尾块只有一种形状,缺 nb%8∈{2..7}、纯尾块(nb<8)、rows%8!=0 |
✅ 已补 4 个探针形状(第 8.4 节),全部 maxrel ≈1.2e-07、纯尾块逐位相同 |
| G | 建议 | 第 8.4 节的「发射延迟主导」是机制推断,未标注 | ✅ 已标「推断」并换了支撑证据 |
| H | 建议 | 第 8.7 节的三条解释还漏了三条同向可能(合成权重的预期本身有偏 / 基线构成无法自证 / 未记录时钟状态) | ✅ 已补为第 4--6 条 |
| --- | 已确认 | 双射正确、尾块与主循环不重叠且全覆盖、无 OOB、「只差求和序」成立;全仓库只有一个调用点 、无任何路径能把宿主 / 非对齐缓冲送进核;两份拷贝逐字等价;rq_put 的 cols%32 契约已被尾块补齐;-fmad=false 属已单列改动、不影响任何引擎归档数字 |
--- |
另外两条由审计带出、必须写死的口径纪律:
- 本轮不是位级改动 :它是「归约树重排 + TOKIDS 级对齐」,而本系列明确「TOKIDS 相同不作为位级证明」。 对外不得说「位级一致」。
- 第七节的表与第八节的表不可并排 (不同
-fmad构建)------数值基线一律以第 8.4 节为准。
九、方法链总结:一条可复用的套路
把这一篇从头到尾的动作抽出来,是一条五段式的套路,每一段都有明确的产出与判据:
| 段 | 动作 | 产出 | 判据 |
|---|---|---|---|
| ① 整层对拍 | 用同口径参考逐层对(--layers N → h{N}) |
单层对上 / 对不上 | maxrel 落在预期量级(~1e-6)内 |
| ② 逐级下钻 | 从「整模型不对」拆到相(--skip)、拆到层型(--synthetic) |
每相 / 每层的耗时与字节账 | 反算带宽表统一口径 ,可加性残差明写不归因 |
| ③ 单核微基准 | 把「核在什么速率上跑」量出来(--sweep hot/cold/bulk) |
一个量化基线 + 方向 | 依赖 cold/bulk(hot 抖 13% 不用);敢证伪 |
| ④ 落地改动 | 按基线做结构性改动,引擎与工具两份同步 | 新内核 + 两条契约 | 两条 entry 逐位相同;边角覆盖补全 |
| ⑤ 端到端验收 | 全 64 层跑 --layers 64 |
ms/token / tok/s / 反算带宽 | 精度量级不变 + 守卫负向用例仍 PASS |
几处反复出现的纪律,值得单列:
- 「对上了」要能解释成什么 。整层对拍先问「差异应该来自哪儿」------如果预期只是归约序和 ULP 级的超越函数差(~1e-6),那门禁就该取 1e-4 而不是 1e-6;落在 1e-2 量级则必是接线 / 语义错。 这条读法比任何单点数字都值钱。
- 「宁可拒绝,不要静默错」 。三条守卫(全有或全无、
pos == 游标、池重建清引用)都是这个立意; 而守卫的负向用例 必须专门造,因为正常回归永远不触发它们------不测就等于没有 , 而且负向用例本身也要测「敏感性」(第一版attn-stale就是「为错误的理由通过」)。 - 审计入档,且订正经得起复算 。两轮审计订正的都是口径(基线取错中间态、
clock()量化、 混口径、把推断写成事实),而不是功能。订正后逐位不变的回归才是「订正没改数值」的证据。 - 推断与实测要分清 。外推 44--59 tok/s 被实测 36.8 推翻;「1.64× 的干净预期」被审计列出三条 削弱理由;「行数⇒warp 数⇒占用率」被同表
gdn in_z证伪。外推再一次偏乐观,实测为准。 - 两表不可并排引用 。
-fmad口径不同、会话不同、构建不同------跨口径的数字并排就有害。 凡是有口径门槛的地方,都留一句「以某一节为准」。
CPU 对照(迁移前基线)
- CPU 参考 :
src/model/vllm_q35_attn.c:q35_attn_step(全注意力)、src/model/vllm_q35_model.c:q35_model_step(整步编排)。 - 迁移要点:16 层 full-attn 设备化(gate 逐 head 交织 / partial RoPE / output gate 在 o_proj 前);异构 dispatch(48 GDN+16 full,compact 下标);三条守卫(全有或全无 / pos==KV 游标 / 池重建清引用)。
- 真机验证 :命中 E3/E4(
--test-q35-cuda:64 层、前向 14.28 ms/token、logits 位级 maxae=6.1e-05 maxrel=3.08e-06 OK、贪心 64/64)+ E6(q35_attn_checkPASS:5/5、reset 后位级复现 0/5120)。
附录:原始数据表
表 A1 · 4.5 逐层隔离对拍(--layers N → 参考 h{N})
| 档 | 输出量 | max|ref| | maxae | maxrel | 判据 1e-4 |
|---|---|---|---|---|---|
| N=1 对 h1 | 25600 | 2.217e+01 | 3.81e-05 | 1.721e-06 | OK |
| N=2 对 h2 | 25600 | 2.652e+01 | 2.29e-05 | 8.630e-07 | OK |
| N=3 对 h3 | 25600 | 2.899e+01 | 4.39e-05 | 1.514e-06 | OK |
| N=4 对 h4 | 25600 | 3.492e+01 | 4.96e-05 | 1.420e-06 | OK |
| N=4 对 hfinal | 25600 | 4.837e+01 | 3.81e-05 | 7.886e-07 | OK |
| N=4 对 logits | 1241600 | 1.605e+01 | 4.86e-05 | 3.030e-06 | OK |
| N=64 对 h64 | 25600 | 2.167e+02 | 4.27e-04 | 1.972e-06 | OK |
| N=64 对 hfinal | 25600 | 4.972e+01 | 1.14e-04 | 2.302e-06 | OK |
| N=64 对 logits | 1241600 | 1.590e+01 | 5.39e-05 | 3.389e-06 | OK |
十、小结
- 两类层都设备化只是起点,真正的骨架是「逐级下钻」:整层对拍发现问题、逐级下钻定位问题、 单核微基准量化问题、端到端验收收益------每一段都留下可复核的数字。
- 「差异应该来自哪儿」先于「对上了」:预期是归约序加 ULP 级超越函数差(~1e-6), 门禁就该取 1e-4;落到 1e-2 必是接线 / 语义错。
- 守卫必须专门造负向用例:正常回归永远不触发它们,而且负向用例本身也要验敏感性。
- 凡「单核基线」先问它是不是被 L2 兜住 :用
hot ≤ bulk的不等号方向去证伪, 别把 L2 速率当 DRAM 速率。 - 外推偏乐观,实测为准:44--59 tok/s 的外推被 36.8 推翻;1.64× 的干净预期也被审计削弱。
- 跨口径、跨会话、跨构建的数字不可并排:1.44× 只能当「量级」引用,不能说成「同轮实测倍率」。
相关篇目:第 24 篇、第 26 篇 源码与配套资源:本仓库 gitee.com/pei-xiaogua...; CPU 推理源码 gitee.com/pei-xiaogua...