DeepInfer/scripts/h20_quant_check.py
loopyt 7c6fdc7c6e fix(sm90): expand expert scales on the contraction axis only
`requant_expert_fp4` expanded the UE8M0 scale with `_expand_blocks`, which repeats *both*
axes. That is right for dense weights, whose scales are `(n_out/32, k/32)`, but expert scales
are already per-output-channel: `(n_out, k/32)`. Expanding both axes therefore treated
`(2304, 160)` as `(72, 5)` and handed every one of the 2304 output channels the scales of the
first 72 rows, scrambling each expert's weights.

This is what the layer comparison was pointing at: `shared_expert` matched the reference
(cos 0.9987) because it goes through `requant_dense_fp8`, which uses `_expand_blocks`
correctly, while `combined` -- routed experts plus shared -- was off by 3.6e-1.

Verified against the authoritative implementation, `inference/convert.py`'s
`cast_e2m1fn_to_e4m3fn`, on layer 0 / expert 370:

    w1  cos 0.8936 -> 1.0000    rel_err 5.35e-1 -> 2.84e-2
    w3  cos 0.8960 -> 0.9997    rel_err 5.26e-1 -> 2.69e-2
    w2  cos 0.8964 -> 0.9996    rel_err 5.20e-1 -> 2.65e-2

The residual ~2.7% is the per-32 -> per-128 requantization loss, not a defect; the reference
is lossless because it folds the relative per-32 offset into the FP8 values.

End to end on the 11-token chat prompt:

    before:  [17607, 41175, 389, 666]                     "中国的首都是**"
    after:   [17607, 2793, 3139, 666, 6127, 666, 320, 1]  "中国的首都是**北京**。"
    ref:     [17607, 2793, 3139, 6127, 320]               "中国的首都是北京。"

The answer is now correct and the first three tokens match the reference exactly. The remaining
difference is markdown emphasis (`**`), so some numerical drift still moves the top-1 on the
occasional position.

Also adds two checks that would have caught this immediately:
`scripts/h20_expert_weight_check.py` diffs our requantization against DeepSeek's own converter,
and `scripts/h20_quant_check.py` pins the E2M1 table, nibble order and UE8M0 bias. The nibble
order and table turn out to match the reference; only the scale broadcast was wrong.
2026-10-21 16:50:31 +08:00

106 lines
4.7 KiB
Python

"""Check `h20_ops.quant` against PyTorch's native FP4/FP8 semantics.
The SM90 path re-quantizes packed E2M1 experts into FP8 while loading, and that code makes three
assumptions the checkpoint does not spell out: which nibble holds the even element, the E2M1 code
table, and the UE8M0 bias. If any of them is wrong every expert weight is scrambled, which is
exactly the symptom the layer comparison shows (shared expert matches, routed experts do not).
python scripts/h20_quant_check.py
"""
import torch
from deepinfer.h20_ops.quant import _E2M1_VALUES, _unpack_fp4, requant_expert_fp4, ue8m0_to_fp32
# PyTorch's FP4 cast kernel does not exist for SM90 (device-side assert on H20), and the decode
# under test is pure integer/table work, so everything here runs on the CPU.
DEV = "cpu"
def check_e2m1_table() -> None:
"""Every 4-bit code must decode to the value PyTorch assigns it."""
codes = torch.arange(16, dtype=torch.uint8, device=DEV)
# PyTorch's own decoder: pack two codes per byte and view as the FP4 dtype.
packed = torch.stack([codes, torch.zeros_like(codes)], dim=-1).reshape(-1).contiguous()
ref = packed.view(torch.float4_e2m1fn_x2).to(torch.float32)[0::2]
table = torch.tensor(_E2M1_VALUES, dtype=torch.float32, device=DEV)
ok = torch.equal(ref, table)
print(f"E2M1 table: {'OK' if ok else 'MISMATCH'}")
if not ok:
for code in range(16):
if ref[code] != table[code]:
print(f" code {code:04b}: torch={ref[code].item():+.1f} ours={table[code].item():+.1f}")
raise SystemExit("E2M1 table disagrees with PyTorch")
def check_nibble_order() -> None:
"""`_unpack_fp4` must interleave in the same order as the packed dtype."""
# Distinct, exactly representable values so an order swap is unambiguous.
values = torch.tensor([1.0, 2.0, 4.0, 6.0, 0.5, 1.5, 3.0, -1.0], dtype=torch.float32, device=DEV)
packed = values.to(torch.float4_e2m1fn_x2).view(torch.uint8) # (4,) bytes
ref = packed.view(torch.float4_e2m1fn_x2).to(torch.float32)
ours = _unpack_fp4(packed.view(torch.int8)).reshape(-1)
ok = torch.equal(ref, ours)
print(f"nibble order: {'OK' if ok else 'MISMATCH'}")
if not ok:
print(f" torch: {ref.tolist()}")
print(f" ours : {ours.tolist()}")
swapped = ours.view(-1, 2).flip(-1).reshape(-1)
print(f" ours (nibbles swapped): {swapped.tolist()}")
print(f" -> swapping would {'fix it' if torch.equal(ref, swapped) else 'NOT fix it'}")
raise SystemExit("nibble order disagrees with PyTorch")
def check_ue8m0() -> None:
"""`ue8m0_to_fp32` must match the checkpoint's scale dtype."""
bits = torch.tensor([127, 128, 126, 130, 124], dtype=torch.uint8, device=DEV)
scales = bits.view(torch.float8_e8m0fnu)
ours = ue8m0_to_fp32(scales)
ref = torch.pow(2.0, bits.to(torch.float32) - 127)
ok = torch.equal(ours, ref)
print(f"UE8M0 bias: {'OK' if ok else 'MISMATCH'} ours={ours.tolist()} ref={ref.tolist()}")
if not ok:
raise SystemExit("UE8M0 decode disagrees")
def check_requant_roundtrip() -> None:
"""End to end: dequantize FP4 on the CPU, requantize, and measure the error."""
torch.manual_seed(0)
n_out, k = 256, 512
x = torch.randn(n_out, k, device=DEV, dtype=torch.float32) * 0.05
# Quantize to FP4 with the checkpoint's per-32 UE8M0 grid, so the round trip starts from a
# realistic representation rather than an exact float. FP8 casts also stay on the CPU here.
groups = x.view(n_out, k // 32, 32)
amax = groups.abs().amax(dim=-1).clamp_min(torch.finfo(torch.float32).tiny)
exp = torch.ceil(torch.log2(amax / 6.0)).clamp(-127, 127)
scale = torch.pow(2.0, exp)
q4 = (groups / scale.unsqueeze(-1)).to(torch.float4_e2m1fn_x2)
packed = q4.view(torch.int8)
sf_bits = (exp.to(torch.int32) + 127).to(torch.uint8)
sf = sf_bits.view(torch.float8_e8m0fnu)
deq_ref = q4.to(torch.float32) * scale.repeat_interleave(32, dim=1)
weight, sfb = requant_expert_fp4(packed, sf, out_block=128)
assert weight.dtype == torch.float8_e4m3fn, weight.dtype
assert sfb.dtype == torch.float32, sfb.dtype
assert sfb.shape == (k // 128, n_out // 128), f"scale layout {tuple(sfb.shape)}"
deq_ours = weight.to(torch.float32) * sfb.t().repeat_interleave(128, 0).repeat_interleave(128, 1)[:n_out, :k]
err = (deq_ours - deq_ref).norm() / deq_ref.norm()
print(f"requant roundtrip: rel_err={err.item():.4f} (fp8 e4m3 floor ~0.01, fp4 source itself is ~0.1)")
assert err < 0.05, "requantization lost more precision than FP8 should"
def main() -> None:
check_e2m1_table()
check_nibble_order()
check_ue8m0()
check_requant_roundtrip()
print("QUANT_CHECK_DONE")
if __name__ == "__main__":
main()