云计算百科
云计算领域专业知识百科平台

Kestrel_VLLM - 24 · GDN 线性注意力上 GPU:递推状态、conv 历史与往返成本

24 · GDN 线性注意力上 GPU:递推状态、conv 历史与往返成本

一句话:Gated DeltaNet(GDN)是线性注意力,它每一步都要读一份递推状态、算一次更新再写回,还随行一段卷积历史——这是本系列第一次遇到「有状态递推算子」;本篇记录它上 GPU 的全过程:先用 f32 把七步算子序列钉死,再把三个大投影换成 raw-q4,补上逐算子回读,最后用同内核 A/B 推翻前文一句关于往返成本的定性结论。 前置:建议先读 第 23 篇(raw-q4 内核与常驻池)。 环境:x86-64 + NVIDIA RTX 5090;模型 Qwen3.5-27B(48 层 GDN + 16 层 full attention);导出 gdn_l0 oracle(f32 权重 + 真实前向)。 三个数:f32 档对 oracle 的 worst max_rel = 3.800e-07(T=5、判据 1e-4;第 六 节「f32 档实测」);同内核 A/B 量出每层往返 ≈0.01–0.04 ms(48 层 ≈0.5–2 ms/token)⇒ 推翻前文 48×F≈13 ms 的定性结论(第 十一 节);q4 单层内核三次迭代 0.268 → 0.224 → 0.156 ms(累计 1.72×)、折算有效带宽 238 → 410 GB/s(第 十二 节)。 怎么读:结论看[一、问题与结论]、[十一、往返成本 A/B]与[十五、小结](必读);机制看[三/四、核心机制]与[十二、按形状拆开 gemv](想深挖再看);[六、f32 档实测][八、q4 档][十、逐算子回读]的实测输出块与[十三、试过并回退]是过程与原始数据(可跳,逐算子原始读数见文末附录)。

第 23 篇把 raw-q4 dense 内核与独立常驻池落地:权重终于能像 MoE 那样 以 4-bit 原始布局常驻显存,不再被 int8 展开撑爆。但那条路走的都是无状态算子—— 一次 GEMM 出一份结果,读多少写多少,谁先谁后不影响答案的对错。

27B 的 48 层里还有另一种东西:Gated DeltaNet(GDN)线性注意力。它每一步都要读一份 递推状态 S、算一次 δ 更新、再写回去,还随行一段 conv 历史。这是本系列第一次遇到 「有状态递推算子」——并行怎么切、对拍用什么判据、状态镜像怎么命名与清零, 全都要从零想一遍,不能照抄前四个阶段的无状态 GEMM / attention。

状态:✅ 已落地。

一、问题与结论

  • GDN 全套设备内核落地(①投影 → β/g → ②conv+SiLU → ③切分 → ④q/k 归一 → ⑤delta 递推 → ⑥gated-RMSNorm → ⑦out_proj),外加递推状态镜像。 f32 档对 oracle 的 worst max_rel = 3.800e-07,比 1e-4 的判据宽 260 倍。
  • 三个大投影改走 rq raw-q4 池:三项合计 61.9 MB,f32 同层是 442.0 MB,7.1×。入口是全有或全无,绝不静默退回 f32,且池重建 ⇒ 各层登记的 key 全部失效。
  • 逐算子回读解除前文留下的一个未决项:误差在第一步就全部进入、后续不放大; β/g 相对误差恒为 0(trace 自洽校验);并且推翻了一个猜测—— q4 误差预算必须在真实激活上标定,均匀随机向量会低估一个数量级(4.53e-2 vs 3.26e-3)。
  • ★ 往返成本被证伪。前文曾定性说「固定开销占大头 / 48×F≈13 ms」, 同内核 A/B 测出每层往返只有 ≈0.01–0.04 ms(噪声量级), 48 层全程 host-facing 的往返是 ≈0.5–2 ms/token。真正的瓶颈是 raw-q4 内核自身的有效带宽(当时 ~238 GB/s,峰值的 13%)。
  • 内核三次迭代把 q4 单层从 0.268 → 0.224 → 0.156 ms(累计 1.72×), 根因定位到软件 f16→f32 转换。
  • 一个负结果:把「一个 warp 处理 4 行」试过并回退——精度逐位不变但性能退步, 已按规矩在源码注释里写明「不要重做这一刀」。
  • 二、背景:为什么先做 f32 档,再做 q4 档

    gdn_l0 那份 oracle 导出是 f32 权重 + 真实前向(transformers)。这就是先后顺序的全部理由:

    • 若这里直接上 q4,误差里会同时混进两样东西:内核写错(量级 O(1))与 权重量化(rel ~1e-2)。两者混在一起,无法区分。
    • f32 档把设备侧算子序列单独钉死之后,第二步的 q4 档只需看差值是否落在量化误差预算内即可。

    这与本系列「先隔离再集成」是同一条纪律:一次只引入一个变量, 让每一次失败都有唯一的解释。

    本档全部 f32 权重;q4 档(三个大投影走 rq 池)放在 f32 档判据通过之后。

    三、核心机制(一):CPU ↔ 内核逐算子对应

    CPU 参考在 src/model/vllm_q35_gdn.c,该文件已由仓库的 GDN 探针验证。 设备内核在 src/npu/cuda/vllm_cuda_kernels.cu。下表把七步(含 ① 里的 β/g 子步)逐列抄全, 「关键点」一列是逐字摘录:

    CPU内核关键点
    ① 四个投影 k_f32_gemv ×4 ★ in_proj_b 出 β、in_proj_a 出门控 a(与直觉相反)
    β / g k_gdn_beta_g ★ g 在对数域:−exp(A_log)·softplus(a+dt_bias),含 ±20 的溢出分支
    ② conv+SiLU k_gdn_conv_silu ★ 窗口 [s0,s1,s2,x_t] 最旧在前;历史存 conv 的输入(SiLU 前);卷积与历史左移同核完成(每线程独占一个通道 ⇒ 无跨线程竞争)
    ③ 切分 无 q/k/v 就是 xc 的三段视图
    ④ q/k 归一 k_gdn_qk_norm ★ 先按 k-head 归一(q 再乘 1/sqrt(kd)),之后才按 repeat_interleave 展开到 v-head
    ⑤ 递推 k_gdn_delta ★ 顺序 α 缩放 → (S·k) → (v−·)·β → 秩一加 → (S·q)(用更新后的 S)
    ⑥ gated-RMSNorm k_gdn_gate_norm ★ rsqrt 缩放 → 乘 weight → 最后乘 silu(gate=z)
    ⑦ out_proj k_f32_gemv

    #mermaid-svg-FQXldKhdQCOvsCZG{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-FQXldKhdQCOvsCZG .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-FQXldKhdQCOvsCZG .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-FQXldKhdQCOvsCZG .error-icon{fill:#552222;}#mermaid-svg-FQXldKhdQCOvsCZG .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-FQXldKhdQCOvsCZG .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-FQXldKhdQCOvsCZG .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-FQXldKhdQCOvsCZG .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-FQXldKhdQCOvsCZG .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-FQXldKhdQCOvsCZG .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-FQXldKhdQCOvsCZG .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-FQXldKhdQCOvsCZG .marker{fill:#333333;stroke:#333333;}#mermaid-svg-FQXldKhdQCOvsCZG .marker.cross{stroke:#333333;}#mermaid-svg-FQXldKhdQCOvsCZG svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-FQXldKhdQCOvsCZG p{margin:0;}#mermaid-svg-FQXldKhdQCOvsCZG .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-FQXldKhdQCOvsCZG .cluster-label text{fill:#333;}#mermaid-svg-FQXldKhdQCOvsCZG .cluster-label span{color:#333;}#mermaid-svg-FQXldKhdQCOvsCZG .cluster-label span p{background-color:transparent;}#mermaid-svg-FQXldKhdQCOvsCZG .label text,#mermaid-svg-FQXldKhdQCOvsCZG span{fill:#333;color:#333;}#mermaid-svg-FQXldKhdQCOvsCZG .node rect,#mermaid-svg-FQXldKhdQCOvsCZG .node circle,#mermaid-svg-FQXldKhdQCOvsCZG .node ellipse,#mermaid-svg-FQXldKhdQCOvsCZG .node polygon,#mermaid-svg-FQXldKhdQCOvsCZG .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-FQXldKhdQCOvsCZG .rough-node .label text,#mermaid-svg-FQXldKhdQCOvsCZG .node .label text,#mermaid-svg-FQXldKhdQCOvsCZG .image-shape .label,#mermaid-svg-FQXldKhdQCOvsCZG .icon-shape .label{text-anchor:middle;}#mermaid-svg-FQXldKhdQCOvsCZG .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-FQXldKhdQCOvsCZG .rough-node .label,#mermaid-svg-FQXldKhdQCOvsCZG .node .label,#mermaid-svg-FQXldKhdQCOvsCZG .image-shape .label,#mermaid-svg-FQXldKhdQCOvsCZG .icon-shape .label{text-align:center;}#mermaid-svg-FQXldKhdQCOvsCZG .node.clickable{cursor:pointer;}#mermaid-svg-FQXldKhdQCOvsCZG .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-FQXldKhdQCOvsCZG .arrowheadPath{fill:#333333;}#mermaid-svg-FQXldKhdQCOvsCZG .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-FQXldKhdQCOvsCZG .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-FQXldKhdQCOvsCZG .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-FQXldKhdQCOvsCZG .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-FQXldKhdQCOvsCZG .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-FQXldKhdQCOvsCZG .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-FQXldKhdQCOvsCZG .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-FQXldKhdQCOvsCZG .cluster text{fill:#333;}#mermaid-svg-FQXldKhdQCOvsCZG .cluster span{color:#333;}#mermaid-svg-FQXldKhdQCOvsCZG div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-FQXldKhdQCOvsCZG .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-FQXldKhdQCOvsCZG rect.text{fill:none;stroke-width:0;}#mermaid-svg-FQXldKhdQCOvsCZG .icon-shape,#mermaid-svg-FQXldKhdQCOvsCZG .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-FQXldKhdQCOvsCZG .icon-shape p,#mermaid-svg-FQXldKhdQCOvsCZG .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-FQXldKhdQCOvsCZG .icon-shape .label rect,#mermaid-svg-FQXldKhdQCOvsCZG .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-FQXldKhdQCOvsCZG .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-FQXldKhdQCOvsCZG .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-FQXldKhdQCOvsCZG :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

    读写

    读写

    ① 四投影 GEMV ×4

    β/g:b→β, a→门控

    ② conv+SiLU

    ③ 切分 q/k/v

    ④ q/k 归一

    ⑤ delta 递推

    ⑥ gated-RMSNorm

    ⑦ out_proj

    conv 历史 ≈5.9 MB

    状态镜像 S ≈151 MB

    图 1:GDN 上 GPU 的七步递推链与两处状态。设备内核按序对应 CPU 的 q35_gdn_step:① 四个投影(★ in_proj_b 出 β、in_proj_a 出门控 a,与直觉相反)→ β/g(★ 门控在对数域 −exp(A_log)·softplus(a+dt_bias))→ ② depthwise 因果卷积 + SiLU(★ 窗口 [s0,s1,s2,x_t] 最旧在前)→ ③ 切成 q/k/v 视图 → ④ q/k 归一(★ 先按 k-head 归一)→ ⑤ delta 递推(α 缩放 → S·k → (v−·)·β → 秩一加 → 用更新后的 S·q)→ ⑥ gated-RMSNorm → ⑦ out_proj。虚箭头是两处需跨 token 保持的设备状态:递推状态 S([nl][vh][kd][vd] = 48 层 × 3.15 MB ≈ 151 MB)与 conv 历史([nl][ck-1][conv_dim] ≈ 5.9 MB),合计 ≈157 MB。⑤递推与②卷积无跨线程归约、逐字同序,差异只来自 ①⑦/④/⑥ 三处归约树,故判据取容差而非位级(f32 档 worst max_rel 3.800e-07)。

    有三个点是反直觉的,值得单独拎出来(写错任何一处,误差都会是 O(1)):

  • in_proj_b 出的是 β,in_proj_a 出的是门控 a——名字与直觉相反。 β 再取 sigmoid 作为 δ 更新的步长;a 则进对数域门控。别按名字猜。
  • g 在对数域:g = −exp(A_log)·softplus(a + dt_bias),其中 softplus 含 ±20 的溢出分支。整个门控是在对数域里做的,不是直接对 A 取指数。
  • conv 窗口 [s0,s1,s2,x_t] 最旧在前,而且历史里存的是 conv 的输入(SiLU 之前)—— 即存的是「喂进卷积的原始量」,不是卷积后的输出。卷积与历史左移在同一个核里完成, 每线程独占一个通道,因此天然无跨线程竞争。
  • 这两处「反直觉」在源码里就是两个小核,值得原样贴出来对照:

    /* 文件名:src/npu/cuda/vllm_cuda_kernels.cu —— β/g:b 出 β、a 进门控,且 g 在对数域 */
    __global__ void k_gdn_beta_g(const float *__restrict a,
    const float *__restrict b,
    const float *__restrict a_log,
    const float *__restrict dt_bias,
    float *__restrict beta, float *__restrict g,
    int vh) {
    const int i = blockIdx.x * blockDim.x + threadIdx.x;
    if (i >= vh) return;
    const float xa = a[i] + dt_bias[i];
    float sp;
    if (xa > 20.0f) sp = xa; /* 防 exp 溢出,同 CPU 的分支 */
    else if (xa < –20.0f) sp = expf(xa);
    else sp = log1pf(expf(xa));
    g[i] = –expf(a_log[i]) * sp; /* ★ 对数域(负值) */
    beta[i] = 1.0f / (1.0f + expf(–b[i])); /* ★ β 来自 b */
    }

    /* ② depthwise 因果卷积 + SiLU,并在同核内推进历史(历史存 conv 的输入,非输出) */
    __global__ void k_gdn_conv_silu(const float *__restrict raw_in,
    float *__restrict out,
    float *__restrict hist,
    const float *__restrict conv_w,
    int conv_dim, int conv_k) {
    const int ch = blockIdx.x * blockDim.x + threadIdx.x;
    if (ch >= conv_dim) return;
    const int nprev = conv_k – 1;
    const float *cw = conv_w + (size_t)ch * (size_t)conv_k;
    const float raw = raw_in[ch]; /* conv 的输入(历史要存的就是它) */
    float acc = 0.0f;
    for (int j = 0; j < nprev; j++)
    acc += cw[j] * hist[(size_t)j * conv_dim + ch];
    acc += cw[nprev] * raw;
    /* 历史推进:先读完再写(同线程内顺序保证),左移一帧、末帧放 raw */
    for (int j = 0; j + 1 < nprev; j++)
    hist[(size_t)j * conv_dim + ch] = hist[(size_t)(j + 1) * conv_dim + ch];
    if (nprev > 0) hist[(size_t)(nprev – 1) * conv_dim + ch] = raw;
    out[ch] = acc / (1.0f + expf(–acc)); /* silu(acc) */
    }

    四、核心机制(二):并行划分与「差异来自哪里」(订正过一次)

    并行划分决定了判据形态:

    • ⑤ 递推:一个 block 一个 v-head,**thread = dv(独占 S 的一列)**⇒ 对 dk 的三遍扫描 全在线程内完成,既不需跨线程归约、也不会争同一地址;
    • ② conv:每线程独占一个通道;
    • ④ q/k 归一:一个 warp 一个 k-head;
    • ⑥ gated-RMSNorm:一个 block 一个 v-head。

    ★ 差异来自哪里(这一条订正过一次,别记错):

    ⑤ 递推(含 δ 的秩一更新)与 ② 卷积在这里没有任何跨线程归约,逐元素算式与 CPU 逐字相同,单看它们是位级可复现的;真正变序的是 ①⑦ 投影(warp 树形归约)、 ④ q/k 归一、⑥ gated-RMSNorm(shared 二分) 这三处的归约树。 不要把差异归给 dk 求和——它是串行的。

    这段订正必须原样保留,因为它直接决定了判据:

    • 变序的地方只有三处归约树(①⑦ / ④ / ⑥),它们重排浮点加法,重排就换位;
    • 而递推与卷积是串行的、逐字相同的,理论上单看它们可以位级复现;
    • 因此整体判据是容差而非位级(与仓库的 GDN 探针同一判据)。

    换句话说:GDN 的对拍判据不是「逐字节相同」,而是「相对误差落在归约树能解释的量级内」。 这也解释了为什么 f32 档的 3.8e-7 是个好结果——只差三处归约树就该是这个量级; 哪一步写错,量级会跳到 O(1)。

    ⑤ 递推的内核就是这条「串行、逐字可复现」纪律的落点:每个线程独占 S 的一列, 对 dk 做三遍串行扫描,顺序严格照 CPU:

    /* 文件名:src/npu/cuda/gdn_delta_twin.inc(引擎与探针共享的唯一一份源)
    * 一个 block 一个 v-head,thread = dv 独占 S 的第 dv 列 ⇒ dk 三遍扫描全在线程内。 */

    __global__ void k_gdn_delta(float *__restrict S, const float *__restrict q,
    const float *__restrict k, const float *__restrict v,
    const float *__restrict g, const float *__restrict beta,
    float *__restrict o, int kh, int kd, int vh, int vd) {
    const int vi = blockIdx.x;
    const int dv = threadIdx.x;
    if (vi >= vh) return;
    extern __shared__ float sh[]; /* [kd] 存 q 然后 [kd] 存 k */
    float *sq = sh, *sk = sh + kd;
    const int rep = vh / kh;
    const int hh = vi / rep;
    for (int d = threadIdx.x; d < kd; d += blockDim.x) {
    sq[d] = q[(size_t)hh * (size_t)kd + d];
    sk[d] = k[(size_t)hh * (size_t)kd + d];
    }
    __syncthreads();
    float *Sm = S + (size_t)vi * (size_t)kd * (size_t)vd; /* [kd][vd] */
    const float alpha = expf(g[vi]);
    const float bt = beta[vi];
    const float vv = v[(size_t)vi * (size_t)vd + dv];
    float kv = 0.0f;
    for (int dk = 0; dk < kd; dk++) { /* ① α 缩放 + (S·k) */
    float *sp = Sm + (size_t)dk * (size_t)vd + dv;
    const float t = (*sp) * alpha; *sp = t; kv += t * sk[dk];
    }
    const float tmp = (vv – kv) * bt;
    float oa = 0.0f;
    for (int dk = 0; dk < kd; dk++) { /* ② 秩一加 + (S·q),用**更新后**的 S */
    float *sp = Sm + (size_t)dk * (size_t)vd + dv;
    const float t = *sp + sk[dk] * tmp; *sp = t; oa += t * sq[dk];
    }
    o[(size_t)vi * (size_t)vd + dv] = oa;
    }

    五、状态镜像:S 151 MB + conv 历史 5.9 MB

    有状态算子的第一件工程事,是把状态放在哪、多大、怎么清零。

    状态形状体量
    递推状态 S [nl][vh][kd][vd] = 48 层 × 3.15 MB ≈ 151 MB
    conv 历史 [nl][ck-1][conv_dim] ≈ 5.9 MB
    合计 ≈ 157 MB

    本档是单份镜像(单序列),只提供 gdn_reset(新序列复用); 投机解码的分支克隆留到后面接 spec_verify 时再加(KV 镜像已有 8 槽池可参照)。

    这里有个必须在接线前解决、但本篇只需登记的语义缺口: gdn_step 是 hit-only(未预载就返回 -1,让调用方回退 CPU),但 GDN 的 S / conv_hist 只存在于显存,本轮没有状态导出 API。对无状态算子(GEMM)「miss → 回退」是安全的; 对有状态递推,中途回退会让 CPU 拿着不一致的状态算出错误结果。接线时必须三选一: 保证序列开始前 GDN 权重全常驻(miss 不可能发生) / 加状态导出-导入 / 整序列 all-GPU 或 all-CPU。 这一条与「单份镜像不支持并发与投机分支」一起,是两个准入门槛。

    六、f32 档实测

    gdn_l0 导出,T=5 token,真权重,–nl 48(下列均为 x86 + RTX 5090 实测):

    [CUDA] gdn: nl=48 dim=5120 k=16×128 v=48×128 ck=4; state 149.6 MB, scratch 0.13 MB
    [gdn] tok max_abs_err max_abs_ref max_rel verdict
    [gdn] 0 1.907e-06 2.042e+01 9.340e-08 OK
    [gdn] 1 9.537e-07 1.251e+01 7.621e-08 OK
    [gdn] 2 1.907e-06 1.969e+01 9.688e-08 OK
    [gdn] 3 9.537e-07 1.455e+01 6.557e-08 OK
    [gdn] 4 5.722e-06 1.506e+01 3.800e-07 OK
    [gdn] PASS (0/5 tokens out of tolerance), worst max_rel=3.800e-07
    [gdn] reset 后首 token 位级复现(同进程/同发射配置):不等元素 = 0 / 5120 [相同]
    [gdn] 跨层切片(层 0 vs 层 47,同权重、各 5 token):逐位不等元素 = 0 / 25600 [逐位相同]
    [gdn] step(host-facing): 0.400 ms/token/层 有效吞吐 295.5 GMAC/s
    [gdn] 本档每 token 读 f32 权重 442.0 MB ⇒ 反算带宽 **1159 GB/s 为上界**(分母含 H2D/D2H/同步,非纯权重读时间;不得当带宽结论引用)

    逐条读:

    • 正确性(实测,对 oracle):worst max_rel 3.8e-7,比判据(1e-4)宽 260 倍。 与「只差 ①④⑥ 的归约树」的预期一致;CPU 参考探针同期是 7.8e-7,同量级。
    • 性能(实测):0.400 ms/token/层(另一次运行为 0.450 ms;单次 20 迭代、未做重复次数与 方差统计,只可作量级参考)。反算出的 1159 GB/s 是上界(分母含每层一次 H2D/D2H 往返 + 同步 + 5 次 launch,不是纯权重读时间,不得作带宽 / roofline 结论)。
    • f32 档可用的定性结论只有一个:它在显存上根本放不下—— 48 层 × 442 MB = 21 GB/token 的权重读(与第 23 篇的硬阻塞同源)⇒ 必须走 q4。

    七、reset 复现与跨层切片:它们是必要条件,不是证明

    这两个输出块容易被误读,必须把它们的边界写在前面。

    reset 复现:把状态推进 5 个 token 后 reset,重放首 token 与第一次的首 token 位模式相同(0 / 5120,同进程、同发射配置)。★ 这是「清零 + 确定性」的 必要条件检验,不是设备路径的位级证明:

    它看不到 S / hist 本身,也不覆盖跨进程 / 跨线程数 / 跨 device。

    也就是说,它证明的是「重放一致」,不是「状态结构正确」。必要条件不能当充分条件用。

    ★ 跨层状态切片(本轮补上的覆盖):把同一份权重装到第 0 层与第 47 层, 各自 reset 后跑同样的 5 个 token,两串输出 25600 个元素逐位相同(0 / 25600)。

    为什么这一条重要——因为它此前根本不存在:

    • 切片直接覆盖了 layer*vh*kd*vd 与 layer*(ck-1)*conv_dim 两个偏移量;
    • 而在这之前跑的都是 nl=1,layer 恒为 0 ⇒ 偏移恒为 0,等于没测;
    • 这与「bump 分配的偏移寻址」是同类风险,而那次是真踩过的。

    所以跨层切片不是「多测一条」,而是把「状态偏移寻址」这个此前完全没被覆盖的面补上; 它同时顺带验证了「未预载层被拒绝」(hit-only 生效)。

    八、q4 档:三个大投影改走 rq raw-q4 池

    落地要点:

    • vc_gdn_w_t 新增 rq_in_qkv / rq_in_z / rq_out_w(0 = 该档不启用); gdn_step 里每张大投影二选一:有 rq key 走 vc_rq_launch_gemv,否则走 k_f32_gemv。
    • 新入口 vllm_cuda_gdn_use_rq(layer, key_qkv, key_z, key_out):全有或全无—— 三条 key 必须都在池里且形状精确匹配,否则整体拒绝、不改状态 (不留「一半 q4 一半 f32」的含糊配置)。
    • vllm_cuda_gdn_preload_f32 放宽:三张大投影的指针可为 NULL(表示"本档不供这张"), 6 张小张量仍必须提供。⇒ q4 档下那 3 个 f32 大缓冲根本不分配(省 442 MB/层)。
    • ★ 绝不静默退回 f32:声明了 rq key 就以 rq 为准;池里缺失 / 形状不符 → gdn_step 返回失败,而不是拿一个不存在的缓冲。这与「未预载 → CPU 回退」的语义一致。
    • ★ 两条 both 状态由代码强制拦住(注释里的不变量不能只是注释): preload_f32 在「该投影已切 rq」时拒绝(放在分配之前,避免白分配 442 MB 再拒); use_rq 在「已有 f32 大投影」时拒绝。否则会出现 both 状态:step 按 rq 走, 而那块 f32 缓冲静默泄漏 442 MB/层。
    • ★ 池重建 ⇒ 引用失效:rq_init / rq_clear 会调 vc_gdn_clear_rq_keys, 把各层登记的 key 全部清零。原因:vc_rq_expect 只校验形状;若同一 key 在新池里 绑到另一张形状相同的张量,光靠形状校验会静默用错权重。 (比加 generation 计数器更简单、也更难绕过。)

    单投影 q4 误差预算(q35_fused_check –dump 从 model_q4.vqf 导出打包字节; 下面的对照里只差权重量化这一项,不掺别的变量):

    张量形状maxrelRMSrel打包字节
    in_proj_qkv 10240×5120 3.257e-03 3.613e-03 29 491 200
    in_proj_z 6144×5120 3.376e-03 3.595e-03 17 694 720
    out_proj 5120×6144 6.998e-03 3.703e-03 17 694 720

    三项合计 61.9 MB(f32 同层是 442.0 MB,7.1×)。

    实测(同一次运行里跑两档:层 0 = f32,层 1 = q4;同权重、同输入、各 5 token):

    [gdn] tok max_abs_err max_abs_ref max_rel verdict ← 层 0(f32)
    [gdn] PASS (0/5 tokens out of tolerance), worst max_rel=3.800e-07
    [gdn] q4 档(第 1 层,三张大投影走 rq raw-q4):
    [gdn] tok=0 vs oracle: maxae=5.556e-01 maxrel=2.721e-02
    [gdn] tok=1 vs oracle: maxae=6.421e-02 maxrel=5.131e-03
    [gdn] tok=2 vs oracle: maxae=1.296e-01 maxrel=6.581e-03
    [gdn] tok=3 vs oracle: maxae=5.014e-02 maxrel=3.447e-03
    [gdn] tok=4 vs oracle: maxae=3.923e-02 maxrel=2.605e-03
    [gdn] q4 vs f32 档(同权重、只差量化):maxae=5.556e-01 maxrel=2.721e-02
    [gdn] q4 档量级闸门(maxrel < 5e-2):OK
    [gdn] 层 0 (f32): 0.467 ms/token 有效吞吐 253.3 GMAC/s
    [gdn] 每 token 读权重 442.0 MB ⇒ 反算带宽 993 GB/s 为上界
    [gdn] 层 1 (q4): 0.300 ms/token 有效吞吐 394.0 GMAC/s
    [gdn] 每 token 读权重 63.9 MB ⇒ 反算带宽 223 GB/s 为上界

    一条口径声明(必须写明):本档 GPU 走的是 q4 权重 + f32 激活(k_rq_q4_gemv_f32act)。 而 CPU 的生产路径 q35_gdn_step → q35_wv_gemv 在 q4 分支里会先把激活量化成 q8 再算。⇒ GPU-q4 与 CPU-q4 不是同一口径(GPU 这条更准,少一层激活量化误差)。 因此:① 这一节的数字不能与 CPU q4 路径的数字直接比; ② 要与出货口径对齐必须另做 GPU-q4 vs CPU-q4 的对拍。

    九、正确性证据是什么(订正:原表述是个恒等式)

    这一节是本篇最该记住的一段——因为最初写下的"证据"其实是一个恒等式,什么都证明不了。

    原表述(错):q4 vs f32 档 与 q4 vs oracle 两行数值完全相同 ⇒ 差全部来自量化 ⇒ 连线正确。

    为什么是空的:因为 f32 档对 oracle 只差 8e-6 量级,对任意 rec1 都有 |rec1−rec0| ≈ |rec1−exp|(差 ≤1.6e-5),两行必然相等——接错时它也会相等。

    真正能证明连线正确的是另外两件事:

  • 形状门:三张大投影的形状两两不同(10240×5120 / 6144×5120 / 5120×6144), use_rq 与 gdn_step 都会用 vc_rq_expect 精确比对 ⇒ key 互串在开跑前就被拒, 这比「事后发现」更强;
  • 量级闸门(见下),它抓的是形状门看不见的那类错 (例如 d_gdn_xc 与 d_gdn_z 这种同尺寸终点缓冲对调)。
  • 量级闸门:q4 档 maxrel 2.72e-02 < 1e-1。★ 这个阈值只是 O(1) 探测器: 连线接错的量级是 1e0(比它大一个数量级),而实测合法值 2.72e-02 距它 3.7×, 留余量以免对正确实现误报 FAIL。它不是"精度合格"判据。

    ★ 未决项(当时不要当成已通过):tok0 的 maxrel 2.72e-02 是最坏单投影 (out_proj 6.998e-03)的 3.9×;tok1…4(5.1e-3 / 6.6e-3 / 3.4e-3 / 2.6e-3) 则贴着单投影预算。注意 tok0 的绝对误差 0.556 也是 tok1(0.064)的 ~9×, 不是分母假象。当时的合理解释是「tok0 递推状态为 0、gated-RMSNorm 的归一化因子最小 ⇒ 量化误差被归一化放大」,但本轮没有验证——要定论必须做逐算子 dump 对拍。 在补齐之前,q4 档只能算「连线正确、量级未越界」,不能说"精度满足要求"; 而且 48 层的 2.7e-2 最坏值会层间累积,端到端精度必须另行过闸。

    这个未决项,后来在下一节被解除了——不是"放大被解释掉了",而是 “预算标定的口径本身就错了”。见下节。

    十、逐算子回读:解除未决项

    为什么先做这个再进下一步:下一步会把 GDN 的驱动方式整个换掉(每层 host 往返 → 设备内常驻)。在那之前若没有逐算子观测,任何数值回归都只能表现为「最后一个数不对」, 无法定位。

    为让它可观测而做的改动(数值零变化):两处原地算子改成出参独立—— k_gdn_conv_silu(保留 ① 原始投影)与 k_gdn_gate_norm(保留 ⑤ 递推输出), 新增 d_gdn_xcv / d_gdn_onn 两个缓冲。这只是目的指针变了,算式与顺序一字未改; 证据:f32 档端到端仍是 worst max_rel 3.800e-07(与上一档逐位相同的判据值)。

    新增入口 vllm_cuda_gdn_get(layer, what, dst, cap),what 覆盖 {XC,XCV,Z,Q,K,A,B,BETA,G,ON,ONN,OUT,S,HIST};dst == NULL 为元素个数查询模式; 装不下则拒绝而不是截断。不引入任何每步开销(按需 cudaMemcpy)。

    实测表(gdn_l0,T=5;列 = 该算子自身归一的相对误差; 层 0 = f32,层 1 = q4;maxrel@t0 / rmsrel@t0 / rmsrel@tT-1):

    逐算子原始读数见文末 附录 · 表 A1。

    结论(三条,第一条是自洽校验,第二条解开了那个未决项):

  • β / g 的相对误差恒为 0 —— 因为 in_a / in_b 在两档都是 f32。 这是 trace 自身的自洽校验:若回读接错了缓冲,这两个不可能恰好全零。
  • ★ 误差在第一步就全部进入,后续各级不放大。 ①(q4 gemv 的直接输出) RMS-相对已是 4.53e-02,而 ②/⑤/⑥/⑦ 分别是 2.96 / 3.54 / 5.85 / 4.06e-02 —— 整张表落在 1.4e-2 – 5.8e-2 一个窄带里,没有任何一级出现 O(1) 级放大。 ⑦(4.06e-02)甚至略小于 ①(4.53e-02)。 ⇒ 「最坏单投影 6.998e-03 的 3.9×」这个比较本身是口径错误: q35_fused_check 的 3.26e-3 / 3.61e-3 是用均匀随机 x ∈ [-3,3) 测的; 换成真实激活,同一份 q4 权重给出 4.53e-02 RMS-相对——差 12.5×。 原因:真实激活是重尾的,per-32-block 的 int4 标度对「远低于块内最大值的 大量小元素」相对表示很粗,而 RMS-相对恰恰惩罚这一点。 ★ 可推广的教训:q4 的误差预算必须在真实激活上标定,用均匀随机向量会低估一个数量级。
  • ★ tok0 并不"特殊"(推翻先前"归一化放大"的猜测):逐算子看,t0 与 t4 的 RMS-相对只差 ≤2×(S 5.21e-2→3.77e-2,⑥ 5.85e-2→2.35e-2);而端到端的 max-相对从 2.721e-02 掉到 5.131e-03(5×)。⇒ 「tok0 更差」主要是 尾部效应(少数元素),不是系统性放大;RMS 口径下误差在整个序列上是 平稳的 ~3–4e-2。先前的解释(gated-RMSNorm 归一化放大)不成立。
  • 未决项的处置:那个未决项已解除——不是"放大未解释",而是"预算标定口径错了"。 q4 档的准确定性描述是:整层 RMS-相对 ~3–6e-2,误差源是权重 int4 量化本身, 在本层内部不放大。端到端(48 层串联 + 全模型)是否可接受仍需另行过闸。

    十一、往返成本 A/B:推翻前文的一句定性结论

    动机:前文只能靠"两点模型"猜固定开销,而那个模型自证不成立 (反解出的带宽超过硬件峰值)。于是先做纯粹的 A/B——它决定「设备内常驻解码值不值得做」这个方向性问题。

    做法(保证同内核):把步进体抽成 vc_gdn_step_dev(s, layer, d_h, d_out, stream) (设备指针、不同步),host-facing 与设备内两条路径共用同一份,因此 kernel 与 每 token 的 launch 数完全相同,唯一差别是往返次数(T 次 vs 1 次)。 新增 vllm_cuda_gdn_run_dev(layer, T, h_seq, out_seq)(1 次 H2D + 1 次 D2H + 1 次同步)。

    实测(gdn_l0,T=5,–iters 50):

    层 0 等价性:host-facing(T 次往返) vs 设备内(1 次往返) 逐位不等元素 = 0 / 25600 [逐位相同]
    层 1 等价性:host-facing(T 次往返) vs 设备内(1 次往返) 逐位不等元素 = 0 / 25600 [逐位相同]
    层 0 (f32):host-facing 0.420 → 设备内 0.404 ms/token
    层 1 (q4) :host-facing 0.260 → 设备内 0.224 ms/token

    • 等价性(实测):两条路径逐位相同(0/25600,两档各一次)。这是最强的等价证据—— 在同内核前提下,把往返从 T 次降到 1 次不改变任何一位。
    • ★ 往返成本 ≈ 0.01–0.04 ms/层(两次运行给出 0.012/0.012 与 0.016/0.036; 同一流程里的两次差值是噪声,说明这个量级已到 clock() 的分辨极限)。 ⇒ 48 层全程 host-facing 的往返开销是 ≈0.5–2 ms/token, 不是前文那个 48×F≈13 ms。那句话是错的,已就地标注更正。

    ★ 真正的瓶颈(这才是本节的结论):把往返扣掉后,q4 档每 token 仍要 0.224 ms 去读 63.9 MB 权重 ⇒ 有效带宽只有 ~285 GB/s,是 5090 峰值(1792 GB/s)的 16%。 不是带宽 bound,也不是往返 bound,而是 kernel 自身。

    由这个发现直接驱动的改动(k_rq_q4_gemv_f32act):

  • 标度改为每 lane 自读(同一 warp 读同一地址 ⇒ warp 内广播),去掉 lane0 读 + __shfl_sync 这个每列块一个的串行点——它把 160 个列块逐个串到 「lane0 的全局读 → shuffle → 才能算」这条链上;
  • 列块循环拆成 4 条独立累加链,打断 acc += … 的单链 FMA 依赖。
  • 改后实测:

    口径改前改后倍数
    内核档(17408×5120,纯 kernel 背靠背) 630.8 GMAC/s / 355 GB/s 895.0 GMAC/s / 503 GB/s 1.42×
    GDN q4 档单层(含 8 个非 gemv 内核) 0.268 ms/token 0.224 ms/token 1.20×
    有效带宽(GDN q4 档折算) 238 GB/s 285 GB/s 1.20×

    正确性未退化(实测):maxrel 2.293e-06 → 2.372e-06、RMSrel 1.227e-06 → 1.208e-06 (都还在 1e-6 量级,变化来自求和序由 1 链变 4 链);q35_rq_check 两条 entry 仍 0/17408 逐位相同;f32 档 GDN 端到端仍是 3.800e-07;q4 档 O(1) 闸门仍 OK; 逐算子表(上节)在打印精度内完全不变(量化误差 1e-2 量级淹没了 1e-6 级的序差)。

    对下一步方向的影响:

    • 「为了去掉每层往返而做设备内常驻」的理由被削掉了(往返只值 0.5–2 ms/token);
    • 但设备内常驻仍然要做,理由换了:全模型串联时 hidden 每层上下一次 PCIe 仍是浪费 (≈2 ms/token 量级),且后续的异构 dispatch 本来就需要一个设备内的层循环; 只是它现在不是性能关键路径的第一优先;
    • 新的第一优先是 raw-q4 内核的访问策略。

    十二、按形状拆开 gemv:定位到软件 f16→f32

    第一步拆分(同内核、按形状分别测 gemv 纯 kernel 时间):

    投影形状打包字节纯 kernel 时间有效带宽
    in_proj_qkv 10240×5120 29.49 MB 0.0683 ms 432 GB/s
    in_proj_z 6144×5120 17.69 MB 0.0378 ms 468 GB/s
    out_proj 5120×6144 17.69 MB 0.0449 ms 394 GB/s

    三个 gemv 合计 0.151 ms,占 GDN 单层 0.224 ms 的 67% ⇒ 瓶颈确定在这三个 gemv,而非那 8 个非 gemv 内核,也不是往返。

    根因:rq_coef 里每个 lane、每个列块都要跑一次软件 d_f16_to_f32 (含 while (m < 0x400u) 的 subnormal 归一化循环)。每 lane 160 个列块 ⇒ 最多 160 次带分支的软件转换,而它摊到的 FMA 只有 1 次。这才是主开销。

    改法:换成硬件 __half2float(1 条指令)。两者对 f16→f32 在非 NaN 输入上 精确且唯一,故应逐位等价。软件版保留在源码里作为「硬件指令必须与之相等」的 参考语义(可肉眼核对,而不是口头断言)。

    /* 文件名:src/npu/cuda/q4_gemv_twin.inc
    * 软件版 d_f16_to_f32 保留为「参考语义」(subnormal 用 113 – shift,不是 127-15-shift);
    * 热路径改用硬件 __half2float。两者对非 NaN 输入精确且唯一 ⇒ 逐位等价。 */

    static __device__ __forceinline__ float d_f16_to_f32(uint16_t h) {
    uint32_t sign = (uint32_t)(h >> 15) & 1u;
    uint32_t exp = (uint32_t)(h >> 10) & 0x1Fu;
    uint32_t mant = (uint32_t)h & 0x3FFu;
    uint32_t u;
    if (exp == 0) {
    if (mant == 0) u = sign << 31;
    else {
    uint32_t m = mant; int shift = 0;
    while (m < 0x400u) { m <<= 1; shift++; }
    u = (sign << 31) | ((uint32_t)(113 – shift) << 23) | ((m & 0x3FFu) << 13);
    }
    } else if (exp == 0x1F) {
    u = (sign << 31) | (0xFFu << 23) | (mant << 13);
    } else {
    u = (sign << 31) | ((exp + 112) << 23) | (mant << 13);
    }
    float f; memcpy(&f, &u, 4);
    return f;
    }

    /* 热路径取值:标度每 lane 自读(warp 广播),转换走硬件 __half2float */
    __device__ __forceinline__ float rq_coef(const uint8_t *blk, int m,
    int byte_off, int hi) {
    __half h; memcpy(&h, blk + m * 2, 2);
    const float s = __half2float(h);
    const uint8_t byte = blk[byte_off];
    int n4 = hi ? (int)(byte >> 4) : (int)(byte & 0x0F);
    n4 = (n4 >= 8) ? n4 – 16 : n4; /* int4 two's complement */
    return (float)n4 * s;
    }

    改后实测(同一天、同机、同 dump):

    口径原始+去 shuffle / 4 链+硬件转换累计
    gate_proj(17408×5120,纯 kernel) 630.8 GMAC/s / 355 GB/s 895.0 / 503 1713.5 / 964 2.72×
    in_proj_qkv(10240×5120) 768.2 GMAC/s / 432 GB/s — 1394.9 / 785 1.82×
    in_proj_z(6144×5120) 833.5 GMAC/s / 468 GB/s — 1259.8 / 708 1.51×
    out_proj(5120×6144) 700.1 GMAC/s / 394 GB/s — 1465.2 / 824 2.09×
    GDN q4 单层(含 8 个非 gemv 内核) 0.268 ms 0.224 0.156 ms 1.72×
    GDN 有效带宽(按 63.9 MB 折算) 238 GB/s 285 410 GB/s 1.72×

    ★ 精度逐位不变(这就是等价性的实测证据):

    • maxrel 2.372e-06 / RMSrel 1.208e-06——与用软件转换的上一次运行完全相同; 三张 GDN 矩阵的 maxrel/RMSrel 也逐项相同(2.193e-06/1.201e-06、2.802e-06/1.171e-06、 1.798e-06/1.361e-06)。
    • GDN f32 档端到端仍 3.800e-07(f32 路径不经过 rq_coef,本就应不变); q4 档 O(1) 闸门 OK;host-facing vs 设备内两条等价性检查仍 0/25600 逐位相同。
    • 唯一未覆盖:NaN 输入下硬件指令可能改变 payload(软件版保留符号与尾数); 实际块标度不会是 NaN,故不影响,但这是等价性的边界,写在这里备查。

    往返成本再次确认很小:本轮的 A/B 给出 0.004 ms/层(比上一轮的 0.012 更小, 同样受 clock() 分辨限制)。⇒ 结论不变:往返不是瓶颈。

    仍然剩下的差距:964 GB/s 是 5090 峰值的 54%;GDN 三矩阵加权只有 ~780 GB/s。 剩下的部分要靠换访问策略(当前每 lane 每块只拿 1 个字节,且同组 4 行重复读同一 tile ⇒ L1 流量是 DRAM 流量的 4 倍),而不是继续微调循环。

    十三、试过并回退:一个 warp 处理 4 行(负结果,勿重做)

    动机:按上面的分析,当前每列块要发 3 条 LDG(x / 系数 / 标度)才换 1 次乘加, 且 x 被同一 tile 组的 4 行各读一遍。于是让一个 warp 直接处理 4 行: x 每块只加载 1 次供 4 行共用,每 128 个乘加的 LDG 由 12 条降到 9 条。 每行仍保持原来的 4 链累加顺序 ⇒ 数值预期与上一版逐位相同。

    实测(精度确实逐位不变,但性能退步):

    矩阵上一版(warp/行)4 行/warp倍数
    gate_proj 17408×5120 1713.5 GMAC/s 1531.4 0.89×
    in_proj_qkv 10240×5120 1394.9 1250.2 0.90×
    in_proj_z 6144×5120 1259.8 756.5 0.60×
    out_proj 5120×6144 1465.2 688.6 0.47×

    精度三项(maxrel / RMSrel)逐项与上一版完全相同 ⇒ 改动本身没写错。

    原因:warp 总数降到 1/4。out_proj 只剩 5120/4 = 1280 个 warp, 而 5090 有 170 SM × 最多 64 warp = 上万 warp 槽位 ⇒ 占用率掉到 ~12%, 并行度不足以隐藏访存延迟,LDG 减少的收益被完全吃掉(行数越少的矩阵越惨)。 ⇒ 这一刀的方向(记账是对的)但必须与「把每行的 warp 数补回来」配套, 否则不能单独做。

    处置:已回退到「warp/行 + 4 链 + 每 lane 自读标度 + 硬件 f16→f32」这一版 (并按规矩在源码注释里写明「不要重做这一刀」)。 回退后四项数字恢复到 1713.4 / 1394.9 / 1244.9 / 1459.7 GMAC/s,精度不变。

    ★ 教训(写给下一次):在这个 kernel 上,占用率优先于 LDG 计数。 要减少每 warp 的 LDG,正解是保持 warp 数不变、在 warp 内部把 tile 读取向量化(例如 8 个 lane 各取一个 uint32 的系数块再 shuffle 分发), 而不是把多个行塞进一个 warp。

    十四、边界与已知限制

    已知边界(f32 档与 q4 档共用,后者另加一条):

  • T 只有 5。oracle 只导出了 5 个 token ⇒ 递推只被推进了 5 步, 长序列的漂移未被验证(本档最大的未覆盖面)。
  • 计时走的是 host-facing 入口,每层含一次 h 的 H2D + out 的 D2H + 一次同步; 48 层就是 96 次往返/token。设备内常驻解码会消掉这些,届时需重测。
  • 只验证了 kd = vd = 128 这一组几何;k_gdn_gate_norm 的二分归约要求 vd 是 2 的幂 (已在 gdn_init 里拒绝非幂次),k_gdn_delta 的 q/k 走 shared 要求 kd ≤ 1024。
  • 只对拍了最终 out,①…⑦ 的中间量(S / q / k / v / β / g)未单独比对。 判据以整向量最大 |expect| 归一,理论上「小而有系统性」的偏差可能藏在 1e-4 内。 (这一条后来由逐算子回读部分覆盖。)
  • 逐算子表只覆盖 q4 vs f32(量化贡献);f32 vs oracle 的逐算子对照没有做。
  • q4 档只在单层(第 1 层)验证,48 层混用两档的组合未测; 且 GPU-q4 与 CPU-q4 口径不同(前者 f32 激活,后者 q8 激活),不能互推。
  • 下一步:

  • 有状态回退语义与状态槽 / 克隆——这是设备内解码的架构性前置;
  • raw-q4 内核的访问策略(当前 28% 峰值,潜在 1.5–3×)——先做同内核的分段计时 把 0.156 ms 拆开,确认是延迟还是带宽,再选改法;
  • 其后是设备内层循环 + 异构 dispatch(连同 full-attn 的 output gate / partial rope);
  • 长序列 oracle(≥512 token)看递推漂移上界;48 层混档满模型运行;预算核算 (rq slab + GDN 状态 157 MB + KV 槽 + decode 工作区)。
  • CPU 对照(迁移前基线)

    • CPU 参考:src/model/vllm_q35_gdn.c:q35_gdn_step(GDN 七步递推)、include/model/vllm_q35_ops.h:q35_rmsnorm_unit / q35_gemv。
    • 迁移要点:首次把「有状态递推算子」搬上 GPU——先用 f32 档钉死七步算子序列(β/g→conv+SiLU→切分→q/k 归一→delta 递推→gated-RMSNorm→out_proj);建状态镜像 S(≈151MB)+conv 历史;递推/卷积串行逐字同序,差异只归给三处归约树。
    • 真机验证:命中 E7(q35_gdn_check PASS:5/5 OK、跨层逐位 0/25600、host↔设备位级一致、每层往返≈0.010ms)。

    附录:原始数据表

    表 A1 · 逐算子回读实测表(q4 vs f32)(原第 十 节)

    算子nmax|f32|rms|f32|maxrel@t0rmsrel@t0rmsrel@tT-1
    ①xc 投影原始(conv 前) 10240 6.718e+01 2.936e+00 1.158e-02 4.531e-02 3.360e-02
    ②xcv conv+SiLU 后 10240 2.319e+01 7.215e-01 8.573e-03 2.957e-02 7.213e-03
    ④q L2 归一后 2048 8.649e-02 7.813e-03 5.653e-02 4.681e-02 2.584e-02
    ④k L2 归一后 2048 9.182e-01 8.839e-02 5.432e-02 4.797e-02 5.448e-02
    ①z gate 投影 6144 3.287e+01 2.339e+00 1.097e-02 5.643e-02 5.352e-02
    β = sigmoid(b) 48 9.958e-01 7.460e-01 0 0 0
    g 对数域门控 48 8.370e+00 1.030e+00 0 0 0
    ⑤on 递推输出(⑥ 前) 6144 1.398e+00 2.530e-02 7.311e-03 3.540e-02 1.447e-02
    ⑥onn RMSNorm 后 6144 2.502e+00 6.525e-02 4.133e-02 5.848e-02 2.354e-02
    ⑦out 层输出 5120 2.042e+01 2.414e-01 2.721e-02 4.058e-02 2.901e-02
    S 递推状态(步进后) 786432 1.573e+01 8.008e-02 1.017e-02 5.206e-02 3.769e-02

    十五、小结

  • 有状态算子要把「状态放在哪、多大、怎么清零」当成一等公民:先定点(状态镜像)、 再定判据(容差而非位级)、最后定回退(hit-only 的语义对递推不安全)。
  • 先隔离再集成:先用 f32 把算子序列单独钉死,再换 q4 —— 一次只引入一个变量, 每次失败才有唯一解释。
  • 差异只能归给真正变序的那几处归约树(①⑦ 投影 / ④ q/k 归一 / ⑥ gated-RMSNorm); 递推与卷积是串行、逐字相同的,不要把差异归给 dk 求和。
  • 必要条件的检验不等于位级证明:reset 复现只证「重放一致」,证明不了「状态结构正确」; 跨层切片补的是「偏移寻址」这个此前完全没覆盖的面。
  • 精度预算必须在真实激活上标定:均匀随机向量会低估 q4 误差一个数量级(3.26e-3 vs 4.53e-2)。
  • 测量推翻定性结论:同内核 A/B 证明「往返成本占大头」是错的(实际 ≈0.5–2 ms/token), 真正的瓶颈在 kernel 自身的有效带宽——所以先量、再改。
  • 负结果要记「它在什么条件下为负」:「一个 warp 处理 4 行」的失败条件是占用率, 而不是「这个方向不行」。

  • 相关篇目:第 23 篇、第 25 篇 源码与配套资源:本仓库 https://gitee.com/pei-xiaoguang/kestrel-llm-cuda.git; CPU 推理源码 https://gitee.com/pei-xiaoguang/kestrel-llm

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » Kestrel_VLLM - 24 · GDN 线性注意力上 GPU:递推状态、conv 历史与往返成本
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!