Skip to content

[Model Loader] Write weight-derived tensors back in place on reload - #180

Open
aoshen02 wants to merge 37 commits into
wu/stack/direct-base-v2from
wu/stack/direct-d1n
Open

aoshen02 wants to merge 37 commits into
wu/stack/direct-base-v2from
wu/stack/direct-d1n

Conversation

@aoshen02

@aoshen02 aoshen02 commented Oct 27, 2026 •

Copy link
Copy Markdown
Owner

Problem

process_weights_after_loading reruns on every weight reload (layerwise and direct). Many quant methods, kernels and models build tensors derived from the weights in that step: kernel constants, activation global scales, decode-path copies, fused drafter buffers. They allocate them as new tensors each time. Captured CUDA graphs and kernels keep reading the tensors from startup, so after a reload they read freed memory.

The weights themselves are reloaded correctly (diff 0 against cold start), yet accuracy drops or collapses. The symptom is model-specific and silent: no error, only wrong output.

Fix

Write weight-derived tensors back into their startup storage on every rerun instead of allocating new ones.

  • Parameters go through replace_parameter, which already refills in place; it now returns the kept parameter.
  • Buffers and plain attributes go through the new refill_derived(obj, name, tensor): copy in place when shape, dtype and device match, else replace. It returns the held tensor.
  • MoE experts objects, which quant methods rebuild on every rerun, name their constants in kept_constants. keep_constants(layer) keeps them on the layer through refill_derived and hands them back to the new object.

Sites:

  • MoE kernel constants:
    • TRTLLM MXFP4 SwiGLU gemm1_alpha/beta/clamp_limit.
    • TRTLLM FP8 _g1_alphas/_g2_alphas/_g1_scale_c and SwiGLU gemm1_alpha/beta/clamp_limit.
    • FlashInfer CUTLASS (FP8/NVFP4/MXFP4) SwiGLU constants and MXFP8 fake_input_scale.
    • CuteDSL NVFP4 per_token_global_scale.
    • FP8 per-tensor FlashInfer CUTLASS g1/g2_alphas.
    • NVFP4 MoE a1/a2_gscale.
    • TRTLLM NVFP4 g1_scale_c and gemm1_* (registered by the experts' post-load step; kept from the constructor for Quark NVFP4, which skips that step).
    • CUTLASS FP8 ab/c_strides and CUTLASS W4A8 a/b/c/s_strides (online fp8_per_channel MoE on Qwen3.5-35B-A3B: illegal memory access after a reload before, GSM8K 0.644 → 0.623 with oracle unchanged after).
    • A runtime-dispatch wrapper (FallbackExperts: Triton-or-CUTLASS, Triton-or-DeepGEMM) passes keep_constants to both experts it holds.
  • Kimi-K3 specialized decode: the packed MoE front, the padded TP16 dense down projection, and the KDA conv weight and FP32 norm. The MLA reciprocal q/k (inverse) scales are refilled in their buffers.
  • GLM-5-next: indexer gate and KDA merged conv weight.
  • Drafters:
    • DFlash/DSpark fused K/V and norm stacks (Qwen3, Laguna, Gemma4, Muse).
    • Gemma4 MTP gathered lm_head.
    • LiLiCorr inference buffers.
  • Muse Spark:
    • The residual alpha/beta buffers.
    • The NVIDIA router output multiplier, now folded by the router weight's loader (once per load) instead of once per post-load run.
    • The NVIDIA NVFP4 MoE constants (g1_alphas, g1_scale_c, g2_alphas, a1/a2_gscale).

Tests

Unit tests:

  • tests/model_executor/test_utils.py: refill_derived refills buffers and attributes in place and never makes a parameter.
  • tests/quantization/test_keep_param_objects.py: a rebuilt experts object keeps the first object's constant addresses, for TRTLLM MXFP4/FP8/NVFP4, FlashInfer CUTLASS, CuteDSL, CUTLASS FP8/W4A8 and a FallbackExperts wrapper. Also covers the FP8 FlashInfer CUTLASS scales.
  • tests/models/test_muse_spark_logits_local.py: the router fold happens once per load; the NVFP4 MoE constants keep their addresses across reruns.

All pass on GB200 (slurm 39929, 40053, 40106).

E2E on GB200: same-weight NUPD≥2 reloads. GSM8K is compared before and after, and every tensor is fingerprinted against cold start.

Reload in place (direct mode), stacked on vllm-project#57733 + vllm-project#62407 + vllm-project#58382 (+ fork #179):

Model Job GSM8K pre → post Tensors changed
Muse Spark NVFP4 (NUPD=3) 40042 0.8385 → 0.8378 (oracle PASS) 0/3011
Muse Spark NVFP4, this head (NUPD=3) 40107 0.815 → 0.839 (oracle PASS; 98 lost / 130 gained of 1319, sampling noise) 0/3311
Muse Spark NVFP4, earlier head 40090 0.836 → 0.823 (oracle PASS; 125 lost / 107 gained of 1319, within sampling noise) 0/3311
DeepSeek-V4.1 (NUPD=3) 40010 0.909 → 0.904 0/1709
MiniMax-M3.1 vendor image (NUPD=3) 40013 0.9507 → 0.9507 0/3148
gpt-oss-120b TRTLLM MXFP4 39950 0.938 → 0.936 0/796
  • Muse before this PR:
    • Without the router fold fix: 0.848 → 0.690, with 62 router weights differing from cold start (39889).
    • With the router fold fix but without the MoE constants fix: 0.833 → 0.751, with 420 tensors at new addresses (40036).
  • Address check: with a moved-address check on, Muse 40044 completes NUPD=3 with nothing moved.

Layerwise reload, CuteDSL NVFP4 MoE, Qwen3-30B-A3B NVFP4:

Job GSM8K pre → post
Base 39882 0.832 → 0.039
This PR 39883 0.840 → 0.844

Dependencies

Stacked on vllm-project#62407 (keep param objects across post-load processing) and vllm-project#57733 (post-load placement). The base branch of this PR is their merge, 5e23af126d. Follow-up to vllm-project#54477.

🤖 Generated with Claude Code

aoshen02 and others added 17 commits October 21, 2026 08:37
…ss_weights_after_loading

process_weights_after_loading no longer unregisters a loaded param or
leaves it without its data, except where keeping the data would cost
real memory:

- Renames go through the new move_parameter helper, which registers the
  same param object under the runtime name too, so the loader writes
  through the checkpoint name and kernels read the runtime name of one
  storage.
- Loaded params no kernel reads (consumed per-tensor scales, input
  scales of weight-only Marlin/Humming MoE backends) keep their data and
  are marked retired; the MoE quant configs get None for unused input
  scales instead of the layer attribute being set to None.
- Large released sources (weights repacked into a different layout,
  KV-cache scale placeholders) stay registered with empty storage.

kernel_parameters/get_live_param give the kernel view (live params, each
object once under its runtime name) to the Humming conversion, and the
Triton MXFP4 MoE wrapper moves to a plain <name>_triton attribute.

Signed-off-by: aoshen02 <aoshen@inferact.ai>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Marlin/CPU/zentorch/conch: a loaded zero-point param that the config does
  not use (symmetric GPTQ qzeros) is emptied in place again, as on main,
  instead of keeping its data (~1/group_size of the int4 weight).
- Revert the unreachable deepseek_v4 XPU and dots3 vision changes.
- Tests that asserted deleted or renamed params now assert the param is
  still registered (renamed ones alias the runtime name); drop the
  blanket non-empty check, since Triton MXFP4 release empties by design.

Signed-off-by: aoshen02 <aoshen@inferact.ai>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
replace_parameter now copies a new tensor that is smaller than the param
into the param's own storage (a prefix view) instead of rebinding .data to a
new, smaller allocation, so a loaded param never ends up with fewer bytes
than its checkpoint tensor (e.g. ModelOpt NVFP4 MoE w13_weight_scale_2 and
w13_input_scale [E,2] -> [E], merged qkv per-tensor scales [3] -> scalar).
Online quantization (uses_meta_device) still frees its high-precision source.

The mixed-precision CUTLASS FP8 scale conversion and the K3 gfx942 MXFP4
repack now go through replace_parameter instead of rebinding or emptying
.data directly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: aoshen02 <aoshen@inferact.ai>
The Triton MXFP4 MoE path emptied w13/w2_weight and their scales after
swizzling them into triton_kernels tensors. The params now hold the
swizzled storage and the triton tensors point at it, so the loaded params
keep their bytes without a second copy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: aoshen02 <aoshen@inferact.ai>
A converted tensor under a new name is now registered as the leftover
loaded param it came from (same object under both names, as move_parameter
does) when it lives in that param's storage, or takes the place of a
large leftover. This removes the second Parameter object that block-FP8
MoE w13/w2_weight_scale had over w13/w2_weight_scale_inv, and the emptied
CT WNA16 *_weight_packed params. Other leftovers keep their data, retired.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: aoshen02 <aoshen@inferact.ai>
- Unused zero points (Marlin, Conch, CPU, Zentorch), dynamic 4-bit scales
  and INC ARK qzeros/scales keep their data, retired.
- Zentorch WOQ/DA8W4, the DeepSeek-V4 CPU wo_a pack, CPU AWQ repack, CT
  W4A8 int8 MoE and CT WNA8O8 int weights put the repacked tensor into the
  loaded param (the same object is registered under the packed name where
  one exists).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: aoshen02 <aoshen@inferact.ai>
…put scales

Humming block-FP8 MoE registered the runtime w13/w2_weight_scale as a second
Parameter over the storage of the loaded w13/w2_weight_scale_inv, and the
Humming NVFP4 linear did the same for input_scale_2 over input_global_scale.
Both are now one object registered under both names (move_parameter).

Signed-off-by: aoshen02 <aoshen@inferact.ai>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: aoshen02 <aoshen@inferact.ai>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…irect-reload base

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: aoshen02 <aoshen@inferact.ai>
Post-load processing reruns on every weight reload, while captured CUDA
graphs and kernels keep reading the tensors at their startup addresses.
Tensors derived from the weights that live outside the parameters (fused
DFlash/DSpark K/V and norm stacks, GLM-5-next indexer gate and KDA merged
conv weight, Gemma4 MTP gathered lm_head, NVFP4 MoE activation gscales and
TRTLLM constants, host KV scales) were rebuilt as new tensors, so a reload
left graphs reading the old values.

`replace_parameter` now also handles buffers and plain tensor attributes,
by the same rule as parameters (copy in place when the layout matches,
else into the existing storage when it fits, else rebind), and keeps their
kind: a buffer stays a buffer and an attribute never becomes a parameter.
It returns the tensor now held under the name. The derived tensors above go
through it; attributes first built during post-load processing are declared
(None) beforehand so they stay plain attributes.

GLM-5-next and its MTP rebuild their caches from the model-level post-load
hook after every load; TRTLLM NVFP4 registers its folded constants through
`replace_parameter` instead of `register_parameter`.

Tests: tests/model_executor/test_utils.py (kind kept and in-place refill for
parameter, buffer, attribute and declared attribute).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: aoshen02 <aoshen@inferact.ai>
…fter reload

- Kimi K3: the specialized decode keeps copies derived from the weights
  (packed MoE front, padded TP16 dense down projection, KDA conv weight and
  FP32 norm); the model-level post-load hook refreshes them in place after a
  reload. The MLA reciprocal q/k scales are refilled in their buffers.
- Muse Spark: the norm-preserving residual connection refills its alpha/beta
  buffers in place when the coefficients are rebuilt.

Captured CUDA graphs keep the startup addresses of all of these.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: aoshen02 <aoshen@inferact.ai>
`materialize_inference_buffers` runs after every load. Refill the attention
bias, fused edge projection and factor input splits in place instead of
allocating new tensors (captured CUDA graphs keep the startup addresses);
the rank constants depend only on topk and are built once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: aoshen02 <aoshen@inferact.ai>
…n reload

Quant methods build a new experts object in `process_weights_after_loading`,
so every weight reload builds one, and the constant tensors it allocates in
its constructor (TRTLLM MXFP4 SwiGLU `gemm1_alpha/beta/clamp_limit`) are new
tensors. Captured CUDA graphs keep reading the tensors of the first object:
after a reload they read freed memory.

An experts class now names those tensors in `kept_constants`;
`keep_constants(layer)`, called wherever an FP8/MXFP4/NVFP4 MoE kernel is
built, keeps them on the layer as plain attributes through
`replace_parameter` (refilled in place with the fresh values) and hands them
back to the new object.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: aoshen02 <aoshen@inferact.ai>
… addresses

Continuation for the remaining kernels whose post-load processing allocates
new tensors on every weight reload while captured CUDA graphs keep reading
the first ones:

- TRTLLM FP8 per-tensor experts: `_g1_alphas`, `_g2_alphas`, `_g1_scale_c`
  (they change with the weight and activation scales);
- FlashInfer CUTLASS experts (FP8, NVFP4, MXFP4): the SwiGLU
  `gemm1_alpha/beta/clamp_limit` and MXFP8 `fake_input_scale`;
- CuteDSL NVFP4 experts: the per-token `per_token_global_scale`;
- FP8 per-tensor FlashInfer CUTLASS/HPC quant config: `g1_alphas` and
  `g2_alphas` were registered as new parameters on every run, now refilled
  through `replace_parameter`; the inverse activation scales `a1_gscale` and
  `a2_gscale` are kept on the layer as plain attributes.

Tests: tests/quantization/test_keep_param_objects.py (a rebuilt experts
object of each class gets the first object's tensors with the new values,
no parameter added; FP8 FlashInfer CUTLASS scales refilled in place).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: aoshen02 <aoshen@inferact.ai>
…er load

The NVIDIA MuseSpark1xMoE folded config.output_multiplier into the router
weight and then dropped the multiplier, so a weight reload left the raw
router weight unscaled while routing no longer applied the multiplier.
Keep the multiplier and fold once per load of the router weight, like
the AMD model: the router weight's loader resets the folded flag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: aoshen02 <aoshen@inferact.ai>
…heir startup addresses on reload

Every post-load rerun of MuseSparkModelOptNvFp4FusedMoE built new
g1_alphas, g1_scale_c, g2_alphas and a1/a2 gscale tensors and re-registered
g1_scale_c as a new Parameter. Captured CUDA graphs kept reading the
freed first tensors, so a same-weight direct reload left every weight
bitwise equal yet dropped GSM8K from 0.84 to 0.75.

Keep them as layer attributes written through replace_parameter, which
refills them in place on a reload, and build the quant config from
those tensors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: aoshen02 <aoshen@inferact.ai>
…o the direct-reload base

Signed-off-by: aoshen <aoshen@inferact.ai>
aoshen and others added 2 commits October 27, 2026 10:53
replace_parameter goes back to handling parameters only. Weight-derived
buffers and plain attributes that kernels read go through a separate
refill_derived(obj, name, tensor): copy in place when shape, dtype and
device match (captured CUDA graphs keep the address), else replace. Call
sites no longer pre-declare the attribute to keep it from becoming a
parameter.

Drop the TRT-LLM NVFP4 unfolded-constant refill: kernels read the folded
parameters, which replace_parameter already refills in place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: aoshen <aoshen524@gmail.com>
- TRT-LLM FP8: keep gemm1_alpha/beta/clamp_limit, allocated per experts
  object and read by the kernels.
- TRT-LLM NVFP4: keep g1_scale_c and gemm1_* from the constructor. Quark
  NVFP4 never runs the experts post-load step, so they moved on every
  rerun; methods that run it still register the folded values afterwards.
- Muse Spark: fold the router output multiplier in its weight loader,
  dropping the fold flag.
- Drop the attention host KV scale refill (host tensors, read by name)
  and a dead GLM-5-next check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: aoshen <aoshen524@gmail.com>
aoshen and others added 9 commits October 27, 2026 18:14
CutlassExpertsFp8Base and CutlassExpertsW4A8Fp8 allocate the grouped-GEMM
stride tensors in their constructor; the object is rebuilt on every
post-load rerun, so captured CUDA graphs read freed memory after a reload
(illegal memory access on Qwen3.5-35B-A3B with online FP8 per-channel).
Name them in kept_constants like the other experts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: aoshen <aoshen524@gmail.com>
…result

_in_own_storage only reused the loaded storage for a strictly smaller
result, so a repack to the same byte count (FP8 weight -> Marlin int32,
a transposed copy) was rebound to fresh memory and moved the parameter's
address on every post-load rerun; captured CUDA graphs keep reading the
old one. An equal-size result fits exactly: copy it in place.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SeFSSrpw7yTpLz2C2sHDKA
Signed-off-by: Aaron Hao <aaronhao@inferact.ai>
…aded storage

`replace_parameter` now writes a smaller result into the existing
parameter's storage instead of rebinding `.data`. Two tests relied on the
old behaviour:

- `test_online_int8_moe_w2_scale_matches_unsharded` built the layer's
  parameters directly from the test's own `w13`/`w2` tensors, so the int8
  result overwrote expert 0 of `w2` before the test reused it for the
  unsharded amax and the TP shard. Clone the inputs.
- `test_humming_replace_keeps_loaded_params` asserted the kept storage size
  with a hard-coded float32 element size; under a bfloat16 default dtype
  (as on the Intel XPU runner) the loaded storage is 1024 bytes, which the
  test misread as shrunk. Use the parameter's element size.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SeFSSrpw7yTpLz2C2sHDKA
Signed-off-by: Aaron Hao <aaronhao@inferact.ai>
…_after_loading

`replace_parameter` now writes a same-size result into the loaded parameter's
own storage. The repack test computed its expected scales / zero points from
the checkpoint tensors after processing, when that storage already held the
transposed result.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SeFSSrpw7yTpLz2C2sHDKA
Signed-off-by: Aaron Hao <aaronhao@inferact.ai>
FallbackExperts (Triton-or-CUTLASS, Triton-or-DeepGEMM) holds the experts
that own the kernel constants; keep_constants on the wrapper now reaches
both of them, under their own prefix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: aoshen <aoshen524@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: aoshen <aoshen524@gmail.com>
@hao-aaron

Copy link
Copy Markdown
Collaborator

Gap to cover later: DeepSeek-V4 ROCm compressor GEMM fusion (found while auditing storage sharing for vllm-project#64336)

vllm/models/deepseek_v4/amd/rocm.py:1217 prepare_compressor_gemm_fusion (run from DeepseekV4ForCausalLM.process_weights_after_loading, amd/model.py:1578) does

fused_weight = torch.cat((main_weight, indexer_weight), dim=0)
main_weight.set_(fused_weight[:main_size])
indexer_weight.set_(fused_weight[main_size:])

so two checkpoint params end up on one storage, and the indexer weight moves to offset main_size * K while its recorded checkpoint offset is 0. On a direct reload:

  • begin (_checkpoint_views) re-views the indexer weight at offset 0 of the fused storage, so the loader writes the indexer's checkpoint bytes over the main compressor rows;
  • finish returns early (_fused_compressor_weight is not None), so the fused weight is never rebuilt. The early return also leaves it stale after a layerwise reload.

The forward (_run_parallel_input_projections, rocm.py:1288) only reads _fused_compressor_weight, so this fits this PR's refill_derived: keep the params on their own storage and refill the fused copy in place.

fused = torch.cat((main_weight, indexer_weight), dim=0)
refill_derived(self, "_fused_compressor_weight", fused)  # in place on reload
self._fused_compressor_split_sizes = (main_size, indexer_size)
# drop the set_() calls and the early return

Cost: one extra copy of the C4 compressor weights per layer (the set_ was saving that). Scope: DeepSeek-V4 (not V4.1, whose version returns False) on ROCm AITER, C4 layers with an indexer, NoopOffloader. Not a tracked family in vllm-project#54477 and not validated; untested here (no AMD hardware).

Posted by Claude Code on behalf of Aaron Hao.

aoshen02 and others added 2 commits October 30, 2026 06:04
Stack PR 5 on the current PR 1 and PR 4 heads. Laguna DFlash and LiLiCorr
changes are dropped here: PR 1 leaves those models to the F9 follow-up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: aoshen02 <aoshen524@gmail.com>
The V2 speculator captures the context K/V step in its FULL CUDA graph, so
the stacked K/V and K-norm weights rebuilt by the post-load hook must keep
their addresses. Split layerwise reload, proto-beam-early-0923-NVFP4 TP4 with
the DFlash2 drafter: draft acceptance after the reload was 7.03 -> 4.89 with
the buffers re-created, 7.02 -> 7.03 refilled in place (7.02 -> 7.04 with
--enforce-eager).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: aoshen02 <aoshen524@gmail.com>
@aoshen02
aoshen02 changed the base branch from wu/stack/direct-base to wu/stack/direct-base-v2 October 30, 2026 06:04
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.

2 participants