Skip to content

mla: add inline per-token outer scales to 368-byte NVFP4 records - #86

Open
yatesdr wants to merge 3 commits into
local-inference-lab:masterfrom
yatesdr:nvfp4-dynamic-token-scale
Open

mla: add inline per-token outer scales to 368-byte NVFP4 records#86
yatesdr wants to merge 3 commits into
local-inference-lab:masterfrom
yatesdr:nvfp4-dynamic-token-scale

Conversation

@yatesdr

@yatesdr yatesdr commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Summary

Add an optional per-token outer scale to the existing 368-byte
nvfp4_ds_mla KV-cache record used with KV_FP8_ROPE=1.

The writer derives the scale from each token's 512-value compressed latent,
stores it in four bytes of existing padding, and writes the 32 E4M3 group
scales relative to it. Decode and MG-prefill readers apply the stored scale
during dequantization. Record size, page geometry, RoPE encoding, and
transport size do not change.

The mode is off by default and selected once per server process. Companion
vLLM PR local-inference-lab/vllm#189 makes writer/reader mode immutable,
rejects incompatible SparkInfer APIs, and separates persistent DRAM/NVMe
cache namespaces by record ABI.

Why

The existing NVFP4 NoPE lane encodes 512 E2M1 values with one E4M3 scale per
16-value group and an implicit outer scale of 1.0. GLM-5.2's cached latent
magnitude varies substantially by layer. In low-magnitude layers, many group
scales therefore fall into E4M3's subnormal range and lose mantissa
precision.

This affects the hidden-state trajectory consumed by the sparse indexer. In
the tested GLM-5.2 NF3/NVFP4 configuration, a frozen 343,727-token retrieval
prompt omitted the required needle tokens from the exact top-2,048 candidate
set until layers 62–74, while a 245,497-token control retrieved correctly.
Changing only KV scale positioning restored the frozen failures and the
randomized depth ladder.

PR #145 addresses the same scale-positioning issue with a checkpoint-specific
per-layer calibration file. This change derives the scale from each token at
write time, so no calibration artifact is required. Static calibration and
dynamic per-token scaling remain mutually exclusive.

Record format

For token t:

s_t = amax(abs(kv_c[t, :])) / (6 * 448)

where 6 is the E2M1 maximum and 448 is the E4M3 maximum.

[  0, 256)  512 packed E2M1 latent values
[256, 288)  32 E4M3 group scales, relative to s_t
[288, 292)  FP32 RoPE scale (unchanged)
[292, 296)  FP32 s_t (new; previously zero padding)
[296, 304)  zero padding
[304, 368)  64 E4M3 RoPE values (unchanged)

Readers reconstruct e2m1 * e4m3(group_scale) * s_t.

This positions the largest group scale in each token near the top of the
E4M3 range. Smaller groups in the same token may still be subnormal; the
implementation does not claim otherwise.

The bytes are not independently self-identifying. Safe persistent-cache use
therefore requires the immutable mode and external ABI namespace implemented
by vLLM #189.

Implementation

  • Writer: one warp-wide max reduction and one FP32 store per token.
  • Decode: gather the stored scale into the existing kv_sc shared-memory
    buffer and apply it in QK and P·V dequantization.
  • MG prefill: gather and consume the same record field.
  • Compile identities include latent_scale_per_token; writer, decode, and
    prefill compile-spec versions are incremented.
  • Dynamic mode rejects record layouts other than the 368-byte FP8-RoPE
    NVFP4 layout.
  • Mode off preserves the existing writer and reader behavior.

Validation

Hardware: 4× RTX PRO 6000 (SM120), TP4/DCP4. Model: GLM-5.2 NF3 hybrid.
Long-context gates used MTP3, CUDA graphs, max_model_len=480000,
max_num_batched_tokens=3072, exact top-k, cold requests, and a fresh cache
namespace.

Correctness and retrieval

Gate Static/uncalibrated Dynamic per-token
Frozen 245,497-token control correct correct
Frozen 343,727-token rows 0/3 correct 3/3 correct
Randomized ladder, 49,098–466,493 actual tokens 6/6 correct
Production-path SparkInfer GPU tests 28 passed

The GPU suite covers static and dynamic records, zero-token handling,
multisplit production decode, and multitile MG prefill. Ruff check and format
also pass for all four cleanup-touched files.

KL divergence

KL(BF16 reference || candidate), n=3, same image/tokens/runner/runtime,
2,047 scored positions; lower is better:

Mode Runs Mean Sample SD
Static calibrated (#145 file) 0.145742, 0.151139, 0.141801 0.146228 0.004688
Dynamic per-token 0.139997, 0.140385, 0.136725 0.139036 0.002010

The matched mean improves by 4.92%. This KLD gate exercises the cache
record/scale path at 2,048-token context; it does not exercise sparse top-k.

Performance and capacity

Metric Static calibrated Dynamic per-token Delta
MTP0 decode, 10 matched 1,024-token samples 46.076 tok/s (SD 0.0268) 45.901 tok/s (SD 0.0218) -0.380%
Prefill wall time, six ladder depths baseline +0.74% to +1.87% within 2% gate
KV pool at max_model_len 480,000 549,888 tokens 550,144 tokens allocator rounding

The MTP0 comparison uses identical image bits, model, TP/DCP posture, fixed
prompts, two uncounted warmups per arm, no speculative drafts, and recorded
GPU clocks/temperatures. It isolates reader-kernel cost from MTP acceptance
changes and passes the pre-committed 1% decode-overhead limit.

Compatibility

  • Mode off: existing record semantics are unchanged.
  • Mode on: writer and readers must agree for the life of the server.
  • Persistent DRAM/NVMe records must use the dynamic record-ABI namespace from
    vLLM #189. Static calibration namespaces include the scale-file SHA-256.
  • Opaque record transports carry [292,296) without modification.

Reproduction

Companion vLLM PR: local-inference-lab/vllm#189.

--kv-cache-dtype nvfp4_ds_mla
KV_FP8_ROPE=1
VLLM_NVFP4_MLA_DYNAMIC_SCALE=1
# VLLM_NVFP4_MLA_SCALES_FILE must be unset

Behavioral evaluation image:
ghcr.io/yatesdr/glm52-serve@sha256:db82fdcb5756d4a547853ba1330538bdd8a3dc0c6443c29bc49ba77b69b51cd1.
The later PR-head commits contain documentation, tests, and cache-contract
hardening; they do not change the measured writer/reader arithmetic.

Limitations

  • End-to-end results cover one model family and one SM120 PCIe system.
  • The mode does not change the RoPE lane's E4M3 encoding.

Implementation and text were drafted with AI assistance. All changed lines
were independently reviewed, and measurements were executed and validated by
the submitting operators. This is not a duplicate of #145: #145 supplies
checkpoint-specific static calibration; this PR implements calibration-free
per-token scaling.

@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The change adds optional per-token latent scaling for NVFP4 MLA cache records and wires it through public APIs, unified decode, MG prefill, shared-memory staging, device dequantization, compilation keys, validation, and regression tests.

Changes

NVFP4 per-token latent scaling

Layer / File(s) Summary
Per-token cache record encoding
sparkinfer/attention/_shared/mla/kv_cache.py
The NVFP4 fp8-rope cache writer optionally stores per-token latent scales, encodes group scales relative to them, and specializes compilation and public wrappers by mode.
Scaling contracts and runtime routing
sparkinfer/attention/_shared/mla/api.py, sparkinfer/attention/_shared/mla/traits.py, sparkinfer/attention/_shared/mla/kernel.py, sparkinfer/attention/_shared/mla/prefill*.py
Decode and prefill entry points accept the new flag, validate supported records, propagate traits, and separate compilation variants.
Shared-memory scale staging
sparkinfer/attention/_shared/mla/io*.py, sparkinfer/attention/_shared/mla/smem*.py, sparkinfer/attention/_shared/mla/kernel.py
Unified and MG layouts allocate KV scale buffers, and gather paths populate candidate-row latent scales for device consumers.
Decode and MG prefill dequantization
sparkinfer/attention/_shared/mla/decode_math.py, sparkinfer/attention/_shared/mla/prefill_mg.py
NVFP4 QK and XV paths optionally load per-token outer scales from shared KV scale storage.
Record and production-path validation
tests/attention/test_attention_mla_kv_cache.py, tests/attention/test_mla_kv_cache_per_token_scale.py, tests/_reference/helpers.py
Tests cover record ABI values, scale positioning, reconstruction accuracy, zero inputs, and production decode/prefill in both scaling modes.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant MLAAPI
  participant CacheWriter
  participant UnifiedKernel
  participant NVFP4Math
  Caller->>MLAAPI: enable latent_scale_per_token
  MLAAPI->>UnifiedKernel: validate traits and launch mode
  CacheWriter->>UnifiedKernel: provide NVFP4 records with token scales
  UnifiedKernel->>NVFP4Math: stage and load candidate-row scales
  NVFP4Math-->>Caller: compute scaled decode or prefill output
Loading

Suggested reviewers: lukealonso

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 35.59% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: inline per-token outer scales for 368-byte NVFP4 records.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

yatesdr and others added 2 commits July 27, 2026 22:31
The nvfp4_ds_mla writer quantizes at an implicit outer scale of 1.0,
which parks small-magnitude tokens' E4M3 group scales in subnormals
(GLM-5.2 kv_c spans ~240x across layers; shallow layers land at
max_abs ~0.02).  The static per-layer calibration file corrects the
positioning but is checkpoint-specific and goes stale.  This mode
makes the record self-describing instead:

  writer (per_token_scale=True):
    s_t = token_amax / (6*448)   # warp-bfly reduce over group amaxes
    stored fp32 at record bytes [292, 296) (existing zero pad; the
    record stays 368 bytes) and every group scale byte is encoded
    relative to s_t, so the largest group's E4M3 scale sits at the
    top of range by construction -- no calibration artifact, for any
    checkpoint, ever.

  readers (latent_scale_per_token=True):
    value = e2m1 * e4m3(group scale) * s_t, with s_t scalar-gathered
    per candidate into the kv_sc smem buffer (decode io + MG io,
    same idiom as the DSV4 footer gather; 8-aligned nc.v2 load at
    [288, 296), second word).  QK hoists per candidate row; the
    launch-scalar latent_scale is dead in this mode.

Threading: traits field + central fail-closed checks (NVFP4_E4M3 +
368-byte fp8-rope record only), decode/MG smem kv_sc allocation
(BI x 4 per buffer), api/kernel entry kwargs (default off), compile
spec bumps (writer 2->3, decode 18->19, MG prefill 4->5) so stale
cubins can never run against the new record semantics.  Mode off
keeps every specialization byte-identical.

Tests: tests/attention/test_mla_kv_cache_per_token_scale.py -- record
ABI (bit-exact s_t, pad, mode-independent rope lane), the positioning
invariant (max group scale >= 256 per token; the static writer's
shallow-magnitude subnormal defect reproduced as a unit test),
accuracy dominance at shallow magnitudes, zero-token edge.

DRAFT: authored without CUDA; not yet compiled.  Validation runbook:
glm52-opt design/nvfp4-dynamic-second-level-scale-phaseA-addendum.md.

Co-authored-by: Fable <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHuYXXq9krPdvcgpzY4EfE
…cords

Both production-head tests (multisplit decode, multitile MG prefill) now run
against writer records in per_token_scale mode with the reference dequant
applying the in-record fp32 outer scale. Validated on CN4 SM120.

Co-authored-by: Sol <noreply@openai.com>
Claude-Session: https://claude.ai/code/session_01YHuYXXq9krPdvcgpzY4EfE
@yatesdr
yatesdr force-pushed the nvfp4-dynamic-token-scale branch from 2b72c29 to 7f3d270 Compare July 28, 2026 02:31
@yatesdr
yatesdr marked this pull request as ready for review July 28, 2026 05:59

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (3)
tests/attention/test_attention_mla_kv_cache.py (1)

217-244: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Stale _PAD_OFFSET name now points at the latent-scale field.

_PAD_OFFSET = 292 is reused to read the new fp32 latent scale (records[:, _PAD_OFFSET:_PAD_OFFSET+4]), but the sibling test file (test_mla_kv_cache_per_token_scale.py) names the same byte range _LATENT_SCALE_OFFSET = 292 and reserves _PAD_OFFSET = 296 for the actual trailing pad. The offsets are numerically correct here, but the name no longer describes what it points to, which will confuse future readers/editors of this file.

♻️ Suggested rename for clarity
-_PAD_OFFSET = 292
+_LATENT_SCALE_OFFSET = 292
+_PAD_OFFSET = 296

and update the two usages accordingly (records[:, _ROPE_SCALE_OFFSET:_LATENT_SCALE_OFFSET] for rope scales, records[:, _LATENT_SCALE_OFFSET:_LATENT_SCALE_OFFSET+4] for the new latent scale).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/attention/test_attention_mla_kv_cache.py` around lines 217 - 244,
Rename the offset symbol used for the fp32 latent-scale field from _PAD_OFFSET
to _LATENT_SCALE_OFFSET, matching the sibling test’s layout naming. Update both
references in _dequantize_records: the rope-scale slice should end at
_LATENT_SCALE_OFFSET, and the latent-scale slice should start there; preserve
the existing numeric offsets and dequantization behavior.
tests/attention/test_mla_kv_cache_per_token_scale.py (1)

69-110: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Triplicated dequant reference logic across two test files.

_dequantize_two_level and _dequantize_static reimplement the same e2m1/group-scale decode already present as _dequantize_records in tests/attention/test_attention_mla_kv_cache.py. Three near-identical copies of the record layout logic (already showing naming drift between the two files, see the _PAD_OFFSET comment there) means any future record-layout change requires synced edits in three places.

Consider hoisting a single parametrized dequant helper (e.g., into tests/_reference/helpers.py, which already hosts E2M1_TO_FLOAT32) and having both test files call it.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/attention/test_mla_kv_cache_per_token_scale.py` around lines 69 - 110,
Extract the shared e2m1 and group-scale decoding logic from
_dequantize_two_level, _dequantize_static, and _dequantize_records into one
parametrized helper in the existing reference helpers module. Support the
optional latent-scale multiplication needed by the two-level path, then update
both test files to call the helper and remove their duplicated record-layout
logic.
sparkinfer/attention/_shared/mla/kv_cache.py (1)

33-50: 🗄️ Data Integrity & Integration | 🔵 Trivial

Per-token scale mode has no runtime cross-check between writer and reader.

The 368-byte record is byte-identical between per_token_scale=True/False; only the semantic content at [292, 296) differs (a valid fp32 scale vs. zero). If a decode/prefill caller's latent_scale_per_token flag ever drifts out of sync with how the record was actually written (e.g. mixed writer/reader configs during a rollout), a legacy record read in per-token mode yields latent_scale=0 for every token — a silent all-zero/degenerate output rather than an exception, since nothing in the record itself signals which mode was used to write it.

This is presumably an accepted tradeoff given the "server-static" per-instance design noted here, but worth calling out explicitly for anyone changing this config at runtime or per-request in the future.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@sparkinfer/attention/_shared/mla/kv_cache.py` around lines 33 - 50, Ensure
the per-token scale mode remains server-static and cannot drift between record
writers and readers: validate that the shared
`per_token_scale`/`latent_scale_per_token` configuration is consistent for the
instance, rather than attempting to infer the mode from the 368-byte record.
Preserve the existing record layout and legacy-record behavior, and document the
configuration invariant near the relevant MLA cache setup.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@sparkinfer/attention/_shared/mla/kv_cache.py`:
- Around line 33-50: Ensure the per-token scale mode remains server-static and
cannot drift between record writers and readers: validate that the shared
`per_token_scale`/`latent_scale_per_token` configuration is consistent for the
instance, rather than attempting to infer the mode from the 368-byte record.
Preserve the existing record layout and legacy-record behavior, and document the
configuration invariant near the relevant MLA cache setup.

In `@tests/attention/test_attention_mla_kv_cache.py`:
- Around line 217-244: Rename the offset symbol used for the fp32 latent-scale
field from _PAD_OFFSET to _LATENT_SCALE_OFFSET, matching the sibling test’s
layout naming. Update both references in _dequantize_records: the rope-scale
slice should end at _LATENT_SCALE_OFFSET, and the latent-scale slice should
start there; preserve the existing numeric offsets and dequantization behavior.

In `@tests/attention/test_mla_kv_cache_per_token_scale.py`:
- Around line 69-110: Extract the shared e2m1 and group-scale decoding logic
from _dequantize_two_level, _dequantize_static, and _dequantize_records into one
parametrized helper in the existing reference helpers module. Support the
optional latent-scale multiplication needed by the two-level path, then update
both test files to call the helper and remove their duplicated record-layout
logic.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 400788aa-b60c-4584-be1f-0dfc8359eeb4

📥 Commits

Reviewing files that changed from the base of the PR and between f9be272 and 7f3d270.

📒 Files selected for processing (13)
  • sparkinfer/attention/_shared/mla/api.py
  • sparkinfer/attention/_shared/mla/decode_math.py
  • sparkinfer/attention/_shared/mla/io.py
  • sparkinfer/attention/_shared/mla/io_mg.py
  • sparkinfer/attention/_shared/mla/kernel.py
  • sparkinfer/attention/_shared/mla/kv_cache.py
  • sparkinfer/attention/_shared/mla/prefill.py
  • sparkinfer/attention/_shared/mla/prefill_mg.py
  • sparkinfer/attention/_shared/mla/smem.py
  • sparkinfer/attention/_shared/mla/smem_mg.py
  • sparkinfer/attention/_shared/mla/traits.py
  • tests/attention/test_attention_mla_kv_cache.py
  • tests/attention/test_mla_kv_cache_per_token_scale.py

Name the inline scale accurately, share the host reference decoder, and pin the largest-group positioning invariant without claiming every group avoids E4M3 subnormals.

Assisted-by: OpenAI Codex
@yatesdr yatesdr changed the title mla: add self-describing per-token outer scales to 368-byte NVFP4 records mla: add inline per-token outer scales to 368-byte NVFP4 records Jul 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant