[Bugfix] Fix stale HPC QK-norm weights after weight refit (#56706)

Signed-off-by: aoshen02 <aoshen02@users.noreply.github.com>

aoshen02 committed Sep 15, 2026 at 12:34 UTC eb61707851f35281aa82c3ba7714f1ce9c1860e0
6 files changed +143 -98
tests/model_executor/model_loader/test_reload.py
+107
@@ -1,7 +1,10 @@
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 import gc
4 +import importlib.machinery
5 import inspect
6 +import sys
7 +import types
8 from unittest.mock import Mock
9 from weakref import WeakKeyDictionary, ref
10
@@ -817,6 +820,110 @@ def test_layerwise_reload_updates_loaded_non_persistent_buffers(monkeypatch):
820 assert "0.scale" not in model.state_dict()
821
822
823 +@pytest.fixture
824 +def hpc_rope_norm(monkeypatch, default_vllm_config):
825 + """Import HpcRopeNorm with the external ``hpc`` package stubbed out."""
826 + if "hpc" not in sys.modules:
827 + stub = types.ModuleType("hpc")
828 + stub.__spec__ = importlib.machinery.ModuleSpec("hpc", loader=None)
829 + stub.QuantType = types.SimpleNamespace( # type: ignore[attr-defined]
830 + QPERTOKEN_PERHEAD_KPERTENSOR_VPERTENSOR=types.SimpleNamespace(value=0)
831 + )
832 + monkeypatch.setitem(sys.modules, "hpc", stub)
833 + from vllm.model_executor.layers.hpc import rope_norm
834 +
835 + monkeypatch.setattr(rope_norm, "_hpc_rope_norm_instances", {})
836 + return rope_norm
837 +
838 +
839 +def test_hpc_rope_norm_kernel_sees_refit_norm_weights(monkeypatch, hpc_rope_norm):
840 + """The fused HPC kernel is handed the live QK-norm weights after a refit.
841 +
842 + Drives the production ``_forward_impl`` with a recording ``hpc`` stub. The
843 + Q/K norm weights it receives must be the model's own float32 parameters,
844 + so a layerwise reload that rewrites them in place (same storage) is what
845 + the kernel sees. Previously the kernel read separate mirrors that no
846 + reload path refreshed.
847 + """
848 + from vllm.model_executor.layers.layernorm import RMSNorm
849 +
850 + head_dim, num_heads, num_kv_heads, block_size = 128, 8, 1, 4
851 + layer = torch.nn.Module()
852 + layer.q_norm = RMSNorm(head_dim, 1e-6, dtype=torch.float32)
853 + layer.k_norm = RMSNorm(head_dim, 1e-6, dtype=torch.float32)
854 + layer.hpc_rope_norm = hpc_rope_norm.HpcRopeNorm(
855 + num_heads=num_heads,
856 + num_kv_heads=num_kv_heads,
857 + head_dim=head_dim,
858 + cos_sin_cache=torch.ones(16, head_dim),
859 + use_qk_norm=True,
860 + fallback_qnorm=layer.q_norm,
861 + fallback_knorm=layer.k_norm,
862 + kv_cache_dtype="auto",
863 + layer_name="hpc_test_layer",
864 + )
865 + model = torch.nn.Sequential(layer)
866 + rnorm = layer.hpc_rope_norm
867 +
868 + calls: list[dict] = []
869 +
870 + def record(*args, **kwargs):
871 + calls.append(kwargs)
872 +
873 + monkeypatch.setattr(sys.modules["hpc"], "rope_norm_store_kv", record, raising=False)
874 +
875 + def kernel_norm_weights():
876 + q_size, kv_size = num_heads * head_dim, num_kv_heads * head_dim
877 + qkv = torch.zeros(1, q_size + 2 * kv_size, dtype=torch.bfloat16)
878 + kv_cache = torch.zeros(
879 + 2, num_kv_heads, block_size, 2 * head_dim, dtype=torch.bfloat16
880 + )
881 + attn_layer = types.SimpleNamespace(
882 + _k_scale=torch.ones(1), _v_scale=torch.ones(1)
883 + )
884 + attn_metadata = types.SimpleNamespace(
885 + num_actual_tokens=1,
886 + num_decodes=1,
887 + num_decode_tokens=1,
888 + num_prefills=0,
889 + num_prefill_tokens=0,
890 + max_query_len=1,
891 + decode_query_len=1,
892 + qo_indptr=None,
893 + qo_indptr_decode=None,
894 + slot_mapping=torch.tensor([4]),
895 + seq_lens=torch.tensor([1]),
896 + block_table_tensor=torch.tensor([[1]]),
897 + hpc_kv_written=False,
898 + )
899 + output = torch.zeros(1, q_size, dtype=torch.bfloat16)
900 + rnorm._forward_impl(qkv, kv_cache, attn_metadata, attn_layer, output)
901 + return calls[-1]["q_norm_weight"], calls[-1]["k_norm_weight"]
902 +
903 + def loaded(value):
904 + return torch.full((head_dim,), value, dtype=torch.bfloat16)
905 +
906 + default_weight_loader(layer.q_norm.weight, loaded(0.5))
907 + default_weight_loader(layer.k_norm.weight, loaded(0.25))
908 + q, k = kernel_norm_weights()
909 + assert torch.equal(q, loaded(0.5).float())
910 + assert torch.equal(k, loaded(0.25).float())
911 + q_ptr, k_ptr = q.data_ptr(), k.data_ptr()
912 +
913 + record_metadata_for_reloading(model)
914 + initialize_layerwise_reload(model)
915 + layer.q_norm.weight.weight_loader(layer.q_norm.weight, loaded(2.0))
916 + layer.k_norm.weight.weight_loader(layer.k_norm.weight, loaded(3.0))
917 + finalize_layerwise_reload(model, model_config=None)
918 +
919 + q, k = kernel_norm_weights()
920 + assert q.dtype == k.dtype == torch.float32
921 + assert torch.equal(q, loaded(2.0).float())
922 + assert torch.equal(k, loaded(3.0).float())
923 + assert (q.data_ptr(), k.data_ptr()) == (q_ptr, k_ptr)
924 + assert not hasattr(rnorm, "qnorm_weight")
925 +
926 +
927 @pytest.mark.parametrize(
928 "tp_size", [pytest.param(1), pytest.param(2, marks=[pytest.mark.slow_test])]
929 )
vllm/model_executor/layers/hpc/hpc_ihc.py
+13 -38
@@ -20,9 +20,8 @@ Constraints:
20 - x / residual must be bfloat16; hc_fn weights stay float32 (the checkpoint
21 keeps them out of fp8 quantization via modules_to_not_convert)
22
23 -The fused modules own no parameters: they hold references to the weights of the
24 -eager layer they replace, bound in ``process_weights_after_loading`` (invoked
25 -generically for every ``HpcModule`` by the model loader).
23 +The fused modules own no parameters: their forwards read the weights of the
24 +eager layer they replace directly via the captured owner reference.
25
26 NOTE: The reference implementation also offers a cross-layer post+pre fusion
27 (``HpcIHCPostPre``) that folds one segment's post into the next segment's pre.
@@ -147,41 +146,26 @@ class HpcIHCPre(HpcModule):
146 object.__setattr__(self, "_fallback_op", fallback_op)
147 # Same anti-cycle idiom for the fused RMSNorm owner (a sibling module).
148 object.__setattr__(self, "_norm_owner", norm_owner)
150 - self.weight = None
151 - self.hc_scale = None
152 - self.hc_base = None
153 - self.rms_weight = None
154 - self.rms_eps = 0.0
149
150 @classmethod
151 def support(cls, hc_mult: int, hidden_size: int) -> bool:
152 return _ihc_supported(hc_mult, hidden_size)
153
160 - def process_weights_after_loading(self, model=None):
161 - # hc_fn is a ReplicatedLinear whose weight is [2 * hc_mult, hc_mult * d].
162 - # It stays float32 -- the HPC kernel consumes fp32 weights directly, so
163 - # there is no conversion (and no precision loss) here.
164 - owner = self._fallback_op
165 - self.weight = owner.hc_fn.weight
166 - self.hc_scale = owner.hc_scale
167 - self.hc_base = owner.hc_base
168 - if self._norm_owner is not None:
169 - self.rms_weight = self._norm_owner.weight
170 - self.rms_eps = self._norm_owner.variance_epsilon
171 -
154 def forward(self, x: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
155 import hpc
156
157 + owner = self._fallback_op
158 + norm_owner = self._norm_owner
159 return hpc.fuse_ihc_pre(
160 x,
177 - self.weight,
178 - self.hc_scale,
179 - self.hc_base,
161 + owner.hc_fn.weight,
162 + owner.hc_scale,
163 + owner.hc_base,
164 self.norm_eps,
165 self.hc_eps,
166 self.magnitude,
183 - self.rms_weight,
184 - self.rms_eps,
167 + norm_owner.weight if norm_owner is not None else None,
168 + norm_owner.variance_epsilon if norm_owner is not None else 0.0,
169 )
170
171
@@ -233,29 +217,20 @@ class HpcIHCHead(HpcModule):
217 self.norm_eps = norm_eps
218 # See HpcIHCPre: keep the owner out of the module tree.
219 object.__setattr__(self, "_fallback_op", fallback_op)
236 - self.weight = None
237 - self.hc_scale = None
238 - self.hc_base = None
220
221 @classmethod
222 def support(cls, hc_mult: int, hidden_size: int) -> bool:
223 return _ihc_supported(hc_mult, hidden_size)
224
244 - def process_weights_after_loading(self, model=None):
245 - # hc_head_fn weight is [hc_mult, hc_mult * d], float32.
246 - owner = self._fallback_op
247 - self.weight = owner.hc_head_fn.weight
248 - self.hc_scale = owner.hc_head_scale
249 - self.hc_base = owner.hc_head_base
250 -
225 def forward(self, x: torch.Tensor) -> torch.Tensor:
226 import hpc
227
228 + owner = self._fallback_op
229 return hpc.fuse_ihc_head(
230 x,
256 - self.weight,
257 - self.hc_scale,
258 - self.hc_base,
231 + owner.hc_head_fn.weight,
232 + owner.hc_head_scale,
233 + owner.hc_head_base,
234 self.norm_eps,
235 self.hc_eps,
236 )
vllm/model_executor/layers/hpc/hpc_module.py
-3
@@ -11,8 +11,5 @@ class HpcModule(nn.Module):
11 def support(cls, *args, **kwargs):
12 return True
13
14 - def process_weights_after_loading(self, model):
15 - pass
16 -
14 def forward(self, *args, **kwargs):
15 pass
vllm/model_executor/layers/hpc/rope_norm.py
+18 -45
@@ -90,13 +90,13 @@ class HpcRopeNorm(CustomOp, HpcModule):
90 """HPC fused RoPE + QK-Norm + KV-Cache-Write (+ optional FP8 Q quant).
91
92 Registered as a sub-module in model layers (e.g. HunYuanAttention).
93 - Norm weights are extracted from fallback norm modules via
94 - process_weights_after_loading() after all weights are loaded.
93 + The QK-Norm weights are read directly from the fallback norm modules
94 + (built in float32 when HPC is active), eliminating the derived copy.
95
96 forward() is dispatched by CustomOp framework:
97 - In compiled mode: forward_cuda() calls torch.ops.vllm.hpc_rope_norm_forward
98 as a splitting point — internal Python control flow is opaque
99 - to torch.compile and not captured by CUDA Graph.
99 + to torch.compile (its kernel launches can still be CUDA-graph captured).
100 - In eager/native mode: forward_native() falls back to forward_cuda().
101 """
102
@@ -124,8 +124,6 @@ class HpcRopeNorm(CustomOp, HpcModule):
124 self.num_kv_heads = num_kv_heads
125 self.head_dim = head_dim
126
127 - self.use_qk_norm = use_qk_norm
128 -
127 self.q_size = num_heads * head_dim
128 self.kv_size = num_kv_heads * head_dim
129
@@ -139,31 +137,18 @@ class HpcRopeNorm(CustomOp, HpcModule):
137
138 self.head_per_group = num_heads // num_kv_heads
139
142 - # Pre-allocate norm weight tensors as Parameters so they are tracked by
143 - # CuMemAllocator (for sleep/wake_up) and have stable addresses for CUDA
144 - # Graph replay. process_weights_after_loading() updates them inplace via
145 - # copy_() so refit does not invalidate captured graph tensor pointers.
146 - # Shape is [head_dim] to match the HPC kernel's q/k_norm_weight layout.
147 - if use_qk_norm and fallback_qnorm is not None:
148 - self.qnorm_weight: torch.nn.Parameter | None = torch.nn.Parameter(
149 - torch.empty(head_dim, dtype=torch.float32),
150 - requires_grad=False,
151 - )
152 - else:
153 - self.qnorm_weight = None
154 - if use_qk_norm and fallback_knorm is not None:
155 - self.knorm_weight: torch.nn.Parameter | None = torch.nn.Parameter(
156 - torch.empty(head_dim, dtype=torch.float32),
157 - requires_grad=False,
158 - )
159 - else:
160 - self.knorm_weight = None
161 -
140 self.use_fp8 = "fp8" in kv_cache_dtype
141 # The RMSNorm/RoPE ordering is model dependent (e.g. HunYuan V3 applies
142 # QK-Norm before RoPE -> NORM_THEN_ROPE), so it is supplied by the
143 # caller. When QK-Norm is disabled the policy is forced to NONE.
144 self.qk_norm_policy = qk_norm_policy if use_qk_norm else QkNormPolicy.NONE
145 + if self.qk_norm_policy != QkNormPolicy.NONE and (
146 + fallback_qnorm is None or fallback_knorm is None
147 + ):
148 + raise ValueError(
149 + "QK-Norm is enabled but fallback_qnorm/fallback_knorm were not "
150 + "supplied; the fused kernel reads their weights directly."
151 + )
152
153 # Register layer_name + add self to the global instance registry so the
154 # module-level custom op (hpc_rope_norm_forward) can route back here.
@@ -215,20 +200,6 @@ class HpcRopeNorm(CustomOp, HpcModule):
200 logger.info_once("enable hpc rope_norm")
201 return True
202
218 - def process_weights_after_loading(self, model: torch.nn.Module = None) -> None:
219 - """Copy norm weights (float32) from fallback norm modules inplace.
220 -
221 - Uses copy_() to preserve tensor addresses for CUDA Graph / refit
222 - compatibility. Called by the model's load_weights() after all weights
223 - are loaded (and generically from the model loader for DummyModelLoader
224 - / sleep-wake_up reload paths).
225 - """
226 - if self.use_qk_norm:
227 - if self.fallback_qnorm is not None and self.qnorm_weight is not None:
228 - self.qnorm_weight.data.copy_(self.fallback_qnorm.weight.data.float())
229 - if self.fallback_knorm is not None and self.knorm_weight is not None:
230 - self.knorm_weight.data.copy_(self.fallback_knorm.weight.data.float())
231 -
203 def register_layer_name(self, layer_name: str) -> None:
204 """Register layer_name and add self to the global registry.
205
@@ -430,12 +401,14 @@ class HpcRopeNorm(CustomOp, HpcModule):
401 k_scale = attn_layer._k_scale.reshape(1)
402 v_scale = attn_layer._v_scale.reshape(1)
403
433 - q_norm_weight = (
434 - self.qnorm_weight if self.qk_norm_policy != QkNormPolicy.NONE else None
435 - )
436 - k_norm_weight = (
437 - self.knorm_weight if self.qk_norm_policy != QkNormPolicy.NONE else None
438 - )
404 + if self.qk_norm_policy != QkNormPolicy.NONE:
405 + assert self.fallback_qnorm is not None
406 + assert self.fallback_knorm is not None
407 + q_norm_weight = self.fallback_qnorm.weight
408 + k_norm_weight = self.fallback_knorm.weight
409 + else:
410 + q_norm_weight = None
411 + k_norm_weight = None
412
413 # --- Prefill ---
414 if num_prefill_reqs > 0:
vllm/model_executor/model_loader/utils.py
-10
@@ -15,7 +15,6 @@ import vllm.envs as envs
15 from vllm.config import ModelConfig, VllmConfig, set_current_vllm_config
16 from vllm.logger import init_logger
17 from vllm.model_executor.layers.attention import is_deferred_attention_layer
18 -from vllm.model_executor.layers.hpc import HpcModule
18 from vllm.model_executor.layers.quantization.base_config import (
19 QuantizationConfig,
20 QuantizeMethodBase,
@@ -154,15 +153,6 @@ def process_weights_after_loading(
153 with device_loading_context(module, target_device):
154 module.process_weights_after_loading(model_config.dtype)
155
157 - # Process HPC modules (HpcRopeNorm, etc.) that rely on
158 - # process_weights_after_loading being called from the model's
159 - # load_weights(). When using DummyModelLoader (e.g. profiling or
160 - # sleep/wake_up reload), the model's load_weights() is not called, so we
161 - # must handle HPC modules here generically.
162 - for _, module in model.named_modules():
163 - if isinstance(module, HpcModule):
164 - module.process_weights_after_loading(model)
165 -
156 # Model-level post-load hook, after the per-layer quant finalize.
157 if hasattr(model, "process_weights_after_loading"):
158 model.process_weights_after_loading()
vllm/model_executor/models/hy_v3.py
+5 -2
@@ -300,8 +300,11 @@ class HYV3Attention(nn.Module):
300 prefix=f"{prefix}.attn",
301 )
302 if self.use_qk_norm:
303 - self.q_norm = RMSNorm(self.head_dim, rms_norm_eps)
304 - self.k_norm = RMSNorm(self.head_dim, rms_norm_eps)
303 + # The HPC fused kernel reads norm weights directly in float32;
304 + # the fallback path keeps the model default dtype.
305 + norm_dtype = torch.float32 if rope_support else None
306 + self.q_norm = RMSNorm(self.head_dim, rms_norm_eps, dtype=norm_dtype)
307 + self.k_norm = RMSNorm(self.head_dim, rms_norm_eps, dtype=norm_dtype)
308
309 # HPC fused RoPE + QK-Norm + KV-Cache-Write (+ optional FP8 Q quant).
310 # HunYuan V3 applies QK-Norm *before* RoPE, so NORM_THEN_ROPE.