feat(speechlm): support native RL checkpoints and frozen CTC export by vmendelev · Pull Request #16387 · NVIDIA-NeMo/Speech · GitHub
Skip to content

feat(speechlm): support native RL checkpoints and frozen CTC export - #16387

Open
vmendelev wants to merge 10 commits into
mainfrom
task/speechlm-rl-main
Open

vmendelev wants to merge 10 commits into
mainfrom
task/speechlm-rl-main

Conversation

@vmendelev

@vmendelev vmendelev commented Oct 15, 2026 •

Copy link
Copy Markdown
Collaborator

What does this PR do?

Enable native SpeechLM RL loading, selected refits and canonical HF export while preserving optional frozen CTC timestamp artifacts and the exact configured parameter precision.

Collection: SpeechLM2.

Changelog

  • Apply Automodel's explicit parameter configuration before model loading and perception activation-checkpoint wrapping; retain frozen BF16 frontend state and native MTP state.
  • Register and freeze bundled CTC decoder/tokenizer state. Preserve its original storage and metadata through strict HF loading, native checkpoint resume and HF export.
  • Add strict native NeMo-RL checkpoint loading and validated partial perception refits, with optional bounded refit verification.
  • Extend the canonical exporter with checkpoint_format=nemo_rl, intrinsic-dtype preservation and atomic publication. Add a helper that derives the export configuration from the original HF seed.
  • Preserve namespace co-imports and current Transformers config serialization; document the native RL workflow.

Usage

Prepare the export configuration from the original full HF seed:

python examples/speechlm2/prepare_rl_export_config.py \
  --seed /shared/models/speechlm-seed --output /shared/results/export-config.json

Then run the canonical converter in an eight-GPU allocation with the prepared environment:

python -m torch.distributed.run --standalone --nproc-per-node=8 \
  examples/speechlm2/to_hf.py \
  class_path=nemo.collections.speechlm2.models.SALMAutomodel \
  checkpoint_format=nemo_rl \
  ckpt_path=/shared/results/gspo/checkpoints/step_400/policy/weights/model \
  ckpt_config=/shared/results/export-config.json output_dir=/shared/results/export

Validation and GitHub Actions CI

Latest focused CPU run: 77 passed across test_ctc_timestamp_checkpoint.py, test_weight_loading.py, test_native_export.py, test_to_hf.py and test_hf_hub.py under tests/collections/speechlm2. Run these with pytest --cpu. Branch pre-commit and diff checks passed.

Earlier real training/resume/export exercised the CTC preservation implementation on separately pinned image-compatible sources. A fresh current-main distributed EP/vLLM run has not been completed. Frozen CTC preservation does not establish unchanged timestamp accuracy after encoder updates.

Before your PR is "Ready for review"

  • Contributor guidelines and author-matching DCO signoffs.
  • Strict loading, precision, CTC, refit and export regression tests.
  • Updated SpeechLM training/export documentation.
  • Optional Automodel/vLLM components are affected; existing optional import boundaries are retained.

PR Type: New feature, bugfix, documentation.

Additional Information

Depends on the companion Automodel precision-selection API. The companion NeMo-RL recipe consumes these loading/refit/export APIs. No existing issue is closed.

Companion PRs

Signed-off-by: Valentin Mendelev <vmendelev@nvidia.com>
…-main-20261015

Signed-off-by: Valentin Mendelev <vmendelev@nvidia.com>

# Conflicts:
#	nemo/collections/speechlm2/models/salm_automodel.py
#	nemo/collections/speechlm2/parts/hf_hub.py
Signed-off-by: Valentin Mendelev <vmendelev@nvidia.com>
@copy-pr-bot

copy-pr-bot Bot commented Oct 15, 2026

Copy link
Copy Markdown

@vmendelev

Copy link
Copy Markdown
Collaborator Author

/ok to test 1a996c5

from nemo.utils import logging, logging_mode

if TYPE_CHECKING:
from nemo_automodel.components.utils.parameter_config import ParameterConfig
Comment thread nemo/collections/speechlm2/parts/hf_hub.py Fixed
Set parameter_config on the bare SALMAutomodel used by the MTP tests, and
compare checkpoint round-trips against CPU copies of the source tensors,
since other speechlm2 modules make CUDA the default device on GPU runners.

Signed-off-by: Valentin Mendelev <vmendelev@nvidia.com>
Signed-off-by: Valentin Mendelev <vmendelev@nvidia.com>
Signed-off-by: Valentin Mendelev <vmendelev@nvidia.com>
@vmendelev

Copy link
Copy Markdown
Collaborator Author

/ok to test a8165e7

…ed link

Signed-off-by: Valentin Mendelev <vmendelev@nvidia.com>
@github-actions

Copy link
Copy Markdown
Contributor

[🤖]: Hi @vmendelev 👋,

We wanted to let you know that a CICD pipeline for this PR just finished successfully.

So it might be time to merge this PR or get some approvals.

@vmendelev

Copy link
Copy Markdown
Collaborator Author

/ok to test 8a1b217

@github-actions

Copy link
Copy Markdown
Contributor

[🤖]: Hi @vmendelev 👋,

We wanted to let you know that a CICD pipeline for this PR just finished successfully.

So it might be time to merge this PR or get some approvals.

2. **W&B Keys**: Update Weights & Biases API keys in configuration files
3. **Batch Size Tuning**: Adjust batch size based on your GPU memory and model size

Refit destination diagnostics

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This section is too much of an implementation detail IMO, I'd skip it

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed the section. The opt-in check is now described in the refit_verification.py module docstring instead.

Done in 4fd4c20.

parameter surface, training/resume commands, and canonical HF export. It supports
compatible full SpeechLM checkpoints with or without a bundled CTC timestamp head.

For CTC-bearing checkpoints, Speech registers the head as frozen state and retains

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This paragraph seems unnecessary

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed.

Done in 4fd4c20.

shared speech encoder can still affect timestamp quality even though the head is
frozen; timestamp accuracy requires a separate evaluation.

Use ``examples/speechlm2/prepare_rl_export_config.py --seed <full-hf-seed> --output <config.json>``

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what is "full hf seed"?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It meant the Hugging Face model directory the GSPO run started from. The command now uses --seed <hf-model-dir> and says that directory contains config.json, llm_backbone/ and the tokenizer.

Done in 4fd4c20.

dtype: str = "bfloat16"

# Native NeMo-RL uses flat DCP tensors under weights/model.
checkpoint_format: str = "lightning"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The default format's name should be "safetensors", Lightning's DCP is the input not the output

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

checkpoint_format is the format of the input ckpt_path: lightning, or the nemo_rl DCP directory that NeMo RL saves. The output is always HF safetensors. I kept the name and rewrote the comment to say this.

Done in 4fd4c20.

Comment thread examples/speechlm2/to_hf.py Outdated
save_llm_backbone_config(model, cfg.output_dir)
load_checkpoint(model, cfg.ckpt_path, cfg.checkpoint_format)
if cfg.checkpoint_format == "nemo_rl":
# The canonical saver preserves intrinsically-FP32 Mamba/router

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unnecessary comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed.

Done in 4fd4c20.

# prevent_freeze_params pattern is set for configure_optimizers.
ensure_lora_trainable(self)

if device_mesh is None:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are you sure that skipping device_mesh is None condition here is not breaking anything?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. Without parameter_config, the no-mesh path behaves as before:

  • Perception is still not cast: the cast now runs only with a device mesh or a parameter_config.
  • Perception activation checkpointing is still applied. It moved after the LoRA setup, which only touches the LLM, so the order doesn't matter.
  • The same early return (maybe_load_pretrained_models and the MTP training mode) follows.

The early return moved below the parameter_config block so that single-process construction, such as to_hf.py export, also applies the selected parameter dtypes before activation-checkpoint wrapping. The unchanged path is exercised by the shared fixture in tests/collections/speechlm2/test_salm_automodel.py, which calls configure_model() without a mesh.

*,
require_complete: bool,
) -> set[str]:
"""Validate all supplied state before mutation and optionally require full coverage.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand this docstring

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rewrote it: the loader checks every name and shape before copying anything. The first load must cover every perception tensor; later RL weight updates may send only some of them. The function now lives in weight_loading.py (see the merge thread).

Done in 4fd4c20.

# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

"""Opt-in, bounded verification of received refit weights after normal loading.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this docstring explain what do you mean by "Refit"

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docstring now starts with the definition: in NeMo RL, a refit copies the trainer's updated weights into the vLLM generation engine after each training step. It also describes how to enable the check, which used to be in the docs.

Done in 4fd4c20.

@@ -0,0 +1,114 @@
# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we merge perception_weight_loading.py into this file?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Merged: load_perception_state is now in weight_loading.py, and perception_weight_loading.py is deleted.

Done in 4fd4c20.

Comment thread nemo/__init__.py
from pkgutil import extend_path

# Speech and telemetry distributions install distinct modules under nemo.*.
__path__ = extend_path(__path__, __name__)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what is this doing?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NeMo RL depends on nemo-lens for telemetry, which installs nemo.lens into the same nemo package. The GSPO guide installs Speech in editable mode, so without extend_path the nemo package resolves only to the Speech checkout and import nemo.lens fails. extend_path lets Python find the other installed nemo.* modules and changes nothing otherwise. tests/test_namespace_package.py covers it. I rewrote the comment to say this.

Done in 4fd4c20.

@pzelasko

Copy link
Copy Markdown
Collaborator

/review

@svcnvidia-nemo-ci svcnvidia-nemo-ci left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed all 22 changed files and traced the affected checkpoint, precision, export, and serving-loader paths. Five correctness or validation issues need changes; details and proposed acceptance checks are inline.

The added tests cover useful isolated round trips, but do not exercise the new explicit parameter-policy path or the identified native-export cases. Documentation was also checked against the implementation.

This was a static review: no tests, GPU jobs, or distributed export/refit runs were executed. Suggested patches require author verification.

Note: formal approval is not available for this request, so this review is posted as a comment only.

Review generated by codex. This was a full review. Request a full review with /review reset=true.

# The LLM is already in the target dtype (loaded via torch_dtype=dtype).
# FSDP2 requires uniform parameter dtype, so we cast all parameters.
if dtype != torch.float32:
if dtype != torch.float32 and (device_mesh is not None or self.parameter_config is not None):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Apply the explicit parameter dtype policy before any blanket cast

Failure: With torch_dtype=bfloat16 and a ParameterConfig selecting FP32 trainable perception parameters, this casts those parameters to BF16 before apply_parameter_config casts them back to FP32. For example, a pristine weight of 0.1234567 becomes approximately 0.12353515625, stored as FP32. This affects initialization from pretrained ASR weights, which setup_speech_encoder has already loaded; without a subsequent full-model checkpoint load, the precision loss is permanent for that model instance.

Correction: Let the explicit policy cast parameters directly from their pristine values. Preserve the existing buffer conversion separately. A suggested replacement for this cast block is:

if dtype != torch.float32:
    if self.parameter_config is None:
        if device_mesh is not None:
            self.perception.to(dtype=dtype)
    else:
        for module in self.perception.modules():
            for name, buffer in module._buffers.items():
                if buffer is not None and (
                    buffer.is_floating_point() or buffer.is_complex()
                ):
                    module._buffers[name] = buffer.to(dtype=dtype)

Keep the following apply_parameter_config call before activation-checkpoint wrapping. The invariant is that explicit FP32 parameters must never pass through BF16 storage first.

Preservation and acceptance: Add a proposed configure_model unit test with a tiny mocked perception stack, a selected FP32 weight initialized to 0.1234567, a frozen BF16-selected parameter, and a floating frontend buffer. Assert exact equality for the selected FP32 value, the declared frozen dtype/trainability, and the existing frontend-buffer cast. Cover mesh and no-mesh construction. Without ParameterConfig, retain the current distributed BF16 cast and the no-mesh behavior that leaves perception storage unchanged. These checks are proposed, not executed.

Comment thread examples/speechlm2/to_hf.py Outdated
# tensors. A blanket model.to(BF16) would round them before saving.
save_hf_checkpoint(model, consolidate_state_dict(model), cfg)
else:
model = model.to(str_to_dtype(cfg.dtype))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Preserve CTC storage in the single-process Lightning export path

Failure: A Lightning checkpoint containing the newly registered CTC artifact takes this branch unless checkpoint_format=nemo_rl. The whole-model .to(bfloat16) rounds FP32 CTC decoder weights before HFHubMixin._save_pretrained saves them. Thus a CTC weight such as 1.000123 is exported as BF16 1.0, despite the preservation contract. The canonical saver exempts CTC tensors, but this path bypasses it.

Correction: Exclude the artifact from this dtype-only cast while retaining the existing save_pretrained serialization, including its shared-tensor handling. One suggested replacement for model.to(...) is:

artifact = getattr(model, "ctc_timestamp", None)
protected = set() if artifact is None else {
    id(t) for t in (*artifact.parameters(), *artifact.buffers())
}
target_dtype = str_to_dtype(cfg.dtype)

def cast_for_export(tensor):
    if id(tensor) in protected or not (
        tensor.is_floating_point() or tensor.is_complex()
    ):
        return tensor
    return tensor.to(dtype=target_dtype)

model = model._apply(cast_for_export)

The invariant is that CTC parameters and tokenizer bytes retain their loaded storage and values, while ordinary floating weights still use the requested export dtype.

Acceptance: Add a proposed test through the non-native single-process branch using the existing CTC bundle fixture and dtype=bfloat16. Reload the output and assert exact FP32 CTC values, unchanged uint8 tokenizer bytes, identical CTC metadata, and BF16 ordinary weights. Retain the existing native/distributed saver preservation assertions and no-CTC Lightning behavior. This patch and these checks have not been executed.

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["retain_native_mtp"] = True

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Do not invent an MTP head when exporting a headless native checkpoint

Failure: Native export unconditionally sets retain_native_mtp=True. For a valid SpeechLM checkpoint whose backbone has MTP depth zero and whose SpeechLM MTP block is disabled, configure_model consequently supplies positive-depth MTP overrides. The LLM loader treats the absent head as a request to initialize a fresh one. Strict native loading then rejects the checkpoint for missing llm.mtp.* tensors. Export should retain the actual topology, including the absence of a head.

Correction: Resolve whether a head is expected before overriding the training switch. For example, in the native-format block in main, preserve the original enabled/retention switches and resolve the backbone config before calling this helper:

from transformers import AutoConfig
from nemo.collections.speechlm2.parts.pretrained import _automodel_config_mtp_depth

mtp_requested = bool((model_cfg.get("mtp") or {}).get("enabled", False))
retention_requested = bool(model_cfg.get("retain_native_mtp", False))
backbone_cfg = AutoConfig.from_pretrained(
    model_cfg["pretrained_llm"],
    trust_remote_code=model_cfg.get("trust_remote_code", False),
)
model_cfg = prepare_native_export_model_config(model_cfg)
model_cfg["retain_native_mtp"] = (
    mtp_requested or retention_requested or _automodel_config_mtp_depth(backbone_cfg) > 0
)

Do not infer optionality solely from missing checkpoint tensors: that would weaken strict coverage for a genuinely expected head.

Preservation and acceptance: Proposed tests should export a depth-zero/disabled native model successfully with no MTP tensors and disabled output metadata; retain a native positive-depth head; retain repeated-head physical depth one plus its logical depth; and retain a recipe-created head when the original SpeechLM MTP switch was enabled even though the base backbone had depth zero. Keep the existing assertion that an explicitly requested retained head which was not constructed fails export. No tests were executed.

Comment thread examples/speechlm2/to_hf.py Outdated
model_cfg = deepcopy(model_cfg)
model_cfg["retain_native_mtp"] = True
model_cfg["preserve_parameter_dtypes"] = True
model_cfg["mtp"] = {**model_cfg.get("mtp", {}), "enabled": False}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Normalize nullable optional configuration blocks before merging

Failure: prepare_native_export_model_config({"mtp": None}) raises TypeError at **None; the same happens with automodel_backend=None. Both are valid disabled/default representations in the model constructor: MTP is normalized with or {}, and a null backend is explicitly skipped. A seed/config with either null block therefore fails before native checkpoint loading.

Correction: Use these replacements in this helper:

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,
}

Apply the same nullable-MTP normalization to the retained-head merge in _hf_export_config, since direct model configurations can retain a head while leaving the optional training block null.

Preservation and acceptance: Parameterize proposed tests over absent, null, and dictionary-valued blocks. Absent/null inputs must prepare successfully; dictionary inputs must retain backend settings and repeated-head options while changing only the intended switches. Preserve the existing assertion that preparation does not mutate the input. With an attached positive-depth retained head and mtp=None, config export must produce enabled MTP metadata rather than a TypeError. These are static deductions and proposed checks, not executed results.

loaded = load_perception_state(
self.perception,
perception_weights,
require_complete=requires_exact_architecture and not self._perception_weights_initialized,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Initialize the new loader state in the existing FP8 test fixture

Failure: This new unconditional attribute access breaks the existing test_plugin_perception_loader_loads_fp8_checkpoint. That test constructs the model through object.__new__, initializes nn.Module, and sets _uses_pe_encoder=True, but never runs the constructor that initializes _perception_weights_initialized. In a vLLM-enabled test environment, a valid FP8 checkpoint now raises AttributeError before loading instead of satisfying the existing regression assertions.

Correction: Add this line to that fixture before _load_perception_weights(state):

model._perception_weights_initialized = False

Production construction already initializes the flag correctly; this does not require weakening the first-load coverage check.

Preservation and acceptance: Run that test in a vLLM-enabled environment and retain its assertions that every perception name is reported loaded, FP8 weight bytes match exactly, and the diarization weights are BF16. Also retain the neighboring invalid-quantization test's ValueError expectation and the updated PE loader test's initial-complete/subsequent-partial behavior. The failure is established by source inspection; tests were not executed here.

…ight loading

- Drop the refit diagnostics section and the CTC paragraph from the
  training guide; explain the --seed directory for the export config.
- Clarify that to_hf.py checkpoint_format describes ckpt_path, and drop an
  unnecessary comment.
- Simplify the train_gate comment in SALMAutomodel.
- Merge load_perception_state into parts/weight_loading.py with a clearer
  docstring.
- Explain refits and how to enable the check in refit_verification.py.
- Explain why nemo/__init__.py extends the package path.

Signed-off-by: Valentin Mendelev <vmendelev@nvidia.com>
…ccept null config blocks

Cast perception parameters once through parameter_config instead of rounding them through the training dtype first, keep the CTC timestamp artifact's storage in Lightning export, accept null mtp/automodel_backend blocks in native export, and initialize the perception loader flag in the FP8 vLLM test.

Signed-off-by: Valentin Mendelev <vmendelev@nvidia.com>
@vmendelev

Copy link
Copy Markdown
Collaborator Author

/ok to test 758f9c6

@github-actions

Copy link
Copy Markdown
Contributor

[🤖]: Hi @vmendelev 👋,

We wanted to let you know that a CICD pipeline for this PR just finished successfully.

So it might be time to merge this PR or get some approvals.

Keep main's strict init_from_checkpoint parameter coverage and add exact coverage for strict from_pretrained. Use main's NeMoSpeechLMConfig.validate.

Signed-off-by: Valentin Mendelev <vmendelev@nvidia.com>
@vmendelev

Copy link
Copy Markdown
Collaborator Author

/ok to test 8896150

@github-actions

Copy link
Copy Markdown
Contributor

This branch was successfully deployed

2 active deployments
public — 88961500 Deployed Oct 21, 2026 by copy-pr-bot[bot] via release / finalize / notify #1716
test — 88961500 Deployed Oct 21, 2026 by copy-pr-bot[bot] via cicd-wait-in-queue #21345
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.

4 participants