Kestrel_VLLM - 25 · 全注意力层设备化、整层对拍与访存粒度

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。

这张表里最难、也最容易写错的是三处,规格把它们逐条点出来:

  1. q 的 gate 是「逐 head 交织」,不是两段拼接 。q_proj 的输出宽度是 2 * nh * hd, 其中 gate 部分不是 跟在 q 后面的一大段,而是每个 head 各带着自己那 hd 维的 gate 。 写成 cat(q_all, gate_all) 就错了。
  2. partial RoPE 只旋转每 head 的前 rd 维 (本模型 rd = 64,而 hd = 256), 且 RoPE 的 emb 是 cat((f, f)) 的形式,只有前 rd 维参与。
  3. 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 还缺的三块设备实现

  1. RMSNorm with (1 + w) :引擎现有的 k_rms_norm 用的是 w,而 Qwen3.5 的 input/post/final layernorm 都是 Qwen3_5RMSNorm ⇒ 要用 (1 + w)。不能复用,要新写。
  2. SwiGLU MLP :gate/up/down 三张投影(已有 vc_rq_launch_gemv)+ silu(gate) * up 的逐元素内核(引擎有 k_swiglu,但激活口径 / 布局要对齐)。
  3. 残差累加 与层间驱动 (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 分目录,便于对照)。三个坑都是本轮踩出来的:

  1. 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 个文件。
  2. -DVLLM_HAVE_CUDA 必须默认开启 。不定义时 vllm_cuda.c 整体编译成无害桩 : vllm_cuda_available() 恒 0,且 note 是空串 ⇒ 工具只报「CUDA 后端不可用」, 连一句 [CUDA] 诊断都不打 ,极难查。脚本里 -NoCuda 才退回桩。
  3. 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 ★ 三条有意加的硬约束(都是「宁可拒绝,不要静默错」)

  1. 全有或全无 :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=...) 分配的。两者不等时按前者索引会越界读,所以先校验相等、不等即拒绝。
  2. pos 必须等于 attn 层的 KV 游标 ,否则拒绝。两者不等 = RoPE 与注意力窗口错位, 是静默错(结果仍是有限数、量级也对);显式拒绝比事后逐层对数字便宜得多。

  3. 池重建时清引用(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 / device expf)⇒ 已重写口径段, 并把「判据 ~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 三条结论 + 一条被证伪的假设

  1. ★ 证伪:基线的 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 兜住」的怀疑不成立。
  2. 内核自身的 DRAM 上限 ≈ 1030 GB/s(峰值的 58%) ,且与形状基本无关 (除 mlp down 951)。 这是单核里最大的一块钱:1.74× 的理论空间在核里,不在模型结构里。
  3. 逐份发射(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 由此得到的方向(本轮不改)

  1. 提高单核 DRAM 效率 (1030 → 向峰值靠):当前每个 lane 只读 1 字节 (字节级寻址) ⇒ 访存粒度太细。原方案的「8 lane 各取 uint32 再 shuffle 分发」正是为此, 现在它有了量化依据与目标(1034.7 GB/s 这条基线)。✅ 已落地为下一节的访存粒度改动。
  2. 小行数投影沿 K 拆并行------★ 审计订正:这条的机制错了 (见上节第 3 条的订正), gdn in_z 反例证明真正的区分量是每份权重的字节数 ,不是 warp 数 / 占用率。 该条暂不作为方向。
  3. 融合小投影 (把 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× (这就是端到端收益的来源);bulk 1532--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× ⇒ 差额来自:

  1. 非 GEMM 的部分一点没快:两处 norm、GDN 的 conv/beta-g/递推、attn 的 rope/KV 追加/softmax 核、 embedding 行收集(本来就没走 GPU)。
  2. 逐层发射的空隙:每层约 10 次发射,64 层的发射间隙不随内核变快而缩小。
  3. attn k/v 仍是 233 GB/s(未拆 K)⇒ 16 层 × 12 µs 量级。

⚠ 上面三条是推断,不是实测 :要定论得做逐相 CUDA 事件计时 。 因此 1.44× 不能拆成「GEMM 1.64 × 其余 1.00」再反推。

审计另点了三条同向的可能原因(同样未证实,但会削弱「1.64 是干净的预期」这个前提):

  1. 1.64 这个预期本身来自合成权重 (标度恒为 f16(1.0))。真实权重里 subnormal / 零标度更多, 预期值本身可能是有偏的。
  2. 基线构成歧义 :27.2 ms/token 那次 DLL 与 18.9 这次是否只差这一刀 , 无法从仓库自证(本仓库不是 git 仓库,也没有旧 DLL 留档)。
  3. 两端都没记录 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 属已单列改动、不影响任何引擎归档数字 ---

另外两条由审计带出、必须写死的口径纪律:

  1. 本轮不是位级改动 :它是「归约树重排 + TOKIDS 级对齐」,而本系列明确「TOKIDS 相同不作为位级证明」。 对外不得说「位级一致」。
  2. 第七节的表与第八节的表不可并排 (不同 -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

几处反复出现的纪律,值得单列:

  1. 「对上了」要能解释成什么 。整层对拍先问「差异应该来自哪儿」------如果预期只是归约序和 ULP 级的超越函数差(~1e-6),那门禁就该取 1e-4 而不是 1e-6;落在 1e-2 量级则必是接线 / 语义错。 这条读法比任何单点数字都值钱。
  2. 「宁可拒绝,不要静默错」 。三条守卫(全有或全无、pos == 游标、池重建清引用)都是这个立意; 而守卫的负向用例 必须专门造,因为正常回归永远不触发它们------不测就等于没有 , 而且负向用例本身也要测「敏感性」(第一版 attn-stale 就是「为错误的理由通过」)。
  3. 审计入档,且订正经得起复算 。两轮审计订正的都是口径(基线取错中间态、clock() 量化、 混口径、把推断写成事实),而不是功能。订正后逐位不变的回归才是「订正没改数值」的证据。
  4. 推断与实测要分清 。外推 44--59 tok/s 被实测 36.8 推翻;「1.64× 的干净预期」被审计列出三条 削弱理由;「行数⇒warp 数⇒占用率」被同表 gdn in_z 证伪。外推再一次偏乐观,实测为准。
  5. 两表不可并排引用 。-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_check PASS: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

十、小结

  1. 两类层都设备化只是起点,真正的骨架是「逐级下钻」:整层对拍发现问题、逐级下钻定位问题、 单核微基准量化问题、端到端验收收益------每一段都留下可复核的数字。
  2. 「差异应该来自哪儿」先于「对上了」:预期是归约序加 ULP 级超越函数差(~1e-6), 门禁就该取 1e-4;落到 1e-2 必是接线 / 语义错。
  3. 守卫必须专门造负向用例:正常回归永远不触发它们,而且负向用例本身也要验敏感性。
  4. 凡「单核基线」先问它是不是被 L2 兜住 :用 hot ≤ bulk 的不等号方向去证伪, 别把 L2 速率当 DRAM 速率。
  5. 外推偏乐观,实测为准:44--59 tok/s 的外推被 36.8 推翻;1.64× 的干净预期也被审计削弱。
  6. 跨口径、跨会话、跨构建的数字不可并排:1.44× 只能当「量级」引用,不能说成「同轮实测倍率」。

相关篇目:第 24 篇、第 26 篇 源码与配套资源:本仓库 gitee.com/pei-xiaogua...; CPU 推理源码 gitee.com/pei-xiaogua...

相关推荐
yliucherish1 小时前
从零实现一个 SQL 注入检测引擎:报错注入与布尔盲注的误报控制
后端
bigbread1 小时前
状态模式-住院患者状态管理
后端
卷无止境2 小时前
给AI装上一双眼睛:快速构建代码验证器的思路与落地方案
后端·python
学长毕设2 小时前
基于SpringBoot的餐厅管理系统-附源码
java·spring boot·后端
ZWZhangYu4 小时前
从 Dify 实践理解大模型上下文缓存
java·后端·spring
考虑考虑5 小时前
瀚高数据库
数据库·后端·postgresql
锋行天下5 小时前
DeepSeek写Langgraph就是个大傻逼,一本正经胡说八道
前端·人工智能·后端
小小张说故事7 小时前
Python 的 str 为什么设计成不可变?拆开字符串驻留、字符串池,以及 += 拼接到底慢在哪
后端·python
AI架构师之家7 小时前
我把 Hermes 这匹"马"养了两周,它干了一件让我有点发毛的事
后端