feat(speechlm): support native RL checkpoints and frozen CTC export by vmendelev · Pull Request #16387 · NVIDIA-NeMo/Speech · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
de7b885
feat(speechlm): support native RL update surfaces and export
vmendelev Oct 13, 2026
5f55cf6
Merge remote-tracking branch 'origin/main' into task/gspo-ctc-refresh…
vmendelev Oct 15, 2026
1a996c5
feat(speechlm): preserve frozen CTC checkpoints in full-scale GSPO
vmendelev Oct 15, 2026
8fa4682
test(speechlm): fix speechlm2 GPU CI failures
vmendelev Oct 16, 2026
6e7f73e
fix(speechlm): avoid mixed returns in HFHubMixin._save_pretrained
vmendelev Oct 16, 2026
a8165e7
docs(speechlm): fix speechlm2 RST heading warnings
vmendelev Oct 16, 2026
8a1b217
docs(speechlm): reference the NeMo RL GSPO guide without an unpublish…
vmendelev Oct 16, 2026
4fd4c20
refactor(speechlm): address review comments on docs, comments, and we…
vmendelev Oct 20, 2026
758f9c6
fix(speechlm): keep selected FP32 perception and CTC weights exact; a…
vmendelev Oct 20, 2026
8896150
Merge remote-tracking branch 'origin/main' into task/speechlm-rl-main
vmendelev Oct 21, 2026
18fe624
feat(speechlm): use Automodel FreezeConfig for partial-model training
vmendelev Oct 22, 2026
792b2a7
fix(export): omit training freeze policies from serving checkpoints
vmendelev Oct 22, 2026
260c54a
test(speechlm): skip exact FreezeConfig tests on older Automodel
vmendelev Oct 22, 2026
a854a2c
Preserve portable Nemotron-H layer names in SpeechLM exports
vmendelev Oct 23, 2026
f1e185b
fix(speechlm): retain absent MTP heads without creating them
vmendelev Oct 23, 2026
5fd0166
docs(speechlm): explain GSPO training and export workflow
vmendelev Oct 26, 2026
ce16985
docs(speechlm): defer RL instructions to NeMo-RL guide
vmendelev Oct 26, 2026
b3567d0
docs(speechlm): simplify post-training guide pointer
vmendelev Oct 26, 2026
5bc0c79
test(speechlm): compare native export weights on CPU
pzelasko Oct 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs/source/speechlm2/training_and_scaling.rst
56 changes: 56 additions & 0 deletions examples/speechlm2/prepare_rl_export_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
"""Build canonical native-RL export configuration from the original local HF seed."""

import argparse
import json
from pathlib import Path

from nemo.collections.speechlm2.parts.ctc_timestamp_checkpoint import read_ctc_timestamp_config


def prepare_config(seed: Path, world_size: int, expert_parallel_size: int) -> dict:
seed = seed.resolve()
if world_size < 1 or expert_parallel_size < 1 or world_size % expert_parallel_size:
raise ValueError('World size must be positive and divisible by expert parallel size')
model = json.loads((seed / 'config.json').read_text())
if not (seed / 'llm_backbone' / 'config.json').is_file():
raise FileNotFoundError(seed / 'llm_backbone' / 'config.json')
model['pretrained_llm'] = str(seed / 'llm_backbone')
model['tokenizer_path'] = str(seed)
ctc_config = read_ctc_timestamp_config(seed / 'model.safetensors')
if ctc_config is not None:
model['ctc_timestamp_artifact'] = ctc_config
strategy = {
'_target_': 'nemo.collections.speechlm2.parts.parallel.AutomodelParallelStrategy',
'dp_size': world_size,
'dp_replicate_size': 1,
'tp_size': 1,
'pp_size': 1,
'cp_size': 1,
'ep_size': expert_parallel_size,
}
return {'model': model, 'trainer': {'strategy': strategy}}


def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--seed', type=Path, required=True)
parser.add_argument('--output', type=Path, required=True)
parser.add_argument('--world-size', type=int, default=8)
parser.add_argument('--expert-parallel-size', type=int, default=8)
parser.add_argument('--strategy-overrides', type=json.loads, default={})
parser.add_argument('--backend-config', type=json.loads)
args = parser.parse_args()
config = prepare_config(args.seed, args.world_size, args.expert_parallel_size)
config['trainer']['strategy'].update(args.strategy_overrides)
if args.backend_config is not None:
config['model']['automodel_backend'] = args.backend_config
args.output.parent.mkdir(parents=True, exist_ok=True)
with args.output.open('x') as output:
json.dump(config, output, indent=2)
output.write('\n')


if __name__ == '__main__':
main()
85 changes: 74 additions & 11 deletions examples/speechlm2/to_hf.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,33 @@ class HfExportConfig:
# Dtype used for stored parameters
dtype: str = "bfloat16"

# Format of ckpt_path: "lightning" for a Lightning checkpoint, or "nemo_rl" for the
# flat DCP directory NeMo-RL saves under weights/model. The output is always HF safetensors.
checkpoint_format: str = "lightning"
Comment thread
vmendelev marked this conversation as resolved.


def prepare_native_export_model_config(model_cfg: dict) -> dict:
"""Keep native MTP topology and enable the canonical dtype adapter."""
model_cfg = deepcopy(model_cfg)
model_cfg.pop("automodel_freeze_config", None)
model_cfg.pop("perception_freeze_config", None)
model_cfg["retain_native_mtp"] = True
Comment thread
vmendelev marked this conversation as resolved.
model_cfg["mtp"] = {**(model_cfg.get("mtp") or {}), "enabled": False}
model_cfg["automodel_backend"] = {
**(model_cfg.get("automodel_backend") or {}),
"enable_hf_state_dict_adapter": True,
}
return model_cfg


def load_checkpoint(model: torch.nn.Module, checkpoint_path: str, checkpoint_format: str = "lightning") -> None:
if checkpoint_format == "nemo_rl":
from nemo.collections.speechlm2.parts.weight_loading import load_nemo_rl_checkpoint

def load_checkpoint(model: torch.nn.Module, checkpoint_path: str) -> None:
load_nemo_rl_checkpoint(model, checkpoint_path)
return
if checkpoint_format != "lightning":
raise ValueError(f"Unsupported checkpoint_format={checkpoint_format!r}")
if Path(checkpoint_path).is_dir():
from torch.distributed.checkpoint import load

Expand Down Expand Up @@ -131,6 +156,10 @@ def _hf_export_config(model: torch.nn.Module, dtype: str | torch.dtype) -> dict[
# Remote-code trust is a runtime security decision. Do not persist a
# training-time opt-in in checkpoints that may be loaded by another user.
config.pop("trust_remote_code", None)
config.pop("preserve_parameter_dtypes", None)
# A new fine-tuning run chooses its own update surface.
config.pop("automodel_freeze_config", None)
config.pop("perception_freeze_config", None)
pe_encoder_path = config.get("pe_encoder_path", None)
pe_encoder_config = config.get("pe_encoder_config", None)
if pe_encoder_path not in (None, "", False) or pe_encoder_config not in (
Expand Down Expand Up @@ -193,6 +222,8 @@ def _hf_export_config(model: torch.nn.Module, dtype: str | torch.dtype) -> dict[

llm = getattr(model, "llm", None)
text_config = getattr(llm, "config", None)
if config.pop("retain_native_mtp", False):
config["mtp"] = {**(config.get("mtp") or {}), "enabled": getattr(llm, "mtp", None) is not None}
explicit_mtp = config.get("mtp")
mtp_enabled = (
bool(explicit_mtp.get("enabled", True))
Expand Down Expand Up @@ -276,17 +307,33 @@ def save_hf_checkpoint(model: torch.nn.Module, state_dict: dict, cfg: HfExportCo
if callable(getattr(state_dict_adapter, "forced_hf_dtype_mapping", None)):
forced_dtypes = state_dict_adapter.forced_hf_dtype_mapping(state_dict)
state_dict = {
key: value.to(torch.float32 if forced_dtypes.get(key) == "F32" else target_dtype)
key: (
value
if key.startswith("ctc_timestamp.")
else value.to(torch.float32 if forced_dtypes.get(key) == "F32" else target_dtype)
)
for key, value in state_dict.items()
}

config = _hf_export_config(model, cfg.dtype)
save_file(state_dict, output_dir / "model.safetensors")
ctc_timestamp = getattr(model, "ctc_timestamp", None)
metadata = ctc_timestamp.artifact_metadata if ctc_timestamp is not None else None
save_file(state_dict, output_dir / "model.safetensors", metadata=metadata)
with open(output_dir / "config.json", "w") as f:
json.dump(config, f, indent=2)
save_llm_backbone_config(model, output_dir)


def cast_model_for_export(model: torch.nn.Module, dtype: str | torch.dtype) -> torch.nn.Module:
"""Cast the model to the export dtype; the CTC timestamp artifact keeps its loaded storage."""
ctc_timestamp = getattr(model, "ctc_timestamp", None)
ctc_state = None if ctc_timestamp is None else ctc_timestamp.state_dict()
model = model.to(str_to_dtype(dtype))
if ctc_state is not None:
ctc_timestamp.load_state_dict(ctc_state, assign=True)
return model


def save_llm_backbone_config(model: torch.nn.Module, output_dir: str | Path) -> None:
"""Save the original LLM config separately from the NeMo wrapper config."""
llm_config = getattr(getattr(model, "llm", None), "config", None)
Expand All @@ -295,7 +342,18 @@ def save_llm_backbone_config(model: torch.nn.Module, output_dir: str | Path) ->

llm_backbone_dir = Path(output_dir) / LLM_BACKBONE_DIR
llm_backbone_dir.mkdir(parents=True, exist_ok=True)
llm_config.save_pretrained(str(llm_backbone_dir))
if getattr(llm_config, "model_type", None) == "nemotron_h":
# Transformers >= 5.15 renames these discriminators in memory. Serialize
# the original names, which both older and newer serving runtimes accept.
# Keep the live model config untouched, including its native MTP topology.
config = json.loads(llm_config.to_json_string())
aliases = {"linear_attention": "mamba", "full_attention": "attention"}
for key in ("layers_block_type", "mtp_layers_block_type"):
if config.get(key) is not None:
config[key] = [aliases.get(block, block) for block in config[key]]
(llm_backbone_dir / "config.json").write_text(json.dumps(config, indent=2) + "\n")
else:
llm_config.save_pretrained(str(llm_backbone_dir))


def _detect_vllm_architecture(model_cfg: dict) -> tuple[str, int]:
Expand Down Expand Up @@ -436,12 +494,12 @@ def prepare_for_vllm(output_dir: str, model_cfg: dict) -> None:
if tok.pad_token_id is not None:
gen_cfg["pad_token_id"] = tok.pad_token_id

# Build every tokenizer-side artifact in a sibling staging directory. A
# Build every tokenizer-side artifact inside the output filesystem. A
# caught validation/serialization ``ValueError`` must leave the original
# HF checkpoint untouched instead of advertising a half-prepared vLLM
# model. Once staging succeeds, publish each file atomically and write the
# authoritative root config last.
with TemporaryDirectory(prefix=f".{output_dir.name}-vllm-", dir=output_dir.parent) as staging:
with TemporaryDirectory(prefix=f".{output_dir.name}-vllm-", dir=output_dir) as staging:
staging_dir = Path(staging)
artifacts_dir = staging_dir / "artifacts"
artifacts_dir.mkdir()
Expand Down Expand Up @@ -578,6 +636,8 @@ def main(cfg: HfExportConfig) -> None:
# The vLLM prompt processor must reserve exactly as many audio
# placeholders as the checkpoint's encoder emits.
model_cfg["audio_token_estimator"] = audio_token_estimator
if cfg.checkpoint_format == "nemo_rl":
model_cfg = prepare_native_export_model_config(model_cfg)
model_cfg["torch_dtype"] = _canonical_torch_dtype_name(cfg.dtype)
cls = import_class_by_path(cfg.class_path)

Expand All @@ -602,7 +662,7 @@ def main(cfg: HfExportConfig) -> None:
model = cls(model_cfg)
model.configure_model(distributed_setup=strategy.distributed_setup)

load_checkpoint(model, cfg.ckpt_path)
load_checkpoint(model, cfg.ckpt_path, cfg.checkpoint_format)

# Consolidate DTensors to regular tensors and save on rank 0.
consolidated = consolidate_state_dict(model)
Expand All @@ -616,10 +676,13 @@ def main(cfg: HfExportConfig) -> None:
model_cfg["init_configure_model"] = True
model_cfg["pretrained_weights"] = False
model = cls(model_cfg)
load_checkpoint(model, cfg.ckpt_path)
model = model.to(str_to_dtype(cfg.dtype))
model.save_pretrained(cfg.output_dir, config=_hf_export_config(model, cfg.dtype))
save_llm_backbone_config(model, cfg.output_dir)
load_checkpoint(model, cfg.ckpt_path, cfg.checkpoint_format)
if cfg.checkpoint_format == "nemo_rl":
save_hf_checkpoint(model, consolidate_state_dict(model), cfg)
else:
model = cast_model_for_export(model, cfg.dtype)
model.save_pretrained(cfg.output_dir, config=_hf_export_config(model, cfg.dtype))
save_llm_backbone_config(model, cfg.output_dir)
_try_prepare_for_vllm(cfg.output_dir, model_cfg)


Expand Down
4 changes: 4 additions & 0 deletions nemo/__init__.py
Loading
Loading