第 8 章 前向通路:内核、量化与 CUDA 图
MoE 大模型低成本部署框架第 8 章 前向通路:内核、量化与 CUDA 图
CHAPTER 08

第 8 章 前向通路:内核、量化与 CUDA 图

12 分钟阅读 · 2,824 字

48 层到底在算什么、用什么精度算、以及为什么必须用 CUDA 图来跑。
核心文件:src/core/layer.cpp、include/strata/core/graph.hpp、src/kernels/(93 个文件)。


8.1 一个 token 经过的七个阶段

引擎自带的分阶段计时把一层切成七段(generate.cpp:3467-3473 注册的名字):

0  gr_read (attn)      门控残差:读
1  attention block     注意力(GDN 或 QSA)
2  gr_write (attn)     门控残差:写
3  gr_read (ffn)       门控残差:读
4  moe_route           路由
5  moe_finish          专家计算与合并
6  gr_write (ffn)      门控残差:写

GDN 内部再细分(:3477-3483):

8   gdn: quantize x
9   gdn: qkv gemv
10  gdn: conv+silu
11  gdn: l2 norms
12  gdn: alpha/beta/gate
13  gdn: gdn_step
14  gdn: z + out_norm

门控残差在每层出现两次读、两次写(注意力前后各一次,FFN 前后各一次)。
这个结构是第 09 章「prefill 为什么串行」的直接原因:gr_read → 计算 → gr_write 是硬依赖链。


8.2 四类计算

类型 层数 关键参数 内核文件
GDN(门控线性注意力) 36 16 个 key 头 ×128、48 个 value 头 ×128、conv1d 宽度 4、conv 通道 10240、value 维 6144 native_gdn.cu、native_gdn_preprocess.cu
QSA(稀疏全注意力) 12 24 头 ×256、2 个 KV 头、索引器 4 个 query 头 + 1 个共享 key 头 128 native_qsa.cu、native_qsa_indexer.cu、native_qsa_score.cu
门控残差 每层 hc = 4 流 ×2560、低秩 320、每层可学习混合 native_gr_norm.cu、native_gr_postops.cu
MoE 每层 512 专家 top-10 + 1 共享专家、n_ff = 640 native_moe.cu、native_mmvq.cu、s2_expert_grouped.cu、CPU 侧 native_expert.cpp

GDN 与 QSA 的分布由 qsa_interval = 4 派生(第 06 章),层号 3,7,…,47 是全注意力。

src/core/layer.cpp 的 include 列表本身就是一份通路清单:
gdn.hpp、qsa.hpp、kv_q4.hpp、kv_q8.hpp、quantize_act.hpp、rope.hpp、mrope.hpp、
router_top10.hpp、shared_expert.hpp、s2_gemv_q8.hpp、s_gemv.hpp、
native_moe.hpp、native_mmvq.hpp、native_gdn.hpp、native_router.hpp、native_qsa.hpp、
bf16_gemv.hpp、elementwise.hpp。


8.3 量化:三套口径不要混

本书必须提醒的一处混乱来源——同一个引擎里存在多套量化口径,数字之间不能直接换算:

口径 内容 出处
打包形态 稠密权重 5.305 GiB + embd 0.444 GiB weights.hpp 头部
引擎形态 稠密 4.067 GiB + embd 0.444 GiB + 展宽 scale 10.88 MiB = 4.522 GiB 同上
专家文件形态 每层 blob 1,868,800(第 0 层)/ 1,305,600(第 1 层)字节 native_experts.txt
内核布局 BLOB = 1,382,400 expert_source.hpp
KV --kv int8,常驻窗口 32768 生产命令行
激活 fp32 scale(CPU 路径)vs fp16 d(块内) generate.cpp:3401 的 R4.2h 注释

weights.hpp 提到 pack 里「90 of the 303 quantized tensors carry fp16 SCALES
(all 58 Q2_0 expert tensors, plus Q4_0, Q5_0, Q8_0 and IQ4_NL)」,
而引擎内核统一接 const float* scales,所以加载器会把 fp16 scale 展宽成 f32(11,407,360 字节)。

展宽是精确的、不改变任何数值,它只是让内核接口统一。
但「一个 scale 是 2 字节还是 4 字节」这个口径搞错,会让预算算错一整轮——这条注释自己写了。


8.4 CUDA 图:三条被测量钉死的规则

include/strata/core/graph.hpp 头部说:

THREE THINGS ARE BAKED INTO THIS INTERFACE, and each one is a measurement rather than a preference.
All three come from bench/micro/graph_capture.cu, which was written to answer the phase’s own instruction -
“verify on the installed CUDA version with a minimal test before building on it”.

规则一:等待必须轮询驱动,不能只读内存

A spin that only reads memory NEVER RUNS THE KERNEL: 5,907,703 spins over 500 ms,
and the datum flips to 1 the instant anything calls into the driver and never before.
On Windows the display driver model BATCHES command submission, and a thread that only reads memory
gives it no reason to flush. Hence wait_ms, which polls an event -
a QUERY and not a blocking sync, so P2.X3’s “zero synchronization calls in the layer loop” still holds,
and it costs 0.021 ms.

5,907,703 次自旋、500 毫秒,内核一次都没跑。 这个实验把一件反直觉的事钉死了:
「自旋等标志位」在批处理提交模型的驱动上根本不会触发提交。
解决办法是轮询一个 event——它是查询、不是同步,所以「层循环里零同步调用」的约束仍然成立,代价 0.021 ms。

先写最小实验验证平台行为,再在上面建东西——这条方法论在本项目里反复出现。

规则二:图会重读输入缓冲,但不会重读内核参数

Verified: eight replays with a different pinned input each time all tracked correctly, so the data path is live;
but a kernel argument is copied into the node at capture and is never re-read.
Anything that changes per step - a position, a page-table base, a token id -
must be DATA in a device buffer, never an argument.

这条规则的实际后果非常隐蔽:如果某个随步骤变化的值被当成参数传进去,
图会静默地重放一个过期地址,产出错误结果而不报错。
注释直接点名这是「the phase’s ‘graph capture with pointers that change between steps
silently replays stale addresses’ hazard」,并强调「it now has a measurement behind it」。

规则三:固定地址是调用方的责任

FIXED ADDRESSES ARE THE CALLER’S OBLIGATION. The registry cannot check them.
Every buffer a captured body touches must be allocated before record and never reallocated.

注册表按 (layer_type, n_tokens) 索引 capture/replay,带固定地址的静态输入输出缓冲。
它无法校验地址稳定性——所以这条只能靠约定,而约定被写进 header。


8.5 pre 图与 post 图:一个被发表错的数字

引擎每层有两组图:pre 与 post。--graph-only 的帮助文本(generate.cpp:554):

--graph-only MEASURE: replay the 48 pre graphs only. OMITS the 48 post graphs

generate.cpp:3882 的 R0.3: THE TRUE PER-TOKEN GPU FLOOR 注释,是全书最有价值的自我纠错记录之一:

--graph-only above launches ONLY gr.execs[l], the pre graphs. It omits the 48 post graphs -
the shared expert, the combine and the second gr_write - and the LM head.
Everything this project published as “39.8 ms pure GPU” came from that loop while being described as
the whole GPU
, which also made the “host’s share = 49.4 - 39.8 = 9.6 ms” figure wrong
by however much the missing work costs. See Memory/ERRORS.md A4/A5.
This flag is what “pure GPU” has to mean, and it replaces that number everywhere.

拆开看这个错误的连锁:

  1. 一个只跑 pre 图的测量循环,被当成「整个 GPU」来描述。
  2. 于是「纯 GPU 39.8 ms」这个数字被发表。
  3. 于是「host 占 49.4 − 39.8 = 9.6 ms」这个推论也错了,错多少取决于漏掉的工作量。
  4. 修正方式是新增一个正确的测量入口并替换所有引用,而不是在原数字上打补丁。

「漏掉一部分工作的测量」比「测量有噪声」更危险:它产出的是一个自洽但错误的结论,
而且会污染所有基于它的推论。第 14 章的数字总表里,39.8 ms 这一条要按已废弃处理。

同一区域还有一处细节值得抄下来(:3896):

for (int r = -1; r < reps; ++r) {   // r == -1 is the warm pass, not counted

预热轮不计入统计。 这一行代码比任何「我们做了预热」的说明都可靠。


8.6 按层类型分解:一个 4 倍的错误如何被发现

generate.cpp:3738:

AND THE MIXER BY LAYER KIND, because 36 of the 48 are GDN and 12 are QSA and a total cannot separate them.
Round 309’s uncaptured table put GDN at 10.88 ms for 36 layers against QSA’s 4.56 for 12,
which would make the recurrence the largest single R3 target -
and that table had moe_finish wrong by 4x, so the ratio is re-derived here
from the captured graph rather than inherited.

「一个 4 倍错误足以改变整个优化方向的排序」——这就是为什么测量必须可复现、可复核。
处理方式也值得学:不继承旧表格的比值,从捕获的图重新导出。

紧接着的一段(:3745)是全书最好的「测量算术错误」教材:

ACCUMULATED OVER reps, NOT MEASURED ONCE AND THEN DIVIDED.
The first version called the per-layer replay a single time and printed gdn / reps,
which reported GDN at 0.480 ms/token against a mixer total of 14.039 -
a factor of exactly reps, and the tell was that 0.480 + 0.220 = 0.700 = 14.039 / 20.

错误被发现的方式值得记住:各部分之和与总量对不上。
0.480 + 0.220 = 0.700,而 14.039 / 20 也等于 0.700 —— 说明所有分项都被多除了一次 reps。

任何分解式测量都要检查「分项之和是否等于总量」。 这一条能抓住绝大多数归一化错误。


8.7 C1 预言机:逐层残差快照

generate.cpp:3659:

THE C1 ORACLE: ONE RESIDUAL SNAPSHOT PER LAYER PER POSITION, so the engine can be bisected
against llama-debug’s l_last-<il> node instead of against a single end-to-end perplexity.
The buffer is PINNED because session_loop enqueues a device-to-host copy into it after every layer
and the transfer would otherwise be staged through a pageable bounce buffer on the critical path.

缓冲区大小(:3666):

const size_t layer_floats = (n_layers + 1) * g.hc * g.n_embd;   // 49 × 4 × 2560 ≈ 50 万 float ≈ 2 MB

「对拍一个端到端困惑度」与「逐层二分」是两种完全不同强度的正确性验证。
前者只能告诉你「整体不对」,后者能告诉你「第几层开始不对」。
本项目把它命名为 C1(一个预言机),并明确它是用来 bisect 的。

配套的启动顺序约束(generate.cpp:2140):

THE HALF-LEVEL DUMP HAS TO BE ARMED BEFORE session_capture, AND THE LADDER MUST NOT BE.

因为它「CAPTURED INTO THE LAYER GRAPHS」(:243),必须在图捕获之前装好;
而另一个机制(ladder)反过来不能在捕获前装。两个观测机制对启动顺序的要求相反,
这种细节只能靠注释记录。


8.8 内核清单

src/kernels/ 共 93 个文件,按用途分组(完整清单见附录 A):

组 代表文件
GDN native_gdn.cu、native_gdn_preprocess.cu、fused_gdn.hpp、gdn.hpp
QSA native_qsa.cu、native_qsa_indexer.cu、native_qsa_score.cu、qsa_select.hpp、qsa_decode_attn.hpp、qsa_prompt_attn.hpp
门控残差 native_gr_norm.cu、native_gr_postops.cu、fused_gr.hpp、gr.hpp
MoE native_moe.cu、native_mmvq.cu、s2_expert_grouped.cu、shared_expert.hpp、router_top10.hpp、native_router.cu
量化解码 iq_kernels.cu、dequant_s2.hpp、dequant_bf16.hpp、s2_gemv.hpp、s2_gemv_q8.hpp、s_gemv.hpp、dp4a.hpp
KV kv_q4.hpp、kv_q8.hpp、kv_stream.hpp
位置编码 native_rope.cu、rope.hpp、mrope.hpp、rope_scaling.hpp
激活量化 quantize_act.hpp、cvec.hpp、f16_bits.hpp、bf16_bits.hpp
验证/投机 verify_kernels.hpp、ngram.hpp、ple.hpp、sampler.hpp
CPU 侧 src/kernels/cpu/:native_expert.cpp、expert.cpp、expert_layout.cpp、pool.cpp、iq_avx2.cpp、iq_avx512.cpp、kq_avx2.cpp、q2_avx2.cpp

CPU 侧的存在本身就是第 07 章的注脚:专家计算的主战场在 CPU,
所以 AVX2 / AVX512 的内核数量与 CUDA 内核同等重要。


8.9 本章小结

  • 一层七个阶段,门控残差贡献两次读两次写;这条依赖链是 prefill 串行的根源。
  • 四类计算:36 层 GDN、12 层 QSA、每层门控残差、每层 MoE(512 专家 top-10 + 共享专家)。
  • 量化有多套口径(pack / engine / 专家文件 / 内核布局 / KV / 激活),数字之间不能直接换算。
  • CUDA 图三条规则全部来自最小实验:等待要轮询驱动(5,907,703 次自旋不跑内核)、
    变化的值必须是缓冲数据不能是内核参数、固定地址由调用方保证。
  • pre 图与 post 图是两组;「纯 GPU 39.8 ms」这个发表过的数字来自只跑 pre 图的循环,已废弃。
  • 分解式测量必须检查「分项之和 = 总量」(0.480 + 0.220 = 0.700 = 14.039/20 这个 tell 抓住了 4 倍错误)。
  • C1 预言机提供逐层逐位置的残差快照,用于二分而非端到端对拍。
  • 预热轮不计入统计(r == -1)。
  • 内核清单里 CPU 内核与 CUDA 内核同等重要,因为专家计算主要在 CPU。

下一章:09 prefill:最大的瓶颈 —— 串行点具体在哪一行。

本章出处

内容 出处
七个阶段与 GDN 子阶段 generate.cpp:3467-3483
四类计算参数 include/strata/core/layout.hpp;src/core/layer.cpp include 列表
量化多口径 weights.hpp 头部;native_experts.txt;expert_source.hpp;生产命令行
CUDA 图三条规则 include/strata/core/graph.hpp 头部;bench/micro/graph_capture.cu
pre/post 图与 39.8 ms 更正 generate.cpp:554,3882;Memory/ERRORS.md A4/A5
预热轮 generate.cpp:3896
按层类型分解与 4 倍错误 generate.cpp:3738,3745
C1 预言机 generate.cpp:3659,3666;generate.cpp:243,2140
内核清单 src/kernels/(93 文件)、src/kernels/cpu/
全部章节