第 3 章 单核 MLP fp9 起点

fp9 不是为了标新立异而设计的格式,而是 N100 + AVX2 这组硬件在逼迫下得出的务实答案。


3.1 为什么不是 bf16 或 fp16

最直观的问题是:鸭子上有 32 GB DDR4-3200,为什么不直接用 bf16 或 fp16 存权重,然后用标准 AVX2 浮点路径做推理?

理由不复杂,就是容量和带宽两件事放在一起不成立。

容量层面:32 只鸭子每台 32 GB,理论上总容量约 1 TB。但 bf16/fp16 是两字节一个参数,685B 参数的模型(如 DeepSeek-V3.2)的 FFN/MoE 专家层就需要约 1.3 TB 的 bf16 权重。这已经超过了系统的总内存,更不用说还需要为 OS、ELF、input/output buffer 等预留空间,因此根本无法完全装入

计算层面:即使能通过某种方式装下,N100 上缺乏对 bf16 的原生计算指令支持。这意味着,虽然权重能以 16-bit 格式存储,节省了一半的存储带宽,但在计算前必须将其转换(提升)为标准 AVX2 支持的 fp32 格式。结果是,内存带宽的压力虽然减轻了,但计算和 32-bit 一样耗时。

真正的瓶颈是 decode 速度:在这套系统里,每次 decode forward 都是一次 GEMV(矩阵向量乘)——矩阵是权重,向量是 hidden state。对于 MoE 专家层,矩阵的每一行都要被逐一读出并与向量点乘。这个过程的主矛盾是“能多快把权重从内存里读出来”——也就是内存带宽利用率,而不是浮点吞吐。

换成 fp8 存储可以把读权重的带宽需求砍一半(相比 bf16),但 fp8 → fp32 的 decode 操作本身也有成本。在 AVX2 + FMA 的 N100 上,fp8 解码涉及位操作、移位、指数偏置计算,每次乘加前都要先做一次格式转换。如果这个转换做得不够快,节省下来的带宽优势会被转换本身的 latency 吃掉。

这是 fp9 这个格式被提出的出发点。


3.2 fp9 格式的设计

fp9 是这个项目里技术含金量最高的创新之一:专门为 N100 + AVX2 定制的 9-bit 浮点格式。核心权衡可以一句话说清楚:多用 12.5% 的存储空间,换取 fp9 → fp32 decode 路径的大幅提速

位布局

fp9 的 9 个 bit 分三段:

bit 8      bit 7..3      bit 2..0
  s         eeeee          mmm
(符号)     (指数, 5位)    (尾数, 3位)

归一化数的值为 (-1)^s × 2^(e − 15) × (1 + m/8),指数偏置为 15。

相比之下,标准 fp8 e4m3 是 1 + 4 + 3 = 8 位,指数偏置为 7,表示范围受限,且在 AVX2 上 decode 到 fp32 的路径相对繁琐。fp9 把指数域扩展到 5 位,把偏置调到 15——这个数字不是随便选的,而是与 IEEE fp16 的指数偏置完全一致。

存储布局:符号位分离

fp9 的 9 个 bit 并不是以某种奇怪的紧密格式打包在一起的。实际存储时,它被拆成两部分:

  • 主体字节(fp9_e << 3) | fp9_m,即去掉符号位后的 8 bit,照原样存在 fp8 原来的位置上
  • 符号字节:每 8 个权重的符号位被打包成 1 个字节,单独存在一个并行数组里

这样,8 个权重占 8 字节(主体)+ 1 字节(符号)= 9 字节,正好是 9 bits/value,存储开销比 fp8 多 12.5%。

转换代码如下,发生在权重加载阶段(不在热路径上):

cpp
static inline uint16_t convert_fp8_to_fp9_element_prepare(uint8_t fp8, int use_fp9_e_bias_16) {
    uint16_t fp8_u16 = static_cast<uint16_t>(fp8);
    uint16_t fp8_s = (fp8_u16 >> 7) & 0b1;
    uint16_t fp8_e = (fp8_u16 >> 3) & 0b1111;
    uint16_t fp8_m = fp8_u16 & 0b111;

    uint16_t fp9_s = fp8_s;
    uint16_t fp9_e = fp8_e + (15 - 7);  // 指数偏置从 7 升到 15
    uint16_t fp9_m = fp8_m;

    if (fp8_e == 0) {
        // 处理 fp8 次正规数:规格化到 fp9 的表示范围内
        if (fp9_m == 0) {
            fp9_e = 0; fp9_m = 0;
        } else if ((fp9_m & 0b100) != 0) {
            fp9_m = static_cast<uint16_t>((fp9_m << 1) - 0b1000);
        } else if ((fp9_m & 0b010) != 0) {
            fp9_e = static_cast<uint16_t>(fp9_e - 1);
            fp9_m = static_cast<uint16_t>((fp9_m << 2) - 0b1000);
        } else {
            fp9_e = static_cast<uint16_t>(fp9_e - 2);
            fp9_m = 0;  // 最后一种极小次正规数:规格化后恰好落到 fp9_m = 0
        }
    }

    if (use_fp9_e_bias_16 && fp9_s == 1) {
        fp9_e ^= 0b10000;  // e_bias_16 编码:负号写入指数高位
    }

    return static_cast<uint16_t>((fp9_s << 8) | (fp9_e << 3) | fp9_m);
}

decode 加速:e_bias_16、cvtepi8_epi16 与 f16c

fp9 的 decode 整体上可以分成三个步骤:

  1. 加载并展开:把 16 个主体字节(__m128i)用 _mm256_cvtepu8_epi16 零扩展为 16 个 uint16_t
  2. 处理符号:把 sign plane 里的 16 个符号位展开成 16 路 mask,再 XOR 进主体
  3. 左移 7 位,交给 f16c_mm256_slli_epi16(fp16_sem, 7) 之后,16 个值恰好是合法的 fp16 格式(符号在 bit 15,5 位指数偏置 15,10 位尾数上对齐);_mm256_cvtph_ps 用 x86 的 f16c 硬件指令直接转成 fp32

其中最别扭的是第 2 步,因为 sign bit 不是和主体一起存成 16 路字节,而是单独 packed 在 sign plane 里。一种有效的方法是:先用 _pdep_u64 把这 16 个 sign bit 分别打到每个字节的 bit7 上,也就是先得到一串 0x80 / 0x00;再用 _mm256_cvtepi8_epi16 把这些 int8 扩成 int16。由于 0x80 在有符号 8 位里正好是 -128,符号扩展之后自然得到的是 0xff80,而不是更像“纯符号掩码”的 0xff00

写成伪代码,大致就是:

cpp
fp9_em_u16 = cvtepu8_epi16(weight_main_16B);
pdep_bytes = pdep(weight_sign_bits, 0x8080808080808080...);
sign_mask  = cvtepi8_epi16(pdep_bytes);   // 16 lanes of 0xff80 / 0x0000
fp16_sem   = fp9_em_u16 ^ sign_mask;
fp16_bits  = fp16_sem << 7;
fp32       = cvtph_ps(fp16_bits);

这时 e_bias_16 的设计就顺理成章了:既然第 2 步天然构造出来的是 0xff80,那么负数 lane 上这次 XOR 除了写入 fp16 sign bit,还会顺手翻动主体字节里的 bit7,也就是指数的最高位。于是 fp9 在 prepare 时干脆把负号先折进这一位;解码时这一 XOR 就同时完成两件事:一边写入 fp16 的 sign,一边把那一位额外的 bias 编码翻回去。整个 decode 因此不需要额外的 exponent 修正逻辑。

因为 fp9 的指数偏置(15)本来就和 fp16 相同,这三步没有任何偏置修正的额外运算——移位完就是合法 fp16,硬件接手。对 N100 这种只有 AVX2 + f16c 的小核来说,这种“先在整数域里把位形摆好,再一次性交给 fp16 硬件转换”的路径非常划算。

一个顺手的好处:零值也能自然对齐

fp9 这套布局还有一个很漂亮的性质:不只是普通数值能顺着这条二进制对齐路径直接落进 fp16,连 +0 / -0 这样的特殊情况也能自然处理正确。

+0,prepare 后就是 (em=0x00, sign=0);对 -0,由于 e_bias_16 这套编码把负号也折进了指数高位,prepare 后会自然变成 (em=0x80, sign=1)。后面的 decode 再通过符号位展开、异或和左移,把这两种情况分别还原成 fp16 的 +0 / -0,不需要额外特判,也不需要为了热路径去牺牲这类边界值的正确性。

同样地,转换代码里 else 分支对应的最小那档 fp8 次正规数,最后落到 fp9_m = 0,也是规格化后的精确结果。真正的 tradeoff 仍然只有这一章前面讲过的那一个:多花 12.5% 的存储与带宽,把 decode 路径做快。


3.3 从想法到第一行代码

fp9 这个格式是在研究 N100 上 fp8 → fp32 乘法实现时想出来的。

2025 年 8 月底,项目已经有了 Qwen3 dense、Qwen3 MoE 和初步的 fp8 支持(duck-llm 仓库在此时期的提交包括 2025-08-04 的 Add initial implementation for qwen3 dense、2025-08-11 的 Add qwen3 moe implementation 和 2025-08-28 的 Support fp8 dequantization)。这时候的 duck 侧还没有自定义格式,权重存储直接沿用 fp8 来自 HuggingFace 的 checkpoint 格式。

在研究如何在 AVX2 上最高效地做 fp8 → fp32 批量乘法时,具体的问题是:_mm256_mul_ps 在遇到 denormal 数时会明显变慢,而 fp8 格式的指数偏置设计有时会把小值落进 denormal 区间。

在这个讨论过程中想到了另一条路:与其去处理 fp8 decode 的各种边界情况,不如在设计格式时就让 decode 路径本身变得直接。fp9 就是在这个思路下被提出来的——不是 AI 给出的方案,而是研究已有格式的问题时自己想出来的一条替代路线,后续才让 AI 分析它在位级实现上是否可行。


3.4 第一次进入真实路径

2025 年 8 月底,fp9 已经被放进了真实 forward 路径。

当时的系统形态还很早:duck 侧先承担单核 dense MLP forward,其他部分仍在 CPU 上计算。也正因为这已经是“真实路径”而不是纯 harness,留下来的是一串很具体的运行时间。对当时那组特定 workload 来说,forward 时间从 8 月 30 日晚间的约 7 ms,一路压到 8 月 31 日下午的 3.02 ms2.87 ms2.76 ms,并继续逼近 2.45 ms

更关键的是,8 月 31 日傍晚已经出现了这样一组判断:单次实际运行约 2.45 ms,但端到端仍在 12 ms 左右。这说明 fp9 第一版的单核 MLP 本体已经先跑出来了,随后暴露出来的主矛盾反而是 JudgeDuck-OS 一侧的固定成本。这也正是下一章会进入的主题。


3.5 往后看:这只是起点

这一章只讲 fp9 的起点:为什么要重写格式、第一版单核 MLP 怎样先跑出来,以及它如何先把“鸭子上的权重读取才是真正主瓶颈”这个判断压成可测量的结果。

后面 fp9 还会继续扩到 MoEprefill、多核执行、weight cache 和常驻会话;但这些都属于系统后期的演化。对这一章来说,最重要的是起点已经成立了。


项目声明 / 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.