Skip to content

Commit 191f9ce

Browse files
committed
fix: port ModelOpt W4A16 NVFP4 rollout to vllm 0.25
- vLLM 0.25's ModelOptNvFp4Config.__init__ installs LinearMethodCls as an instance attribute keyed off the quant algo, shadowing the NeMo subclass class attribute: the W4A16 config silently instantiated the native W4A4 linear method, whose process_weights_after_loading reads an input_scale a W4A16 checkpoint never loads. from_config now rebinds the instance attribute to the NeMo Marlin weight-only method. - W4A16_NVFP4 is a natively understood algo in 0.25, so the from_config normalization to NVFP4 is gone (validate-only); the base FusedMoE __init__ keys weight-only mode off the algo (activation_key=None), replacing the 0.20-era duplicated __init__ in NemoModelOptW4A16FusedMoE. - vLLM 0.25's prepare_nvfp4_moe_layer_for_marlin pads rank-local intermediate tiles itself and asserts on unpadded checkpoint shapes, so the NeMo-side Marlin pre-padding (_pad_nvfp4_moe_for_marlin) would double-pad and trip that assertion; it is removed. Only the E4M3 sign-bit canonicalization of ModelOpt scale exports remains NeMo's concern. Fake-vllm test harness updated to model the 0.25 instance-attribute and use_a16 behavior so the shadowing bug is covered by a regression test. Signed-off-by: Terry Kong <terryk@nvidia.com>
1 parent 8c968b7 commit 191f9ce

2 files changed

Lines changed: 67 additions & 176 deletions

File tree

nemo_rl/modelopt/models/generation/vllm_modelopt.py

Lines changed: 27 additions & 110 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@
2323
method-owned MoE kernel references across layerwise reload.
2424
"""
2525

26-
import copy
2726
from types import MethodType
2827
from typing import Any
2928

@@ -88,17 +87,14 @@ def _load_modelopt_moe_input_scale(
8887
return True if return_success else None
8988

9089

91-
def _normalized_w4a16_config(config: dict[str, Any]) -> dict[str, Any]:
92-
normalized = copy.deepcopy(config)
93-
quantization = normalized.get("quantization")
94-
target = quantization if isinstance(quantization, dict) else normalized
90+
def _validated_w4a16_config(config: dict[str, Any]) -> dict[str, Any]:
91+
quantization = config.get("quantization")
92+
target = quantization if isinstance(quantization, dict) else config
9593
if str(target.get("quant_algo", "")).upper() != _W4A16_ALGO:
9694
raise ValueError(f"{NEMO_MODELOPT_W4A16} requires quant_algo={_W4A16_ALGO!r}")
97-
# vLLM 0.20 validates known ModelOpt algorithms before dispatching to a
98-
# custom subclass. Normalize only for its parser; class identity selects
99-
# the W4A16 methods below.
100-
target["quant_algo"] = _W4A4_ALGO
101-
return normalized
95+
# vLLM 0.25 understands W4A16_NVFP4 natively, so the algo passes through
96+
# unchanged (the base __init__ keys use_a16/LinearMethodCls off it).
97+
return config
10298

10399

104100
def _canonicalize_nvfp4_scale_(scale: torch.Tensor) -> None:
@@ -107,54 +103,6 @@ def _canonicalize_nvfp4_scale_(scale: torch.Tensor) -> None:
107103
scale.copy_(scale.to(torch.float32).abs().to(scale.dtype))
108104

109105

110-
def _pad_nvfp4_moe_for_marlin(
111-
w13: torch.Tensor,
112-
w13_scale: torch.Tensor,
113-
w2: torch.Tensor,
114-
w2_scale: torch.Tensor,
115-
*,
116-
is_act_and_mul: bool,
117-
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor, int]:
118-
"""Apply rank-local post-load padding required by the Marlin MoE kernel."""
119-
num_experts = w13.shape[0]
120-
num_shards = 2 if is_act_and_mul else 1
121-
intermediate_size = w13.shape[1] // num_shards
122-
hidden_size = w13.shape[2] * 2
123-
if hidden_size % 128 == 0:
124-
tile_size = 64
125-
elif hidden_size % 64 == 0:
126-
tile_size = 128
127-
else:
128-
raise ValueError(
129-
f"W4A16 Marlin MoE requires hidden_size divisible by 64, got {hidden_size}"
130-
)
131-
padded_size = (intermediate_size + tile_size - 1) // tile_size * tile_size
132-
if padded_size == intermediate_size:
133-
return w13, w13_scale, w2, w2_scale, intermediate_size
134-
135-
def pad_w13(tensor: torch.Tensor) -> torch.Tensor:
136-
tensor = tensor.view(
137-
num_experts,
138-
num_shards,
139-
intermediate_size,
140-
tensor.shape[-1],
141-
)
142-
tensor = torch.nn.functional.pad(
143-
tensor,
144-
(0, 0, 0, padded_size - intermediate_size),
145-
)
146-
return tensor.reshape(num_experts, num_shards * padded_size, -1)
147-
148-
w13 = pad_w13(w13)
149-
w13_scale = pad_w13(w13_scale)
150-
w2 = torch.nn.functional.pad(w2, (0, (padded_size - intermediate_size) // 2))
151-
w2_scale = torch.nn.functional.pad(
152-
w2_scale,
153-
(0, (padded_size - intermediate_size) // 16),
154-
)
155-
return w13, w13_scale, w2, w2_scale, padded_size
156-
157-
158106
def register_nemo_modelopt_nvfp4() -> None:
159107
"""Register NeMo's two ModelOpt NVFP4 configs through vLLM's public API."""
160108
global _registered
@@ -165,14 +113,7 @@ def register_nemo_modelopt_nvfp4() -> None:
165113
MarlinNvFp4LinearKernel,
166114
NvFp4LinearLayerConfig,
167115
)
168-
from vllm.model_executor.layers.fused_moe.fused_moe_method_base import (
169-
FusedMoEMethodBase,
170-
)
171-
from vllm.model_executor.layers.fused_moe.oracle.nvfp4 import (
172-
NvFp4MoeBackend,
173-
is_global_sf_supported_for_nvfp4_backend,
174-
select_nvfp4_moe_backend,
175-
)
116+
from vllm.model_executor.layers.fused_moe.oracle.nvfp4 import NvFp4MoeBackend
176117
from vllm.model_executor.layers.linear import (
177118
register_weight_loader_v2_supported_method,
178119
)
@@ -182,8 +123,6 @@ def register_nemo_modelopt_nvfp4() -> None:
182123
ModelOptNvFp4FusedMoE,
183124
ModelOptNvFp4LinearMethod,
184125
)
185-
from vllm.model_executor.layers.quantization.utils.quant_utils import kNvfp4Static
186-
from vllm.model_executor.utils import replace_parameter
187126

188127
class NemoModelOptNvFp4FusedMoE(ModelOptNvFp4FusedMoE):
189128
"""Native W4A4 MoE plus the vLLM 0.20 input-scale loader fix."""
@@ -297,30 +236,17 @@ def apply(
297236
return self.kernel.apply_weights(layer=layer, x=x, bias=bias)
298237

299238
class NemoModelOptW4A16FusedMoE(ModelOptNvFp4FusedMoE):
300-
"""ModelOpt W4A16 MoE using vLLM's NVFP4 Marlin implementation."""
239+
"""ModelOpt W4A16 MoE using vLLM's NVFP4 Marlin implementation.
240+
241+
vLLM 0.25's base __init__ keys weight-only mode off
242+
quant_config.quant_method == "W4A16_NVFP4" (activation_key=None), so
243+
the 0.20-era duplicated __init__ is gone; the algo passes through
244+
from_config unchanged.
245+
"""
301246

302247
moe_kernel: Any
303248
moe_quant_config: Any
304249

305-
def __init__(self, quant_config: object, moe_config: object) -> None:
306-
# Duplicates vLLM v0.20.0 ModelOptNvFp4FusedMoE.__init__ except
307-
# activation_key=None (weight-only); the base hard-wires
308-
# kNvfp4Dynamic and offers no hook:
309-
# https://github.com/vllm-project/vllm/blob/v0.20.0/vllm/model_executor/layers/quantization/modelopt.py#L1218-L1234
310-
# Intentionally calls FusedMoEMethodBase.__init__ to skip the
311-
# parent __init__; do not replace it with super().__init__().
312-
# Re-sync on vLLM bumps.
313-
FusedMoEMethodBase.__init__(self, moe_config)
314-
self.quant_config = quant_config
315-
self.nvfp4_backend, self.experts_cls = select_nvfp4_moe_backend(
316-
config=self.moe,
317-
weight_key=kNvfp4Static,
318-
activation_key=None,
319-
)
320-
self.use_global_sf = is_global_sf_supported_for_nvfp4_backend(
321-
self.nvfp4_backend
322-
)
323-
324250
def create_weights(
325251
self,
326252
layer: Any,
@@ -344,35 +270,20 @@ def create_weights(
344270
def process_weights_after_loading(self, layer: Any) -> None:
345271
reload_kernel = self.moe_kernel
346272
reload_quant_config = self.moe_quant_config
347-
original_intermediate_size = (
348-
layer.moe_config.intermediate_size_per_partition
349-
)
350273
if self.nvfp4_backend == NvFp4MoeBackend.MARLIN:
351-
w13, w13_scale, w2, w2_scale, padded_size = _pad_nvfp4_moe_for_marlin(
352-
layer.w13_weight,
353-
layer.w13_weight_scale,
354-
layer.w2_weight,
355-
layer.w2_weight_scale,
356-
is_act_and_mul=self.moe.is_act_and_mul,
357-
)
358-
replace_parameter(layer, "w13_weight", w13)
359-
replace_parameter(layer, "w13_weight_scale", w13_scale)
360-
replace_parameter(layer, "w2_weight", w2)
361-
replace_parameter(layer, "w2_weight_scale", w2_scale)
274+
# vLLM 0.25's prepare_nvfp4_moe_layer_for_marlin pads the
275+
# rank-local intermediate tiles itself (and asserts on the
276+
# unpadded checkpoint shapes), so no NeMo-side pre-padding.
277+
# Only the E4M3 sign-bit canonicalization of the ModelOpt
278+
# export remains our concern.
362279
_canonicalize_nvfp4_scale_(layer.w13_weight_scale)
363280
_canonicalize_nvfp4_scale_(layer.w2_weight_scale)
364-
layer.moe_config.intermediate_size_per_partition = padded_size
365281
# W4A16 checkpoint metadata deliberately omits activation scales so
366282
# layerwise reload never waits for tensors that do not exist. The
367283
# native Marlin converter accepts None and removes these attributes.
368284
layer.w13_input_scale = None
369285
layer.w2_input_scale = None
370-
try:
371-
super().process_weights_after_loading(layer)
372-
finally:
373-
layer.moe_config.intermediate_size_per_partition = (
374-
original_intermediate_size
375-
)
286+
super().process_weights_after_loading(layer)
376287
if reload_kernel is not None:
377288
self.moe_kernel = reload_kernel
378289
self.moe_quant_config = reload_quant_config
@@ -401,7 +312,13 @@ def override_quantization_method(
401312

402313
@classmethod
403314
def from_config(cls, config: dict[str, Any]) -> Any:
404-
return super().from_config(_normalized_w4a16_config(config))
315+
instance = super().from_config(_validated_w4a16_config(config))
316+
# vLLM 0.25's ModelOptNvFp4Config.__init__ selects LinearMethodCls
317+
# from the quant algo as an *instance* attribute, which shadows
318+
# the class attribute above; rebind the NeMo method explicitly so
319+
# W4A16 linears keep the refit-friendly Marlin implementation.
320+
instance.LinearMethodCls = NemoModelOptW4A16LinearMethod
321+
return instance
405322

406323
register_quantization_config(NEMO_MODELOPT_W4A4)(NemoModelOptNvFp4Config)
407324
register_quantization_config(NEMO_MODELOPT_W4A16)(NemoModelOptW4A16Config)

tests/unit/models/generation/test_vllm_modelopt_real_quant_config.py

Lines changed: 40 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@
2727
from nemo_rl.modelopt.models.generation.vllm_modelopt import (
2828
NEMO_MODELOPT_W4A4,
2929
NEMO_MODELOPT_W4A16,
30-
_pad_nvfp4_moe_for_marlin,
3130
quantization_method_for_mode,
3231
register_nemo_modelopt_nvfp4,
3332
)
@@ -334,6 +333,13 @@ def __init__(self, quant_config, moe_config):
334333
self.moe = moe_config
335334
self.moe_kernel = None
336335
self.moe_quant_config = None
336+
# Mirror vLLM 0.25: weight-only mode and backend selection are
337+
# keyed off the config's quant_method in the base __init__.
338+
self.use_a16 = (
339+
getattr(quant_config, "quant_method", "NVFP4") == "W4A16_NVFP4"
340+
)
341+
self.nvfp4_backend = "marlin"
342+
self.use_global_sf = False
337343

338344
def create_weights(self, layer, *args, **kwargs):
339345
events.append(("native_create_weights", layer, args, kwargs))
@@ -372,17 +378,35 @@ def process_weights_after_loading(self, layer):
372378
else:
373379
self.moe_quant_config = types.SimpleNamespace(source="native")
374380

381+
class FakeModelOptNvFp4W4A16LinearMethod(FakeModelOptNvFp4LinearMethod):
382+
pass
383+
375384
class FakeModelOptNvFp4Config:
376385
LinearMethodCls = FakeModelOptNvFp4LinearMethod
377386
FusedMoEMethodCls = FakeModelOptNvFp4FusedMoE
378387

379-
def __init__(self, group_size=16):
388+
def __init__(self, quant_method="NVFP4", group_size=16):
389+
self.quant_method = quant_method
380390
self.group_size = group_size
391+
# Mirror vLLM 0.25: __init__ installs LinearMethodCls as an
392+
# *instance* attribute keyed off quant_method, shadowing any
393+
# subclass class attribute.
394+
if quant_method == "NVFP4":
395+
self.LinearMethodCls = FakeModelOptNvFp4LinearMethod
396+
elif quant_method == "W4A16_NVFP4":
397+
self.LinearMethodCls = FakeModelOptNvFp4W4A16LinearMethod
398+
else:
399+
raise ValueError(
400+
f"Unsupported ModelOpt NVFP4 quant_algo: {quant_method}"
401+
)
381402

382403
@classmethod
383404
def from_config(cls, config):
384405
target = config.get("quantization", config)
385-
instance = cls(group_size=target.get("group_size", 16))
406+
instance = cls(
407+
quant_method=str(target.get("quant_algo", "NVFP4")).upper(),
408+
group_size=target.get("group_size", 16),
409+
)
386410
instance.parsed_config = config
387411
return instance
388412

@@ -2325,8 +2349,12 @@ def test_register_nemo_modelopt_nvfp4_uses_public_vllm_registry(monkeypatch):
23252349
source_config = {"quant_algo": "W4A16_NVFP4", "group_size": 16}
23262350
w4a16_config = fake_vllm.registry[NEMO_MODELOPT_W4A16].from_config(source_config)
23272351
assert source_config["quant_algo"] == "W4A16_NVFP4"
2328-
assert w4a16_config.parsed_config["quant_algo"] == "NVFP4"
2352+
# vLLM 0.25 understands W4A16_NVFP4 natively; the algo passes through.
2353+
assert w4a16_config.parsed_config["quant_algo"] == "W4A16_NVFP4"
23292354
assert w4a16_config.get_name() == NEMO_MODELOPT_W4A16
2355+
# The base __init__ installs its own LinearMethodCls instance attribute;
2356+
# the NeMo config must rebind it to the refit-friendly Marlin method.
2357+
assert w4a16_config.LinearMethodCls.__name__ == "NemoModelOptW4A16LinearMethod"
23302358

23312359
with pytest.raises(ValueError, match="requires quant_algo='W4A16_NVFP4'"):
23322360
fake_vllm.registry[NEMO_MODELOPT_W4A16].from_config({"quant_algo": "NVFP4"})
@@ -2562,63 +2590,6 @@ def test_registered_w4a16_dense_method_uses_marlin_weight_only(monkeypatch):
25622590
assert kernel_args["bias"] is None
25632591

25642592

2565-
@pytest.mark.parametrize(
2566-
("is_act_and_mul", "packed_hidden_size", "expected_padded_size"),
2567-
[
2568-
(False, 64, 192),
2569-
(True, 32, 256),
2570-
],
2571-
)
2572-
def test_pad_nvfp4_moe_for_marlin_uses_hidden_size_tile_alignment(
2573-
is_act_and_mul,
2574-
packed_hidden_size,
2575-
expected_padded_size,
2576-
):
2577-
num_shards = 2 if is_act_and_mul else 1
2578-
intermediate_size = 144
2579-
w13 = torch.ones(
2580-
1,
2581-
num_shards * intermediate_size,
2582-
packed_hidden_size,
2583-
)
2584-
w13_scale = torch.ones(1, num_shards * intermediate_size, 2)
2585-
w2 = torch.ones(1, 2, intermediate_size // 2)
2586-
w2_scale = torch.ones(1, 2, intermediate_size // 16)
2587-
2588-
padded_w13, padded_w13_scale, padded_w2, padded_w2_scale, padded_size = (
2589-
_pad_nvfp4_moe_for_marlin(
2590-
w13,
2591-
w13_scale,
2592-
w2,
2593-
w2_scale,
2594-
is_act_and_mul=is_act_and_mul,
2595-
)
2596-
)
2597-
2598-
assert padded_size == expected_padded_size
2599-
assert padded_w13.shape == (
2600-
1,
2601-
num_shards * expected_padded_size,
2602-
packed_hidden_size,
2603-
)
2604-
assert padded_w13_scale.shape == (1, num_shards * expected_padded_size, 2)
2605-
assert padded_w2.shape == (1, 2, expected_padded_size // 2)
2606-
assert padded_w2_scale.shape == (1, 2, expected_padded_size // 16)
2607-
2608-
padded_w13 = padded_w13.view(
2609-
1, num_shards, expected_padded_size, packed_hidden_size
2610-
)
2611-
padded_w13_scale = padded_w13_scale.view(1, num_shards, expected_padded_size, 2)
2612-
assert torch.all(padded_w13[:, :, :intermediate_size] == 1)
2613-
assert torch.count_nonzero(padded_w13[:, :, intermediate_size:]) == 0
2614-
assert torch.all(padded_w13_scale[:, :, :intermediate_size] == 1)
2615-
assert torch.count_nonzero(padded_w13_scale[:, :, intermediate_size:]) == 0
2616-
assert torch.all(padded_w2[..., : intermediate_size // 2] == 1)
2617-
assert torch.count_nonzero(padded_w2[..., intermediate_size // 2 :]) == 0
2618-
assert torch.all(padded_w2_scale[..., : intermediate_size // 16] == 1)
2619-
assert torch.count_nonzero(padded_w2_scale[..., intermediate_size // 16 :]) == 0
2620-
2621-
26222593
def test_registered_w4a16_moe_create_weights_keeps_checkpoint_layout(monkeypatch):
26232594
fake_vllm = _install_fake_registered_vllm_modelopt(monkeypatch)
26242595
monkeypatch.setattr(vllm_modelopt, "_registered", False)
@@ -2683,11 +2654,14 @@ def test_registered_w4a16_moe_preserves_kernel_during_reload(monkeypatch):
26832654

26842655
assert quant_method.moe_kernel is original_kernel
26852656
assert quant_method.moe_quant_config is original_quant_config
2657+
# vLLM 0.25's native Marlin converter owns tile padding, so the NeMo
2658+
# override leaves shapes and moe_config untouched and only canonicalizes
2659+
# the ModelOpt sign-carrying scales in place.
26862660
assert layer.moe_config.intermediate_size_per_partition == 80
2687-
assert layer.w13_weight.shape == (1, 128, 32)
2688-
assert layer.w13_weight_scale.shape == (1, 128, 2)
2689-
assert layer.w2_weight.shape == (1, 2, 64)
2690-
assert layer.w2_weight_scale.shape == (1, 2, 8)
2661+
assert layer.w13_weight.shape == (1, 80, 32)
2662+
assert layer.w13_weight_scale.shape == (1, 80, 2)
2663+
assert layer.w2_weight.shape == (1, 2, 40)
2664+
assert layer.w2_weight_scale.shape == (1, 2, 5)
26912665
assert torch.all(layer.w13_weight_scale >= 0)
26922666
assert torch.all(layer.w2_weight_scale >= 0)
2693-
assert fake_vllm.events == [("native_process_moe", 128)]
2667+
assert fake_vllm.events == [("native_process_moe", 80)]

0 commit comments

Comments
 (0)