DeepInfer/scripts/h20_kv_roundtrip.py
loopyt 2ea88a3431 test(sm90): rule out the KV cache and the decode indexer
Two closed-loop checks, both clean, which finally retires the two standing suspects.

P4.3 KV cache. The repo's own store/load tests pass for the fp32-scale index layout SM90 uses
(6 each). Running every layout gives 48 passed / 12 failed, and every failure is a ue8m0 index
layout -- `FP4_INDEX_KV_FORMAT` or `MXFP8_INDEX_KV_FORMAT`, neither of which SM90 selects. Those
failures are in the test's reference, not the kernel: it reinterprets ue8m0 bytes as
`PLATFORM.fp8_sf_type`, which is float32 here, so `index_copy_` hits a dtype mismatch.

`scripts/h20_kv_roundtrip.py` then closes the loop without any reference at all -- write known
BF16 through the paged cache, read it back, compare. Both SM90 layouts, block 32 and 64, 1/233/
1029 tokens:

    V41_FORMAT (main/swa KV)              rel 0.0266-0.0295   cos 0.9996
    FP8_FLOAT32_SF_INDEX_KV_FORMAT (132B) rel 0.0266-0.0327   cos 0.9996

That is the fp8 floor, the same order as the 2.7e-2 on weights, so the cache is not where the
decode error comes from and the 132-byte layout question from P3.9 is closed.

One trap: the first version of the script compared raw fp8 values against BF16 and reported
rel=126. Index layouts are deliberately *not* dequantized by `load_paged_kv_cache` -- the
consumer is a kernel that wants bytes plus scales. Multiplying by the scale gives 0.027. The
127x was the test, not the implementation.

P4.4 the decode indexer. `_decode_p_sum_sm90` reimplements DeepGEMM's paged MQA logits in torch
because Hopper's kernel requires 64-token pages; nothing else exercised it. Compared against a
float64 reference that shares no code -- explicit per-token offsets instead of strided views --
it agrees to rel~1e-7 / cos 1.0 across page sizes 32 and 64, boundary-crossing lengths,
multi-page contexts and 32 heads. The `q_sf[..., :1]` dequantization is also sound: head_dim is
128, equal to the granularity, so one scale per (row, head) is correct.

What this changes. `logits_prompt` from the max_tokens=1 run puts the prefill error at cos
0.9846 with an absolute error of 3.30 on the winning token, against a reference margin of 3.112:
the margin is thin because the error is the same size as the margin, not by coincidence. The
reference's own prefill-to-decode transition also costs about 2x margin (3.112 -> 1.638) purely
from the quantized KV round trip. Our 0.256 halved would still be positive, yet it flips, so
roughly 0.5-1.0 of logit-level error in decode remains unexplained -- now narrowed to attention
over the cached main KV and the SWA-window / compressed-position mapping.

Also corrects an earlier overstatement: "the difference is introduced by decode" does not mean
decode has a discrete bug, and P4.3/P4.4 show its two main suspects do not.

One more dump trap worth recording: with max_tokens > 1, `logits_prompt` is contaminated too.
mysteps6 (max_tokens=7) gives cos 0.68 and a 16.5 top-1 error for the same input where mylogits3
(max_tokens=1) gives 0.9846 and 3.30. Prefill logits are only trustworthy from a
max_tokens=1 run.
2026-10-21 20:02:19 +08:00

103 lines
4.3 KiB
Python

"""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()