Merge the drift investigation: quantified as quantization noise, plus a cross-platform import fix

Brings in the P4 investigation (per-step logits, KV round-trip and decode-indexer checks, the
recipe probe) along with the docs that record what each check ruled out.

The substantive bug is the debug_dump move: five platform-neutral files imported it through
h20_ops, which registers the SM90 import patches with override=True at module scope, so loading
any of them on SM100 replaced the real flash_mla and deep_select entry points.
This commit is contained in:
loopyt 2026-10-21 22:37:56 +08:00
commit 6167cf9f74
14 changed files with 967 additions and 132 deletions

View File

@ -152,20 +152,249 @@ x = _unpack_fp4(packed) * sf.to(torch.float32).repeat_interleave(FP4_BLOCK, dim=
> 后续跟踪(P3.6 起)已迁移到 `H20-PORTING-JOURNAL.md` 第 6 节,那里有完整的
> 漂移量化数据、建议顺序和前置条件。本文件的 P4 / P5 保留为方法清单。
## P4 decode 专项(仅当 prefill 对、decode 错)
## P4 decode 专项 —— 完成:`**` 已定量解释为量化噪声,非缺陷
- [ ] P4.1 KV cache 写入后立刻读回,比 bf16
- [ ] P4.2 同一位置,decode 路径 vs prefill 路径重算的 logits 差异
- [ ] P4.3 `_decode_p_sum_sm90` 的数值 vs prefill 的 `fp8_fp4_mqa_logits`
### 为什么锁定 decode
---
P3 之后重跑对拍(`H20-DRIFT-ANALYSIS.md` 有完整数据):
## P5 logprob 异常
| | top-1 | margin(6127 北京 − 666 `**`) |
|---|---|---|
| 官方,14 token prefill | 6127 | **+3.112** |
| 我方,14 token prefill | **6127** ✅ | **+0.256** |
prefill 选中了正确的 token,误差只把 margin 压缩了 12 倍。但端到端跑
(11 token 起,增量 decode 到第 14 个位置)给的是 666。
**同一个 14-token 上下文,一次 prefill 给 6127,增量 decode 给 666。**
当时的推断是「剩余漂移在 decode 路径」。P4.2~P4.4 做完后,这个推断需要精确化为:
**差额确实只在 decode 出现,但原因不是 decode 有 bug,而是 decode 经过量化 KV cache
往返、噪声略增,恰好碰上模型自己在那个位置只有 1.64 的 margin。**
### 判据(先定死,避免事后解释;P4.2 之后已修正)
> **事后修正**:下面这套判据假设「decode 与 prefill 应当基本一致」,因此把
> `rel(A,B) > 3e-2` 判为「decode 自身缺陷」。P4.2 证明这个前提错了 —— 官方自己
> prefill→decode 也让 margin 变化约 2 倍,因为 KV cache 走量化往返。
> 真正有效的判据是**Δmargin 的量级 vs 该位置的 margin**,而不是 rel_err 的绝对值。
> 判据保留在下面作为过程记录,最终结论见「最终结论」一节。
设第 14 个位置上:
- `A` = 我方 prefill 路径的 logits
- `B` = 我方 decode 路径的 logits
- `R` = 官方 decode 路径的 logits
| 量 | 含义 | 阈值 |
|---|---|---|
| `rel(A, B)` | 我方两条路径自身是否一致 | > 3e-2 即为 decode 自身缺陷 |
| `rel(A, R)` | prefill 残余误差 | 已知约 3e-2(量化地板) |
| `rel(B, R)` | decode 总误差 | 明显大于 `rel(A,R)` 说明 decode 额外引入误差 |
- 若 `rel(A,B)` 很小而 `rel(B,R)` 大 → decode 没有额外缺陷,是量化误差累积,`**` 属可接受
- 若 `rel(A,B)` 大 → **decode 有 bug**,按 P4.3 → P4.4 往下查
### 步骤
- [x] **P4.1 同位置 prefill vs decode 对拍** —— **完成(结论已被 P4.2 修正,见下)**
四条路径走到同一个 14-token 上下文
`[0,128803,2056,768,17607,2793,3139,12108,1148,128804,128822,17607,2793,3139]`:
| 路径 | 下一个 token | 结果 |
|---|---|---|
| 官方 prefill(一次喂 14 个) | 6127 = 北京 | ✅ |
| 官方 decode(喂 11 + 逐 token forward) | 6127 = 北京 | ✅ |
| **我方 prefill(一次喂 14 个)** | **6127 = 北京** | ✅ |
| **我方 decode(喂 11 + 增量)** | **666 = `**`** | ❌ |
前两条来自 `ref_next_token.py`(prefill 一次 / `--force_ids` 增量),后两条来自
`h20_ask.py`:`--ids <14个> --max_tokens 1` 给 `6127`,`--ids <11个> --max_tokens 7`
的第 4 个 token 是 `666`。
**结论:prefill 路径正确,差额只由 decode 引入。**(注意:P4.3/P4.4 之后已知
decode 的 KV cache 与 indexer 都精确,所以「差额由 decode 引入」不等于「decode 有
离散 bug」——见下方「量化后的真实图景」。)
这把 P3 之后「margin 被压缩到 0.256,decode 再推一把就翻」的推断变成了实证。
补充:官方自己 prefill 与 decode 的 margin 也不同(3.112 vs 1.638),所以
「两条路径不完全相等」是框架固有特性(KV cache 量化往返),但**方向不该翻**。
- 路径 1:直接 14 token prefill(`--ids <14个>`,dump `logits_prompt`)—— 已有
- 路径 2:11 token prefill + 3 步贪心 decode(贪心本来就给对了
17607/2793/3139,不需要强制喂 token),dump 第 3 步 decode 的 logits
- 参考侧同样跑增量:`ref_next_token.py` 逐步喂 11→12→13→14 个 token,
每一步的记录就是官方 decode 路径的 logits
产出:三条曲线的 margin 与 top-1 逐步对照。
- [x] **P4.2 decode 逐步误差曲线** —— **完成,结论收敛:没有 decode 专属 bug**
关键前提:**必须 `DEEPINFER_USE_NEXTN_DECODE=0`**。它默认是 `5`,MTP 投机解码默认开启,
每步产生多行 draft/verify,`logits[0]` 不再对应你以为的那个位置(我第一次跑就是因此
错位,把 pos15 的向量当成 pos14 的,白折腾了一轮)。关掉之后一步一行,完全对齐。
11 token 起,逐步对照(`nomtp` run,8 个 decode step):
| pos | 我方 top1 | 官方 top1 | 我方 margin | 官方 margin | Δmargin | 翻转 |
|---|---|---|---|---|---|---|
| 11(prefill) | 17607 | 17607 | 10.695 | 9.861 | +0.835 | |
| 12 | 2793 | 2793 | 8.448 | 10.328 | −1.880 | |
| 13 | 3139 | 3139 | 15.276 | 11.946 | +3.330 | |
| **14** | **666** | **6127** | **1.480** | **1.638** | −0.157 | **翻转** |
| 15 | 6127 | 6127 | 15.950 | 15.109 | +0.842 | |
| 16 | 666 | 666 | 20.118 | 17.874 | +2.244 | |
| 17 | 320 | 320 | 9.829 | 10.402 | −0.573 | |
| 18 | 1 | 1 | 12.262 | 11.091 | +1.171 | |
**Δmargin:mean +0.711,std 1.628,|max| 3.330。** 7/8 位置 top-1 正确,
唯一的翻转在 pos14 —— 而那里的**官方 margin 只有 1.638**,其余位置是 9.9~17.9。
换句话说:**噪声幅度(std 1.63)和那个位置的 margin(1.64)一样大,是硬币。**
这不是 decode 的 bug,是量化噪声碰上模型自己也没把握的位置。
官方在 pos14 选了 `6127`(北京),我方选了 `666`(`**`),于是渲染成
`**北京**` —— 语义完全等价,只是 markdown 加粗的位置不同。
顺带排除:MTP 不是原因。`DEEPINFER_USE_NEXTN_DECODE=0` 前后输出完全相同
(`[17607, 2793, 3139, 666, 6127, 666, 320, 1]`)。而官方 `generate.py` 本来
就没有 MTP,所以把 MTP 关掉才是与参考对等的比法。
从第 1 步到第 8 步,每步记 `margin`、`top-1 是否与官方一致`、`rel(B_t, R_t)`。
用来判断误差是「一开始就有」还是「某一步突然放大」——后者意味着踩到了某个
batch/位置相关的边界(例如 page 边界、SWA window 回绕)。
- [x] **P4.3 KV cache 写入/读回一致性** —— **完成,干净,排除**
两层验证,都通过。
其一,仓库自带的单测(`store_kv_cache.py` / `load_paged_kv_cache.py`,与各自的 ref
逐元素比),过滤 SM90 实际使用的 fp32-sf index 布局:**各 6 passed**。
全布局跑一遍是 48 passed / 12 failed,失败**全部**落在 `ue8m0` 的 index 布局
(`FP4_INDEX_KV_FORMAT`、`MXFP8_INDEX_KV_FORMAT`),而 SM90 不用这两个。失败点在
ref 自身:它用 `PLATFORM.fp8_sf_type`(SM90 是 fp32)去重解释 ue8m0 字节,
`index_copy_` 撞 dtype。属 SM90 上 ue8m0 分支的遗留缺口,不影响本任务。
其二,闭环 round-trip(`scripts/h20_kv_roundtrip.py`):写入已知 bf16 → 读回 → 与
原值比,不依赖任何 ref 实现。覆盖 SM90 实际用的两种布局、block 32/64、1/233/1029 token:
| 布局 | bytes/token | rel_err | cos |
|---|---|---|---|
| `V41_FORMAT`(main/swa KV) | 528 | 0.0266–0.0295 | 0.9996 |
| `FP8_FLOAT32_SF_INDEX_KV_FORMAT`(index KV) | **132** | 0.0266–0.0327 | 0.9996 |
正是 FP8 的理论地板,与权重那 2.7% 同量级。**KV cache 不是 decode 误差的来源。**
P3.9 遗留的 132B 字节布局问题就此关闭。
坑:这个脚本第一版把「原始 fp8 值」直接和 bf16 比,得到 rel=126 —— 因为 index 布局
的 load **不解量化**(文档写明「返回量化值 + scale」,消费方是 kernel)。
按 `量化值 × scale` 解量化后才是 0.027。差 127 倍的假警报来自测试脚本,不是实现。
- [x] **P4.4 `_decode_p_sum_sm90` vs 参考实现** —— **完成,数值精确,排除**
`scripts/h20_index_decode_check.py` 用一份**刻意不共享代码**的 float64 参考实现
(按显式偏移逐 token 切片,而非 strided view)对照:
```
page=32 lens=[1,64] rel=1.4e-07 cos=1.00000
page=32 lens=[31,32] rel=1.5e-07 cos=1.00000 (页边界)
page=32 lens=[96,65] rel=1.2e-07 cos=1.00000 (跨页)
page=32 lens=[7,33,159] rel=9.4e-08 cos=1.00000 (3 行 32 头)
page=64 ... rel≈1.3e-07 cos=1.00000
```
rel 就是 fp32 舍入量级。Q 的解量化用 `q_sf.reshape(rows, heads, -1)[..., :1]` 也没问题:
`head_dim == 128 == gran`,每个 (row, head) 恰好一个 scale(源码里有
`assert self.n_head <= 32 and self.head_dim == 128` 兜住)。
**decode 的 indexer 路径不是误差来源。**
- [x] **P4.5 采样器 / logits processor 排除** —— **无需再查**
pos14 我方 top1=666、top2=6127,差值 1.480。如果 6127 被任何 mask / blacklist 抑制,
它就不会是第二名。所以 pos14 的差异来自 logits 本身,不是采样后处理。
(`token_logprobs` 恒为 0 的 P5 问题仍然独立存在,与本题无关。)
### 最终结论:`**` 是量化噪声在「模型自己也没把握」的位置上的一次抛硬币
三条线索合起来:
1. **噪声幅度是恒定的约 1.6 logit**(Δmargin std = 1.63,跨 7 个 decode step),
与 prefill 的残余误差同量级(pos11 的 Δmargin +0.835,14-token prefill 的 top-1
误差 −3.30)。它来自 per-128 量化的累积 —— SM90 的 DeepGEMM 强制的那条路。
2. **参考模型的 margin 分布很宽**:9.86 / 10.33 / 11.95 / **1.64** / 15.11 / 17.87 / 10.40 / 11.09。
只有 1.64 那个位置会被 1.6 的噪声翻掉。
3. **只有 pos14 翻转,其余全部正确。** 而 pos14 正是 1.64 那个。
所以:
- 不是 KV cache 的问题(P4.3 闭环 round-trip 通过,rel≈0.027)
- 不是 decode indexer 的问题(P4.4 与独立 float64 参考 rel≈1e-7)
- 不是 MTP 的问题(开关 MTP 输出相同)
- 不是采样后处理的问题(6127 在 pos14 是第二名,没被 mask)
**是「在 SM90 上必须用 per-128 量化」这件事本身**。它在多数位置无感,只在模型自己
接近 50/50 的地方改变结果。而模型在这里确实接近 50/50 —— `**北京**` 的两个位置
(先 `**` 还是先 `北京`)在语义上没有区别,参考模型也只是略偏一边。
**要继续压这个噪声,唯一的杠杆是回到 P3.6 那条路,而它在 SM90 上被 DeepGEMM 的
`gran_k == 128` 断言封死了**(见 `H20-DRIFT-ANALYSIS.md`)。所以本项调查到此收敛:
剩余差异是硬件量化口径的必然结果,不是实现缺陷。
### P4.1 的工具坑(都已修,但 dump 仍建议配合黑盒交叉验证)
| 坑 | 现象 | 修法 |
|---|---|---|
| 多 DP 进程写同一文件 | 8 个 engine 都是单进程组,`RANK` 全为 0,互相覆盖 | 文件名带 `_rank<R>_pid<P>` |
| decode 的 batch 有 padding 行 | 取 `logits[-1]` 读到占位 token 的垃圾(恒定 `top1=69`) | 取第 0 行(第一个真实请求) |
| follower rank 的 logits 是占位值 | 同一向量被重复记录几十次,淹没真实步 | 判据 `driver_inputs is not None` |
| profile forward 用合成 token | 抓到 profile 的数据 | `debug_dump.profiling()` 挂起 |
即便都修了,逐 step dump 与端到端 token 序列仍无法可靠对齐(`max_tokens=7` 的 run 里
出现 75 次 sampler 调用)。所以 P4.1 的结论**以黑盒四格对照为准**,dump 只作辅助。
**另一个必须记住的坑**:`max_tokens > 1` 的运行里,`logits_prompt` 也会被污染 ——
`mysteps6`(`max_tokens=7`)的 `logits_prompt` 算出 cos=0.68、top-1 误差 16.5,
而 `mylogits3`(`max_tokens=1`)同一输入是 cos=0.9846、误差 3.30。差 5 倍。
**比较 prefill logits 只能用 `max_tokens=1` 的运行。**
### 已备好的工具
- `debug_dump.record(..., once=True)` + `logits_prompt` / `logits_decode` 两个键
- `debug_dump.profiling()`:profile forward 期间整体挂起,避免抓到合成 token
- `ref_next_token.py`:吃 token ids,给官方 greedy next token + top5;`--force_ids` 走增量
decode,`--logits_out` 存每一步的完整 logits
- `scripts/h20_kv_roundtrip.py`:KV cache 闭环 round-trip(不依赖 ref)
- `scripts/h20_index_decode_check.py`:`_decode_p_sum_sm90` 的独立数值对照
## P5 logprob 异常(已排查,未修,非阻塞)
当前 8 个 token 的 `token_logprobs` 全是 `0.0`(概率=1),不正常。
- [ ] P5.1 确认是采样器 bug 还是 logits 极端(打印 logits 的 max/median)
- [ ] P5.2 检查 `DEEPINFER_SAMPLER_FORCE_TOPK=4096` 与 logprob 回填的关系
### 已排除(约 20 分钟静态排查 + 单测)
| 嫌疑 | 结论 |
|---|---|
| `map_lookup` kernel | 无罪。单测 6 passed,`by_key` + float32 + `force_topk=4096` 全覆盖,与 `map_lookup_ref` 逐元素相等 |
| sampler 全部 kernel | 无罪。`sampler.py -m "not benchmark"` → **74 passed / 0 failed** |
| `SampledInfo.from_reqcnt` 的 `torch.zeros` | 死代码,全仓库无调用点;`update(is_final, candidate)` 也无调用点 |
| sampler→engine 字段传递 | 直通,`llm_engine.py:747` 原样传 `output.token_logprobs` |
**一个反向线索**:`map_lookup` 里 `out = torch.empty`,键未命中时本应写 `-inf` 而**不是 0**。
所以 0.0 更像「上层根本没把值带下来」,而不是「查表查空了」。
### 剩余候选与判定路径
- `process_sampling` 返回的 `logprobs` 本身为 0(`fused_sample` 的 logprob 输出路径;
现有单测只覆盖 kernel 本身,没有覆盖「整个 forward 的 logprob 输出」)
- `build_request_outputs` 里逐 token 对象的属性回填
判定只需跑 1 次整机(~5 min):在 sampler 里打印 `logprobs.max()` 与 `token_logprobs_cur`。
sampler 层就是 0 → 定位在 `fused_sample`;sampler 层正常 → 定位在 output 回填。
### 为什么留着不修
不影响推理正确性,只影响 logprob 字段的可用性(AIME 打分、perplexity 这类评估用途)。
预估典型 1 h、最坏 2 h(若牵涉 MTP 异步 logprob 回填),与本次 SM90 支持的目标无关。
---

152
H20-DRIFT-ANALYSIS.md Normal file
View File

@ -0,0 +1,152 @@
# 漂移调查:2.7% 不是主因,问题在 decode 路径
承接 `H20-PORTING-JOURNAL.md` §6.3 的 P3.6(「改用官方无损重化把 2.7% 降到 0」)。
**两个结论**:
1. **P3.6 在 SM90 上不可行** —— DeepGEMM 在 Hopper 上强制 per-128 scale,per-32 是 SM100
专属。2.7% 是硬件地板,不是实现缺陷。
2. **但 2.7% 不是 `**` 的成因** —— prefill 路径的 top-1 与官方一致,翻转发生在 **decode**。
第 2 点是本次最有价值的发现,它把问题从「量化精度无解」重新定位到一个具体的、可能可修的
bug 上。
---
## 一、P3.6 不可行:DeepGEMM 的 SM90 硬约束
### 证据
`csrc/apis/layout.hpp:100`(DeepGEMM 2.8.2),`check_sf_layout` 里唯一的架构断言:
```cpp
DG_HOST_ASSERT((arch_major == 9 and gran_k == 128 and k_alignment == 128) or
(arch_major == 10 and (gran_k == 32 or gran_k == 128) and k_alignment % 128 == 0));
```
- `arch_major == 9`(Hopper)→ **`gran_k` 必须 128**
- `arch_major == 10`(Blackwell)→ 32 或 128 都行
同文件 38-51 行的 `transform_sf_into_required_layout` 印证同一件事 —— SM90 只有两个分支,
都要求 `gran_k == 128` 且 `sf` 是 **fp32**:
```cpp
// (FP32, 1, 128) on SM90
if (sf.scalar_type() == kFloat and gran_mn == 1 and gran_k == 128 and (arch_major == 9 or disable_ue8m0_cast))
return get_mn_major_tma_aligned_tensor(sf);
// (FP32, 128, 128) on SM90
if (sf.scalar_type() == kFloat and gran_mn == 128 and gran_k == 128 and (arch_major == 9 or disable_ue8m0_cast))
return check_sf_layout(...);
// (FP32, x, gran_k) on SM100 —— 只有这里才出现 packed ue8m0
if (sf.scalar_type() == kFloat and (gran_k == 32 or gran_k == 128) and arch_major == 10) { ... }
```
官方 `cast_e2m1fn_to_e4m3fn` 产出的是 **e8m0 per-32**。SM90 要 **fp32 per-128**。
两者不兼容,无中间等价物。
### 逐条排除的降损方案
| 想法 | 结论 |
|---|---|
| 换 per-32 recipe | ❌ 上述断言,SM90 不允许 |
| scale 用 e8m0 省位宽 | ❌ SM90 分支要求 fp32,e8m0 触发 `sf_dtype` 断言 |
| 专家权重用 BF16 绕开量化 | ❌ 显存 78.5 GiB/卡 → 约 157 GiB/卡 |
| 让 per-128 的 scale 更精确 | ❌ 已是 `amax/448`,per-128 网格上的最优表示 |
**所以 2.7% 是「用 SM90 的 GEMM 跑一个按 MXFP8 per-32 导出的 checkpoint」的固有代价。**
参照量级:dense 权重(`requant_dense_fp8`,用法本来就对)也有 4.7e-2,同一量级。
---
## 二、修复确实有效:layer0 误差降了 4.4 倍
专家 scale 修复(提交 `7c6fdc7`)之后重跑逐层对拍,14-token 输入:
| 层 | 修复前 cos / rel_err | **修复后** cos / rel_err |
|---|---|---|
| layer 0 | 0.99190 / 1.27e-1 | **0.99959 / 2.88e-2** |
| layer 1 | 0.97707 / 2.29e-1 | **0.99908 / 4.45e-2** |
| layer 2 | 0.96411 / 2.76e-1 | **0.99852 / 5.75e-2** |
| layer 5 | 0.93275 / 3.61e-1 | **0.99834 / 7.16e-2** |
| layer 10 | — | 0.99310 / 1.22e-1 |
| layer 20 | — | 0.98709 / 1.61e-1 |
| layer 39 | 0.86515 / 5.28e-1(旧 layer11 数据) | **0.97972 / 2.00e-1** |
这是 P3.7 想验证的东西,结论明确:误差曲线整体压平了一个数量级。
---
## 三、关键发现:prefill 的 top-1 是对的
之前一直没能比较最终 logits,原因是 `ref_layer_dump.py` 写的是 `logits[0, -1]`,
而 `MainPredictHeadHC` 在 `full_logits=False` 时已经压掉了 seq 维(`x = x[:, -1]`),
所以 logits 是 `[b, vocab]` —— 那个索引会得到一个 **0 维标量**。修成 `logits[0]` 之后
才拿到可以比较的数据。
14-token 输入,两侧最终 logits:
| | 官方 | 移植后 |
|---|---|---|
| cos | — | **0.98460** |
| top-1 | **6127(北京)** | **6127(北京)** ✅ |
| logit(6127) | 39.343 | 36.047 |
| logit(666 = `**`) | 36.232 | 35.791 |
| **margin** | **+3.112** | **+0.256** |
**prefill 路径的 top-1 与官方一致。** 残余误差的表现为 margin 从 3.11 压到 0.26(约 12 倍),
但方向没翻。
### 所以 `**` 从哪来
端到端(11-token 输入,greedy)实际输出:
```
[17607, 2793, 3139, 666, 6127, 666, 320, 1] → 中国的首都是**北京**。
↑ 前 3 个与官方逐 token 一致 ↑ 第 4 步翻成 666
```
而同样这 14 个 token **一次性 prefill** 时,top-1 是 6127(正确)。
**同一个 14-token 上下文,prefill 给 6127,增量 decode 给 666。**
这就是 `**` 的来源:不是量化精度,是 **decode 路径**引入了额外误差,把本来只有 0.26 的
margin 彻底翻转。
---
## 四、下一步:P4 decode 专项
定位已经收窄到 decode,建议按这个顺序:
- [ ] **P4.1 同位置 prefill vs decode 对拍**(最高优先)
输入 14 token:① 一次 prefill,dump 最后位置 logits(已有:margin +0.256);
② 先喂 11 token,再用已确认正确的 3 个 token(17607/2793/3139)逐步 decode,
dump 第 4 步的 logits。两者不同即确认 decode 引入偏差,且可以直接量出偏差大小。
- [ ] **P4.2 decode 逐步误差曲线**
从第 1 步到第 8 步,每步记录 top-1 和 margin,找出误差开始放大的步数。
- [ ] **P4.3 KV cache 写入/读回一致性**
写入已知向量后读回,与 bf16 原值比。这是 P3.9 遗留项,也是最可能的嫌疑人:
decode 用 `_decode_p_sum_sm90` 走 torch 兜底算 indexer 分数,而 prefill 走
TileLang kernel —— 两条路径从未对拍过。
- [ ] **P4.4 `_decode_p_sum_sm90` vs prefill 的 MQA logits**
同一个 KV cache、同一个位置,两条 indexer 路径的分数差。
判据:如果 P4.1 的差距 < 1e-3,说明 decode 无误,`**` 另有来源(回到采样器/logits
processor 查);如果差距明显,就从 P4.3/P4.4 里找。
---
## 五、两个工具修正(否则上面的数据拿不到)
1. **`ref_layer_dump.py` 的 logits 索引**:`logits[0, -1]` → `logits[0]`。之前所有
dump 里的 `final_row_logits` 都是 0 维标量,等于没有。
2. **profile run 污染 dump**:`execute_model_profile_run` 会走完整的 forward + sampler,
用的是合成 token。层 dump 靠 token 数门控侥幸过滤掉了,但 logits 的行数是「选中 token 数」
而不是 prompt 长度,门控不适用,于是抓到的是 profile 的垃圾(第一版得到 cos = 0.34)。
现在用 `debug_dump.profiling()` 上下文在 profile 期间整体挂起,并且把 logits 拆成
`logits_prompt` / `logits_decode` 两个键 —— 只有前者能和 prefill-only 的参考 dump 比较。

183
deepinfer/debug_dump.py Normal file
View File

@ -0,0 +1,183 @@
"""Opt-in tensor dumps for H20 bring-up comparisons.
Enabled with `DEEPINFER_DUMP_LAYERS=<dir>`. Each call appends one row to
`<dir>/deepinfer_layers.npz`-style storage under a per-tag key, always as the *last* row of
the batch: the reference implementation only generates from the final position, so that is
the only row the two sides can be compared on.
The dump is deliberately simple (host copy + `numpy.savez` at exit) because it exists to
answer "which layer diverges first", not to be a profiler.
"""
from __future__ import annotations
import os
from pathlib import Path
import numpy as np
import torch
_DUMP_DIR = os.getenv("DEEPINFER_DUMP_LAYERS")
# The engine runs a dummy profile forward before it allocates the KV cache, and that one has a
# different token count. Gating on the real prompt length keeps the dump comparable.
_DUMP_TOKENS = int(os.getenv("DEEPINFER_DUMP_TOKENS", "0") or 0)
_CAPTURED: dict[str, np.ndarray] = {}
# Only the first N layers of MoE internals are kept: the routing and per-branch outputs are the
# interesting part for bring-up, and dumping all 40 layers would slow the forward down for no gain.
_MOE_LAYERS = int(os.getenv("DEEPINFER_DUMP_MOE_LAYERS", "2") or 0)
# Per-tag call counter for `record_step`.
_STEPS: dict[str, int] = {}
# Number of rows in the most recent logits tensor, used to skip warmup passes.
_ROWS = 0
# A decode step that serves real requests has at most this many rows. Anything wider is warmup.
_REAL_ROWS = int(os.getenv("DEEPINFER_DUMP_MAX_ROWS", "1") or 1)
_SUSPENDED = False
def enabled() -> bool:
return bool(_DUMP_DIR) and not _SUSPENDED
class profiling:
"""Suspend capture while the engine runs its dummy profile forward.
`execute_model_profile_run` goes through the same forward and sampler as a real step, with
synthetic tokens, so anything captured there is garbage that later masking cannot detect. The
layer dumps happen to be filtered by their token-count gate; the logits are not, because their
row count is the number of *selected* tokens rather than the prompt length.
"""
def __enter__(self) -> None:
global _SUSPENDED
self._previous = _SUSPENDED
_SUSPENDED = True
def __exit__(self, *_exc: object) -> None:
global _SUSPENDED
_SUSPENDED = self._previous
def record(tag: str, tensor: torch.Tensor, once: bool = False) -> None:
"""Store the last row of `tensor` under `tag`.
A 3D tensor is treated as `[tokens, hc_mult, dim]` and reduced with `mean(dim=1)`, which is
the closest match to the reference's collapsed residual stream.
`once=True` keeps the first write only. Logits need this: the row count is the number of
selected tokens rather than the prompt length (so the token gate does not apply), and the
decode step that follows prefill would otherwise overwrite the value we care about.
"""
if not _DUMP_DIR:
return
if _DUMP_TOKENS and not once and tensor.shape[0] != _DUMP_TOKENS:
return
if once and tag in _CAPTURED:
return
if not _want_moe(tag):
return
if tensor.dim() == 3:
tensor = tensor.mean(dim=1)
row = tensor[-1] if tensor.dim() == 2 else tensor.reshape(-1)
# Copying to the host is the whole point here, and it happens inside the forward where the
# engine forbids syncs, so take the sanctioned escape hatch for this one transfer.
from deepinfer.utils import allow_cuda_sync
with allow_cuda_sync():
_CAPTURED[tag] = row.detach().float().cpu().numpy()
def set_rows(rows: int) -> None:
"""Tell the dump how many rows the sampler just produced, so warmup can be filtered out."""
global _ROWS
_ROWS = rows
def record_step(tag: str, tensor: torch.Tensor) -> None:
"""Append `tensor`'s last row under `tag_<n>`, numbering the calls.
Decode needs every step kept, not just the first: the point of the decode comparison is to
watch the margin evolve and find the step where it collapses.
"""
if not _DUMP_DIR:
return
# Decode with one request produces exactly `_REAL_ROWS` rows; warmup batches are larger.
if _ROWS > _REAL_ROWS:
return
index = _STEPS.get(tag, 0)
_STEPS[tag] = index + 1
# Row 0, not the last row: during decode the batch is padded (CUDA graph slots, or just
# `max_num_seqs`), and the padding rows hold logits for placeholder tokens. Row 0 is the
# first real request's current token, which is the one to compare against a batch-1 reference.
row = tensor[0] if tensor.dim() >= 2 else tensor
from deepinfer.utils import allow_cuda_sync
with allow_cuda_sync():
_CAPTURED[f"{tag}_{index}"] = row.detach().float().cpu().numpy()
def record_ids(tag: str, tensor: torch.Tensor) -> None:
"""Store an integer tensor as-is (expert ids); reduction would destroy its meaning."""
if not _DUMP_DIR:
return
if not _want_moe(tag):
return
if _DUMP_TOKENS and tensor.shape[0] != _DUMP_TOKENS:
return
from deepinfer.utils import allow_cuda_sync
with allow_cuda_sync():
_CAPTURED[tag] = tensor.detach().to(torch.int32).cpu().numpy()
def _want_moe(tag: str) -> bool:
"""Keep MoE internals only for the first `_MOE_LAYERS` layers."""
if "_" not in tag:
return True
head = tag.split("_", 1)[0]
if not head.startswith("layer"):
return True
try:
return int(head[5:]) < _MOE_LAYERS
except ValueError:
return True
def _rank_suffix() -> str:
"""`_rank<N>` when running distributed, so ranks do not overwrite each other's dump.
With data parallelism every rank registers `atexit(flush)` and writes the same filename; the
last one to exit wins, which silently mixes one rank's tensors with another's. Only rank 0
holds the real prompt anyway, so the suffix makes that explicit instead of accidental.
"""
rank = os.getenv("RANK")
if rank is None:
try:
import torch.distributed as dist
if dist.is_initialized():
rank = str(dist.get_rank())
except Exception: # noqa: BLE001 - torch may not be usable at exit
rank = None
# With data parallelism each engine is its own single-process group, so every rank reports
# RANK=0 and they all target the same file. The pid disambiguates them.
return f"_rank{rank if rank is not None else 'x'}_pid{os.getpid()}"
def flush() -> None:
"""Write everything captured so far. Called once at interpreter exit."""
if not _DUMP_DIR or not _CAPTURED:
return
out = Path(_DUMP_DIR)
out.mkdir(parents=True, exist_ok=True)
path = out / f"deepinfer_layers{_rank_suffix()}.npz"
np.savez(path, **_CAPTURED)
print(f"[dump] wrote {len(_CAPTURED)} tensors to {path}", flush=True)
_CAPTURED.clear()
if _DUMP_DIR:
import atexit
atexit.register(flush)

View File

@ -1,99 +0,0 @@
"""Opt-in tensor dumps for H20 bring-up comparisons.
Enabled with `DEEPINFER_DUMP_LAYERS=<dir>`. Each call appends one row to
`<dir>/deepinfer_layers.npz`-style storage under a per-tag key, always as the *last* row of
the batch: the reference implementation only generates from the final position, so that is
the only row the two sides can be compared on.
The dump is deliberately simple (host copy + `numpy.savez` at exit) because it exists to
answer "which layer diverges first", not to be a profiler.
"""
from __future__ import annotations
import os
from pathlib import Path
import numpy as np
import torch
_DUMP_DIR = os.getenv("DEEPINFER_DUMP_LAYERS")
# The engine runs a dummy profile forward before it allocates the KV cache, and that one has a
# different token count. Gating on the real prompt length keeps the dump comparable.
_DUMP_TOKENS = int(os.getenv("DEEPINFER_DUMP_TOKENS", "0") or 0)
_CAPTURED: dict[str, np.ndarray] = {}
# Only the first N layers of MoE internals are kept: the routing and per-branch outputs are the
# interesting part for bring-up, and dumping all 40 layers would slow the forward down for no gain.
_MOE_LAYERS = int(os.getenv("DEEPINFER_DUMP_MOE_LAYERS", "2") or 0)
def enabled() -> bool:
return bool(_DUMP_DIR)
def record(tag: str, tensor: torch.Tensor) -> None:
"""Store the last row of `tensor` under `tag`.
A 3D tensor is treated as `[tokens, hc_mult, dim]` and reduced with `mean(dim=1)`, which is
the closest match to the reference's collapsed residual stream.
"""
if not _DUMP_DIR:
return
if _DUMP_TOKENS and tensor.shape[0] != _DUMP_TOKENS:
return
if not _want_moe(tag):
return
if tensor.dim() == 3:
tensor = tensor.mean(dim=1)
row = tensor[-1] if tensor.dim() == 2 else tensor.reshape(-1)
# Copying to the host is the whole point here, and it happens inside the forward where the
# engine forbids syncs, so take the sanctioned escape hatch for this one transfer.
from deepinfer.utils import allow_cuda_sync
with allow_cuda_sync():
_CAPTURED[tag] = row.detach().float().cpu().numpy()
def record_ids(tag: str, tensor: torch.Tensor) -> None:
"""Store an integer tensor as-is (expert ids); reduction would destroy its meaning."""
if not _DUMP_DIR:
return
if not _want_moe(tag):
return
if _DUMP_TOKENS and tensor.shape[0] != _DUMP_TOKENS:
return
from deepinfer.utils import allow_cuda_sync
with allow_cuda_sync():
_CAPTURED[tag] = tensor.detach().to(torch.int32).cpu().numpy()
def _want_moe(tag: str) -> bool:
"""Keep MoE internals only for the first `_MOE_LAYERS` layers."""
if "_" not in tag:
return True
head = tag.split("_", 1)[0]
if not head.startswith("layer"):
return True
try:
return int(head[5:]) < _MOE_LAYERS
except ValueError:
return True
def flush() -> None:
"""Write everything captured so far. Called once at interpreter exit."""
if not _DUMP_DIR or not _CAPTURED:
return
out = Path(_DUMP_DIR)
out.mkdir(parents=True, exist_ok=True)
path = out / "deepinfer_layers.npz"
np.savez(path, **_CAPTURED)
print(f"[dump] wrote {len(_CAPTURED)} tensors to {path}", flush=True)
_CAPTURED.clear()
if _DUMP_DIR:
import atexit
atexit.register(flush)

View File

@ -11,6 +11,7 @@ import torch
import torch.nn as nn
from deep_select.interface import topk
from deepinfer import debug_dump as _debug_dump
from deepinfer import envs
from deepinfer.logger import init_logger
from deepinfer.model_executor.input_metadata import InputMetadata
@ -140,6 +141,19 @@ class Sampler(nn.Module):
# CUDA Graph model_output retains graph-batch padding; gather trims hidden_states.
hidden_states = gather(hidden_states, selected_token_indices)
logits = get_logits(hidden_states, embedding, self.vocab_size)
# `driver_inputs is None` means this rank only participates in the embedding all-gather and
# never sees the real result, so its logits are placeholders -- identical on every call.
# Recording those buries the real steps under dozens of copies of the same vector.
if _debug_dump.enabled() and driver_inputs is not None:
# Capture before any masking/penalty so the numbers are comparable with the reference,
# which dumps the raw head output as well. Named per phase: with `max_tokens=1` both a
# prompt step and a decode step run, and only the former is comparable with a
# prefill-only reference dump.
if is_prompt:
_debug_dump.record("logits_prompt", logits, once=True)
else:
# Every decode step, so the comparison can watch the margin collapse step by step.
_debug_dump.record_step("logits_decode", logits)
del hidden_states
if driver_inputs is None:

View File

@ -5,7 +5,7 @@ import torch
from torch import nn
from deepinfer import AttrDict, envs
from deepinfer.h20_ops import debug_dump as _debug_dump
from deepinfer import debug_dump as _debug_dump
from deepinfer.logger import init_logger
from deepinfer.model_executor.input_metadata import InputMetadata
from deepinfer.model_executor.input_metadata.yoco import YocoMetadataMixin

View File

@ -9,8 +9,8 @@ import torch
from torch import nn
from deepinfer import AttrDict, envs
from deepinfer import debug_dump as _debug_dump
from deepinfer.engine.engine_type import EngineType
from deepinfer.h20_ops import debug_dump as _debug_dump
from deepinfer.logger import init_logger
from deepinfer.model_executor import eplb
from deepinfer.model_executor.input_metadata import InputMetadata

View File

@ -21,8 +21,8 @@ from tile_kernels.quant import swiglu_forward_and_per_token_cast
from torch import nn
from deepinfer import PLATFORM, AttrDict, envs
from deepinfer import debug_dump as _debug_dump
from deepinfer.engine.engine_type import EngineType
from deepinfer.h20_ops import debug_dump as _debug_dump
from deepinfer.logger import init_logger
from deepinfer.model_executor import eplb
from deepinfer.model_executor.input_metadata import InputMetadata

View File

@ -11,6 +11,7 @@ import numpy as np
import torch
import torch.distributed as dist
from deepinfer import debug_dump as _debug_dump
from deepinfer import envs
from deepinfer._C.engine import prepare_sampler_inputs, update_prompt_logprobs # pyright: ignore[reportMissingModuleSource]
from deepinfer.config import CacheConfig, ModelConfig, ParallelConfig, SchedulerConfig
@ -283,26 +284,28 @@ class ModelRunner:
kv_caches: GPUCacheInterface,
) -> None:
"""Profile-run only: run one full step (forward + sample + post-process) synchronously."""
main_model_output = self.execute_main_model(
seq_metadata_list,
kv_caches,
)
input_metadata = main_model_output.input_metadata
model_output = Movable(main_model_output.model_output)
del main_model_output
# Synthetic tokens: keep tensor dumps off, or they capture this step instead of the real one.
with _debug_dump.profiling():
main_model_output = self.execute_main_model(
seq_metadata_list,
kv_caches,
)
input_metadata = main_model_output.input_metadata
model_output = Movable(main_model_output.model_output)
del main_model_output
sampler_output = self.execute_sample(
seq_metadata_list,
input_metadata,
model_output,
kv_caches,
)
torch.cuda.current_stream().synchronize()
self.post_process(
seq_metadata_list,
sampler_output,
input_metadata.is_prompt,
)
sampler_output = self.execute_sample(
seq_metadata_list,
input_metadata,
model_output,
kv_caches,
)
torch.cuda.current_stream().synchronize()
self.post_process(
seq_metadata_list,
sampler_output,
input_metadata.is_prompt,
)
@torch.inference_mode()
def execute_main_model(

View File

@ -0,0 +1,127 @@
"""Numerically test the SM90 decode-time indexer score against a reference formula.
`_decode_p_sum_sm90` reimplements DeepGEMM's `fp8_fp4_paged_mqa_logits` in PyTorch, because
Hopper's paged kernel hard-requires 64-token pages while the index cache uses a different page
size. Nothing else exercises it: prefill goes through the real kernel, so a defect here would be
decode-only -- which is exactly where the remaining drift lives.
The reference below deliberately shares no code with the implementation: it reads the cache with
explicit per-token slicing in float64 rather than strided views, so an offset or stride mistake
in one does not cancel against the other.
python scripts/h20_index_decode_check.py
"""
import torch
from deepinfer.model_executor.layers.tilelang_ops.cache_common import FP8_FLOAT32_SF_INDEX_KV_FORMAT
from deepinfer.model_executor.models.v4_modules.index_attn import MQA
DEV = "cuda"
HEAD_DIM = 128
def build_cache(num_blocks: int, page_tokens: int, scales: torch.Tensor, keys: torch.Tensor) -> torch.Tensor:
"""Cache in the store kernel's layout: block-wide values, then block-wide scales."""
fmt = FP8_FLOAT32_SF_INDEX_KV_FORMAT
raw = torch.zeros((num_blocks, page_tokens, 1, fmt.bytes_per_token), dtype=torch.uint8, device=DEV)
flat = raw.view(-1)
for block in range(num_blocks):
base = block * fmt.bytes_per_token * page_tokens
values = keys[block].to(torch.float8_e4m3fn).view(torch.uint8).reshape(-1)
flat[base : base + page_tokens * HEAD_DIM] = values
sf = scales[block].float().reshape(-1).view(torch.uint8)
flat[base + page_tokens * HEAD_DIM : base + fmt.bytes_per_token * page_tokens] = sf
return raw
def reference_p_sum(
q: torch.Tensor,
kv_cache: torch.Tensor,
weights: torch.Tensor,
klen: torch.Tensor,
block_table: torch.Tensor,
page_tokens: int,
) -> torch.Tensor:
"""float64 reference: gather by explicit offsets, dequantize, `sum_h relu(q.k) w`."""
num_rows = q.shape[0]
max_len = int(klen.max())
out = torch.full((num_rows, max_len), float("-inf"), dtype=torch.float64, device=DEV)
fmt_bytes = FP8_FLOAT32_SF_INDEX_KV_FORMAT.bytes_per_token
for row in range(num_rows):
ctx = int(klen[row])
keys = []
for pos in range(ctx):
page = pos // page_tokens
offset = pos % page_tokens
block = int(block_table[row, page])
base = block * fmt_bytes * page_tokens
raw = kv_cache.view(-1)[base + offset * HEAD_DIM : base + (offset + 1) * HEAD_DIM]
scale = kv_cache.view(-1)[base + page_tokens * HEAD_DIM + offset * 4 : base + page_tokens * HEAD_DIM + offset * 4 + 4]
keys.append(raw.view(torch.float8_e4m3fn).double() * scale.view(torch.float32).double())
key = torch.stack(keys) # [ctx, head_dim]
scores = q[row].double() @ key.t()
out[row, :ctx] = (scores.relu() * weights[row].double().unsqueeze(-1)).sum(dim=0)
return out
def run_case(page_tokens: int, num_blocks: int, num_rows: int, num_heads: int, lens: list[int], seed: int) -> None:
torch.manual_seed(seed)
total_tokens = num_blocks * page_tokens
keys = torch.randn(num_blocks, page_tokens, HEAD_DIM, dtype=torch.float32, device=DEV) * 0.5
# One fp32 scale per token, as `elements_per_sf == quantized_elements == 128` implies.
scales = (keys.abs().amax(dim=-1) / 448.0).clamp_min(1e-8)
cache = build_cache(num_blocks, page_tokens, scales, keys)
q = torch.randn(num_rows, num_heads, HEAD_DIM, dtype=torch.float32, device=DEV) * 0.5
q_sf = (q.abs().amax(dim=-1, keepdim=True) / 448.0).clamp_min(1e-8) # one scale per (row, head)
q_fp8 = (q / q_sf).to(torch.float8_e4m3fn)
q_deq = q_fp8.float() * q_sf
weights = torch.rand(num_rows, num_heads, dtype=torch.float32, device=DEV)
klen = torch.tensor(lens, dtype=torch.int32, device=DEV)
max_pages = (max(lens) + page_tokens - 1) // page_tokens
block_table = torch.zeros(num_rows, max_pages, dtype=torch.int32, device=DEV)
for row in range(num_rows):
pages = (lens[row] + page_tokens - 1) // page_tokens
block_table[row, :pages] = torch.randperm(num_blocks, device=DEV)[:pages]
class _Stub:
max_kv_len = total_tokens
got = MQA._decode_p_sum_sm90(_Stub(), q_fp8, q_sf, cache, weights, klen, block_table)
ref = reference_p_sum(q_deq, cache, weights, klen, block_table, page_tokens)
got = got[:, : ref.shape[1]].double()
# Positions beyond each row's context are `-inf` by design on both sides; compare only the
# live window, which is what top-k actually sees.
live = torch.arange(ref.shape[1], device=DEV).unsqueeze(0) < klen.double().unsqueeze(1)
rel = (got[live] - ref[live]).norm() / ref[live].norm()
cos = torch.nn.functional.cosine_similarity(got[live], ref[live], dim=0)
worst = (got[live] - ref[live]).abs().max().item()
ok = "OK" if rel < 1e-5 else "MISMATCH"
print(
f"page={page_tokens:<4} blocks={num_blocks:<3} rows={num_rows} heads={num_heads:<3} "
f"lens={lens} rel={rel:9.2e} cos={cos:8.5f} max|d|={worst:9.2e} {ok}"
)
if rel >= 1e-5:
# Localize: which row/position disagrees first.
diff = (got - ref).abs().masked_fill(~live, -1)
row, col = divmod(int(diff.argmax()), diff.shape[1])
print(f"{'':4}worst at row {row} pos {col}: got={got[row, col].item():.6f} ref={ref[row, col].item():.6f}")
raise SystemExit("decode indexer disagrees with the reference")
def main() -> None:
print(f"device: {torch.cuda.get_device_name(0)}\n")
for page_tokens in (32, 64):
run_case(page_tokens, 4, 2, 8, [1, page_tokens * 2], seed=0)
run_case(page_tokens, 4, 2, 8, [page_tokens - 1, page_tokens], seed=1)
run_case(page_tokens, 4, 2, 8, [page_tokens * 3, page_tokens * 2 + 1], seed=2)
run_case(page_tokens, 8, 3, 32, [7, 33, page_tokens * 5 - 1], seed=3)
print("\nINDEX_DECODE_CHECK_DONE")
if __name__ == "__main__":
main()

102
scripts/h20_kv_roundtrip.py Normal file
View File

@ -0,0 +1,102 @@
"""Closed-loop KV cache round trip on the layouts SM90 actually uses.
The unit tests next to each op compare it against a reference implementation, and both sides
derive from the same `KVCacheFormat`, so they can agree while both being wrong about what the
engine stores. This closes the loop instead: write a known BF16 tensor through the paged cache,
read it back, and compare against the original. What survives is what decode sees.
Both layouts SM90 runs are covered:
* `V41_FORMAT` -- main/swa KV, e4m3 with per-32 ue8m0 scales, cast on store;
* `FP8_FLOAT32_SF_INDEX_KV_FORMAT` -- index KV, 128 fp8 + one fp32 scale = 132 B/token.
python scripts/h20_kv_roundtrip.py
"""
import torch
from deepinfer import PLATFORM
from deepinfer.model_executor.layers.tilelang_ops.cache_common import (
FP8_FLOAT32_SF_INDEX_KV_FORMAT,
V41_FORMAT,
KVCacheFormat,
)
from deepinfer.model_executor.layers.tilelang_ops.load_paged_kv_cache import load_paged_kv_cache
from deepinfer.model_executor.layers.tilelang_ops.store_kv_cache import store_kv_cache
DEV = "cuda"
def alloc_cache(layout: KVCacheFormat, num_blocks: int, block_size: int) -> torch.Tensor:
"""Exactly what the engine allocates: a uint8 blob with `bytes_per_token` per token."""
return torch.zeros((num_blocks, block_size, 1, layout.bytes_per_token), dtype=torch.uint8, device=DEV)
def roundtrip(layout: KVCacheFormat, block_size: int, num_tokens: int, seed: int = 0) -> None:
torch.manual_seed(seed)
num_blocks = (num_tokens + block_size - 1) // block_size
# One token per slot, sequential; that is the shape prefill produces.
kv = torch.randn(num_tokens, layout.num_elements, dtype=torch.bfloat16, device=DEV)
slots = torch.arange(num_tokens, dtype=torch.int32, device=DEV)
cache = alloc_cache(layout, num_blocks, block_size)
store_kv_cache(kv, cache, slots, layout=layout)
# One segment per source block, covering the tokens actually written.
segments = []
dst = 0
for block in range(num_blocks):
rows = min(block_size, num_tokens - block * block_size)
segments.append([block, 0, rows, dst])
dst += rows
segments = torch.tensor(segments, dtype=torch.int32, device=DEV)
out = load_paged_kv_cache(cache, segments, num_tokens, layout=layout)
if out.bf16_data is not None:
# `do_cast` layouts are dequantized by the kernel itself.
back = out.bf16_data.float()
else:
# Index layouts return quantized values plus scales, in their natural dtype -- the
# consumer is a kernel that wants raw bytes, not BF16. Dequantize here or the comparison
# is raw fp8 against BF16 (which reads as a ~127x magnitude error, direction intact).
assert out.sf_data is not None, "index layout must return scales"
group = layout.elements_per_sf
sf = out.sf_data.float()
if sf.ndim == 1:
sf = sf.unsqueeze(-1)
scale = sf.repeat_interleave(group, dim=-1)[:, : layout.num_elements]
back = out.quantized_data.float() * scale
ref = kv.float()
rel = (back - ref).norm() / ref.norm()
cos = torch.nn.functional.cosine_similarity(back.flatten(), ref.flatten(), dim=0)
# e4m3 keeps 3 mantissa bits, so ~3% is the floor for any value that goes through it.
budget = 0.06
ok = "OK" if rel < budget else "FAIL"
print(
f"{layout.note:34} block={block_size:<4} tokens={num_tokens:<6} "
f"bytes/token={layout.bytes_per_token:<4} rel={rel:8.4f} cos={cos:8.5f} {ok}"
)
if rel >= budget:
raise SystemExit(f"round trip lost more than fp8 should: {layout.note}")
# Scale handling is the part most likely to be silently wrong: check that the per-token
# magnitudes survive, not just the direction.
per_token = (back - ref).norm(dim=1) / ref.norm(dim=1).clamp_min(1e-6)
print(f"{'':34} worst token rel={per_token.max():.4f} median={per_token.median():.4f}")
def main() -> None:
print(f"platform: sm90={PLATFORM.is_sm90} sf_dtype={PLATFORM.fp8_sf_type} pack={PLATFORM.sf_pack_factor}\n")
for layout in (V41_FORMAT, FP8_FLOAT32_SF_INDEX_KV_FORMAT):
for block_size in (32, 64):
for num_tokens in (1, 233, 1029):
roundtrip(layout, block_size, num_tokens)
print()
print("KV_ROUNDTRIP_DONE")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,88 @@
"""Probe which FP8 scale recipes DeepGEMM accepts on this GPU, via its own layout helper.
The SM90 path re-quantizes everything to fp32 per-128 because `get_gemm_recipe()` assumes Hopper
has nothing finer. The reference converter instead keeps an e8m0 scale per 32x32 block and folds
the relative offset into the FP8 values, which is lossless -- worth adopting only if the grouped
GEMM actually accepts it.
`transform_sf_into_required_layout` is the single place DeepGEMM validates a recipe (the engine
calls it for every MoE weight), so probing it answers the question directly and in milliseconds,
without materializing weights or launching a kernel.
python scripts/h20_recipe_probe.py
"""
import deep_gemm
import torch
E8M0 = torch.float8_e8m0fnu
def make_sf(m: int, k: int, block_m: int, block_k: int, num_groups: int, e8m0: bool) -> torch.Tensor:
"""Scales in the plainest layout: one value per (block_m x block_k) tile, per group."""
shape = (num_groups * (m // block_m), k // block_k)
if e8m0:
return torch.full(shape, 127, dtype=torch.uint8, device="cuda").view(E8M0)
return torch.ones(shape, dtype=torch.float32, device="cuda")
def probe(recipe_b: tuple[int, int], block_a: int, e8m0: bool, n: int, k: int, num_groups: int) -> str:
block_n, block_k = recipe_b
sf = make_sf(n, k, block_n, block_k, num_groups, e8m0)
try:
out = deep_gemm.transform_sf_into_required_layout(
sf,
n,
k,
recipe=(1, block_n, block_k),
num_groups=num_groups,
is_sfa=False,
disable_ue8m0_cast=e8m0,
)
got = tuple(out.shape)
# The helper silently re-lays-out whatever it is given, so acceptance alone is not proof;
# report the shape so an obviously wrong result is visible too.
return f"ACCEPTED out={got} dtype={out.dtype}"
except Exception as exc: # noqa: BLE001 - report whatever it raises
first = (str(exc).strip().splitlines() or [type(exc).__name__])[0]
return f"REJECTED {first[:100]}"
def main() -> None:
print(f"device: {torch.cuda.get_device_name(0)}")
print(f"deep_gemm: {deep_gemm.__version__}\n")
n, k, num_groups = 2304, 2560, 8
print("B-operand recipes (what expert weights use, is_sfa=False):")
for recipe, block_a, e8m0, label in [
((128, 128), 128, False, "fp32 per-128 (current SM90)"),
((128, 128), 128, True, "e8m0 per-128"),
((32, 32), 128, False, "fp32 per-32 (ref-converter grid)"),
((32, 32), 128, True, "e8m0 per-32 (ref-converter, lossless)"),
((1, 32), 128, True, "e8m0 per-row/32 (MXFP8)"),
((1, 128), 128, True, "e8m0 per-row/128"),
]:
print(f" {label:34} recipe={recipe} e8m0={e8m0:<5} -> {probe(recipe, block_a, e8m0, n, k, num_groups)}")
print("\nA-operand recipes (activations, is_sfa=True):")
for block_k, e8m0, label in [
(128, False, "fp32 per-128 (current SM90)"),
(32, True, "e8m0 per-32 (MXFP8)"),
(32, False, "fp32 per-32"),
]:
sf = make_sf(n, k, 1, block_k, 1, e8m0)
try:
out = deep_gemm.transform_sf_into_required_layout(
sf, n, k, recipe=(1, 1, block_k), num_groups=1, is_sfa=True, disable_ue8m0_cast=e8m0
)
print(f" {label:34} block_k={block_k:<4} e8m0={e8m0:<5} -> ACCEPTED out={tuple(out.shape)}")
except Exception as exc: # noqa: BLE001
first = (str(exc).strip().splitlines() or [type(exc).__name__])[0]
print(f" {label:34} block_k={block_k:<4} e8m0={e8m0:<5} -> REJECTED {first[:100]}")
print("\nPROBE_DONE")
if __name__ == "__main__":
main()

View File

@ -114,7 +114,9 @@ def main() -> None:
with torch.inference_mode():
output_ids, logits, _ = model.forward(tokens, 0)
captured["final_row_logits"] = logits[0, -1].float().cpu().numpy()
# The head already collapsed the sequence axis (`x = x[:, -1]` when not full_logits), so the
# logits are [b, vocab] -- indexing [0, -1] here would silently store a scalar.
captured["final_row_logits"] = logits[0].float().cpu().numpy()
captured["next_token"] = np.array([int(output_ids[-1])], dtype=np.int32)
if rank == 0:

View File

@ -14,6 +14,7 @@ import json
import os
import sys
import numpy as np
import torch
import torch.distributed as dist
@ -30,6 +31,15 @@ def main() -> None:
parser.add_argument("--ids", required=True, help="comma-separated prompt token ids")
parser.add_argument("--out", default="/data/deepinfer-h20/ref/next_token.json")
parser.add_argument("--topk", type=int, default=5)
parser.add_argument(
"--force_ids",
default="",
help="Comma-separated ids fed one at a time after the prompt, i.e. incremental decode. "
"This mirrors `generate.py`: prefill the prompt, then forward a single token per step with "
"prev_pos advancing, so the resulting logits come from the decode path rather than from "
"recomputing the whole prefix.",
)
parser.add_argument("--logits_out", default="", help="optional .npz to store every step's full logits")
args = parser.parse_args()
ids = [int(x) for x in args.ids.replace(" ", "").split(",") if x]
@ -59,21 +69,45 @@ def main() -> None:
load_model(model, os.path.join(args.ckpt_path, f"model{rank}-mp{world_size}.safetensors"))
torch.set_default_device("cuda")
forced = [int(x) for x in args.force_ids.replace(" ", "").split(",") if x]
tokens = torch.tensor([ids], dtype=torch.long)
steps: list[tuple[str, torch.Tensor]] = []
with torch.inference_mode():
# `Transformer.forward` returns (output_ids, logits, main_hidden).
output_ids, logits, _ = model.forward(tokens, 0)
steps.append(("prefill", logits[0].float().clone()))
row = logits[0, -1].float()
# Same loop shape as `generate.py`: one token per forward, prev_pos advancing. The token
# fed in is the ground truth, which is exactly what the engine does when it accepts a
# greedy token it already agrees with.
prev_pos = tokens.shape[1]
for token in forced:
output_ids, logits, _ = model.forward(torch.tensor([[token]], dtype=torch.long), prev_pos)
steps.append((f"decode_{prev_pos}", logits[0].float().clone()))
prev_pos += 1
row = steps[-1][1]
top = torch.topk(row, args.topk)
assert int(top.indices[0]) == int(output_ids[0, -1]), "argmax disagrees with the sampled id"
payload = {
"prompt_ids": ids,
"force_ids": forced,
"next_token": int(top.indices[0]),
"top_tokens": [int(x) for x in top.indices],
"top_logits": [round(float(x), 4) for x in top.values],
"steps": [
{
"name": name,
"top_tokens": [int(x) for x in torch.topk(v, args.topk).indices],
"top_logits": [round(float(x), 4) for x in torch.topk(v, args.topk).values],
}
for name, v in steps
],
}
if args.logits_out and rank == 0:
os.makedirs(os.path.dirname(args.logits_out), exist_ok=True)
np.savez(args.logits_out, **{f"logits_{name}": v.cpu().numpy() for name, v in steps})
if rank == 0:
print(json.dumps(payload, ensure_ascii=False), flush=True)
os.makedirs(os.path.dirname(args.out), exist_ok=True)