第 8 章 fp9 从单核 MLP 扩展到完整远端路径
fp9 在第 3 章里只是一个起点:单核、dense MLP、主要证明“格式本身跑得通”。 这一章把它后续经历的每次扩展串成一条主线——MoE 接入、sub-128 解锁、多核 SMP、prefill、weight cache、persistent judge,直到最终 kernel 整理落定。
8.1 把 MoE 接进 duck:Qwen3 MoE 原生路线
为什么 MoE 比 dense 更值得上 duck
Qwen3-30B-A3B 和 Qwen3-235B-A22B 都是 MoE 模型。激活专家数是 8,但总专家数是 128——也就是说,每个 token 只用到整个专家池的约 6%。
这个稀疏结构对 decode 场景极为有利:同一 token 只需要算 8 个专家而不是 128 个,但所有 128 个专家的权重都要事先加载到某个地方。在 GPU 上处理这件事会非常耗显存;但在 duck 上,32 台鸭子合计 1 TB 内存,天然能放下所有专家权重。更重要的是,decode 下每个专家仍然是 GEMV,内存带宽仍然是主矛盾,fp9 的价值没有因为换成 MoE 而消失。
MoE 路线的结构
2026-03-09,e4ad7c8 Add native duck MoE support for Qwen3,把 MoE 路线第一次接进 active 代码。
实现上,MoE 和 dense 共用大部分协议层,但在 kernel 侧做了专门拆分。这批提交拆出了 fp9_moe_path.cpp 专门承接 MoE 的 forward 逻辑,同时新增了 src/fp8_moe_core.rs 和 src/fp8_moe_dpdk.rs 这两个 Rust 模块,以及 Python 侧的 duck_llm/rust_moe.py。
tp 切分的逻辑也在这轮重新确认:MoE 专家按 expert_id 分给各 duck,每台 duck 持有约 128 / tp 个完整专家的权重。对于 tp=32,每台 duck 持有 4 个专家。host 侧在 forward 时按路由结果广播 activated expert ids,每台 duck 只计算自己持有的那部分。
fp9_kernels.elf 在这轮升级成了多源 kernel 结构:fp9_kernels.cpp 负责 stdin 解析和 mode dispatch,fp9_dense_path.cpp 和 fp9_moe_path.cpp 各自承接一条 forward 路径。mode 定义如下:
mode=0: dense MLP forward
mode=1: MoE forward
mode=2: dense MLP prepare
mode=3: MoE prepare这个 mode 设计后来随着系统扩展还会继续增加,但这批奠定了基本骨架。
8.2 sub-128:解锁更高 tp 点位
问题在于 multiple_of 的下限
fp9 kernel 最初只支持 in_scale_block_size=128,也就是权重每 128 个参数共享一个 scale_inv。这个设定对 dense MLP 问题不大,但对 MoE 是个硬约束。
以 Qwen3-30B-A3B 为例,每个专家的 moe_intermediate_size=768。fp9 tensor parallel 的做法是在 intermediate 维度上切分——每台 duck 只持有每个专家中 768/tp 个 intermediate neurons 的权重,内核要求这个本地 slice 大小必须是 multiple_of 的整数倍。
这样,multiple_of 就直接决定了 tp 能开到多大:
multiple_of=128:每台 duck 至少持有 128 个 intermediate neurons,tp_max = 768/128 = 6multiple_of=64:tp_max = 768/64 = 12multiple_of=32:tp_max = 768/32 = 24
对 Qwen3-235B-A22B,moe_intermediate_size=1536,各档 tp_max 翻倍:multiple_of=128 时 tp_max=12,切到 64 才能上到 tp=24,multiple_of=32 理论上能到 tp=48,但当时系统只有 32 台鸭子。
简单说:sub-128 的意义不是让单核 kernel 跑更快,而是把原本因 scale block size 限制而无法参与的鸭子解锁出来。
实现方式
2026-03-12,c891591 Support sub-128 fp9 gemv for duck MLP and MoE 落地。
实现上保留 128 作为 fast path,新增 64 和 32 的 micro-kernel——这两个 block size 下的 dot 操作结构基本相同,只是每轮读取的 weight bytes 和 sign words 变少了。scale layout 使用 “repeated scale”,也就是 64/32 的每个 scale block 都单独有自己的 scale_inv,kernel 按 block 边界解包。
Python/Rust 侧新增 fp9_gemv_multiple_of 配置项,对不同规模的模型可以选择不同的 block size。
结果:两步显著提升
对 Qwen3-235B-A22B-FP8:
| tp | multiple_of | decode tok/s |
|---|---|---|
| 12 | 128 | 2.577 tok/s |
| 24 | 64 | 4.253 tok/s |
对 Qwen3-30B-A3B-FP8:
| tp | multiple_of | decode tok/s |
|---|---|---|
| 6 | 128 | 8.736 tok/s |
| 12 | 64 | 12.994 tok/s |
| 24 | 32 | 17.397 tok/s |
multiple_of 更细时单 rank 的 Gparam/s 略有下降(从约 3.86 降到约 3.27),但解锁新 tp 点位之后的并行收益远大于这个损失。对 235B 来说,sub-128 直接把 decode 吞吐拉高 1.65x。
8.3 same-ELF SMP:四核鸭子
JudgeDuck-OS 侧的多核准备
第 4 章描述的是 JudgeDuck-OS 在单核场景下的改造。这里要接续的是它在多核方向的扩展——这部分主要发生在 2026 年 3 月中旬。
2026-03-16,JudgeDuck-OS 接入 judge-smp 协议,duck-llm 随之切换到 judge-smp 作为默认 judge 请求路径。这套机制的核心是 same-ELF SMP:同一份 fp9_kernels.elf 可以在同一台 duck 的多个核心上并发执行,所有核心共享同一块 IB/OB buffer,通过用户态自旋 + __atomic_* barrier 协调。
ABI 侧最关键的两个字段:
DuckInfo_t.smp_cpu_index:当前核心的 secondary 编号(CPU0 是 BSP,CPU1/2/3 是 secondary)DuckInfo_t.smp_cpu_count:本次 judge 启动的总核心数
secondary 的 user stack 也在这轮被显式扩容,以适配 fp9 forward 的实际调用深度。
fp9 kernel 的多核 MoE forward
fp9_kernels.elf 在 secondary 接通之前,所有 mode 都是纯单核路径——secondary entry 直接 sys_exit(0)。
2026-03-16,mode=4 被引入,专门承接 MoE 的多核 forward。同时保留 mode=1 作为单核 fallback。
核心切法是“核间 Expert Parallelism”——按 sorted expert slot 分工:
- CPU0 先对本次激活的专家按
expert_id排好序 - 计算
active_workers = min(smp_cpu_count, num_activated),至少保留 CPU0 - 每个 worker 拿一段连续的 sorted slot,独立完成 gate/up/silu/mul/down
- 每个 slot 的
expert_output * weight写入自己的 fp32 scratch(放在 OB 末尾,不增加 host fetch bytes) - CPU0 最后按 slot 顺序累加并转成 bf16
// fp9_kernels.cpp 里的 SMP 同步原语
volatile uint32_t g_job_ready = 0; // 0=no job, 1=single-core, 4=smp moe
volatile uint32_t g_moe_smp_done_count = 0;
moe_smp_job_t g_moe_smp_job; // CPU0 填好后由 secondary 直接读这套用户态同步完全建立在已有的 same-ELF 共享地址空间上,不需要改动 JudgeDuck-OS 的内核协议或 ABI。
结果
2026-03-16 的人工实测(flattened_decode 基础上):
Qwen3-235B-A22B-FP8 tp=24:4.7 tok/s → ~9.0 tok/s,约1.9x
对应 duck 侧日志里的性能变化:
- kernel 本体
effective_read从单核时代上升到约11.7 Gparam/s / 13.1~13.3 GB/s - 这接近理想的 4x(4 核),说明多核 kernel 本体没有明显的额外同步开销
不过收益并不是线性的。judge_wait 仍有约 772~779 µs,而 duck_max 只有约 533~539 µs——judge_gap 约 238~245 µs,judge_ratio 仍在 1.44x。这说明多核 kernel 本体已经变快,但 host/protocol 周边的固定成本还没有同步跟上。这个观察直接指向后来的 persistent judge 方向。
8.4 prefill:从 host slow path 到 duck
为什么 prefill 也需要进 duck
在 fp9 系统早期,prefill(多 token 输入阶段)走的是 host 侧的 quantized Linear slow path——因为 prefill 只在每次新对话开始时发生一次,当时精力更优先放在 decode 路径。
但有一个问题随着系统成熟而变得越来越刺眼:host slow path 在每次推理前都要把全部 FFN/MoE 参数从 checkpoint 文件里逐层读出来。这个开销对任意长度的 prompt 都存在——即使只输入一个字,host 也要把几百 GB 的权重文件过一遍。而 duck 本身就把这批 fp9 权重常驻在内存里——duck 设计上在加载完成后就一直持有自己的那份权重切片,不需要每次从磁盘重读。把 prefill 也接进 duck,就能直接复用已经 prepare 好的 fp9 权重,让“进来一条请求就立刻算”变成现实。
dense prefill:mode=5
2026-03-18,dense prefill kernel 以 mode=5 进入 fp9_kernels.elf。
dense prefill 是 batched fp9_gemm——把多个 token 的 hidden state 打包成矩阵行,与权重矩阵做批量乘法。相比 decode 下的 GEMV,prefill 有更高的 arithmetic intensity,但权重仍然只读一遍。
性能口径更新为:GFLOP/s、weight_stream GB/s、tiles、avg_tile_batch。
kernel 内部按 N_TILE=2 组织 tile 循环(两行一组),并使用 token-wise SMP:多个核心按 tile 分工,CPU0 最后归并。
MoE prefill:mode=6
MoE prefill 比 dense 稍复杂一些,因为每个 token 的激活专家集合可能不同。
mode=6 的实现思路是“按 expert 分组的任务排序”:
- 先把所有
(token, topk)的(token_idx, expert_id)组合按expert_idcounting-sort - 为每个 expert-group 按 token 数做 worker 负载均衡
- 每个 worker 对自己负责的 experts 跑 batched
fp9_gemm_split_sign - CPU0 最后按
expert_idx升序把每个 token 的 expert outputs 加回去
这个设计保证了:即使不同 token 的路由结果不同,也能有效地把同一专家的 token 攒成 batch,让 GEMM tile 利用率不至于太低。
8.5 weight cache:消除重复 load 开销
问题来源
每次运行推理时,duck-llm 需要把全部模型权重打包并发送到 duck 节点——这是 prepare 阶段的主要工作。对 DeepSeek-V3.2 来说,该模型有 256 个路由专家、58 个 MoE 层、moe_intermediate_size=2048。以 tp=32、multiple_of=64 为例,每台 duck 持有每个专家中 64 个 intermediate neurons 的 fp9 权重切片,58 MoE 层 × 257 专家(256 路由 + 1 共享)× 约 1.5 MB = 每台鸭子约 22 GiB 需要重新 prepare;32 台合计约 700 GiB 的网络分发流量。
在开发和调试阶段,这个开销极为烦人:每次调整一行 Python 代码,都要等上好几分钟才能看到结果。
设计
2026-03-18,baee895 Add fp9 prefill kernels and duck weight cache 顺带把 weight cache 机制一起落地。
duck 侧的 weights buffer 头部新增了固定 metadata 区,记录:tp_size、model_key(由模型路径、hidden_size、intermediate_size 等影响 layout 的字段派生)、slot_count,以及每一层的 cache_slot finish flag。
host 侧在 prepare 阶段调用 begin_weight_cache_session(...),如果 metadata 核验通过(model key 匹配、tp_size 一致),就走 cache hit 路径:
- 不再读取 Python 侧的 MLP/MoE 权重文件
- 不再执行
py_prepack/py_concat - 不再发送
preparepayload 给 duck - 仍然重建 desc 结构(保证 runtime 状态干净),但跳过所有 IO
cache miss 时(model key 变了、tp_size 变了、或强制 invalidate)则走完整 prepare 路径并更新 metadata。
这个机制对开发体验改善极为显著:在 duck 权重已经 warm 的情况下,每次启动可以直接跳过 prepare 阶段,只需等待 Python 权重加载和 Rust 初始化。
8.6 persistent judge:把 open/close 开销压到几十微秒
judge_gap 的来源
在 same-ELF SMP 时代,judge_wait 总是比 duck_max 大一截,这段差距叫 judge_gap。
judge_gap 的来源有几个层面:
- duck 侧 ELF load 和初始化的固定成本
stdin/IB/OBbuffer 的 copy 和映射- OS 协议层的包处理开销
在单核、小模型上,judge_gap 约 130~140 µs,judge_ratio 约 1.38x;在 DeepSeek-V3.2 tp=32 的场景下,由于 duck kernel 本体时间(~950 µs)大得多,judge_gap 上升到 ~418 µs,judge_ratio 约 1.44x。
JudgeDuck-OS 的 smp64+persistent ABI
2026-03-22,JudgeDuck-OS 定义了新的 smp64+persistent ABI:
judge-open-smp:只 load ELF、不执行;返回session-idjudge-step:向已 load 的 ELF 的step(DuckInfo *)入口传入新的DuckInfo,继续执行
与旧的 judge-smp 不同,persistent 模式下 ELF 常驻于 duck,每个 step 只需要更新输入并触发执行,不再重新 load。
ELF 自身需要通过 JUDGEDUCK_SMP64_DECLARE_PERSISTENT_STEP(step) 宏声明 step 入口,并在 step 之间保持全局状态(duck-llm 中使用这个能力来缓存多核协调的同步原语状态,以及避免反复初始化 prefetch 窗口)。
第一批 AP 进入时序的 bug 也在这轮修掉:原本 step 函数中 secondary 可能“晚到”,把“当前 step 已发布”误判成“下一个 step”的等待。修法是把 step 同步改成显式 active/idle phase,由 BSP 控制 phase 切换,secondary 先检查 phase 再判断 job。
2026-03-23,persistent judge 在真实 duck-llm forward 中首次获得端到端验证。
实测收益
judge-open-smp → judge-step 的效果是把每次 forward 的 OS 级固定开销从“ELF load + init”压到“一次轻量 step dispatch”。
真实 forward 日志:
# v32 tp32 旧(judge-smp)
judge_gap = 418.176µs, judge_ratio = 1.438x
# v32 tp32 新(persistent judge)
judge_gap = 78.082µs, judge_ratio = 1.083x对 0.8B tp=8:
# 旧:judge_gap = 138.139µs, judge_ratio = 1.385x
# 新:judge_gap = 64.019µs, judge_ratio = 1.179xjudge_gap 被压到几十微秒量级——相比之前的 138~418 µs,缩短了 5~6x。
这轮改动对 DeepSeek-V3.2 的端到端影响是:TPOT 137ms → 110ms,约等于 decode 7.30 tok/s → 9.09 tok/s。
persistent judge 随后被设置为默认开启,通过 DUCK_FP9_PERSISTENT_JUDGE=0 可回退到旧路径。
2026-03-26 还补了一条 fresh-boot 稳定性修正(JudgeDuck-OS 侧):run_persistent_smp64_step(...) 开头补上 Trap::drain_pending_kernel_ipis(),以排干 fresh boot 后内核态残留的 SMP/IPI 状态。现象是:duck 刚开机后若直接走 persistent judge,第一步结果会明显出错(持续吐错误 token);先跑一次 non-persistent judge 后再跑 persistent 则恢复正常。普通 judge 路径的 run() 开头原本就有这一步,persistent step 路之前漏掉了,这轮补回。
范围说明:persistent 的默认开启范围是 decode forward;prepare 和 prefill 在这轮之后也很快切过来,统一由 judge-open-smp 在第一个 layer 建立 session,后续每步直接 judge-step,避免 decode 第一个 token 还要额外 reopen。
8.7 fp9 kernel 优化与最终整理
从 3.8 到 5.4 Gparam/s 的专项优化
fp9 从第 3 章里的单核 decode 跑出第一版结果,到这一章的各项功能落地,内核本体的性能也在同步压缩。2026-03-12 的真实路径里,fp9 decode 已经到约 3.815 Gparam/s(Qwen3-1.7B-FP8 tp=11)。这个数字在当时已经是能跑、能出 tok/s 的,但还有明显的 headroom 没打到。
到 2026-03-26,针对 harness 的集中优化(以 target1 为主)将单核内核推到了约 5.40 Gparam/s 量级,整理命名为 fast fp9,并接回 active decode 路径。(target1b 是 harness 里性能略高但布局不兼容的实验变体,并未接入主线;最终 exact-128 达到约 5.47 Gparam/s 的是后续整理时选定的 pre-scaled tile2 kernel,详见“exact 64/128 的最终选定”一节。)
接入 active 路径后,真实 decode forward 里也记录到了对应收益:同日的 dense 0.6B tp1 从约 3.9 Gparam/s 提到 5.335 Gparam/s;MoE 30B tp8 从 14.171 提到 17.002 Gparam/s——harness 上的提速在真实模型路径里同样兑现。
这轮优化不是泛泛地针对所有 shape,而是集中在 DeepSeek-V3.2 tp=32 下的真实瓶颈 shape——特别是 down_proj 的 local_intermediate_size=64 这档:整个 K 方向只有一个 block size block,每行权重只跑一次 dot,是最容易让解码函数出现 overhead 的地方。对这档 shape 的专项调优,是后来 exact-64 kernel 比泛用路径快出这一截的核心原因。
为什么要清理
到 2026-03-29,fp9_common.cpp 已经积累了相当多历史路径:旧的非 e_bias_16 分支、带 assert(0) 的 legacy helper、FP9_GEMM_OUT_TILE==4 那套停留在库存里的代码,以及在 inner loop 里反复判断 128/64/32 的 block-size dispatch。
这些路径已经不活,只有去掉才能保持代码可读,同时为“把最终 best kernel 接回 active gemv”腾出空间。
exact 64/128 的最终选定
在这轮整理之前,做了一批系统性的 harness 实验,对 exact 64、exact 128、general 64、general 128 四组 case 分别评估多个 kernel 变体(热缓存、冷缓存)。
最终结论:
| case | 选定 kernel | 热缓存(单核 Gparam/s) | 冷缓存(单核 Gparam/s) |
|---|---|---|---|
| exact 64 | signword_pf(u16 signword + pre-scaled x + prefetch) | 5.219 | 5.059 |
| exact 128 | pre-scaled tile2(双行一组 + pre-scaled x) | 5.472 | 5.011 |
| general 64 | optimized_dot_fp9_fp32_vec_with_e_bias_16_fast<64> | 4.697 | 4.571 |
| general 128 | optimized_dot_fp9_fp32_vec_with_e_bias_16_fast<128> | 5.178 | 5.087 |
signword_pf 比旧的 tile2_64_combo 胜出约 0.95%(热缓存),稳定性也更好。
在 4 核 harness 测量下,barrier wall time 与各核 local max 几乎重合(imbalance 约 1.0001x~1.0019x),热缓存 4 核总吞吐约 18.7~21.3 Gparam/s——基本接近理想 4x。
结构整理
fp9_common.cpp 这轮的主要结构变化:
- 旧的非 fast fallback 全部删除,只保留
e_bias_16fast path - block-size 的判断从 inner loop 提到最外层 dispatch,分别实例化
SignWords<128/64/32>、generic dot helper和gemm block helper - final best kernel 按 active shape 接回
gemv:exact 64 走signword_pf,exact 128 走pre-scaled tile2,general 走 fast helper
提交:ab33e9e fp9: prune dead common paths and land final 64/128 kernels
这轮清理与 MTP1 dual-lane overlap(第 9 章)基本并行推进,final kernel patch 在 MTP1 在线路径落定后的次日(2026-03-30 凌晨)接入真机,并拿到了正向验证结果。因此第 8 章在此结束——最终系统结果需要结合 MTP1 和若干后续优化一起来看,留在第 9 章给出。
8.8 fp9 扩展路径全貌
把这一章的全部里程碑串成一张时间线:
| 时间 | 变化 | 意义 |
|---|---|---|
| 2025-08-31 | 单核 MLP fp9 first run(第 3 章) | 格式验证 |
| 2026-03-09 | MoE 原生 duck 接入 | MoE 路径接通 |
| 2026-03-12 | sub-128(64/32) | 解锁更高 tp |
| 2026-03-16 | same-ELF SMP + mode=4 MoE 多核 | 235B 从 4.7 → 9.0 tok/s |
| 2026-03-18 | prefill mode=5/6 + weight cache | prefill 落地,重复 load 消除 |
| 2026-03-23 | persistent judge 默认开启 | V32 tpot 137ms → 110ms |
| 2026-03-26 | fp9 kernel 专项优化(target1 → fast fp9,3.8→5.4 Gparam/s) | 针对 V32 tp32 瓶颈 shape 的专项调优 |
| 2026-03-29 | final kernel 整理(exact 64 → signword_pf,exact 128 → pre-scaled tile2) | fp9 kernel 最终版本确定 |
到这里,fp9 已经不是一个孤立的权重格式,而是一条完整的远端计算路径:格式转换(prepare)、多核 decode(same-ELF SMP)、批量计算(prefill)、状态复用(weight cache 和 persistent judge),每一个环节都已经落成可工作的代码。
项目声明 / 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.