第 6 章 模型接入路径:Qwen3 → Qwen3.5 → DeepSeek-V3.2
接一个模型不只是“给 config 里换个名字”。每一条新路,都意味着一套新的权重布局假设、新的 attention 结构,以及新的边界要厘清。
6.1 从 Qwen3 Dense 开始的故事
duck-llm 的第一个 commit 是 2025 年 8 月 3 日,Qwen3 dense 的初始实现在次日(6be3e6e,2025-08-04)就进了仓库。当时的系统非常小:纯 PyTorch 实现,能在 CPU 上加载并跑通 Qwen3-0.6B,duck 节点完全不在场。这个阶段的目的只有一个——把模型结构、权重加载、generation loop 这些骨架搭对,让最小的 dense 模型能端到端输出一段话。
2025 年 8 月 11 日,Qwen3 MoE(aa778e9)紧随其后进仓库。同样是纯 PyTorch,不依赖 duck,作为 MoE 结构的参考实现。2025 年 8 月 15 日,fp8 支持加进来(起初还是“slow”的逐元素反量化路),8 月 28 日加入 fp8 dequantization。
在随后几个月里,DPDK 网络层 bring-up(2025-11 到 12 月)、duck kernel 权重发送、buffer sizing 陆续落地,dense duck MLP forward 才真正在 duck 节点上跑起来。到 2026 年 3 月初,系统才走到“需要给 Qwen3 MoE 接上原生 duck 路径”这一步——这才是本章叙述的起点。
之所以把 Qwen3 选作第一个接入 duck 的模型家族,真实的出发点是 DeepSeek-V3 系列。那时候的目标就已经包括 DeepSeek-V3:它的 fp8 权重用的是 128×128 block-scale 格式。在调研 Qwen3 时发现它的官方 fp8 checkpoint 采用完全相同的格式——而且 Qwen3 提供了从 0.6B 到 235B 的完整规模梯度,可以先用小模型把整条链路跑通,再推到大模型验证。这就是选 Qwen3 而不是直接上 DeepSeek-V3 的原因:格式兼容,规模更小,适合先行验证。
6.2 Qwen3 MoE:第一个完整的 duck MoE 路径
从 dense 到 MoE 的差距
Qwen3 dense 的 duck 路径在更早就接通了,但 MoE 是另一回事。MoE 的 decode forward 不是一次固定的矩阵向量乘,而是先由 gate 决定激活哪几个 expert,再把这几个 expert 的输出加权合并。
这带来了两个具体的新问题:
- 权重布局问题:从纯技术角度讲,完全可以按激活 expert 逐个发请求——每次 decode 激活几个 expert,就发几次 MLP forward,duck 收到哪个 expert 的权重就算哪个。但这意味着每个 token、每一层都要多次往返通信,通信代价会叠上去,实际性能会非常差。正确的做法是在 load 阶段把整层全部 expert 的权重打包成一个“大 buffer”,一次性发给各个 duck(
moe_init);decode 时只发一次请求,把 routing 结果(激活了哪些 expert、系数是多少)一起打包传进去,duck 侧自己 select + 计算 + reduce(moe_forward)。一次 init、一次 forward,是 MoE duck 路径的基本设计。 - 动态 routing 问题:每个 token 激活哪些 expert,要在 decode 时把 routing 信息实时发给 duck。这部分信息(
num_activated、activated_expert_ids、activated_weights)不是静态的,要和每次 forward 的 binary payload 一起传。
内核重组与新 ABI
解决这两个问题的同时,顺手把 kernel 的文件组织做了一次重构。原先的 fp8_mlp.cpp 是一个单文件、只处理 dense MLP 的入口;现在拆成了 mode dispatch 模式:
kernels/fp9_kernels.cpp ← main() + mode parse + dispatch
kernels/fp9_common.cpp ← fp9 decode、gemv、activation 等共用原语
kernels/fp9_dense_path.cpp ← dense MLP 路径
kernels/fp9_moe_path.cpp ← MoE 路径(按激活 expert 遍历、fp32 scratch reduce)Rust 侧新增了 moe_init / moe_forward ABI,与原有 mlp_init / mlp_forward 并行。Python 侧对应的是新的 rust_moe.py,负责把整层 expert 权重收拢后调用 Rust。
首批真实结果
2026-03-09,Qwen3 MoE 原生 duck 支持提交进仓库。2026-03-12 前后,在 Qwen3-30B-A3B-FP8 上拿到了第一批 MoE decode 数据:
| tp | multiple_of | decode tok/s | duck max |
|---|---|---|---|
| 6 | 128 | 8.736 | 1.642ms |
| 6 | 64 | 8.614 | 1.668ms |
| 12 | 64 | 12.994 | 0.861ms |
表中 multiple_of 参数控制 tensor parallel 的切分粒度——权重按此粒度对齐后才能切给多个 duck,值越小能支持越细的 tp 分组(详见第 8 章)。tp=12, multiple_of=64 这个点是最关键的:multiple_of=64 的核心价值不在于让单 duck 更快,而在于把 moe_intermediate_size=768 从“只能切到 tp=6”解锁到“可以切到 tp=12”——正好多出一倍的 duck 数。
以上数字均为 eager 模式,尚未启用 flattened decode 与 CUDA Graph(详见第 7 章)。
到 2026-03-14 前后,Qwen3-235B-A22B-FP8 的 tp=24 eager decode 稳定在约 4.253 tok/s,后续经过 flattened decode 优化(详见第 7 章)提到约 4.7 tok/s。
6.3 Qwen3.5:骨架变了,重写才是正解
硬件背景:5090 先到,Qwen3.5 是高压试炼场
在进入 Qwen3.5 适配之前,有一条时序要先说清楚:RTX 5090 的选型和到位,早于 Qwen3.5 适配约六周。
这张卡在 2026 年 2 月初到货,是按照 AFD(Attention-FeedForward Disaggregation)的系统思路主动选出来的——duck 节点承载 MoE/FFN,5090 承载 Attention 侧。约束很直接:主流开源大模型的 Attention 参数量大多能装进 32GB,而 5090 的显存带宽(约 1792 GB/s)明显高于 32 只 duck 的理论总内存带宽(约 819.2 GB/s),把 Attention 放在 5090 上,带宽账是算得过的。
Qwen3.5 和随后的 DeepSeek-V3.2,都是在 AFD 路线已经成型之后,作为更大规模的高压试炼场被接进来的,而不是“接了新模型之后才决定买 5090”。
不是“改几处 config 字段”
2026-03-17,开始着手 Qwen3.5 适配。调查结果直接说明了这件事的复杂程度:Qwen3.5 不能按“给现有 qwen3 改几处 config 字段”来接。
真正的变化有四层:
- 顶层架构不同:checkpoint 的顶层是
Qwen3_5ForConditionalGeneration,带vision + text双配置,文本骨架在嵌套的text_config里,不是老的平铺结构。 - hybrid attention:64 层(27B dense)按
3×linear_attention + 1×full_attention交错。linear attention 是 GatedDeltaNet 结构,有自己的 conv state 和 recurrent state,不同于标准 KV cache。 - zero-centered RMSNorm:归一化不是乘
weight,而是乘(1 + weight)——这个细节如果写错,输出的分布会整体偏移。 - gated full attention:
q_proj输出翻倍,前半做 query,后半做 gate,attn_output最后还要乘sigmoid(gate)再过o_proj。
MoE 侧(35B-A3B)有一个好消息:真实 checkpoint 里 routed expert 的权重已经是 per-expert 展开形式,每个 expert 各自拥有独立的 gate_proj、up_proj、down_proj——和旧 Qwen3 MoE 布局完全一致。现有 rust_moe 路径直接复用,不需要任何额外适配。
基于这些差异,决定新建独立目录 duck_llm/models/qwen3_5/,完全独立实现,不沿用 qwen3 的骨架。
Shared expert 的处理
Qwen3.5 MoE 比老 Qwen3 MoE 多了一个 shared_expert 和 shared_expert_gate。Shared expert 的路由语义和 routed expert 不同:不管 gate 选出哪几个 expert,shared expert 每次都会激活,结果乘以独立的 gate 系数 sigmoid(shared_expert_gate(...)) 之后,再和 routed expert 的输出相加。
把 shared expert 并入 duck dispatch(即和 routed expert 一起打包发送)在技术上可行,但有个不利因素:第 8 章会详细说明,duck 侧的计算是在每个节点的多核上并行跑的,专家数需要整除核数才能做到负载均衡。以 35B 的配置为例,256 个专家里选 8 个激活,8 个 routed expert 恰好能在 4 核上均匀分配(每核 2 个);如果把 1 个 shared expert 并进去变成 9 个,就不再整除,必然有一核多算。
另一种思路是把 shared expert 留在 CPU 上,但这会引入另一个问题:shared expert 每个 token 都要激活,留在 CPU 意味着每一层都要在 host 侧多跑一次 MLP,白白拉回一部分本来已经卸给 duck 的带宽压力。
更直接的解法是让 shared expert 在 host GPU(5090)上计算:不影响 duck 侧的负载均衡,也不引入额外的 host CPU 带宽占用,显存增量也在可接受范围——实际核算结果是,35B-A3B 约 +0.117 GiB,122B-A10B 约 +0.422 GiB,对 32GB 显存来说不是主矛盾。
适配顺序
为了控制风险,按阶段推进:
- 先做 dense text-only eager:只接 27B-FP8,先把 hybrid attention(full + linear 交错)、MRoPE、gated attention 的语义在 Python 侧跑通,不接 duck。
- 接回 dense duck MLP decode:dense 27B 的 FFN 仍然是标准 gate/up/down 三组权重,可以直接复用现有 duck MLP 路径。
- 再做 Qwen3.5 MoE eager:35B-A3B 的 routed expert 布局和 Qwen3 MoE 一致,直接复用已有 duck MoE ABI。
- shared expert 留在本地:理由见上节。
首批真实结果
2026-03-17 晚,Qwen3.5 dense 和 MoE 先后跑通:
Qwen3.5-27B-FP8(dense,tp=24,multiple_of=64):
Generation stats: time 303.530s, input_len 16, output_len 1022,
ttft 11.378s, tpot 0.286s, prefill 1.406 tok/s, decode 3.495 tok/sQwen3.5-35B-A3B-FP8(MoE,tp=16,multiple_of=32):
Generation stats: time 4.677s, input_len 13, output_len 25,
ttft 3.511s, tpot 0.049s, prefill 3.702 tok/s, decode 20.601 tok/sQwen3.5-122B-A10B-FP8(MoE,tp=16,multiple_of=64):
Generation stats: time 11.859s, input_len 13, output_len 10,
ttft 11.084s, tpot 0.086s, prefill 1.173 tok/s, decode 11.615 tok/s三条路线都能输出语义连贯的中文回复。35B-A3B 的 token 时间集中在 45ms ~ 50ms,比较稳定。122B-A10B 能跑通,但 token 级抖动明显更大(首批测量 min=72ms、max=116ms、p90=92ms),被单独标记为需要后续追查,不与 35B 并列为“已收敛”的稳定结果。以上均为 eager 模式,未启用 CUDA Graph(详见第 7 章)。
6.4 DeepSeek-V3.2:最后一关
三处根本性差异
2026-03-18 零时,开始调查 DeepSeek-V3.2 适配。第一步看 config 和 checkpoint 结构,就发现它和前两条路线都不一样。
首先是 attention 结构。DeepSeek-V3.2 用的是 MLA(Multi-head Latent Attention)——不是普通的 Q/K/V 分离结构,而是把 KV 压缩成低秩的 latent vector 再存入 cache。decode 时从 latent 再展开出完整 K/V。这使得 KV cache 的显存占用大幅下降,但实现上比标准 attention 复杂不少。
具体的权重布局是:
q_a_proj.weight:[1536, 7168],LoRA 压缩层q_b_proj.weight:[24576, 1536],展开层kv_a_proj_with_mqa.weight:[576, 7168]kv_b_proj.weight:[32768, 512]
另外还有 indexer(DSA,Dynamic Sparse Attention)的一整组权重,用于在 prefill 阶段构造 sparse top-k attention mask,依赖 hadamard transform 和专用 fp8_index kernel。
其次是模型路由问题。本机 transformers==5.3.0 的 AutoConfig 里只有 deepseek_v3,没有 deepseek_v32——checkpoint 的 model_type = "deepseek_v32" 会直接让自动路由失败。因此不能依赖 HF 的 AutoConfig,需要在 duck-llm 里手工实现 config 解析。
第三是 MoE 权重布局的好消息。虽然 HF 的 DeepseekV3MoE 代码内部假定 packed expert 参数,但这份实际 checkpoint 已经展开成了 per-expert 独立三组:
model.layers.3.mlp.experts.0.gate_proj.weight
model.layers.3.mlp.experts.0.up_proj.weight
model.layers.3.mlp.experts.0.down_proj.weight
... (共 256 个 expert)这反而比 packed 格式更好接——直接复用现有 duck MoE ABI,不需要额外的 unpack 逻辑。
第一版实现路线与 bring-up
2026-03-18 提交的第一版(commit a2b74c5)是一个明确面向“先跑通”的临时版本。它实现了 MLA attention 和 MLA compressed cache,也带了 indexer(DSA)的初始实现——尽管 fast_hadamard_transform 和 fp8_index 两个算子当时是桩函数。测试序列长度全部在 2048 以内,这意味着 sparse attention 的触发条件从未满足,indexer 路径实际上从未参与过真实计算。
一个直接 blocker:tokenizer
在真正开口说话之前,还有一个独立问题被单独记账。DeepSeek-V3.2 的 tokenizer 在当前环境下不能直接用 AutoTokenizer.from_pretrained(...) 加载——现场验证发现,这条路径会把“你好”错编码成空序列,导致模型实际收到的 prompt 是空的,什么都输不出来。解法是绕开 AutoTokenizer,直接从 checkpoint 里的 tokenizer.json 手动构造 fast tokenizer。中文 roundtrip 验证正常之后,才有了后续的真实对话。
首次真实对话
2026-03-18 06:52,随即跑起了 tp=32 的首轮对话:
<|User|>你好<|Assistant|></think>
你好!很高兴见到你!😊 我是DeepSeek,一个热心的AI助手,随时准备为你提供帮助。
无论你想聊天、寻求建议、解答问题,还是需要协助处理文档、分析信息,我都很乐意为你服务。首轮数据:
Generation stats: time 28.864s, input_len 5, output_len 89,
ttft 1.019s, tpot 0.316s, prefill 4.904 tok/s, decode 3.160 tok/s从 stderr 日志里也能直接看到 dense prefill 和 MoE prefill 都在真实工作:
Dense prefill forward (Rust): batch_size=5, tiles=2, avg_tile_batch=2.50,
prepare=40.695µs, send=2.102ms, judge_wait=8.201ms, fetch=449.283µs,
duck max=7.976ms, weight_stream=26.601 MiB (3.497 GB/s)
MoE prefill forward (Rust): batch_size=5, top_k=8, tasks=40, unique_experts=35,
judge_wait=7.090ms, duck max=6.294ms,
weight_stream=51.725 MiB (8.617 GB/s)连续三轮对话均稳定产出可读中文,decode 稳定在约 3.15 tok/s(此时还是 eager 模式,Triton fp8 路径尚未开启)。
主线收敛:回退到 official fp8 + full attention
首次 bring-up 的 decode 约 3.15 tok/s,但这个数字几乎测不到 sparse/indexer 路径是否真的带来了收益——序列太短,sparse 从未触发。2026-03-21,主线改成更接近官方 inference contract 的路线:official_fp8,只做 <=2048 的 full attention,不再继续推 sparse/indexer 实现。2026-03-22,旧的 legacy / indexer / BF16 kv-cache 代码从代码入口完全移除,只保留 official 主线。
6.5 显存:一个额外的战场
DeepSeek-V3.2 还暴露出一个独立问题:duck-llm 的通用 Linear 模块在加载时,凡是前缀不含 "mlp" 的 FP8 权重,会直接乘上 weight_scale_inv,膨胀成 BF16 常驻显存。self_attn、indexer 里有大量投影,在 61 层的深度下,这会把 GPU 显存撑到 OOM。
临时解法是把 embed_tokens、lm_head 和 shared_experts 临时 offload 到 CPU,把显存压力转移出去。这只是止血,不是根治。
根治方案要等到引入 Triton fp8 scaled_mm 路径——让这些权重在加载时保持 FP8 常驻,forward 时用 Triton kernel 直接吃 FP8 权重做 GEMM,不再提前膨胀成 BF16。这条线在 2026-03-19 之后陆续推进(详见第 7 章的 Triton fp8 部分)。
6.6 三条路线的共性
回头看这三段适配经历,有几条规律是共通的。
可复用的层是 MoE expert 路径。Qwen3 MoE、Qwen3.5 MoE、DeepSeek-V3.2 的 routed expert 虽然来自不同架构,但 duck 侧的实际操作是一样的:load 时打包发给 duck,decode 时把 routing 结果一起传下去。只要 checkpoint 里 expert 的权重是 per-expert 展开的,Rust ABI 就可以直接复用。
不可复用的是 attention。每一个新模型家族的 attention 都需要单独实现,而且往往比 FFN 更复杂。Qwen3 是标准 full attention,Qwen3.5 是 hybrid linear/full + gated,DeepSeek-V3.2 是 MLA。三次基本都要从头写。
新 family 单独建目录是正确的选择。Qwen3、qwen3_5、deepseek_v32 各自在 duck_llm/models/ 下独立存放。尝试复用旧 config 类型的代价远高于新建一个独立的实现,这在 Qwen3.5 适配时已经得到了验证。
| 模型 | 适配日期 | 架构要点 | 首轮 decode | tp |
|---|---|---|---|---|
| Qwen3-30B-A3B | 2026-03-09 | dense/MoE 混合,fp8 128×128 | 12.994 tok/s | 12 |
| Qwen3-235B | 2026-03-12 | 全 MoE,94 层 | 4.253 tok/s | 24 |
| Qwen3.5-35B-A3B | 2026-03-17 | hybrid attention,MoE | 20.601 tok/s | 16 |
| Qwen3.5-122B-A10B | 2026-03-17 | hybrid attention,MoE | 11.615 tok/s | 16 |
| DeepSeek-V3.2 | 2026-03-18 | MLA,256 expert,61 层 | 3.160 tok/s | 32 |
这张表的意义不只是“我们接了哪些模型”,而是它在侧面印证了系统底层的 MoE duck 路径有足够的泛化能力——同一套 Rust ABI 和 fp9 kernel,能承接结构差异显著的三个不同模型家族。
项目声明 / Project Disclaimer
本项目为作者以个人身份、利用业余时间推进的个人娱乐项目;除非另有明确说明,它与作者的任何雇主、客户、学校、单位或其他组织均无合作、雇佣、委托、赞助或背书关系,也不代表任何该等主体的立场。除普通个人捐赠外,本项目未获得任何资金支持。
This project is a personal hobby project developed by the author in a personal capacity and in personal time. Unless explicitly stated otherwise, it has no collaboration, employment, commission, sponsorship, endorsement, or institutional affiliation with any employer, client, school, partner organization, or other entity, and it does not represent any such party's views. No funding was received for this project except ordinary personal donations.
许可 / License
除非另有说明,本页原创文字、本站原创图片与本站原创图表采用 CC BY-NC-ND 4.0 发布。
转载时请保留原文标题、署名“JudgeDuck AI”、发布日期与原始链接;禁止商业转载、改写、摘编、翻译或基于原文创作演绎作品。
第三方商标、外部链接内容,以及文中另有标注的材料,不在上述许可范围内。
Unless otherwise noted, the original text, original images, and original figures on this page are licensed under the Creative Commons Attribution-NonCommercial-NoDerivatives 4.0 International License.