50 · prefill 追赶开篇:把 15 倍的账拆开

一句话:15.4× 不是一个能放着不管的数;本篇重新开账 —— 不做内层微优化、也不接受「混合模型 ⇒ GDN 是瓶颈」的直觉,而是先把 15.4× 拆成几个桶:实测否掉了「限速项是指令发射」,「首要环节是投影 GEMM(相对差 18.5×),不是 GDN」,于是候选收成三个(A 分块降重读 / B int8 / C 张量核)。
前置:建议先读 第 47 篇(15.4× 的来源)与 第 48 篇(廉价路径封顶 —— 天花板 ≈4.7×)。
环境:x86-64 + NVIDIA RTX 5090 · Qwen3.5-27B(.vqf q4)· 探针口径为 L2 档(权重 <96 MB)。

一、问题与结论

第 47 篇把 llama.cpp 请到同一块卡上,拿到本轮最不好看的一个数:prefill 512:本引擎 ≈274 t/s vs llama.cpp 4206 t/s ⇒ ≈15.4×(那一篇只报数、不归因)。第 48 / 49 篇把「改个内核」的直觉逐条量死,并算出廉价路径的天花板 ≈4.7×(远小于 15.4×),于是这条线在微优化上收口为「不做」。

可 15.4× 不是一个能放着不管的数。这一篇是重新开账:不做内层微优化、也不接受「混合模型 ⇒ GDN 是瓶颈」的直觉,而是先把 15.4× 拆成几个桶,看每个桶各差多少、哪个桶值得动。

结论数值标注
限速项不是「指令发射」dp4a 把每 MAC 指令数砍掉数倍却更慢;权重带宽只到 HBM 的 19–26%实测
dp4a 出局与显式 FMA 版同速,却多付激活 int8 量化、精度 RMSrel 差 3–4 个数量级实测
跨引擎分桶GEMM ≈18.5×、GDN ≈11.6×、总 ≈15.3×实测(nsys)+ 归一化
首个环节投影 GEMM(相对差 18.5×),不是 GDN实测

逐条结论:

  1. 限速项不是「指令发射」(Phase 0 探针小样):把每 MAC 的指令数砍掉数倍的 dp4a 变体跑得更慢;实测权重带宽只到 HBM 的 19–26%。⇒ 方向从「减指令」改成「降延迟 / 提占用率」。
  2. dp4a 出局(同一轮):与显式 FMA 版同速,却要多付激活 int8 量化,精度 RMSrel 差 3–4 个数量级。
  3. 跨引擎分桶把账拆开了:GEMM ≈18.5×、GDN ≈11.6×、总 ≈15.3×。⇒ 先打 GEMM;「GDN 是瓶颈」的直觉被实测否掉(它还是第二位)。
  4. llama.cpp 那边的机制读出来了(源码/内核名证据):一趟算完 512(我方 ≈22× 的发射/重读次数)、int8 MMQ(我方 f32 + FP32-FMA,只到 FP32 峰值的 ≈18%)、stream-K(我方无)。
  5. 于是候选收成三个:A 分块降重读 / B int8 / C 张量核。用户裁定先走 A。★ 结果 A 在下一篇就被实测否掉,B 在再下一篇否掉 —— 这条线的真正出口是 C。

二、Phase 0:先花最小成本,把「限速项」定下来

按项目纪律,动引擎之前先做探针小样(引擎零改动)。给 tools/q35_gemv_mb.cu 加两个非位级变体。

★ 探针自身的缺陷先记一笔:首跑 dp4a 出 maxrel=0.00 / nan。根因是探针原先用 fill_xor 填随机字节当权重,把 72 B 瓦片里的 f16 标度填成了 NaN/Inf(约 6%)⇒ 精度对照整片失真。补 fill_q4(标度取小正数)后重跑,本节全部数字出自修后的日志:

/* tools/q35_gemv_mb.cu:103-137 —— 有效 q4 权重填充(修掉 NaN/Inf 标度) */
static void fill_xor(uint8_t *p, size_t n, uint32_t s) {
    for (size_t i = 0; i < n; i++) { s ^= s << 13; s ^= s >> 17; s ^= s << 5; p[i] = (uint8_t)s; }
}

/* ★ Phase 0:有效 q4 权重填充(V4/V5 要出 maxrel ⇒ 随机字节不行)。
 *   原 `fill_xor` 会把 f16 标度的位模式填成 NaN/Inf(~6%)⇒ 精度对照整片失真
 *   (V5 曾出现 maxrel=0.00/nan)。本函数按现役 72 B 瓦片布局填:
 *   d[4] = 4 个小正数 f16(0.002–0.020),其余 64 B 为随机 nibble。 */
static void fill_q4(uint8_t *p, size_t nbytes, uint32_t s) {
    for (size_t t = 0; t + VC_RQ_TILE_BYTES <= nbytes; t += VC_RQ_TILE_BYTES) {
        for (int r = 0; r < 4; r++) {
            s ^= s << 13; s ^= s >> 17; s ^= s << 5;
            const float v = 0.002f + 0.018f * (float)((s >> 8) & 0xFFu) / 255.0f;
            const uint16_t h = f32_to_f16(v);
            p[t + r * 2 + 0] = (uint8_t)(h & 0xFFu);
            p[t + r * 2 + 1] = (uint8_t)(h >> 8);
        }
        /* 其余 64 B:随机 nibble */
    }
}

V4 = 显式 FMA 收缩:权重解包外提后内层用 fmaf() ⇒ 每条 MAC 1 条 FFMA,而基线在 -fmad=false 下是 FMUL + FADD 两条:

/* tools/q35_gemv_mb.cu:347-405 —— V4:显式 FMA 变体(解包外提 + fmaf) */
template <int R, int M>
__global__ void k_mb_fma(const uint8_t *__restrict W, const float *__restrict x,
                         float *__restrict y, int rows, int cols) {
    ...
    for (; b + 8 <= nb; b += 8) {
        float wlo[R][4], whi[R][4];              /* ★ 权重解包**外提**、常驻 */
        for (int r = 0; r < R; r++) { /* 解包 nibble → wlo/whi(含 s 标度) */ }
        for (int m = 0; m < M; m++) {
            /* 取 x 的 8 个分量 xs[0..7] */
            for (int r = 0; r < R; r++) {
                float t0 = 0.0f, t1 = 0.0f;
                for (int k = 0; k < 4; k++) {
                    t0 = fmaf(wlo[r][k], xs[k],     t0);   /* ★ 每条 MAC 只 1 条 FFMA */
                    t1 = fmaf(whi[r][k], xs[4 + k], t1);
                }
                acc[r][m] += t0 + t1;
            }
        }
    }
}

V5 = dp4a:nibble→int8 用 3 条无跨字节借位的整数指令;激活每 32 列块对称量化到 int8(计时区外准备):

/* tools/q35_gemv_mb.cu:434-492 —— V5:dp4a 整数点积(每 (r,m) 每块 2 条 dp4a) */
/* 前提(计时区外):激活 xq 每 (m, 32 列块) 对称量化到 int8,dx[m][nb] 为对应 f32 标度;
 *   权重保持现役 q4 打包(nibble 解包仍在计时区内,如实计入)。
 *   内层 nibble(0..15) → 有符号 int8,用 3 条整数指令且无跨字节借位:
 *     i8 = ((nib ^ 0x08080808) + 0x78787878) ^ 0x80808080
 *   ⇒ 每 (r,m) 每块 2 条 dp4a 替代 8 次 (I2F + FMUL + FADD)。 */
...
    int sumi = __dp4a(wlo[r], xlo, 0);
    sumi     = __dp4a(whi[r], xhi, sumi);

结果(M=16,单位 ms):

形状权重 MB基线 R=2V4 R=4V5 R=4最好/基线标注
gate/up 17408×512047.80.1500.1040.1071.44×实测
qkv 10240×512028.10.0900.0530.0641.70×实测
q 12288×512033.80.1140.0920.0871.24×实测
down 5120×1740847.80.1380.1330.2341.04×实测
o 5120×614416.90.0520.0610.0840.85×(更慢)实测

精度(相对基线):V4 元素 maxrel 5.9e-2(大投影)~3.5e-4;V5 的 maxrel 高达 4.0e2(meanrel ≈ 3e-2)。

判定:

  • P1(显式 FMA)= 正。价值不在 R=2(0.85–1.24×,符号不一),而在解锁 R=4 @ M=16 —— 大投影 1.44–1.70×。
  • P2(dp4a)= 负且被支配:R=2 档普遍更慢;R=4 档与 V4 持平,却要多付激活量化、精度差 3–4 个数量级。

★ 本轮最重要的负结果:dp4a 把指令/MAC 砍了数倍却更慢 ⇒ 这个内核不是「指令发射受限」。实测权重带宽只到 335(基线)→ 460(V4)GB/s,即 HBM 峰值的 19–26% ⇒ 真正的形态是「每 token 的工作量导致占用率 / 延迟隐藏不足」,指令数只是它的一个代理量。

★ 这一条顺手订正了第 48 篇的表述(那里把限速项写成「每 token 的指令发射」)。也订正了「M=16 时 R=4 一律慢 1.7–2.0×」这条边界 —— 它只对无 fmaf 的基线成立:V4 下 R=4@M=16 反而最快。⇒ 那不是寄存器的硬墙,而是与内层指令形态耦合的。

★ 边界:探针只出 GEMM 时间 + 元素级 maxrel;未做引擎侧 A2/A3 ⇒ 尚不能判 P1 能否过精度闸门。且元素级 maxrel 会被「点积抵消」放大(V4 仅改结合序就有 5.9e-2)⇒ 精度阈值不能直接取元素 maxrel,须另定口径。

三、跨引擎分桶:把 15.4× 拆成「谁占了多少」

Phase 0 之后的第一件事不是动手,而是把账拆开。两侧各自跑 nsys,比桶的方向与量级。

★ 口径前提:两侧量化格不同(llama.cpp Q4_0 有效 4.60 bpw vs 本引擎 .vqf q4 ≈4.25 bpw),激活精度也不同 ⇒ 本节只比桶的方向 / 量级,不外推绝对倍数、不做桶级绝对归因。

基线先复现:llama.cpp pp512 = 4167.14 ± 234.67 t/s(复现第 47 篇的 4206 ± 220);本引擎 --perf-partA --bench-seqlen 512(门关)= 1.876 s ⇒ 273 t/s(复现第 47 篇的 274)。

各自的分桶(nsys cuda_gpu_kern_sum):

桶本引擎(kernel 总 9093 ms†)占比llama.cpp(kernel 总 113.9 ms)占比标注
GEMM7639 ms(rq_q4_gemv_mr<2,16> 3707 + mr3<2,16> 3921)84.0%79.0 ms(mul_mat_q + stream_k_fixup)69.3%实测(nsys)
GDN1119 ms12.3%18.6 ms16.3%实测(nsys)
attn244 ms2.7%0.8 ms0.7%实测(nsys)
norm/act211 ms2.3%8.1 ms7.1%实测(nsys)

† 我方 nsys 覆盖 6 趟 prefill + 6×40 decode ⇒ 需归一化。

归一化到「一趟 prefill 512」后:

桶本引擎llama.cpp相对差(我方/对侧)标注
GEMM≈1576 ms≈85 ms≈18.5×实测 + 归一化
GDN≈231 ms≈20 ms≈11.6×实测 + 归一化
总1876 ms123 ms≈15.3×实测 + 归一化

★ 正确读法:不是「GDN 是瓶颈」(它还是第二位),也不是「再做一次内层微优化」—— 是**「把 GEMV 骨架换成真 batched / 分块 GEMM」**。

llama.cpp 那边是怎么做的(内核名证据,不是推断):

观察llama.cpp本引擎标注
一趟算完 512mul_mat_q<...> 400 次/趟(≈ 每投影一次)mr<2,16> + mr3<2,16> ≈ 9000 次/趟 ⇒ ≈22×实测(nsys)
计算路径quantize_mmq_q8_1 496 次 ⇒ int8 MMQf32 激活 + FP32-FMA(≈18% FP32 峰值)实测(nsys)
分块mul_mat_q_stream_k_fixup 320 次 ⇒ stream-K无实测(nsys)

★ 两个结构成因:① 每趟 token 数 M(16 vs 512) ⇒ 权重重读 ≈32×;② 计算路径(f32/FP32-FMA vs int8-MMQ)。

重新开账

用户裁定

prefill 512:274 vs 4206 t/s ⇒ ≈15.4×

Phase 0 探针:dp4a 更慢 ⇒ 不是指令发射

跨引擎分桶(归一化到一趟):总 ≈15.3×

GEMM ≈18.5×(占 84%,首个环节)

GDN ≈11.6×(占 12.3%,第二位)

attn 2.7% + norm 2.3%

候选收三个

A 分块降重读

B int8

C 张量核

先走 A

图 1:15.4× 的账怎么拆开。从左到右读:起点是「prefill 512 = 274 vs 4206 t/s(≈15.4×)」;Phase 0 探针先否掉「指令发射」这个限速项(dp4a 指令更少却更慢);跨引擎分桶(归一化到一趟)得到总 ≈15.3×,其中投影 GEMM ≈18.5×(占 84%,首个环节)、GDN ≈11.6×(占 12.3%,第二位),attn(2.7%)与 norm(2.3%)是小桶;据此候选收成 A/B/C,用户裁定先走 A。

四、候选与裁定

候选直击位级备注
A. 真 batched GEMM(smem 分块 / 大 M)成因 1(+2 的一部分)可能位级可保(M/R 不改行内求和序)换访问模式;改动最大但直击 4.7× 上界
B. int8 路径(大 M 下重测)成因 2非位级dp4a 的负结果只在 M=16 测过;大 M 下结论可能不同
C. 张量核成因 2(换天花板)非位级需改布局

用户裁定走 A。本篇到此收口 —— 但 A 的结局在下一篇,而且不是好消息。

五、边界与已知限制

  • 权重全 L2 档(<96 MB),不是整模型冷档 ⇒ 探针只作相对对照。
  • nsys 的 Time (%) 含 profiler 扰动;两侧口径不同 ⇒ 只比方向与量级。
  • 本轮只动 tools 探针 + 文档(引擎 src/** 与 DLL 未动),未改任何默认值。

CPU 对照(迁移前基线)

  • CPU 参考:src/model/vllm_q35_fused.c:q35_fused_gemv —— 融合量化 GEMV 的 CPU f32 参考,探针与对拍的同语义基线。
  • 迁移要点:跨引擎 nsys 分桶把 prefill 15.4× 拆开,定位首个环节是投影 GEMM(≈18.5×)而非 GDN;dp4a 负结果把限速项从「指令发射」改判为「占用率/延迟隐藏」。
  • 真机验证:未独立复现:探针为 L2 档(权重 <96 MB)单读数;两侧量化格不同(llama.cpp Q4_0 4.60 bpw vs .vqf q4 ≈4.25 bpw),只比方向与量级。

六、小结(可复用结论)

  1. ★ 动手前先把「整条差距」拆成桶,再决定先打哪一个。 分桶把「GDN 是瓶颈」的直觉直接推翻了 —— 首个环节是投影 GEMM(相对差 18.5×),而 GDN 只是第二位。
  2. 负结果要量的更细一点:dp4a「指令更少却更慢」,把限速项从「指令发射」改判为「占用率 / 延迟隐藏」—— 这是后面几轮所有优化的方向依据。
  3. 探针自身的缺陷要当场修、当场重采。权重填充把 f16 标度填成 NaN/Inf,会整片污染精度对照;补 fill_q4 后本节数字才成立。

相关篇目:第 47 篇(把 llama.cpp 请上卡 —— 15.4× 的来源)、第 48 篇(廉价路径封顶 —— 天花板 4.7×)、第 49 篇(投机解码拆账 —— 同一套「先拆账再动手」的方法)。仓库 docs/ 的适配规格中有对应的 Phase 0 探针小样与跨引擎分桶记录。
源码与配套资源:本仓库 https://gitee.com/pei-xiaoguang/kestrel-llm-cuda.git;
CPU 推理源码 https://gitee.com/pei-xiaoguang/kestrel-llm

下一篇:51 · 两条廉价路径逐条关闭:占用率与 dp4a

Logo

免费领 150 小时云算力,进群参与显卡、AI PC 幸运抽奖

更多推荐