Skip to content

Commit ca36d54

Browse files
committed
fix(refit): harden native grouped expert export
Signed-off-by: seonjinn <sna@nvidia.com>
1 parent f9c77bc commit ca36d54

7 files changed

Lines changed: 440 additions & 26 deletions

File tree

nemo_rl/models/policy/workers/megatron_policy_worker.py

Lines changed: 92 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,19 @@ def _is_mtp_megatron_param(param_name: str) -> bool:
248248
return param_name.startswith("mtp.") or ".mtp." in param_name
249249

250250

251+
def _grouped_expert_member_views(weight: torch.Tensor) -> list[torch.Tensor]:
252+
"""Return cached TE grouped members without invoking unsupported indexing."""
253+
storage = weight.data if isinstance(weight, torch.nn.Parameter) else weight
254+
splitter = getattr(storage, "split_into_quantized_tensors", None)
255+
if callable(splitter):
256+
members = getattr(storage, "quantized_tensors", None)
257+
if members is None:
258+
members = splitter()
259+
storage.quantized_tensors = members
260+
return list(members)
261+
return list(storage.unbind(0))
262+
263+
251264
def _collect_mtp_hf_layer_names(conversion_tasks: Optional[list]) -> set[str]:
252265
"""Return HF layer names whose weights originate from Megatron's MTP module.
253266
@@ -2474,6 +2487,44 @@ def _build_native_mxfp8_conversion_tasks(self) -> list[Any]:
24742487
)
24752488
self._native_grouped_mxfp8_tasks = grouped_tasks
24762489
grouped_names = {task.global_param_name for task in grouped_tasks}
2490+
grouped_suffixes = (
2491+
".mlp.experts.linear_fc1.weight",
2492+
".mlp.experts.linear_fc2.weight",
2493+
)
2494+
num_experts = int(getattr(self.model.config, "num_moe_experts", 0) or 0)
2495+
ep_size = int(getattr(self.model.config, "expert_model_parallel_size", 1) or 1)
2496+
if num_experts and num_experts % ep_size:
2497+
raise ValueError(
2498+
f"num_moe_experts={num_experts} must be divisible by "
2499+
f"expert_model_parallel_size={ep_size}"
2500+
)
2501+
local_expert_count = num_experts // ep_size if num_experts else 0
2502+
grouped_misc_names: dict[str, list[str]] = {}
2503+
expanded_global_names: list[str] = []
2504+
for global_name in global_names:
2505+
if (
2506+
global_name.endswith(grouped_suffixes)
2507+
and global_name not in grouped_names
2508+
):
2509+
if _is_mtp_megatron_param(global_name):
2510+
raise ValueError(
2511+
"native MXFP8 refit does not yet support co-trained MTP "
2512+
"grouped experts"
2513+
)
2514+
if local_expert_count <= 0:
2515+
raise ValueError(
2516+
f"Cannot expand grouped expert parameter {global_name!r} "
2517+
"without num_moe_experts"
2518+
)
2519+
expanded = [
2520+
f"{global_name}{expert_id}"
2521+
for expert_id in range(local_expert_count)
2522+
]
2523+
grouped_misc_names[global_name] = expanded
2524+
expanded_global_names.extend(expanded)
2525+
else:
2526+
expanded_global_names.append(global_name)
2527+
global_names = expanded_global_names
24772528
remaining_names = [name for name in global_names if name not in grouped_names]
24782529
if not remaining_names:
24792530
return grouped_tasks
@@ -2501,6 +2552,35 @@ def _build_native_mxfp8_conversion_tasks(self) -> list[Any]:
25012552
global_name = _megatron_local_name_to_global(
25022553
models, self.model.config, local_name, 0
25032554
)
2555+
expanded_names = grouped_misc_names.get(global_name)
2556+
if expanded_names is not None:
2557+
local_module, local_weight = get_module_and_param_from_name(
2558+
models, local_name, 0
2559+
)
2560+
members = (
2561+
[]
2562+
if local_weight is None
2563+
else _grouped_expert_member_views(local_weight)
2564+
)
2565+
if len(members) != len(expanded_names):
2566+
raise ValueError(
2567+
f"Grouped expert parameter {global_name!r} has local shape "
2568+
f"{getattr(local_weight, 'shape', None)}, expected "
2569+
f"{len(expanded_names)} local experts"
2570+
)
2571+
if local_module is not None and not hasattr(local_module, "config"):
2572+
setattr(local_module, "config", self.model.config)
2573+
for expert_id, expanded_name in enumerate(expanded_names):
2574+
local_tasks[expanded_name] = WeightConversionTask(
2575+
pp_rank=pp_rank,
2576+
vp_stage=0,
2577+
param_name=f"{local_name}{expert_id}",
2578+
global_param_name=expanded_name,
2579+
megatron_module=local_module,
2580+
param_weight=members[expert_id],
2581+
mapping=mappings[expanded_name],
2582+
)
2583+
continue
25042584
if global_name not in remaining_set:
25052585
continue
25062586
local_module, local_weight = get_module_and_param_from_name(
@@ -2887,8 +2967,18 @@ def _task_uses_native_mxfp8_storage(self, task: Any, *, grouped: bool) -> bool:
28872967
members = get_grouped_quantized_members(
28882968
task.param_weight, create_if_missing=False
28892969
)
2890-
except (RuntimeError, ValueError):
2891-
return False
2970+
except RuntimeError:
2971+
members = get_grouped_quantized_members(
2972+
task.param_weight, create_if_missing=True
2973+
)
2974+
except ValueError as error:
2975+
logical_name = self._native_task_projections(task, grouped=True)[0][
2976+
0
2977+
]
2978+
raise ValueError(
2979+
f"Invalid grouped MXFP8 source {logical_name!r} role "
2980+
f"'weight': {error}"
2981+
) from error
28922982
if not members:
28932983
logical_name = self._native_task_projections(task, grouped=True)[0][
28942984
0

nemo_rl/weight_sync/nccl_reshard_utils.py

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -749,10 +749,25 @@ def check_nccl_reshard_refit_support(master_config: Any) -> None:
749749
# BF16 storage → MXFP8 gen (receiver quantizes the resharded BF16 shard)
750750
# FP8→BF16 has no consumer (vLLM doesn't accept FP8 bytes into a BF16 param).
751751
fp8_cfg = megatron_cfg.get("fp8_cfg", {}) or {}
752-
fp8_param = fp8_cfg.get("fp8_param", False)
752+
fp8_param = bool(
753+
fp8_cfg.get("enabled", False) and fp8_cfg.get("fp8_param", False)
754+
)
753755
fp8_recipe = fp8_cfg.get("fp8_recipe", None)
754756
trainer_precision = policy.get("precision")
755757
gen_precision = vllm_cfg.get("precision", None)
758+
native_mxfp8 = bool(
759+
fp8_param
760+
and fp8_recipe == "mxfp8"
761+
and gen_precision == "fp8"
762+
and vllm_cfg.get("is_mx") is True
763+
)
764+
765+
if native_mxfp8 and (megatron_cfg.get("mtp_num_layers", 0) or 0) > 0:
766+
violations.append(
767+
"native MXFP8 refit does not yet support co-trained MTP layers; "
768+
"set policy.megatron_cfg.mtp_num_layers=0 and load static MTP "
769+
"weights from the generation checkpoint"
770+
)
756771

757772
# The refit byte-copies weights train -> gen, so gen dtype must match
758773
# train: BF16 (unset / "auto" / "bf16" / "bfloat16") or FP8 ("fp8"). A
@@ -770,7 +785,16 @@ def check_nccl_reshard_refit_support(master_config: Any) -> None:
770785

771786
if gen_precision == "fp8":
772787
if fp8_param:
773-
if vllm_cfg.get("is_mx"):
788+
if native_mxfp8:
789+
pass
790+
elif fp8_recipe == "mxfp8":
791+
violations.append(
792+
"native MXFP8 storage requires "
793+
"policy.generation.vllm_cfg.is_mx=True "
794+
"(native MXFP8 values and E8M0 scales cannot be "
795+
"loaded by a blockwise-FP8 target)."
796+
)
797+
elif vllm_cfg.get("is_mx"):
774798
violations.append(
775799
"policy.generation.vllm_cfg.is_mx=True does not support "
776800
"blockwise-FP8 storage from "
@@ -954,6 +978,8 @@ def _build_dst_meshes(num_gpus: int, rank_offset: int):
954978

955979
per_layer_params: dict[str, list] = OrderedDict()
956980
for name, meta in state_dict_metadata.items():
981+
normalized_components = normalize_refit_components(name, meta)
982+
logical_weight = normalized_components[0]
957983
layer = _extract_layer_name(name)
958984
expert = is_expert_param(name)
959985
# Pick the gen (dst) mesh: experts go to the EP/TP-expert mesh, all other
@@ -980,8 +1006,8 @@ def _build_dst_meshes(num_gpus: int, rank_offset: int):
9801006
src_dim_map = stage_src_dim_map
9811007
info = {
9821008
"name": name,
983-
"global_shape": tuple(meta["shape"]),
984-
"dtype": meta["dtype"],
1009+
"global_shape": logical_weight.global_shape,
1010+
"dtype": logical_weight.dtype,
9851011
"pp_stage": stage,
9861012
"src_mesh_info": src_mesh,
9871013
"dst_mesh_info": dst_mesh,
@@ -996,13 +1022,12 @@ def _build_dst_meshes(num_gpus: int, rank_offset: int):
9961022
src_dim_map = this_src_dim_map
9971023
info = {
9981024
"name": name,
999-
"global_shape": tuple(meta["shape"]),
1000-
"dtype": meta["dtype"],
1025+
"global_shape": logical_weight.global_shape,
1026+
"dtype": logical_weight.dtype,
10011027
"src_mesh_info": src_mesh,
10021028
"dst_mesh_info": dst_mesh,
10031029
}
10041030

1005-
normalized_components = normalize_refit_components(name, meta)
10061031
component_infos = []
10071032
for component in normalized_components:
10081033
src_placements = get_placements(

nemo_rl/weight_sync/refit_components.py

Lines changed: 72 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -187,23 +187,83 @@ def component_plan_digest(refit_info: Mapping[str, Any]) -> str:
187187
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
188188

189189

190-
def native_mxfp8_param_names(refit_info: Mapping[str, Any]) -> set[str]:
191-
"""Return parameters represented by a canonical MXFP8 value/scale pair."""
190+
def native_mxfp8_param_names(
191+
refit_info: Mapping[str, Any],
192+
*,
193+
strict: bool = False,
194+
) -> set[str]:
195+
"""Return parameters represented by a canonical MXFP8 value/scale pair.
196+
197+
``strict=False`` is suitable for feature detection: malformed entries are
198+
ignored conservatively. ``strict=True`` validates every serialized
199+
value/scale pair and reports malformed native metadata to the caller.
200+
"""
192201
result: set[str] = set()
193202
per_layer_params = refit_info.get("per_layer_params", {})
194203
for layer_name in refit_info.get("layer_names", []):
195204
for param_info in per_layer_params.get(layer_name, []):
196-
components = param_info.get("components", [])
197-
if [component.get("role") for component in components] != [
198-
"weight",
199-
"weight_scale",
200-
]:
205+
if not isinstance(param_info, Mapping):
206+
if strict:
207+
raise ValueError("refit parameter metadata must be a mapping")
208+
continue
209+
logical_name = param_info.get("name")
210+
if not isinstance(logical_name, str):
211+
if strict:
212+
raise ValueError("refit parameter metadata must contain a name")
213+
continue
214+
try:
215+
serialized = param_info.get("components")
216+
metadata: dict[str, Any] = {
217+
"shape": param_info.get("global_shape"),
218+
"dtype": param_info.get("dtype"),
219+
}
220+
if serialized is not None:
221+
if not isinstance(serialized, Sequence) or isinstance(
222+
serialized, (str, bytes)
223+
):
224+
raise ValueError(
225+
f"{logical_name} components must be a sequence"
226+
)
227+
normalized_components: list[dict[str, Any]] = []
228+
for component in serialized:
229+
if not isinstance(component, Mapping):
230+
raise ValueError(
231+
f"{logical_name} component metadata must be mappings"
232+
)
233+
normalized_components.append(
234+
{
235+
"role": component.get("role"),
236+
"shape": component.get("global_shape"),
237+
"dtype": component.get("dtype"),
238+
}
239+
)
240+
metadata["components"] = normalized_components
241+
normalized = normalize_refit_components(
242+
logical_name,
243+
metadata,
244+
)
245+
except ValueError:
246+
if strict:
247+
raise
248+
continue
249+
if len(normalized) == 1:
250+
continue
251+
weight, scale = normalized
252+
if weight.dtype != "torch.float8_e4m3fn":
253+
if strict:
254+
raise ValueError(
255+
f"{logical_name!r} native weight dtype must be "
256+
"torch.float8_e4m3fn"
257+
)
258+
continue
259+
if scale.dtype != "torch.uint8":
260+
if strict:
261+
raise ValueError(
262+
f"{logical_name!r} weight_scale dtype must be torch.uint8"
263+
)
201264
continue
202-
if (
203-
str(components[0].get("dtype")) == "torch.float8_e4m3fn"
204-
and str(components[1].get("dtype")) == "torch.uint8"
205-
):
206-
result.add(param_info["name"])
265+
if weight.role == "weight" and scale.role == "weight_scale":
266+
result.add(logical_name)
207267
return result
208268

209269

0 commit comments

Comments
 (0)