chore: import upstream snapshot with attribution
Create PR to main with cherry-pick from release / cherry-pick (push) Failing after 0s
CICD NeMo / pre-flight (push) Failing after 0s
CICD NeMo / configure (push) Has been skipped
Build, validate, and release Neural Modules / pre-flight (push) Failing after 1s
CICD NeMo / code-linting (push) Has been skipped
Build, validate, and release Neural Modules / release (push) Has been skipped
Build, validate, and release Neural Modules / release-summary (push) Has been cancelled
CICD NeMo / cicd-test-container-build (push) Has been cancelled
CICD NeMo / cicd-import-tests (push) Has been cancelled
CICD NeMo / L0_Setup_Test_Data_And_Models (push) Has been cancelled
CICD NeMo / cicd-main-unit-tests (push) Has been cancelled
CICD NeMo / cicd-main-speech (push) Has been cancelled
CICD NeMo / Nemo_CICD_Test (push) Has been cancelled
CICD NeMo / Coverage (e2e) (push) Has been cancelled
CICD NeMo / Coverage (unit-test) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
CICD NeMo / cicd-wait-in-queue (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:28:58 +08:00
commit ba4be087d5
2316 changed files with 2668701 additions and 0 deletions
@@ -0,0 +1,63 @@
# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Shared fakes for SALM / SALMAutomodel encoder-chunking tests."""
from types import SimpleNamespace
import torch
def chunking_test_devices():
devices = [torch.device("cpu")]
if torch.cuda.is_available():
devices.append(torch.device("cuda"))
return devices
class ChunkingTestTokenizer:
pad = 0
unk_id = None
bos_id = 1
eos_id = 2
def __init__(self, audio_locator_tag):
self._audio_locator_tag = audio_locator_tag
def token_to_id(self, token):
assert token == self._audio_locator_tag
return 99
class ChunkingTestPerception(torch.nn.Module):
def __init__(self, sampling_rate, hop_length):
super().__init__()
self.preprocessor = SimpleNamespace(featurizer=_ChunkingTestFeaturizer(sampling_rate, hop_length))
self.calls = []
self.time_offsets = []
self.spk_targets_calls = []
def forward(self, input_signal=None, input_signal_length=None, time_offset=None, spk_targets=None):
self.calls.append((input_signal.detach().clone(), input_signal_length.detach().clone()))
self.time_offsets.append(None if time_offset is None else time_offset.detach().clone())
self.spk_targets_calls.append(None if spk_targets is None else spk_targets.detach().clone())
max_len = int(input_signal_length.max().item()) if input_signal_length.numel() > 0 else 0
return input_signal[:, :max_len].unsqueeze(-1), input_signal_length.clone()
class _ChunkingTestFeaturizer:
def __init__(self, sampling_rate, hop_length):
self.sample_rate = sampling_rate
self.hop_length = hop_length
def get_seq_len(self, seq_len):
return torch.floor_divide(seq_len, self.hop_length).to(dtype=torch.long)
@@ -0,0 +1,97 @@
# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import lightning.pytorch as pl
import torch
import torch.nn.functional as F
from nemo.core.neural_types import AudioSignal, LabelsType, LengthsType, NeuralType
class SingleBlockDistributedOOMptimizerModel(pl.LightningModule):
"""Single-transformer-block model used by the distributed OOMptimizer functional test."""
def __init__(self, cfg: dict):
super().__init__()
self.vocab_size = int(cfg.get("vocab_size", 64))
self.sample_rate = int(cfg.get("sample_rate", 32))
self.frame_stride = int(cfg.get("frame_stride", 4))
hidden_size = int(cfg.get("hidden_size", 128))
num_heads = int(cfg.get("num_heads", 4))
ffn_hidden_size = int(cfg.get("ffn_hidden_size", hidden_size * 4))
dropout = float(cfg.get("dropout", 0.0))
self.activation_reserve_elements_per_frame = int(
float(cfg.get("activation_reserve_mb_per_frame", 0.0)) * 1024 * 1024 // 4
)
self.max_activation_reserve_frames = int(cfg.get("max_activation_reserve_frames", 160))
self.input_projection = torch.nn.Linear(self.frame_stride, hidden_size)
self.encoder = torch.nn.TransformerEncoderLayer(
d_model=hidden_size,
nhead=num_heads,
dim_feedforward=ffn_hidden_size,
dropout=dropout,
batch_first=True,
norm_first=True,
)
self.classifier = torch.nn.Linear(hidden_size, self.vocab_size)
@property
def oomptimizer_schema(self) -> dict:
return {
"cls": dict,
"inputs": [
{"name": "audio", "type": NeuralType(("B", "T"), AudioSignal()), "seq_length": "input"},
{"name": "audio_lens", "type": NeuralType(("B",), LengthsType()), "seq_length": "input"},
{
"name": "tokens",
"type": NeuralType(("B", "T"), LabelsType()),
"seq_length": "output",
"vocab_size": self.vocab_size,
},
],
}
def training_step(self, batch: dict, batch_idx: int) -> dict:
audio = batch["audio"].float()
tokens = batch["tokens"].long()
pad = (-audio.shape[1]) % self.frame_stride
if pad:
audio = F.pad(audio, (0, pad))
frames = audio.reshape(audio.shape[0], -1, self.frame_stride)
hidden = self.input_projection(frames)
hidden = self.encoder(hidden)
logits = self.classifier(hidden.mean(dim=1))
target = tokens[:, 0].remainder(self.vocab_size)
loss = F.cross_entropy(logits, target)
self._reserve_peak_memory(hidden)
return {"loss": loss}
def _reserve_peak_memory(self, hidden: torch.Tensor) -> None:
if self.activation_reserve_elements_per_frame <= 0:
return
reserve_frames = min(int(hidden.shape[1]), self.max_activation_reserve_frames)
if reserve_frames <= 0:
return
# Keep transformer compute small while making memory pressure scale with sequence length.
reserve = hidden.new_empty(
(int(hidden.shape[0]), reserve_frames, self.activation_reserve_elements_per_frame), dtype=torch.float32
)
reserve[:, :, :1].zero_()
def configure_optimizers(self) -> dict:
return {"optimizer": torch.optim.SGD(self.parameters(), lr=1e-3)}
@@ -0,0 +1,137 @@
# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import torch
from nemo.collections.speechlm2.models.salm import replace_placeholders_and_build_targets
def test_replace_placeholders():
# fmt: off
PAD = 0
AUDIO = 100
input_ids = torch.tensor([
[7 , AUDIO, 1, 2 , AUDIO, 1],
[PAD, PAD, 3, AUDIO, 4 , 5] # note: left padding required
])
loss_mask = torch.tensor([
[False, False, False, False, False, True], # predict last token
[False, False, False, False, True , True] # predict last two tokens
])
embeds = torch.ones(2, 6, 2)
embeds[1, :2] = 0 # note: indicate left padding
# 3 embedding sequences with varying shapes, corresponding to 3 AUDIO tokens
replacements = [
torch.full((4, 2), fill_value=2.0),
torch.full((3, 2), fill_value=3.0),
torch.full((2, 2), fill_value=4.0),
]
embeds_r, targets_r, attention_mask_r = replace_placeholders_and_build_targets(
input_ids=input_ids,
embeds=embeds,
padding_id=PAD,
placeholder_id=AUDIO,
replacements=replacements,
target_ids=input_ids.where(loss_mask, -100)
)
assert embeds_r.shape == (2, 11, 2)
# batch item 0
assert (embeds_r[0, 0] == 1.0).all() # 1=orig
assert (embeds_r[0, 1:5] == 2.0).all() # 2=repl
assert (embeds_r[0, 5:7] == 1.0).all() # 1=orig
assert (embeds_r[0, 7:10] == 3.0).all() # 3=repl
assert (embeds_r[0, 10] == 1.0).all() # 1=orig
# batch item 1
assert (embeds_r[1, :6] == 0.0).all() # 0=pad
assert (embeds_r[1, 6:7] == 1.0).all() # 1=orig
assert (embeds_r[1, 7:9] == 4.0).all() # 4=repl
assert (embeds_r[1, 9:] == 1.0).all() # 1=orig
assert targets_r.shape == (2, 11)
torch.testing.assert_close(
targets_r,
torch.tensor([
[-100, -100, -100, -100, -100, -100, -100, -100, -100, -100, 1],
[-100, -100, -100, -100, -100, -100, -100, -100, -100, 4 , 5],
])
)
assert attention_mask_r.shape == (2, 11)
torch.testing.assert_close(
attention_mask_r,
torch.tensor([
[True, True, True, True, True, True, True, True, True, True, True],
[False, False, False, False, False, False, True, True, True, True, True],
])
)
# fmt: on
def test_replace_placeholders_removes_excessive_left_padding():
# fmt: off
PAD = 0
AUDIO = 100
input_ids = torch.tensor([
[7 , AUDIO, 1 , 2],
[PAD, PAD, AUDIO, 3] # note: left padding required
])
loss_mask = torch.tensor([
[False, False, True , True], # predict last two tokens
[False, False, False, True] # predict last token
])
embeds = torch.ones(2, 4, 2)
embeds[1, :2] = 0 # note: indicate left padding
# 3 embedding sequences with varying shapes, corresponding to 3 AUDIO tokens
replacements = [
torch.full((3, 2), fill_value=2.0),
torch.full((5, 2), fill_value=4.0),
]
embeds_r, targets_r, attention_mask_r = replace_placeholders_and_build_targets(
input_ids=input_ids,
embeds=embeds,
padding_id=PAD,
placeholder_id=AUDIO,
replacements=replacements,
target_ids=input_ids.where(loss_mask, -100)
)
assert embeds_r.shape == (2, 6, 2)
# batch item 0
assert (embeds_r[0, 0 ] == 1.0).all() # 1=orig
assert (embeds_r[0, 1:4] == 2.0).all() # 2=repl
assert (embeds_r[0, 4: ] == 1.0).all() # 1=orig
# batch item 1
assert (embeds_r[1, :5] == 4.0).all() # 4=repl
assert (embeds_r[1, 5 ] == 1.0).all() # 1=orig
assert targets_r.shape == (2, 6)
torch.testing.assert_close(
targets_r,
torch.tensor([
[-100, -100, -100, -100, 1, 2],
[-100, -100, -100, -100, -100, 3],
])
)
assert attention_mask_r.shape == (2, 6)
torch.testing.assert_close(
attention_mask_r,
torch.tensor([
[True, True, True, True, True, True],
[True, True, True, True, True, True],
])
)
# fmt: on
@@ -0,0 +1,115 @@
# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import pytest
import torch
from lhotse import CutSet
from lhotse.testing.dummies import DummyManifest
from lightning.pytorch.utilities import CombinedLoader
from omegaconf import DictConfig
from nemo.collections.common.data.lhotse.broadcasting import BroadcastingDataLoader
from nemo.collections.common.tokenizers.sentencepiece_tokenizer import SentencePieceTokenizer, create_spt_model
from nemo.collections.speechlm2.data import DataModule
@pytest.fixture
def data_config(tmp_path):
ap, cp = tmp_path / "audio", str(tmp_path) + "/{tag}_cuts.jsonl.gz"
def _assign(k, v):
def _inner(obj):
setattr(obj, k, v)
return obj
return _inner
for tag in ("train", "val_set_0", "val_set_1"):
(
DummyManifest(CutSet, begin_id=0, end_id=2, with_data=True)
.map(_assign("tag", tag))
.save_audios(ap)
.drop_in_memory_data()
.to_file(cp.format(tag=tag))
)
return DictConfig(
{
"train_ds": {
"input_cfg": [
{
"type": "lhotse",
"cuts_path": cp.format(tag="train"),
}
],
"batch_size": 2,
},
"validation_ds": {
"datasets": {
"val_set_0": {"cuts_path": cp.format(tag="val_set_0")},
"val_set_1": {"cuts_path": cp.format(tag="val_set_1")},
},
"batch_size": 2,
},
}
)
@pytest.fixture
def tokenizer(tmp_path_factory):
tmpdir = tmp_path_factory.mktemp("tok")
text_path = tmpdir / "text.txt"
text_path.write_text("\n".join(chr(i) for i in range(256)))
create_spt_model(
text_path,
vocab_size=512,
sample_size=-1,
do_lower_case=False,
output_dir=str(tmpdir),
bos=True,
eos=True,
remove_extra_whitespaces=True,
)
return SentencePieceTokenizer(str(tmpdir / "tokenizer.model"))
class Identity(torch.utils.data.Dataset):
def __getitem__(self, item):
return item
def test_datamodule_train_dataloader(data_config, tokenizer):
data = DataModule(data_config, tokenizer=tokenizer, dataset=Identity())
dl = data.train_dataloader()
assert isinstance(dl, (BroadcastingDataLoader, torch.utils.data.DataLoader))
dli = iter(dl)
batch = next(dli)
assert isinstance(batch, CutSet)
assert len(batch) == 2
assert all(c.tag == "train" for c in batch)
def test_datamodule_validation_dataloader(data_config, tokenizer):
val_sets = {"val_set_0", "val_set_1"}
data = DataModule(data_config, tokenizer=tokenizer, dataset=Identity())
dl = data.val_dataloader()
assert isinstance(dl, CombinedLoader)
dli = iter(dl)
batch, batch_idx, dataloader_idx = next(dli)
assert isinstance(batch, dict)
assert batch.keys() == val_sets
for vs in val_sets:
assert len(batch[vs]) == 2
assert all(c.tag == vs for c in batch[vs])
@@ -0,0 +1,402 @@
# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import os
from types import SimpleNamespace
import pytest
import torch
import torch.distributed as dist
from lhotse import CutSet
from lhotse.testing.dummies import DummyManifest
from omegaconf import DictConfig
try:
from torch.distributed._local_tensor import LocalTensorMode # noqa: E402
except ImportError:
pytest.skip("Local tensor mode requires PyTorch >= 2.10", allow_module_level=True)
from lightning.pytorch.strategies.model_parallel import _setup_device_mesh
from nemo.collections.common.data.lhotse import get_lhotse_dataloader_from_config
from nemo.collections.common.tokenizers.sentencepiece_tokenizer import SentencePieceTokenizer, create_spt_model
from nemo.collections.speechlm2.data import DataModule
nemo_automodel = pytest.importorskip("nemo_automodel")
from nemo_automodel.components.distributed.config import FSDP2Config # noqa: E402
from nemo.collections.speechlm2.parts.parallel import AutomodelParallelStrategy # noqa: E402
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def fake_dist():
"""Set up minimal env for fake distributed backend, tear down after test."""
os.environ.setdefault("MASTER_ADDR", "localhost")
os.environ.setdefault("MASTER_PORT", "0")
yield
if dist.is_initialized():
dist.destroy_process_group()
@pytest.fixture
def tokenizer(tmp_path_factory):
tmpdir = tmp_path_factory.mktemp("tok")
text_path = tmpdir / "text.txt"
text_path.write_text("\n".join(chr(i) for i in range(256)))
create_spt_model(
text_path,
vocab_size=512,
sample_size=-1,
do_lower_case=False,
output_dir=str(tmpdir),
bos=True,
eos=True,
remove_extra_whitespaces=True,
)
return SentencePieceTokenizer(str(tmpdir / "tokenizer.model"))
class Identity(torch.utils.data.Dataset):
def __getitem__(self, item):
return item
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _extract_rank_mapping(sym_val, world_size):
"""Extract ``{global_rank: value}`` mapping from a SymInt produced by LocalTensorMode.
Inside ``LocalTensorMode``, ``DeviceMesh.get_local_rank()`` returns a
``SymInt`` backed by a ``LocalIntNode`` (per-rank varying values) or a
``ConstantIntNode`` (same value on every rank). Arithmetic on these
``SymInt`` values preserves the per-rank structure.
This helper unwraps the result into a plain dict for easy assertions.
"""
if isinstance(sym_val, int):
return {r: sym_val for r in range(world_size)}
node = sym_val.node
if hasattr(node, "_local_ints"):
return dict(node._local_ints)
# ConstantIntNode same value for every rank.
val = node.maybe_as_int() if hasattr(node, "maybe_as_int") else node.val
return {r: val for r in range(world_size)}
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"pp,dp_rep,dp_shard,cp,tp",
[
(1, 1, 1, 1, 1),
(1, 2, 2, 1, 1),
(1, 1, 2, 1, 1),
(2, 3, 2, 1, 2),
(2, 2, 4, 2, 2),
],
)
def test_dp_rank_via_strategy(fake_dist, pp, dp_rep, dp_shard, cp, tp):
"""Verify DataModule DP rank using the real AutomodelParallelStrategy.
Uses ``LocalTensorMode`` to obtain per-rank symbolic results for every
global rank in a single process and checks that:
* The DataModule rank agrees with the strategy's flattened ``"dp"`` submesh.
* The correct number of unique DP ranks exists.
* All DP ranks are in ``[0, dp_size)``.
* Each DP rank covers exactly the non-DP ranks assigned to it.
"""
dp_size = dp_rep * dp_shard
world_size = pp * dp_size * cp * tp
dist.init_process_group(backend="fake", rank=0, world_size=world_size)
strategy = AutomodelParallelStrategy(
pp_size=pp,
tp_size=tp,
cp_size=cp,
dp_size=dp_size,
dp_replicate_size=dp_rep,
distributed_config=FSDP2Config(backend="gloo"),
)
with LocalTensorMode(world_size):
device_mesh, _ = strategy.create_device_mesh()
data = DataModule(DictConfig({"train_ds": {"batch_size": 2}}), tokenizer=None, dataset=Identity())
data.trainer = SimpleNamespace(model=SimpleNamespace(device_mesh=device_mesh))
dp_rank_data = data._get_dp_rank()
dp_world_size = data._get_world_size()
sampler_kwargs = strategy.distributed_sampler_kwargs
assert dp_world_size == dp_size
assert sampler_kwargs["num_replicas"] == dp_size
data_rank = _extract_rank_mapping(dp_rank_data, world_size)
sampler_rank = _extract_rank_mapping(sampler_kwargs["rank"], world_size)
# DataModule and strategy sampler rank must agree for every global rank.
assert data_rank == sampler_rank
# Correct number of unique dp ranks.
assert len(set(data_rank.values())) == dp_size
# All dp_ranks in valid range.
assert all(0 <= v < dp_size for v in data_rank.values())
ranks_per_dp = {dp_rank: [] for dp_rank in range(dp_size)}
for rank, dp_rank in data_rank.items():
ranks_per_dp[dp_rank].append(rank)
assert all(len(ranks) == pp * cp * tp for ranks in ranks_per_dp.values())
def test_non_dp_dims_share_dp_rank(fake_dist):
"""Ranks that differ only in pp / tp / cp get the same dp_rank."""
pp, dp_rep, dp_shard, cp, tp = 2, 3, 2, 1, 2
dp_size = dp_rep * dp_shard
world_size = pp * dp_size * cp * tp
dist.init_process_group(backend="fake", rank=0, world_size=world_size)
with LocalTensorMode(world_size):
strategy = AutomodelParallelStrategy(
pp_size=pp,
tp_size=tp,
cp_size=cp,
dp_size=dp_size,
dp_replicate_size=dp_rep,
distributed_config=FSDP2Config(backend="gloo"),
)
device_mesh, _ = strategy.create_device_mesh()
data = DataModule(DictConfig({"train_ds": {"batch_size": 2}}), tokenizer=None, dataset=Identity())
data.trainer = SimpleNamespace(model=SimpleNamespace(device_mesh=device_mesh))
dp_rank_sym = data._get_dp_rank()
rank_to_dp = _extract_rank_mapping(dp_rank_sym, world_size)
# Mesh layout: (pp=2, dp_rep=3, dp_shard=2, cp=1, tp=2)
# Rank 0: (pp=0, dp_rep=0, dp_shard=0, cp=0, tp=0)
# Rank 1: (pp=0, dp_rep=0, dp_shard=0, cp=0, tp=1) -- differs in TP
# Rank 12: (pp=1, dp_rep=0, dp_shard=0, cp=0, tp=0) -- differs in PP
# Rank 4: (pp=0, dp_rep=1, dp_shard=0, cp=0, tp=0) -- differs in dp_replicate
# Rank 2: (pp=0, dp_rep=0, dp_shard=1, cp=0, tp=0) -- differs in dp_shard
# Same DP coordinates, different non-DP dims → same dp_rank
assert rank_to_dp[0] == rank_to_dp[1], "TP variation should not change dp_rank"
assert rank_to_dp[0] == rank_to_dp[12], "PP variation should not change dp_rank"
# Different DP coordinates → different dp_rank
assert rank_to_dp[0] != rank_to_dp[4], "dp_replicate variation must change dp_rank"
assert rank_to_dp[0] != rank_to_dp[2], "dp_shard variation must change dp_rank"
def test_datamodule_get_dp_rank_automodel(fake_dist, tokenizer):
"""DataModule._get_dp_rank() / _get_world_size() return correct values."""
pp, dp_rep, dp_shard, cp, tp = 2, 3, 2, 1, 2
dp_size = dp_rep * dp_shard
world_size = pp * dp_size * cp * tp
dist.init_process_group(backend="fake", rank=0, world_size=world_size)
strategy = AutomodelParallelStrategy(
pp_size=pp,
tp_size=tp,
cp_size=cp,
dp_size=dp_size,
dp_replicate_size=dp_rep,
distributed_config=FSDP2Config(backend="gloo"),
)
# Create mesh outside LocalTensorMode → real int values for fake rank 0
device_mesh, _ = strategy.create_device_mesh()
cfg = DictConfig({"train_ds": {"batch_size": 2}})
data = DataModule(cfg, tokenizer=tokenizer, dataset=Identity())
# Wire up a mock trainer so _get_dp_rank() can find the device mesh.
data.trainer = SimpleNamespace(model=SimpleNamespace(device_mesh=device_mesh))
dp_rank = data._get_dp_rank()
dp_ws = data._get_world_size()
assert dp_rank is not None, "_get_dp_rank() returned None (missing return?)"
assert dp_ws is not None, "_get_world_size() returned None (missing return?)"
assert dp_rank == 0, f"Fake rank 0 should map to dp_rank 0, got {dp_rank}"
assert dp_ws == dp_size, f"Expected dp_world_size={dp_size}, got {dp_ws}"
def test_dataloader_data_partitioning(tmp_path, tokenizer):
"""Different dp_ranks get disjoint data; same dp_rank is deterministic."""
dp_world_size = 6
num_cuts = 24
cuts_path = str(tmp_path / "cuts.jsonl.gz")
(
DummyManifest(CutSet, begin_id=0, end_id=num_cuts, with_data=True)
.save_audios(tmp_path / "audio")
.drop_in_memory_data()
.to_file(cuts_path)
)
cfg = DictConfig(
{
"input_cfg": [{"type": "lhotse", "cuts_path": cuts_path}],
"batch_size": 2,
"force_finite": True,
"force_map_dataset": True,
"seed": 0,
"num_workers": 0,
}
)
# Collect cut IDs from each dp_rank.
ids_per_rank = {}
for dp_rank in range(dp_world_size):
dl = get_lhotse_dataloader_from_config(
config=cfg,
global_rank=dp_rank,
world_size=dp_world_size,
dataset=Identity(),
tokenizer=tokenizer,
)
ids_per_rank[dp_rank] = [c.id for batch in dl for c in batch]
# Different DP ranks must receive disjoint cuts.
for r1 in range(dp_world_size):
for r2 in range(r1 + 1, dp_world_size):
overlap = set(ids_per_rank[r1]) & set(ids_per_rank[r2])
assert not overlap, f"Ranks {r1} and {r2} share cut IDs: {overlap}"
# Union of all ranks covers the full dataset.
all_ids = set()
for ids in ids_per_rank.values():
all_ids.update(ids)
assert len(all_ids) == num_cuts
# Same dp_rank called again → identical sequence (deterministic).
for dp_rank in (0, dp_world_size - 1):
dl_again = get_lhotse_dataloader_from_config(
config=cfg,
global_rank=dp_rank,
world_size=dp_world_size,
dataset=Identity(),
tokenizer=tokenizer,
)
ids_again = [c.id for batch in dl_again for c in batch]
assert ids_per_rank[dp_rank] == ids_again, f"dp_rank={dp_rank} was not deterministic"
# ---------------------------------------------------------------------------
# Lightning ModelParallelStrategy tests
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"dp_size,tp_size",
[
(1, 1),
(1, 4),
(4, 1),
(3, 4),
(6, 2),
],
)
def test_dp_rank_via_lightning_model_parallel(fake_dist, dp_size, tp_size):
"""Verify DP rank using Lightning's ``_setup_device_mesh``.
Lightning's ``ModelParallelStrategy`` creates a 2D mesh with dimensions
``("data_parallel", "tensor_parallel")``. The DataModule reads DP rank
via ``dm["data_parallel"].get_local_rank()``.
Uses ``LocalTensorMode`` to verify every global rank in one process.
"""
world_size = dp_size * tp_size
dist.init_process_group(backend="fake", rank=0, world_size=world_size)
with LocalTensorMode(world_size):
device_mesh = _setup_device_mesh(dp_size, tp_size, world_size, torch.device("cpu"))
dp_rank_sym = device_mesh["data_parallel"].get_local_rank()
dp_world_size = device_mesh["data_parallel"].size()
assert dp_world_size == dp_size
rank_to_dp = _extract_rank_mapping(dp_rank_sym, world_size)
# Correct number of unique dp ranks.
assert len(set(rank_to_dp.values())) == dp_size
# All dp_ranks in valid range.
assert all(0 <= v < dp_size for v in rank_to_dp.values())
# Ranks sharing the same data_parallel coordinate must have the same
# dp_rank; different coordinates must differ.
mesh_tensor = torch.arange(world_size).reshape(dp_size, tp_size)
for dp_coord in range(dp_size):
ranks_in_group = mesh_tensor[dp_coord, :].flatten().tolist()
dp_ranks_in_group = {rank_to_dp[r] for r in ranks_in_group}
assert (
len(dp_ranks_in_group) == 1
), f"DP group (data_parallel={dp_coord}) maps to multiple dp_ranks: {dp_ranks_in_group}"
def test_lightning_tp_variation_does_not_change_dp_rank(fake_dist):
"""Ranks that differ only in tensor_parallel get the same dp_rank."""
dp_size, tp_size = 3, 4
world_size = dp_size * tp_size
dist.init_process_group(backend="fake", rank=0, world_size=world_size)
with LocalTensorMode(world_size):
device_mesh = _setup_device_mesh(dp_size, tp_size, world_size, torch.device("cpu"))
dp_rank_sym = device_mesh["data_parallel"].get_local_rank()
rank_to_dp = _extract_rank_mapping(dp_rank_sym, world_size)
# Mesh layout: (dp=3, tp=4)
# Rank 0: (dp=0, tp=0)
# Rank 1: (dp=0, tp=1) -- differs only in TP
# Rank 4: (dp=1, tp=0) -- differs in DP
assert rank_to_dp[0] == rank_to_dp[1], "TP variation should not change dp_rank"
assert rank_to_dp[0] == rank_to_dp[2], "TP variation should not change dp_rank"
assert rank_to_dp[0] == rank_to_dp[3], "TP variation should not change dp_rank"
assert rank_to_dp[0] != rank_to_dp[4], "DP variation must change dp_rank"
def test_datamodule_get_dp_rank_lightning_model_parallel(fake_dist, tokenizer):
"""DataModule._get_dp_rank() / _get_world_size() with Lightning's 2D mesh."""
dp_size, tp_size = 3, 4
world_size = dp_size * tp_size
dist.init_process_group(backend="fake", rank=0, world_size=world_size)
# Create mesh outside LocalTensorMode → real int values for fake rank 0
device_mesh = _setup_device_mesh(dp_size, tp_size, world_size, torch.device("cpu"))
cfg = DictConfig({"train_ds": {"batch_size": 2}})
data = DataModule(cfg, tokenizer=tokenizer, dataset=Identity())
data.trainer = SimpleNamespace(model=SimpleNamespace(device_mesh=device_mesh))
dp_rank = data._get_dp_rank()
dp_ws = data._get_world_size()
assert dp_rank is not None, "_get_dp_rank() returned None (missing return?)"
assert dp_ws is not None, "_get_world_size() returned None (missing return?)"
assert dp_rank == 0, f"Fake rank 0 should map to dp_rank 0, got {dp_rank}"
assert dp_ws == dp_size, f"Expected dp_world_size={dp_size}, got {dp_ws}"
@@ -0,0 +1,583 @@
# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import os
import pytest
import torch
from lhotse import CutSet, SupervisionSegment
from lhotse.testing.dummies import dummy_cut, dummy_recording
from nemo.collections.common.data.utils import move_data_to_device
from nemo.collections.speechlm2.data.duplex_ear_tts_dataset import (
DuplexEARTTSDataset,
add_speech_delay,
sample_audio_segments_repeat,
)
from nemo.collections.speechlm2.models import DuplexEARTTS
if torch.cuda.is_available():
torch.set_default_device('cuda')
test_eartts_config = {
"model": {
"pretrained_lm_name": "nvidia/NVIDIA-Nemotron-Nano-9B-v2",
"pretrained_ae_dir": None,
"pretrained_tts_model": None,
"trust_remote_code": True,
"scoring_asr": "stt_en_fastconformer_transducer_large",
"freeze_params": [
r"^audio_codec\..+$", # Keep audio codec frozen as it only provides supervision for training.
r"^embed_tokens\..+$", # Keep embed_tokens frozen as done in eartts
],
"bos_token": "<s>",
"eos_token": "</s>",
"pad_token": "<SPECIAL_12>",
"audio_codec_run_dtype": "float32",
"prevent_freeze_params": [],
"audio_save_path": "",
"inference_guidance_scale": 0.5,
"inference_noise_scale": 0.8,
"inference_top_p_or_k": 0.8,
"inference_guidance_enabled": False,
"subword_mask_exactly_as_eartts": False,
"context_hidden_mask_exactly_as_eartts": False,
"exclude_norm_from_wd": True,
"optimizer": {
"_target_": "torch.optim.AdamW",
"lr": 4e-5,
"betas": [0.9, 0.98],
"weight_decay": 0,
"foreach": True,
},
"lr_scheduler": {
"_target_": "nemo.core.optim.lr_scheduler.InverseSquareRootAnnealing",
"warmup_steps": 2500,
"min_lr": 1e-6,
"max_steps": 100_000_000,
},
"codec_config": {
"latent_size": 512,
"n_fft": 16,
"hop_length": 4,
"base_hidden_size": 384,
"channel_mult": [1, 2, 4],
"rates": [7, 7, 9],
"num_blocks": 3,
"kernel_size": 7,
"groups": 1,
"codebook_size": 1024,
"num_quantizers": 31,
"wav_to_token_ratio": 1764,
},
"tts_config": {
"use_gated_fusion_for_text_audio": True,
"disable_eos_prediction": True,
"use_bos_eos_emb": True,
"use_subword_flag_emb": True,
"num_delay_speech_tokens": 2,
"backbone_type": "gemma3_text",
"backbone_model_class": None,
"backbone_config_class": None,
"backbone_config": {
"hidden_size": 1152,
"intermediate_size": 4608,
"num_hidden_layers": 1,
"num_attention_heads": 16,
"num_key_value_heads": 16,
"head_dim": 72,
"attention_dropout": 0.1,
"use_cache": False,
},
"latent_size": 512,
"codebook_size": 1024,
"num_quantizers": 31,
"context_hidden_size": None,
"cas_config": {
"backbone_type": "t5gemma",
"backbone_model_class": None,
"backbone_config_class": None,
"backbone_config": {
"is_encoder_decoder": False,
"encoder": {
"hidden_size": 1152,
"intermediate_size": 4608,
"num_hidden_layers": 1,
"num_attention_heads": 16,
"num_key_value_heads": 16,
"head_dim": 72,
"use_cache": False,
"attention_dropout": 0.1,
},
},
},
"mog_head_config": {
"intermediate_size": 4608,
"num_layers": 3,
"low_rank": 64,
"num_predictions": 1024,
"min_log_std": -4.0,
"eps": 1e-6,
},
"p_uncond": 0.1,
"label_smoothing": 0.01,
"max_training_rate": 0.8,
"quantizer_dropout": 0.5,
"random_target_masking": False,
"exponent": 3.0,
},
},
"trainer": {
"devices": -1,
"accelerator": "gpu",
"num_nodes": 1,
"precision": 32,
"logger": False,
"enable_checkpointing": False,
"use_distributed_sampler": False,
"max_steps": 100_000_000,
"val_check_interval": 1000,
"limit_train_batches": "${trainer.val_check_interval}",
"limit_val_batches": 2,
"log_every_n_steps": 20,
"num_sanity_val_steps": 0,
"gradient_clip_val": 1.0,
"accumulate_grad_batches": 1,
"strategy": {
"_target_": "lightning.pytorch.strategies.DDPStrategy",
"gradient_as_bucket_view": True,
"find_unused_parameters": True,
},
},
"data": {
"add_text_bos_and_eos_in_each_turn": True,
"add_audio_prompt": True,
"audio_prompt_duration": 3.0,
"frame_length": 0.08,
"source_sample_rate": 22050,
"target_sample_rate": 22050,
"input_roles": ["user", "User"],
"output_roles": ["agent", "Assistant", "assistant", "Agent"],
},
"exp_manager": {
"exp_dir": None,
"explicit_log_dir": "",
"name": "eartts",
"create_tensorboard_logger": False,
"create_checkpoint_callback": True,
"use_datetime_version": True,
"max_time_per_run": "00:03:50:00",
"resume_from_checkpoint": None,
"resume_if_exists": True,
"resume_ignore_no_checkpoint": True,
"create_wandb_logger": True,
"wandb_logger_kwargs": {
"name": "duplex_eartts_test",
"project": "duplex_eartts",
"resume": True,
},
},
}
# set CI cached path
if os.path.exists("/home/TestData/nvidia--NVIDIA-Nemotron-Nano-9B-v2/"):
test_eartts_config["model"]["pretrained_lm_name"] = "/home/TestData/nvidia--NVIDIA-Nemotron-Nano-9B-v2/"
@pytest.fixture(scope="session")
def model():
model = DuplexEARTTS(test_eartts_config)
if torch.cuda.is_available():
model.to("cuda")
return model
@pytest.fixture(scope="session")
def dataset(model):
return DuplexEARTTSDataset(
model.tokenizer,
add_text_bos_and_eos_in_each_turn=True,
add_audio_prompt=True,
audio_prompt_duration=3.0,
frame_length=0.08,
source_sample_rate=22050,
target_sample_rate=22050,
input_roles=["user", "User"],
output_roles=["agent", "Assistant", "assistant", "Agent"],
)
@pytest.fixture(scope="session")
def training_cutset_batch():
cut = dummy_cut(0, recording=dummy_recording(0, with_data=True, duration=1.0, sampling_rate=22050))
cut.target_audio = dummy_recording(1, with_data=True, duration=1.0, sampling_rate=22050)
cut.supervisions = [
SupervisionSegment(
id=cut.id,
recording_id=cut.recording_id,
start=0,
duration=0.1,
text='hi',
speaker="user",
),
SupervisionSegment(
id=cut.id,
recording_id=cut.recording_id,
start=0.3,
duration=0.1,
text='hello',
speaker="assistant",
),
SupervisionSegment(
id=cut.id,
recording_id=cut.recording_id,
start=0.5,
duration=0.1,
text='ok',
speaker="user",
),
SupervisionSegment(
id=cut.id,
recording_id=cut.recording_id,
start=0.6,
duration=0.1,
text='okay',
speaker="assistant",
),
]
return CutSet([cut])
def test_eartts_dataset(dataset, training_cutset_batch):
batch = dataset[training_cutset_batch]
expected_keys = {
"sample_id",
"non_prompt_mask",
"prompt_lens",
"aligned_attention_mask",
"aligned_position_ids",
"source_audio",
"source_audio_lens",
"target_audio",
"target_audio_lens",
"target_text_tokens",
"target_token_lens",
"source_tokens",
"source_token_lens",
"target_texts",
"audio_prompt",
"audio_prompt_lens",
"task",
}
for key in expected_keys:
assert key in batch, f"Missing key: {key}"
tensor_keys = [
"non_prompt_mask",
"aligned_attention_mask",
"aligned_position_ids",
"source_audio",
"source_audio_lens",
"target_audio",
"target_audio_lens",
"target_text_tokens",
"target_token_lens",
"source_tokens",
"source_token_lens",
"audio_prompt",
"audio_prompt_lens",
]
for key in tensor_keys:
assert torch.is_tensor(batch[key]), f"{key} must be a tensor"
# Check target text consistency
assert batch["target_texts"] == ["hello okay"]
assert batch["source_tokens"].tolist() == [
[
2,
12,
12,
12,
12,
12,
12,
12,
12,
12,
12,
12,
12,
12,
12,
12,
12,
12,
12,
12,
12,
12,
12,
12,
12,
12,
12,
12,
12,
12,
12,
12,
12,
12,
12,
12,
12,
2,
1,
2,
12,
12,
12,
12,
1,
1662,
2,
12,
12,
12,
12,
]
]
assert batch["target_text_tokens"].tolist() == [
[
2,
12,
12,
12,
12,
12,
12,
12,
12,
12,
12,
12,
12,
12,
12,
12,
12,
12,
12,
12,
12,
12,
12,
12,
12,
12,
12,
12,
12,
12,
12,
12,
12,
12,
12,
12,
12,
2,
12,
12,
12,
12,
1,
2,
12,
12,
1,
2,
1417,
12,
12,
]
]
# Check task
assert batch["task"] == ["s2s_duplex"]
# test extra functions inside of eartts dataset
def test_add_speech_delay():
source_audio = torch.ones(1, 16000)
target_audio = torch.ones(1, 22050)
source_lens = torch.tensor([16000])
target_lens = torch.tensor([22050])
num_delays = 2
# samples per frame (float → int handled explicitly)
target_samples_per_frame = source_audio.size(1) / 12.5
source_samples_per_frame = target_audio.size(1) / 12.5
expected_extra_src_size = int(source_samples_per_frame * num_delays)
expected_extra_tgt_size = int(target_samples_per_frame * num_delays)
out_src, out_src_lens, out_tgt, out_tgt_lens = add_speech_delay(
source_audio=source_audio,
source_audio_lens=source_lens,
target_audio=target_audio,
target_audio_lens=target_lens,
num_delay_speech_tokens=num_delays,
target_samples_per_frame=target_samples_per_frame,
source_samples_per_frame=source_samples_per_frame,
)
# --------------------------------------------------
# Shape & length bookkeeping
# --------------------------------------------------
assert out_src.shape == (1, source_audio.size(1) + expected_extra_src_size)
assert out_tgt.shape == (1, target_audio.size(1) + expected_extra_tgt_size)
assert out_src_lens.item() == source_lens.item() + expected_extra_src_size
assert out_tgt_lens.item() == target_lens.item() + expected_extra_tgt_size
# --------------------------------------------------
# Padding direction & content
# --------------------------------------------------
# Target audio is left-padded
assert torch.all(out_tgt[:, :expected_extra_tgt_size] == 0)
assert torch.all(out_tgt[:, expected_extra_tgt_size:] == 1)
# Source audio is right-padded
assert torch.all(out_src[:, : source_audio.size(1)] == 1)
assert torch.all(out_src[:, source_audio.size(1) :] == 0)
def test_sample_audio_segments_repeat():
cases = [
# (audio, lens, n_sample, expected_when_sample_false)
(
torch.tensor([[1.0, 2.0, 3.0, 4.0, 5.0]]),
torch.tensor([5]),
3,
torch.tensor([[1.0, 2.0, 3.0]]),
),
(
torch.tensor([[1.0, 2.0]]),
torch.tensor([2]),
5,
torch.tensor([[1.0, 2.0, 1.0, 2.0, 1.0]]),
),
(
torch.zeros(1, 10),
torch.tensor([0]),
4,
torch.zeros(1, 4),
),
]
for prompt_audio, prompt_audio_lens, n_sample, expected in cases:
# --------------------------------------------------
# sample=False → deterministic + sequence check
# --------------------------------------------------
out = sample_audio_segments_repeat(
prompt_audio,
prompt_audio_lens,
n_sample=n_sample,
sample=False,
)
assert out.shape == expected.shape
assert torch.equal(out, expected)
# --------------------------------------------------
# sample=True → stochastic, shape only
# --------------------------------------------------
out = sample_audio_segments_repeat(
prompt_audio,
prompt_audio_lens,
n_sample=n_sample,
sample=True,
)
assert out.shape == expected.shape
def test_eartts_training_step(model, dataset, training_cutset_batch):
model.train()
model.on_train_epoch_start()
batch = dataset[training_cutset_batch]
batch = move_data_to_device(batch, device=model.device)
results = model.training_step(batch, batch_idx=0)
assert torch.is_tensor(results["loss"])
assert not torch.isnan(results["loss"])
assert results["loss"] > 0
def test_eartts_validation_step(model, dataset, training_cutset_batch):
model.eval()
model.on_validation_epoch_start()
batch = dataset[training_cutset_batch]
batch = move_data_to_device(batch, device=model.device)
results = model.validation_step({"dummy_val_set": batch}, batch_idx=0)
assert results is None # no return value
def test_eartts_offline_generation(model):
model.eval()
# generate random subword_ids
subword_ids = torch.ones(2, 10).long()
# set init inputs and get it
model.set_init_inputs(
speaker_audio=torch.randn(1, 22050),
speaker_audio_lens=torch.tensor([22050]),
)
init_inputs = model.get_init_inputs(B=subword_ids.size(0))
torch.manual_seed(42)
if torch.cuda.is_available():
torch.cuda.manual_seed_all(42)
gen_audio, gen_audio_len = model.offline_inference(
next_subword_ids=subword_ids,
init_inputs=init_inputs,
)
torch.manual_seed(42)
if torch.cuda.is_available():
torch.cuda.manual_seed_all(42)
gen_audio_inc, gen_audio_len_inc = model.offline_inference(
next_subword_ids=subword_ids, init_inputs=init_inputs, incremental_audio_decoding=True
)
assert torch.equal(
gen_audio_len, gen_audio_len_inc
), "Audio lengths differ between incremental and non-incremental decoding."
# compare waveform
torch.testing.assert_close(
gen_audio,
gen_audio_inc,
atol=1e-1,
rtol=0,
)
assert gen_audio.shape == (2, 17640)
assert gen_audio_len[0] == gen_audio.size(-1)
assert gen_audio.dtype == torch.float32
@@ -0,0 +1,227 @@
# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import os
import pytest
import torch
from lhotse import CutSet, SupervisionSegment
from lhotse.testing.dummies import dummy_cut, dummy_recording
from nemo.collections.common.data.utils import move_data_to_device
from nemo.collections.speechlm2.data import DuplexS2SDataset
from nemo.collections.speechlm2.models import DuplexS2SModel
if torch.cuda.is_available():
torch.set_default_device('cuda')
def resolve_pretrained_models():
if os.path.exists("/home/TestData/speechlm/pretrained_models"):
# CI pre-cached paths:
return {
"pretrained_llm": "/home/TestData/speechlm/pretrained_models/TinyLlama--TinyLlama_v1.1",
"pretrained_audio_codec": "/home/TestData/speechlm/pretrained_models/low-frame-rate-speech-codec-22khz.nemo",
"pretrained_asr": "/home/TestData/speechlm/pretrained_models/stt_en_fastconformer_hybrid_large_streaming_80ms.nemo",
"scoring_asr": "/home/TestData/speechlm/pretrained_models/stt_en_fastconformer_transducer_large.nemo",
}
else:
# HF URLs:
return {
"pretrained_asr": "stt_en_fastconformer_hybrid_large_streaming_80ms",
"scoring_asr": "stt_en_fastconformer_transducer_large",
"pretrained_llm": "TinyLlama/TinyLlama_v1.1",
"pretrained_audio_codec": "nvidia/low-frame-rate-speech-codec-22khz",
}
@pytest.fixture(scope="session")
def model():
cfg = {
**resolve_pretrained_models(),
"pretrained_weights": False,
"freeze_params": ["^audio_codec\\..+$"],
"audio_loss_weight": 1,
"text_loss_weight": 3,
"perception": {
"target": "nemo.collections.speechlm2.modules.perception.AudioPerceptionModule",
"output_dim": 2048,
"encoder": {
"_target_": "nemo.collections.asr.modules.ConformerEncoder",
"att_context_size": [-1, -1],
"causal_downsampling": False,
"conv_context_size": None,
"conv_kernel_size": 9,
"conv_norm_type": "batch_norm",
"d_model": 1024,
"dropout": 0.1,
"dropout_att": 0.1,
"dropout_emb": 0.0,
"dropout_pre_encoder": 0.1,
"feat_in": 128,
"feat_out": -1,
"ff_expansion_factor": 4,
"n_heads": 8,
"n_layers": 2,
"pos_emb_max_len": 5000,
"self_attention_model": "rel_pos",
"subsampling": "dw_striding",
"subsampling_conv_channels": 256,
"subsampling_factor": 8,
},
"modality_adapter": {
"_target_": "nemo.collections.speechlm2.modules.perception.IdentityConnector",
"d_model": 1024,
},
"preprocessor": {
"_target_": "nemo.collections.asr.modules.AudioToMelSpectrogramPreprocessor",
"dither": 1e-05,
"features": 128,
"frame_splicing": 1,
"log": True,
"n_fft": 512,
"normalize": "per_feature",
"pad_to": 0,
"pad_value": 0.0,
"sample_rate": 16000,
"window": "hann",
"window_size": 0.025,
"window_stride": 0.01,
},
},
"optimizer": {"_target_": "torch.optim.AdamW"},
}
model = DuplexS2SModel(cfg)
if torch.cuda.is_available():
model.to("cuda")
return model
@pytest.fixture(scope="session")
def dataset(model):
return DuplexS2SDataset(
model.tokenizer,
frame_length=0.08,
source_sample_rate=16000,
target_sample_rate=22050,
input_roles=["user"],
output_roles=["assistant"],
)
@pytest.fixture(scope="session")
def training_cutset_batch():
cut = dummy_cut(0, recording=dummy_recording(0, with_data=True))
cut.target_audio = dummy_recording(1, with_data=True)
cut.supervisions = [
SupervisionSegment(
id=cut.id,
recording_id=cut.recording_id,
start=0,
duration=0.1,
text='hi',
speaker="user",
),
SupervisionSegment(
id=cut.id,
recording_id=cut.recording_id,
start=0.3,
duration=0.1,
text='hello',
speaker="assistant",
),
SupervisionSegment(
id=cut.id,
recording_id=cut.recording_id,
start=0.5,
duration=0.1,
text='ok',
speaker="user",
),
SupervisionSegment(
id=cut.id,
recording_id=cut.recording_id,
start=0.6,
duration=0.4,
text='okay',
speaker="assistant",
),
]
return CutSet([cut])
def test_s2s_dataset(dataset, training_cutset_batch):
batch = dataset[training_cutset_batch]
for key in (
"source_audio",
"target_audio",
"source_audio_lens",
"target_audio_lens",
"target_tokens",
"target_token_lens",
"source_tokens",
"source_token_lens",
):
assert key in batch
assert torch.is_tensor(batch[key])
assert batch["source_audio"].shape == (1, 16000)
assert batch["target_audio"].shape == (1, 22050)
assert batch["target_texts"] == ["hello okay"]
assert batch["target_tokens"].tolist() == [[0, 0, 0, 0, 1, 2, 0, 0, 1, 20759, 0, 0, 0]]
assert batch["source_tokens"].tolist() == [[1, 2, 0, 0, 0, 0, 1, 3431, 2, 0, 0, 0, 0]]
def test_s2s_training_step(model, dataset, training_cutset_batch):
model.on_train_epoch_start()
batch = dataset[training_cutset_batch]
batch = move_data_to_device(batch, device=model.device)
results = model.training_step(batch, batch_idx=0)
assert torch.is_tensor(results["loss"])
assert not torch.isnan(results["loss"])
assert results["loss"] > 0
def test_s2s_validation_step(model, dataset, training_cutset_batch):
model.on_validation_epoch_start()
batch = dataset[training_cutset_batch]
batch = move_data_to_device(batch, device=model.device)
results = model.validation_step({"dummy_val_set": batch}, batch_idx=0)
assert results is None # no return value
def test_s2s_offline_generation(model):
# 16000 samples == 1 second == 12.5 frames ~= 14 frames after encoder padding
ans = model.offline_inference(
input_signal=torch.randn(1, 16000),
input_signal_lens=torch.tensor([16000]),
)
assert ans.keys() == {"text", "tokens_text", "tokens_audio", "audio", "audio_len", "tokens_len"}
assert isinstance(ans["text"], list)
assert isinstance(ans["text"][0], str)
gen_text = ans["tokens_text"]
assert gen_text.shape == (1, 13)
assert gen_text.dtype == torch.long
assert (gen_text >= 0).all()
assert (gen_text < model.text_vocab_size).all()
gen_audio_codes = ans["tokens_audio"]
assert gen_audio_codes.shape == (1, 13, 8)
assert gen_audio_codes.dtype == torch.long
assert (gen_audio_codes >= 0).all()
assert (gen_audio_codes < model.speech_vocab_size).all()
gen_audio = ans["audio"]
assert gen_audio.dtype == torch.float32
@@ -0,0 +1,214 @@
# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import os
import pytest
import torch
from lhotse import CutSet, SupervisionSegment
from lhotse.testing.dummies import dummy_cut, dummy_recording
from nemo.collections.common.data.utils import move_data_to_device
from nemo.collections.speechlm2.data import DuplexS2SDataset
from nemo.collections.speechlm2.models import DuplexS2SSpeechDecoderModel
if torch.cuda.is_available():
torch.set_default_device('cuda')
def resolve_pretrained_models():
if os.path.exists("/home/TestData/speechlm/pretrained_models"):
# CI pre-cached paths:
return {
"pretrained_llm": "/home/TestData/speechlm/pretrained_models/TinyLlama--TinyLlama_v1.1",
"pretrained_audio_codec": "/home/TestData/speechlm/pretrained_models/low-frame-rate-speech-codec-22khz.nemo",
"pretrained_asr": "/home/TestData/speechlm/pretrained_models/stt_en_fastconformer_hybrid_large_streaming_80ms.nemo",
"scoring_asr": "/home/TestData/speechlm/pretrained_models/stt_en_fastconformer_transducer_large.nemo",
}
else:
# HF URLs:
return {
"pretrained_asr": "stt_en_fastconformer_hybrid_large_streaming_80ms",
"scoring_asr": "stt_en_fastconformer_transducer_large",
"pretrained_llm": "TinyLlama/TinyLlama_v1.1",
"pretrained_audio_codec": "nvidia/low-frame-rate-speech-codec-22khz",
}
@pytest.fixture(scope="session")
def model():
cfg = {
**resolve_pretrained_models(),
"pretrained_weights": False,
"freeze_params": ["^audio_codec\\..+$"],
"audio_loss_weight": 1,
"text_loss_weight": 3,
"perception": {
"target": "nemo.collections.speechlm2.modules.perception.AudioPerceptionModule",
"output_dim": 2048,
"encoder": {
"_target_": "nemo.collections.asr.modules.ConformerEncoder",
"att_context_size": [-1, -1],
"causal_downsampling": False,
"conv_context_size": None,
"conv_kernel_size": 9,
"conv_norm_type": "batch_norm",
"d_model": 1024,
"dropout": 0.1,
"dropout_att": 0.1,
"dropout_emb": 0.0,
"dropout_pre_encoder": 0.1,
"feat_in": 128,
"feat_out": -1,
"ff_expansion_factor": 4,
"n_heads": 8,
"n_layers": 2,
"pos_emb_max_len": 5000,
"self_attention_model": "rel_pos",
"subsampling": "dw_striding",
"subsampling_conv_channels": 256,
"subsampling_factor": 8,
},
"modality_adapter": {
"_target_": "nemo.collections.speechlm2.modules.perception.IdentityConnector",
"d_model": 1024,
},
"preprocessor": {
"_target_": "nemo.collections.asr.modules.AudioToMelSpectrogramPreprocessor",
"dither": 1e-05,
"features": 128,
"frame_splicing": 1,
"log": True,
"n_fft": 512,
"normalize": "per_feature",
"pad_to": 0,
"pad_value": 0.0,
"sample_rate": 16000,
"window": "hann",
"window_size": 0.025,
"window_stride": 0.01,
},
},
"speech_decoder": {
"n_layers": 1,
"d_model": 768,
"d_ffn": 3072,
"sa_n_heads": 12,
"kernel_size": 3,
"is_causal": True,
},
"optimizer": {"_target_": "torch.optim.AdamW"},
}
model = DuplexS2SSpeechDecoderModel(cfg)
if torch.cuda.is_available():
model.to("cuda")
return model
@pytest.fixture(scope="session")
def dataset(model):
return DuplexS2SDataset(
model.tokenizer,
frame_length=0.08,
source_sample_rate=16000,
target_sample_rate=22050,
input_roles=["user"],
output_roles=["assistant"],
)
@pytest.fixture(scope="session")
def training_cutset_batch():
cut = dummy_cut(0, recording=dummy_recording(0, with_data=True))
cut.target_audio = dummy_recording(1, with_data=True)
cut.supervisions = [
SupervisionSegment(
id=cut.id,
recording_id=cut.recording_id,
start=0,
duration=0.1,
text='hi',
speaker="user",
),
SupervisionSegment(
id=cut.id,
recording_id=cut.recording_id,
start=0.3,
duration=0.1,
text='hello',
speaker="assistant",
),
SupervisionSegment(
id=cut.id,
recording_id=cut.recording_id,
start=0.5,
duration=0.1,
text='ok',
speaker="user",
),
SupervisionSegment(
id=cut.id,
recording_id=cut.recording_id,
start=0.6,
duration=0.4,
text='okay',
speaker="assistant",
),
]
return CutSet([cut])
def test_s2s_speech_decoder_training_step(model, dataset, training_cutset_batch):
model.on_train_epoch_start()
batch = dataset[training_cutset_batch]
batch = move_data_to_device(batch, device=model.device)
results = model.training_step(batch, batch_idx=0)
assert torch.is_tensor(results["loss"])
assert not torch.isnan(results["loss"])
assert results["loss"] > 0
def test_s2s_speech_decoder_validation_step(model, dataset, training_cutset_batch):
model.on_validation_epoch_start()
batch = dataset[training_cutset_batch]
batch = move_data_to_device(batch, device=model.device)
results = model.validation_step({"dummy_val_set": batch}, batch_idx=0)
assert results is None # no return value
def test_s2s_speech_decoder_offline_generation(model):
# 16000 samples == 1 second == 12.5 frames ~= 14 frames after encoder padding
ans = model.offline_inference(
input_signal=torch.randn(1, 16000, device=model.device),
input_signal_lens=torch.tensor([16000], device=model.device),
)
assert ans.keys() == {"text", "tokens_text", "tokens_audio", "audio", "audio_len", "tokens_len"}
assert isinstance(ans["text"], list)
assert isinstance(ans["text"][0], str)
gen_text = ans["tokens_text"]
assert gen_text.shape == (1, 13)
assert gen_text.dtype == torch.long
assert (gen_text >= 0).all()
assert (gen_text < model.text_vocab_size).all()
gen_audio_codes = ans["tokens_audio"]
assert gen_audio_codes.shape == (1, 13, 8)
assert gen_audio_codes.dtype == torch.long
assert (gen_audio_codes >= 0).all()
assert (gen_audio_codes < model.speech_vocab_size).all()
gen_audio = ans["audio"]
assert gen_audio.dtype == torch.float32
@@ -0,0 +1,327 @@
# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import os
import pytest
import torch
from lhotse import CutSet, SupervisionSegment
from lhotse.testing.dummies import dummy_cut, dummy_recording
from nemo.collections.common.data.utils import move_data_to_device
from nemo.collections.speechlm2.data import DuplexSTTDataset
from nemo.collections.speechlm2.models import DuplexSTTModel
if torch.cuda.is_available():
torch.set_default_device('cuda')
def resolve_pretrained_models():
if os.path.exists("/home/TestData/speechlm/pretrained_models"):
return {
"pretrained_llm": "/home/TestData/speechlm/pretrained_models/TinyLlama--TinyLlama_v1.1",
}
return {
"pretrained_llm": "TinyLlama/TinyLlama_v1.1",
}
def create_model(
predict_user_text=False,
force_use_noise_augmentation=False,
old_noise_prob=0.0,
old_noise_min_snr=0.0,
old_noise_max_snr=0.0,
):
"""Helper function to create a model with configurable settings."""
cfg = {
"model": {
**resolve_pretrained_models(),
"pretrained_weights": False,
"trust_remote_code": True,
"audio_loss_weight": 1,
"text_loss_weight": 3,
"source_sample_rate": 16000,
"validation_save_path": "/tmp/test_duplex_stt_logs",
"perception": {
"_target_": "nemo.collections.speechlm2.modules.perception.AudioPerceptionModule",
"preprocessor": {
"_target_": "nemo.collections.asr.modules.AudioToMelSpectrogramPreprocessor",
"features": 80,
},
"encoder": {
"_target_": "nemo.collections.asr.modules.ConformerEncoder",
"feat_in": 80,
"d_model": 512,
"n_heads": 8,
"n_layers": 1,
"subsampling_factor": 8,
},
"modality_adapter": {
"_target_": "nemo.collections.speechlm2.modules.perception.IdentityConnector",
"d_model": 512,
},
"output_dim": 2048,
},
"predict_user_text": predict_user_text,
"force_use_noise_augmentation": force_use_noise_augmentation,
"old_noise_prob": old_noise_prob,
"old_noise_min_snr": old_noise_min_snr,
"old_noise_max_snr": old_noise_max_snr,
"optimizer": {"_target_": "torch.optim.AdamW"},
},
"data": {
"source_sample_rate": 16000,
},
"exp_manager": {
"explicit_log_dir": "/tmp/test_duplex_stt_logs",
},
}
model = DuplexSTTModel(cfg["model"])
if torch.cuda.is_available():
model.to("cuda")
return model
@pytest.fixture(scope="session")
def model():
return create_model(predict_user_text=False)
@pytest.fixture(scope="session")
def dataset(model):
return DuplexSTTDataset(
model.tokenizer,
frame_length=0.08,
source_sample_rate=16000,
input_roles=["user"],
output_roles=["assistant"],
)
@pytest.fixture(scope="session")
def training_cutset_batch():
cut = dummy_cut(0, recording=dummy_recording(0, with_data=True))
cut.supervisions = [
SupervisionSegment(
id=cut.id,
recording_id=cut.recording_id,
start=0,
duration=0.1,
text='hi',
speaker="user",
),
SupervisionSegment(
id=cut.id,
recording_id=cut.recording_id,
start=0.3,
duration=0.1,
text='hello',
speaker="assistant",
),
SupervisionSegment(
id=cut.id,
recording_id=cut.recording_id,
start=0.5,
duration=0.1,
text='ok',
speaker="user",
),
SupervisionSegment(
id=cut.id,
recording_id=cut.recording_id,
start=0.6,
duration=0.4,
text='okay',
speaker="assistant",
),
]
return CutSet([cut])
def test_stt_training_step(model, dataset, training_cutset_batch):
model.on_train_epoch_start()
batch = dataset[training_cutset_batch]
batch = move_data_to_device(batch, device=model.device)
results = model.training_step(batch, batch_idx=0)
assert torch.is_tensor(results["loss"])
assert not torch.isnan(results["loss"])
assert results["loss"] > 0
@pytest.fixture(scope="function")
def model_with_asr():
"""Model fixture with ASR head enabled."""
return create_model(predict_user_text=True)
@pytest.fixture(scope="function")
def model_with_noise():
"""Model fixture with noise augmentation enabled."""
model = create_model(
force_use_noise_augmentation=True,
old_noise_prob=0.9,
old_noise_min_snr=5.0,
old_noise_max_snr=15.0,
)
return model
@pytest.fixture(scope="function")
def model_with_asr_and_noise():
"""Model fixture with both ASR head and noise augmentation enabled."""
model = create_model(
predict_user_text=True,
force_use_noise_augmentation=True,
old_noise_prob=0.9,
old_noise_min_snr=5.0,
old_noise_max_snr=15.0,
)
return model
def test_stt_training_step_with_asr(model_with_asr, dataset, training_cutset_batch):
model_with_asr.on_train_epoch_start()
batch = dataset[training_cutset_batch]
batch = move_data_to_device(batch, device=model_with_asr.device)
results = model_with_asr.training_step(batch, batch_idx=0)
assert torch.is_tensor(results["loss"])
assert not torch.isnan(results["loss"])
assert results["loss"] > 0
assert "asr_loss" in results
assert torch.is_tensor(results["asr_loss"])
assert not torch.isnan(results["asr_loss"])
assert results["asr_loss"] >= 0
def test_stt_training_step_with_noise(model_with_asr_and_noise, dataset, training_cutset_batch):
model_with_asr_and_noise.on_train_epoch_start()
batch = dataset[training_cutset_batch]
batch = move_data_to_device(batch, device=model_with_asr_and_noise.device)
results = model_with_asr_and_noise.training_step(batch, batch_idx=0)
assert torch.is_tensor(results["loss"])
assert not torch.isnan(results["loss"])
assert results["loss"] > 0
assert "asr_loss" in results
assert torch.is_tensor(results["asr_loss"])
assert not torch.isnan(results["asr_loss"])
assert results["asr_loss"] >= 0
def test_stt_validation_step(model, dataset, training_cutset_batch):
model.on_validation_epoch_start()
batch = dataset[training_cutset_batch]
batch = move_data_to_device(batch, device=model.device)
results = model.validation_step({"dummy_val_set": batch}, batch_idx=0)
assert results is None # no return value
def test_stt_offline_generation(model):
# 16000 samples == 1 second == 12.5 frames ~= 14 frames after encoder padding
ans = model.streaming_inference.offline_inference(
input_signal=torch.randn(1, 16000, device=model.device),
input_signal_lens=torch.tensor([16000], device=model.device),
)
assert ans.keys() == {
'text',
'src_text',
'tokens_text_src',
'tokens_text',
'tokens_len',
'source_audio',
'source_audio_len',
}
assert isinstance(ans["text"], list)
assert isinstance(ans["text"][0], str)
gen_text = ans["tokens_text"]
assert gen_text.shape == (1, 14)
assert gen_text.dtype == torch.long
assert (gen_text >= 0).all()
assert (gen_text < model.text_vocab_size).all()
def test_stt_offline_generation_with_asr(model_with_asr):
"""Test offline generation with ASR head enabled for user text prediction."""
# 16000 samples == 1 second == 12.5 frames ~= 14 frames after encoder padding
ans = model_with_asr.streaming_inference.offline_inference(
input_signal=torch.randn(1, 16000, device=model_with_asr.device),
input_signal_lens=torch.tensor([16000], device=model_with_asr.device),
)
# Verify all expected output keys are present
assert ans.keys() == {
'text',
'src_text',
'tokens_text_src',
'tokens_text',
'tokens_len',
'source_audio',
'source_audio_len',
}
# Verify agent text output
assert isinstance(ans["text"], list)
assert isinstance(ans["text"][0], str)
# Verify user text (ASR) output
assert isinstance(ans["src_text"], list)
assert isinstance(ans["src_text"][0], str)
# Verify generated text tokens
gen_text = ans["tokens_text"]
assert gen_text.shape == (1, 14)
assert gen_text.dtype == torch.long
assert (gen_text >= 0).all()
assert (gen_text < model_with_asr.text_vocab_size).all()
# Verify ASR tokens
asr_tokens = ans["tokens_text_src"]
assert asr_tokens.shape[0] == 1 # batch size
assert asr_tokens.dtype == torch.long
assert (asr_tokens >= 0).all()
assert (asr_tokens < model_with_asr.text_vocab_size).all()
def test_trailing_pad_loss_scale_is_masked(dataset, training_cutset_batch):
"""Test that trailing pad positions (from batching) have loss_scale=0 when token_loss_weight is set."""
model = create_model(predict_user_text=True)
# Enable token_loss_weight with non-zero pad weight
model.cfg["token_loss_weight"] = {"pad": 1.0, "bos": 10.0, "eos": 5.0, "text": 5.0}
model.cfg["mask_sequence_loss"] = True
if torch.cuda.is_available():
model.to("cuda")
batch = dataset[training_cutset_batch]
batch = move_data_to_device(batch, device=model.device)
inputs = model.prepare_inputs(batch["audio_data"])
loss_scale = inputs["loss_scale"] # (B, T, 1)
asr_loss_scale = inputs["asr_loss_scale"] # (B, T, 1)
seq_mask = inputs["seq_mask"] # (B, T, 1)
target_token_lens = batch["audio_data"]["target_token_lens"]
for i in range(target_token_lens.size(0)):
end_idx = target_token_lens[i]
# Trailing positions (after target_token_lens) must have loss_scale=0
assert (loss_scale[i, end_idx:, :] == 0).all(), f"Batch {i}: loss_scale not zero after position {end_idx}"
assert (
asr_loss_scale[i, end_idx:, :] == 0
).all(), f"Batch {i}: asr_loss_scale not zero after position {end_idx}"
# In-sequence positions should have non-zero loss_scale
assert (loss_scale[i, :end_idx, :] > 0).any(), f"Batch {i}: loss_scale all zero before position {end_idx}"
@@ -0,0 +1,415 @@
# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import os
import re
import pytest
import torch
from lhotse import CutSet, SupervisionSegment, compute_num_frames
from lhotse.dataset.collation import collate_audio, collate_vectors
from lhotse.testing.dummies import dummy_cut, dummy_recording
from nemo.collections.common.tokenizers import AutoTokenizer
from nemo.collections.speechlm2.data.duplex_stt_dataset import (
DuplexSTTDataset,
collate_system_prompt,
collate_token_channel,
)
from nemo.collections.speechlm2.data.utils import get_pad_id
SR = 16000
FL = 0.08
def _clean_text(text):
"""Strip timestamp tokens and normalize whitespace, matching _text_to_ids(remove_timestamps=True)."""
text = re.sub(r'<\|\d+\|>', '', text)
return ' '.join(text.strip().split())
def _verify_supervision_tokens(tokens_1d, start, duration, raw_text, tokenizer, pad, bos, eos, total_frames):
"""Verify BOS/EOS placement and that decoded token IDs match the original supervision text.
Checks:
1. BOS is placed at the frame corresponding to supervision start.
2. EOS is placed at the frame corresponding to supervision end (if within cut).
3. Text token IDs between BOS and EOS are a prefix of tokenizer.text_to_ids(clean_text).
4. Decoding those IDs back to text matches (or is a prefix of) the original text.
"""
pos = compute_num_frames(start, FL, SR)
eospos = compute_num_frames(start + duration, FL, SR)
clean = _clean_text(raw_text)
# 1. BOS at turn start
assert tokens_1d[pos].item() == bos, f"Expected BOS={bos} at frame {pos}, got {tokens_1d[pos].item()}"
# 2. EOS at turn end (only if within cut bounds)
if eospos < total_frames:
assert tokens_1d[eospos].item() == eos, f"Expected EOS={eos} at frame {eospos}, got {tokens_1d[eospos].item()}"
# 3. Extract text token IDs between BOS and EOS, filtering out pad
end = min(eospos, total_frames)
actual_ids = [t for t in tokens_1d[pos + 1 : end].tolist() if t != pad]
expected_ids = tokenizer.text_to_ids(clean)
# Actual IDs should be a prefix of expected (truncation may occur when text is long)
assert actual_ids == expected_ids[: len(actual_ids)], (
f"Token ID mismatch for '{clean}':\n"
f" actual_ids = {actual_ids}\n"
f" expected_prefix = {expected_ids[: len(actual_ids)]}\n"
f" full_expected = {expected_ids}"
)
# 4. Decode IDs back to text and verify against original
if actual_ids:
decoded = tokenizer.ids_to_text(actual_ids).strip()
if len(actual_ids) == len(expected_ids):
assert decoded == clean, f"Full decode mismatch: '{decoded}' != '{clean}', ids={actual_ids}"
else:
assert clean.startswith(decoded), (
f"Truncated decode '{decoded}' is not a prefix of '{clean}'\n"
f" ids={actual_ids} (truncated {len(expected_ids)}{len(actual_ids)})"
)
@pytest.fixture(scope="session")
def tokenizer():
if os.path.exists("/home/TestData/speechlm/pretrained_models"):
model_path = "/home/TestData/speechlm/pretrained_models/TinyLlama--TinyLlama_v1.1"
else:
model_path = "TinyLlama/TinyLlama_v1.1"
return AutoTokenizer(model_path, use_fast=True)
@pytest.fixture(scope="session")
def cuts():
"""Two cuts: cut1 with plain text, cut2 with timestamped text and a system prompt."""
cut1 = dummy_cut(0, duration=1.0, recording=dummy_recording(0, duration=1.0, with_data=True))
cut1.supervisions = [
SupervisionSegment(
id="s0-user", recording_id=cut1.recording_id, start=0, duration=0.3, text="hi", speaker="user"
),
SupervisionSegment(
id="s0-agent", recording_id=cut1.recording_id, start=0.4, duration=0.3, text="hello", speaker="assistant"
),
]
cut2 = dummy_cut(1, duration=2.0, recording=dummy_recording(1, duration=2.0, with_data=True))
cut2.supervisions = [
SupervisionSegment(
id="s1-user",
recording_id=cut2.recording_id,
start=0,
duration=0.5,
text="<|0|> good <|1|> <|3|> morning <|5|>",
speaker="user",
),
SupervisionSegment(
id="s1-agent",
recording_id=cut2.recording_id,
start=0.6,
duration=0.5,
text="<|0|> good <|2|> <|2|> morning <|4|> <|4|> to <|5|> <|5|> you <|6|>",
speaker="assistant",
),
SupervisionSegment(
id="s1-user2",
recording_id=cut2.recording_id,
start=1.2,
duration=0.3,
text="<|0|> thanks <|3|>",
speaker="user",
),
SupervisionSegment(
id="s1-agent2",
recording_id=cut2.recording_id,
start=1.6,
duration=0.4,
text="<|0|> welcome <|4|>",
speaker="assistant",
),
]
cut2.custom = {"system_prompt": "be helpful"}
return CutSet([cut1, cut2])
def test_collate_audio(cuts):
"""Test collate_audio: shapes, lengths, and zero-padding for shorter cuts."""
audio, audio_lens = collate_audio(cuts.resample(SR))
assert audio.shape == (2, 32000)
assert audio_lens.tolist() == [16000, 32000]
# Padding region for the shorter cut must be zero
assert (audio[0, 16000:] == 0).all(), "Audio padding should be zero"
# Non-padding region should have non-zero data (random audio from dummy_recording)
assert (audio[0, :16000] != 0).any(), "Audio data should be non-zero"
assert (audio[1, :32000] != 0).any(), "Audio data should be non-zero"
def test_collate_token_channel_target(cuts, tokenizer):
"""Test collate_token_channel for target (assistant) role: BOS/EOS placement, token decode."""
pad = get_pad_id(tokenizer)
bos = tokenizer.bos
eos = tokenizer.eos
total1 = compute_num_frames(1.0, FL, SR) # 13
total2 = compute_num_frames(2.0, FL, SR) # 25
target_tokens, target_token_lens = collate_token_channel(
cuts,
tokenizer,
frame_length=FL,
roles={"assistant"},
bos_id=bos,
eos_id=eos,
remove_timestamps=True,
)
assert target_token_lens.tolist() == [total1, total2]
# fmt: off
# Cut 1: "hello"(22172) at frames 59, padded to 25
# Cut 2: "good morning to you"(1781,7250,304,366) at frames 814, "welcome"(12853) at frames 2024
expected_target = torch.tensor([
[0, 0, 0, 0, 0, 1, 22172, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 1, 1781, 7250, 304, 366, 0, 2, 0, 0, 0, 0, 0, 1, 12853, 0, 0, 0],
])
# fmt: on
assert torch.equal(target_tokens, expected_target)
# Decode verification
_verify_supervision_tokens(target_tokens[0], 0.4, 0.3, "hello", tokenizer, pad, bos, eos, total1)
_verify_supervision_tokens(
target_tokens[1],
0.6,
0.5,
"<|0|> good <|2|> <|2|> morning <|4|> <|4|> to <|5|> <|5|> you <|6|>",
tokenizer,
pad,
bos,
eos,
total2,
)
_verify_supervision_tokens(target_tokens[1], 1.6, 0.4, "<|0|> welcome <|4|>", tokenizer, pad, bos, eos, total2)
def test_collate_token_channel_source(cuts, tokenizer):
"""Test collate_token_channel for source (user) role with timestamp-based word alignment."""
pad = get_pad_id(tokenizer)
bos = tokenizer.bos
eos = tokenizer.eos
total1 = compute_num_frames(1.0, FL, SR) # 13
total2 = compute_num_frames(2.0, FL, SR) # 25
source_tokens, source_token_lens = collate_token_channel(
cuts,
tokenizer,
frame_length=FL,
roles={"user"},
bos_id=bos,
eos_id=eos,
remove_timestamps=False,
prepend_word_space=False,
)
assert source_tokens.shape == (2, total2)
assert source_token_lens.tolist() == [total1, total2]
# fmt: off
# Cut 1: "hi"(7251) plain text, placed contiguously at frames 04
# Cut 2: "good"(1781) at ts 01, pad gap, "morning"(7250) at ts 35 → frames 06
# "thanks"(3969) at ts 03 → frames 1519
expected_source = torch.tensor([
[1, 7251, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[1, 1781, 0, 0, 7250, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 1, 3969, 0, 0, 2, 0, 0, 0, 0, 0],
])
# fmt: on
assert torch.equal(source_tokens, expected_source)
# Decode verification
# ── Cut 1: no timestamps, sentence-level tokenization ──
_verify_supervision_tokens(source_tokens[0], 0.0, 0.3, "hi", tokenizer, pad, bos, eos, total1)
assert (source_tokens[0, total1:] == pad).all(), "Batch padding should be pad"
# ── Cut 2: two user turns with timestamp alignment ──
_verify_supervision_tokens(
source_tokens[1],
0.0,
0.5,
"<|0|> good <|1|> <|3|> morning <|5|>",
tokenizer,
pad,
bos,
eos,
total2,
)
_verify_supervision_tokens(source_tokens[1], 1.2, 0.3, "<|0|> thanks <|3|>", tokenizer, pad, bos, eos, total2)
def test_collate_system_prompt(cuts, tokenizer):
"""Test collate_system_prompt: cut1 has no prompt, cut2 has 'be helpful'."""
prompt_tokens, prompt_token_lens = collate_system_prompt(cuts, tokenizer)
# fmt: off
# cut1: no system_prompt → all pad, len=0
# cut2: "be helpful" → [BOS=1, "be"=367, "helpful"=8444, EOS=2], len=4
expected_prompt = torch.tensor([
[0, 0, 0, 0],
[1, 367, 8444, 2],
])
# fmt: on
assert prompt_token_lens.tolist() == [0, 4]
assert torch.equal(prompt_tokens, expected_prompt)
# Decode prompt tokens back to text
prompt_ids = tokenizer.text_to_ids("be helpful")
decoded_prompt = tokenizer.ids_to_text(prompt_ids).strip()
assert decoded_prompt == "be helpful", f"Prompt decode: '{decoded_prompt}'"
def test_collate_text_data(tokenizer):
"""Test collate_vectors for text token inputs: padding and lengths."""
pad = get_pad_id(tokenizer)
# "hi" → [7251], "good morning" → [1781, 7250]
text_tokens_list = [
torch.tensor([7251], dtype=torch.long),
torch.tensor([1781, 7250], dtype=torch.long),
]
text_token_lens = torch.tensor([t.shape[0] for t in text_tokens_list], dtype=torch.long)
text_tokens = collate_vectors(text_tokens_list, padding_value=pad)
assert text_token_lens.tolist() == [1, 2]
assert text_tokens.shape == (2, 2)
# Shorter sequence is right-padded
assert text_tokens[0].tolist() == [7251, pad]
assert text_tokens[1].tolist() == [1781, 7250]
# Decode back to verify
assert tokenizer.ids_to_text([7251]).strip() == "hi"
assert tokenizer.ids_to_text([1781, 7250]).strip() == "good morning"
def test_duplex_stt_dataset(cuts, tokenizer):
"""End-to-end test of DuplexSTTDataset.__getitem__: covers all collate outputs including timestamps."""
dataset = DuplexSTTDataset(
tokenizer=tokenizer,
frame_length=FL,
source_sample_rate=SR,
input_roles=["user"],
output_roles=["assistant"],
cfg={"prepend_word_space": False},
model_cfg={"predict_user_text": True},
)
batch = dataset[cuts]
ad = batch["audio_data"]
total1 = compute_num_frames(1.0, FL, SR) # 13
total2 = compute_num_frames(2.0, FL, SR) # 25
# sample_id
assert len(ad["sample_id"]) == 2
# source_audio
assert ad["source_audio"].shape == (2, 32000)
assert ad["source_audio_lens"].tolist() == [16000, 32000]
# target_tokens (remove_timestamps=True, same as unit test)
assert ad["target_token_lens"].tolist() == [total1, total2]
# fmt: off
assert torch.equal(ad["target_tokens"], torch.tensor([
[0, 0, 0, 0, 0, 1, 22172, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 1, 1781, 7250, 304, 366, 0, 2, 0, 0, 0, 0, 0, 1, 12853, 0, 0, 0],
]))
# fmt: on
# source_tokens (remove_timestamps=False via predict_user_text=True, timestamp-aligned)
assert ad["source_token_lens"].tolist() == [total1, total2]
# fmt: off
assert torch.equal(ad["source_tokens"], torch.tensor([
[1, 7251, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[1, 1781, 0, 0, 7250, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 1, 3969, 0, 0, 2, 0, 0, 0, 0, 0],
]))
# fmt: on
# system prompt (cut2 has "be helpful")
assert "prompt_tokens" in ad
assert ad["prompt_token_lens"].tolist() == [0, 4]
# fmt: off
assert torch.equal(ad["prompt_tokens"], torch.tensor([
[0, 0, 0, 0],
[1, 367, 8444, 2],
]))
# fmt: on
# source/target texts
assert ad["source_texts"] == ["hi", "good morning thanks"]
assert ad["target_texts"] == [
"hello",
"<|0|> good <|2|> <|2|> morning <|4|> <|4|> to <|5|> <|5|> you <|6|> <|0|> welcome <|4|>",
]
# task
assert ad["task"] == ["s2s_duplex", "s2s_duplex"]
# no text data (no Formattable cuts)
assert batch["text_data"] is None
# source_audio is not augmented when augmenter is not configured (audio is unchanged from collate_audio)
def test_duplex_stt_dataset_augmentation(cuts, tokenizer, tmp_path):
"""Test that audio augmentation modifies source_audio in-place when configured."""
import numpy as np
import soundfile as sf
# Create dummy noise files for the augmenter
noise_dir = tmp_path / "noise" / "all"
noise_dir.mkdir(parents=True)
for i in range(3):
noise = np.random.randn(SR).astype(np.float32) * 0.01
sf.write(str(noise_dir / f"noise_{i}.wav"), noise, SR)
cfg = {
"prepend_word_space": False,
"use_noise_aug": True,
"noise_prob": 1.0,
"noise_aug_path": str(tmp_path / "noise"),
"noise_min_snr": 20,
"noise_max_snr": 20,
}
dataset = DuplexSTTDataset(
tokenizer=tokenizer,
frame_length=FL,
source_sample_rate=SR,
input_roles=["user"],
output_roles=["assistant"],
cfg=cfg,
model_cfg={"predict_user_text": True},
)
assert dataset.audio_augmenter is not None
# Get original audio for comparison
original_audio, _ = collate_audio(cuts.resample(SR))
batch = dataset[cuts]
ad = batch["audio_data"]
# source_audio should be augmented (different from original)
assert "source_audio_aug" not in ad
assert ad["source_audio"].shape == original_audio.shape
assert not torch.equal(ad["source_audio"], original_audio)
@@ -0,0 +1,339 @@
# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from unittest.mock import MagicMock, PropertyMock
import pytest
import torch
from nemo.collections.speechlm2.data import DuplexSTTDataset
@pytest.fixture
def mock_tokenizer():
"""Create a mock tokenizer for testing."""
tokenizer = MagicMock()
type(tokenizer).bos = PropertyMock(return_value=1)
type(tokenizer).eos = PropertyMock(return_value=2)
type(tokenizer).pad = PropertyMock(return_value=0)
type(tokenizer).pad_id = PropertyMock(return_value=0)
type(tokenizer).unk_id = PropertyMock(return_value=None)
tokenizer.text_to_ids = MagicMock(return_value=[1])
return tokenizer
@pytest.fixture
def dataset_with_early_interruption(mock_tokenizer):
"""Create a dataset with early interruption enabled."""
cfg = {"early_interruption_prob": 1.0, "early_interruption_overlap_tokens": 5}
model_cfg = {"predict_user_text": False, "force_align_user_text": False}
dataset = DuplexSTTDataset(
tokenizer=mock_tokenizer,
frame_length=0.08,
source_sample_rate=16000,
input_roles=["user"],
output_roles=["assistant"],
cfg=cfg,
model_cfg=model_cfg,
)
return dataset
def test_early_interruption_basic_truncation(dataset_with_early_interruption):
"""Test that early interruption truncates an agent turn correctly."""
# Setup: Create mock tensors with realistic structure:
# BOS, non-pad tokens, pad tokens (silence), then EOS
# Early interruption should move EOS into the non-pad token region
batch_size = 1
seq_len = 25
target_tokens = torch.full((batch_size, seq_len), 0, dtype=torch.long)
target_tokens[0, 0] = 1 # BOS
target_tokens[0, 1:13] = torch.arange(10, 22) # 12 content tokens (positions 1-12)
# Positions 13-17 are PAD (0) - representing silence/gap
target_tokens[0, 18] = 2 # Original EOS at position 18 (after the padding)
source_tokens = torch.full((batch_size, seq_len), 0, dtype=torch.long)
target_audio = torch.zeros((batch_size, 20000), dtype=torch.float32)
source_audio = torch.zeros((batch_size, 16000), dtype=torch.float32)
target_token_lens = torch.tensor([19], dtype=torch.long)
source_token_lens = torch.tensor([1], dtype=torch.long)
target_audio_lens = torch.tensor([20000], dtype=torch.long)
source_audio_lens = torch.tensor([16000], dtype=torch.long)
# Apply early interruption
dataset_with_early_interruption._apply_early_interruption_augmentation(
target_tokens=target_tokens,
source_tokens=source_tokens,
source_audio=source_audio,
source_audio_lens=source_audio_lens,
batch_idx=0,
)
# Verify: EOS should be moved earlier (within the non-pad token region)
eos_positions = (target_tokens[0] == 2).nonzero(as_tuple=True)[0]
assert len(eos_positions) > 0, "EOS should still exist after early interruption"
new_eos_pos = eos_positions[0].item()
# The new EOS position should be before the original position (18)
assert new_eos_pos < 18, f"New EOS position {new_eos_pos} should be before original position 18"
# CRITICAL: New EOS should be at cutoff_pos + overlap_tokens
# cutoff_pos is in non-pad region (1-12), overlap_tokens is 5
# So new_eos_pos should be in range (1+5) to (12+5) = 6 to 17
overlap_tokens = 5 # from fixture cfg
assert 6 <= new_eos_pos <= 17, (
f"New EOS at {new_eos_pos} should be within cutoff + overlap range (6-17), "
f"meaning agent continues for {overlap_tokens} tokens after user interruption"
)
# Check that tokens after new EOS are shifted or padded
assert target_tokens[0, -1] == 0, "Last token should be PAD after early interruption"
def test_early_interruption_with_multiple_turns(dataset_with_early_interruption):
"""Test early interruption with multiple agent turns."""
batch_size = 1
seq_len = 50
target_tokens = torch.full((batch_size, seq_len), 0, dtype=torch.long)
# First turn: BOS at 0, tokens 1-10, PAD 11-13, EOS at 14
target_tokens[0, 0] = 1
target_tokens[0, 1:11] = torch.arange(10, 20) # 10 content tokens
# Positions 11-13 are PAD
target_tokens[0, 14] = 2 # EOS after padding
# Second turn: BOS at 18, tokens 19-28, PAD 29-31, EOS at 32
target_tokens[0, 18] = 1
target_tokens[0, 19:29] = torch.arange(20, 30) # 10 content tokens
# Positions 29-31 are PAD
target_tokens[0, 32] = 2 # EOS after padding
source_tokens = torch.full((batch_size, seq_len), 0, dtype=torch.long)
target_audio = torch.zeros((batch_size, 40000), dtype=torch.float32)
source_audio = torch.zeros((batch_size, 16000), dtype=torch.float32)
target_token_lens = torch.tensor([33], dtype=torch.long)
source_token_lens = torch.tensor([1], dtype=torch.long)
target_audio_lens = torch.tensor([40000], dtype=torch.long)
source_audio_lens = torch.tensor([16000], dtype=torch.long)
# Apply early interruption
dataset_with_early_interruption._apply_early_interruption_augmentation(
target_tokens=target_tokens,
source_tokens=source_tokens,
source_audio=source_audio,
source_audio_lens=source_audio_lens,
batch_idx=0,
)
# Verify: Should still have valid BOS and EOS tokens
bos_positions = (target_tokens[0] == 1).nonzero(as_tuple=True)[0]
eos_positions = (target_tokens[0] == 2).nonzero(as_tuple=True)[0]
assert len(bos_positions) >= 1, "Should have at least one BOS token"
assert len(eos_positions) >= 1, "Should have at least one EOS token"
# Each BOS should be followed eventually by an EOS
for bos_pos in bos_positions:
matching_eos = eos_positions[eos_positions > bos_pos]
assert len(matching_eos) > 0, f"BOS at position {bos_pos} should have a matching EOS"
def test_early_interruption_overlap_tokens(dataset_with_early_interruption):
"""Test that overlap tokens parameter works correctly."""
# Test with custom overlap tokens
dataset_with_early_interruption.cfg["early_interruption_overlap_tokens"] = 3
batch_size = 1
seq_len = 25
target_tokens = torch.full((batch_size, seq_len), 0, dtype=torch.long)
target_tokens[0, 0] = 1 # BOS
target_tokens[0, 1:11] = torch.arange(10, 20) # 10 content tokens (positions 1-10)
# Positions 11-14 are PAD
target_tokens[0, 15] = 2 # Original EOS at position 15 (after padding)
original_eos_pos = 15
overlap_tokens = 3
# Place a marker token in source_tokens AFTER original_eos_pos to track the shift
# The implementation shifts source_tokens from original_eos_pos+1 to cutoff_pos+1
marker_token = 999
marker_original_pos = 20 # Position after original EOS
source_tokens = torch.full((batch_size, seq_len), 0, dtype=torch.long)
source_tokens[0, marker_original_pos] = marker_token
target_audio = torch.zeros((batch_size, 20000), dtype=torch.float32)
source_audio = torch.zeros((batch_size, 16000), dtype=torch.float32)
target_token_lens = torch.tensor([16], dtype=torch.long)
source_token_lens = torch.tensor([1], dtype=torch.long)
target_audio_lens = torch.tensor([20000], dtype=torch.long)
source_audio_lens = torch.tensor([16000], dtype=torch.long)
# Apply early interruption
dataset_with_early_interruption._apply_early_interruption_augmentation(
target_tokens=target_tokens,
source_tokens=source_tokens,
source_audio=source_audio,
source_audio_lens=source_audio_lens,
batch_idx=0,
)
# Find new EOS position
eos_positions = (target_tokens[0] == 2).nonzero(as_tuple=True)[0]
assert len(eos_positions) > 0, "EOS should exist after early interruption"
new_eos_pos = eos_positions[0].item()
# Find where the marker moved to in source_tokens
# The shift is: source_tokens[cutoff_pos+1:...] = source_tokens[original_eos_pos+1:...]
# So marker moves from marker_original_pos to cutoff_pos + 1 + (marker_original_pos - original_eos_pos - 1)
# = cutoff_pos + (marker_original_pos - original_eos_pos)
marker_new_positions = (source_tokens[0] == marker_token).nonzero(as_tuple=True)[0]
assert len(marker_new_positions) > 0, "Marker token should still exist after transformation"
marker_new_pos = marker_new_positions[0].item()
# Calculate actual cutoff position from the marker shift
# marker_new_pos = cutoff_pos + (marker_original_pos - original_eos_pos)
# => cutoff_pos = marker_new_pos - (marker_original_pos - original_eos_pos)
actual_cutoff_pos = marker_new_pos - (marker_original_pos - original_eos_pos)
# Verify the overlap is exactly overlap_tokens
actual_overlap = new_eos_pos - actual_cutoff_pos
assert actual_overlap == overlap_tokens, (
f"Overlap should be {overlap_tokens} tokens, but got {actual_overlap} "
f"(cutoff at {actual_cutoff_pos}, new EOS at {new_eos_pos})"
)
# Cutoff should be in non-pad region (user interrupts during non-pad region)
assert 1 <= actual_cutoff_pos <= 10, (
f"Cutoff at {actual_cutoff_pos} should be in non-pad region (1-10), "
f"meaning user interrupts during active speech"
)
print(f"\n✓ Overlap tokens verification:")
print(f" - Configured overlap: {overlap_tokens} tokens")
print(f" - Actual cutoff position (from marker shift): {actual_cutoff_pos}")
print(f" - New EOS position: {new_eos_pos}")
print(f" - Actual overlap: {actual_overlap} tokens ✓")
def test_early_interruption_no_valid_turns():
"""Test that early interruption handles cases with no valid turns gracefully."""
mock_tokenizer = MagicMock()
type(mock_tokenizer).bos = PropertyMock(return_value=1)
type(mock_tokenizer).eos = PropertyMock(return_value=2)
type(mock_tokenizer).pad = PropertyMock(return_value=0)
type(mock_tokenizer).pad_id = PropertyMock(return_value=0)
type(mock_tokenizer).unk_id = PropertyMock(return_value=None)
mock_tokenizer.text_to_ids = MagicMock(return_value=[1])
cfg = {"early_interruption_prob": 1.0, "early_interruption_overlap_tokens": 5}
model_cfg = {"predict_user_text": False, "force_align_user_text": False}
dataset = DuplexSTTDataset(
tokenizer=mock_tokenizer,
frame_length=0.08,
source_sample_rate=16000,
input_roles=["user"],
output_roles=["assistant"],
cfg=cfg,
model_cfg=model_cfg,
)
# Create tensors with no valid turns (all padding)
batch_size = 1
seq_len = 20
target_tokens = torch.full((batch_size, seq_len), 0, dtype=torch.long)
source_tokens = torch.full((batch_size, seq_len), 0, dtype=torch.long)
target_audio = torch.zeros((batch_size, 16000), dtype=torch.float32)
source_audio = torch.zeros((batch_size, 16000), dtype=torch.float32)
target_token_lens = torch.tensor([1], dtype=torch.long)
source_token_lens = torch.tensor([1], dtype=torch.long)
target_audio_lens = torch.tensor([16000], dtype=torch.long)
source_audio_lens = torch.tensor([16000], dtype=torch.long)
# Apply early interruption - should not crash
dataset._apply_early_interruption_augmentation(
target_tokens=target_tokens,
source_tokens=source_tokens,
source_audio=source_audio,
source_audio_lens=source_audio_lens,
batch_idx=0,
)
# Verify: Tokens should remain unchanged (all zeros)
assert torch.all(target_tokens == 0), "Tokens should remain unchanged when no valid turns exist"
def test_early_interruption_frames_to_remove_calculation(dataset_with_early_interruption):
"""Test that frames_to_remove is calculated correctly."""
batch_size = 1
seq_len = 25
target_tokens = torch.full((batch_size, seq_len), 0, dtype=torch.long)
target_tokens[0, 0] = 1 # BOS
target_tokens[0, 1:11] = torch.arange(10, 20) # 10 content tokens (positions 1-10)
# Positions 11-14 are PAD
target_tokens[0, 15] = 2 # Original EOS at position 15 (after padding)
original_eos_pos = 15
source_tokens = torch.full((batch_size, seq_len), 0, dtype=torch.long)
target_audio = torch.zeros((batch_size, 20000), dtype=torch.float32)
source_audio = torch.zeros((batch_size, 16000), dtype=torch.float32)
target_token_lens = torch.tensor([16], dtype=torch.long)
source_token_lens = torch.tensor([1], dtype=torch.long)
target_audio_lens = torch.tensor([20000], dtype=torch.long)
source_audio_lens = torch.tensor([16000], dtype=torch.long)
# Apply early interruption
dataset_with_early_interruption._apply_early_interruption_augmentation(
target_tokens=target_tokens,
source_tokens=source_tokens,
source_audio=source_audio,
source_audio_lens=source_audio_lens,
batch_idx=0,
)
# Verify: New EOS should be in the non-pad region
new_eos_positions = (target_tokens[0] == 2).nonzero(as_tuple=True)[0]
assert len(new_eos_positions) > 0, "EOS should exist after early interruption"
new_eos_pos = new_eos_positions[0].item()
# cutoff_pos must satisfy (eos_pos - pos) > overlap_tokens, i.e., pos < 10
# So valid cutoff positions are 1-9, and new_eos_pos = cutoff_pos + 5
# Range: (1+5) to (9+5) = 6 to 14
overlap_tokens = 5 # from fixture cfg
assert 6 <= new_eos_pos <= 14, (
f"New EOS at {new_eos_pos} should be within cutoff + overlap range (6-14), "
f"meaning agent continues for {overlap_tokens} tokens after user interruption"
)
# Verify: Check that padding is added at the end
# Since we always use frames_to_remove = original_eos_pos - cutoff_pos,
# we should have more padding at the end after truncation
num_pad_tokens = (target_tokens[0] == 0).sum().item()
# 9 is a conservative lower bound: new_eos_pos ranges 6-14, so minimum trailing
# PAD is 10 (positions 15-24), plus PAD positions 11-13 before EOS = 13 total minimum
assert num_pad_tokens >= 9, "Should have increased padding after truncation"
if __name__ == "__main__":
pytest.main([__file__, "-v"])
@@ -0,0 +1,229 @@
# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import pytest
import torch
from nemo.collections.speechlm2.data.salm_dataset import MultiSpeakerConfig
from nemo.collections.speechlm2.parts.encoder_chunking import (
_recombine_chunked_audio_embeddings,
_split_audio_into_chunks,
_split_spk_targets_into_chunks,
encode_audio_with_optional_chunking,
)
from tests.collections.speechlm2._chunking_helpers import ChunkingTestPerception
@pytest.mark.parametrize(
(
"input_signal_lengths",
"chunk_size_samples",
"min_chunk_size_samples",
"expected_chunk_lens",
"expected_chunks_per_audio",
"expected_chunk_spans",
),
[
(
[5, 0, 7],
3,
2,
[3, 2, 0, 3, 4],
[2, 1, 2],
[(0, 0, 3), (0, 3, 5), (1, 0, 0), (2, 0, 3), (2, 3, 7)],
),
(
[6],
2,
1,
[2, 2, 2],
[3],
[(0, 0, 2), (0, 2, 4), (0, 4, 6)],
),
],
)
def test_split_audio_into_chunks_returns_spans_independent_of_spk_targets(
input_signal_lengths,
chunk_size_samples,
min_chunk_size_samples,
expected_chunk_lens,
expected_chunks_per_audio,
expected_chunk_spans,
):
max_signal_len = max(input_signal_lengths, default=0)
input_signal = torch.arange(len(input_signal_lengths) * max_signal_len, dtype=torch.float32).reshape(
len(input_signal_lengths), max_signal_len
)
chunks, chunk_lens, chunks_per_audio, chunk_spans = _split_audio_into_chunks(
input_signal=input_signal,
input_signal_lengths=input_signal_lengths,
chunk_size_samples=chunk_size_samples,
min_chunk_size_samples=min_chunk_size_samples,
)
assert chunk_lens == expected_chunk_lens
assert chunks_per_audio == expected_chunks_per_audio
assert chunk_spans == expected_chunk_spans
for chunk, (audio_idx, begin, end) in zip(chunks, chunk_spans):
assert torch.equal(chunk, input_signal[audio_idx, begin:end])
assert _split_spk_targets_into_chunks(None, input_signal_lengths, chunk_spans) is None
@pytest.mark.parametrize(
("chunk_values", "chunk_lens", "chunks_per_audio", "expected_audio_values"),
[
(
[[1.0, 2.0, 0.0], [3.0, 4.0, 5.0]],
[2, 3],
[2],
[[1.0, 2.0, 3.0, 4.0, 5.0]],
),
(
[[1.0, 2.0, 0.0], [3.0, 4.0, 5.0], [10.0, 11.0, 0.0], [12.0, 13.0, 14.0]],
[2, 3, 2, 3],
[2, 2],
[[1.0, 2.0, 3.0, 4.0, 5.0], [10.0, 11.0, 12.0, 13.0, 14.0]],
),
],
)
def test_recombine_chunked_audio_embeddings_reconstructs_original_rows(
chunk_values,
chunk_lens,
chunks_per_audio,
expected_audio_values,
):
chunked_embs = torch.tensor(chunk_values, dtype=torch.float32).unsqueeze(-1)
chunked_emb_lens = torch.tensor(chunk_lens, dtype=torch.long)
audio_embs = _recombine_chunked_audio_embeddings(chunked_embs, chunked_emb_lens, chunks_per_audio)
assert len(audio_embs) == len(expected_audio_values)
for audio_emb, expected_values in zip(audio_embs, expected_audio_values):
assert torch.equal(audio_emb.squeeze(-1), torch.tensor(expected_values))
@pytest.mark.parametrize(
(
"input_signal_lengths",
"chunk_spans",
"expected_chunks",
),
[
(
[5],
[(0, 0, 2), (0, 2, 5)],
[
[[0.0, 1.0, 2.0, 3.0], [4.0, 5.0, 6.0, 7.0], [4.0, 5.0, 6.0, 7.0]],
[[8.0, 9.0, 10.0, 11.0], [12.0, 13.0, 14.0, 15.0], [16.0, 17.0, 18.0, 19.0]],
],
),
(
[4],
[(0, 0, 2), (0, 2, 4)],
[
[[0.0, 1.0, 2.0, 3.0], [4.0, 5.0, 6.0, 7.0], [4.0, 5.0, 6.0, 7.0]],
[[8.0, 9.0, 10.0, 11.0], [12.0, 13.0, 14.0, 15.0], [16.0, 17.0, 18.0, 19.0]],
],
),
],
)
def test_split_spk_targets_into_chunks_uses_chunk_spans(
input_signal_lengths,
chunk_spans,
expected_chunks,
):
cfg = MultiSpeakerConfig()
spk_targets = torch.arange(5 * cfg.num_speakers, dtype=torch.float32).reshape(1, 5, cfg.num_speakers)
chunked_spk_targets = _split_spk_targets_into_chunks(spk_targets, input_signal_lengths, chunk_spans)
assert torch.equal(chunked_spk_targets, torch.tensor(expected_chunks))
@pytest.mark.parametrize(
("audio_values", "audio_len", "expected_chunk_lens"),
[
([1.0, 2.0, 3.0, 4.0, 5.0], 5, [2, 3]),
([1.0, 2.0, 3.0, 4.0], 4, [2, 2]),
],
)
def test_encode_audio_with_optional_chunking_does_not_forward_absent_spk_targets(
audio_values, audio_len, expected_chunk_lens
):
perception = ChunkingTestPerception(sampling_rate=2, hop_length=1)
audios = torch.tensor([audio_values])
audio_lens = torch.tensor([audio_len], dtype=torch.long)
embs = encode_audio_with_optional_chunking(
perception,
audios,
audio_lens,
chunk_size_seconds=1.0,
sampling_rate=2,
spk_targets=None,
)
chunked_signal, chunked_lens = perception.calls[0]
assert chunked_signal.shape == (len(expected_chunk_lens), max(expected_chunk_lens))
assert torch.equal(chunked_lens, torch.tensor(expected_chunk_lens, dtype=torch.long))
assert perception.spk_targets_calls[0] is None
assert torch.equal(embs[0].squeeze(-1), audios[0])
@pytest.mark.parametrize(
("audio_values", "audio_len", "expected_chunk_lens", "expected_spk_targets"),
[
(
[1.0, 2.0, 3.0, 4.0, 5.0],
5,
[2, 3],
[
[[0.0, 1.0, 2.0, 3.0], [4.0, 5.0, 6.0, 7.0], [4.0, 5.0, 6.0, 7.0]],
[[8.0, 9.0, 10.0, 11.0], [12.0, 13.0, 14.0, 15.0], [16.0, 17.0, 18.0, 19.0]],
],
),
(
[1.0, 2.0, 3.0, 4.0],
4,
[2, 2],
[
[[0.0, 1.0, 2.0, 3.0], [4.0, 5.0, 6.0, 7.0], [4.0, 5.0, 6.0, 7.0]],
[[8.0, 9.0, 10.0, 11.0], [12.0, 13.0, 14.0, 15.0], [16.0, 17.0, 18.0, 19.0]],
],
),
],
)
def test_encode_audio_with_optional_chunking_forwards_chunked_spk_targets(
audio_values, audio_len, expected_chunk_lens, expected_spk_targets
):
cfg = MultiSpeakerConfig()
perception = ChunkingTestPerception(sampling_rate=2, hop_length=1)
audios = torch.tensor([audio_values])
audio_lens = torch.tensor([audio_len], dtype=torch.long)
spk_targets = torch.arange(5 * cfg.num_speakers, dtype=torch.float32).reshape(1, 5, cfg.num_speakers)
embs = encode_audio_with_optional_chunking(
perception,
audios,
audio_lens,
chunk_size_seconds=1.0,
sampling_rate=2,
spk_targets=spk_targets,
)
chunked_signal, chunked_lens = perception.calls[0]
assert chunked_signal.shape == (len(expected_chunk_lens), max(expected_chunk_lens))
assert torch.equal(chunked_lens, torch.tensor(expected_chunk_lens, dtype=torch.long))
assert torch.equal(perception.spk_targets_calls[0], torch.tensor(expected_spk_targets))
assert torch.equal(embs[0].squeeze(-1), audios[0])
@@ -0,0 +1,169 @@
# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import multiprocessing as mp
import os
import pytest
import torch
from lhotse import CutSet, Recording, SupervisionSegment
from lhotse.testing.dummies import dummy_cut, dummy_recording
from nemo.collections.speechlm2.data.force_align import ForceAligner
# Set spawn method to avoid fork+CUDA conflicts that cause ForceAligner to fall back to CPU
if mp.get_start_method(allow_none=True) != 'spawn':
mp.set_start_method('spawn', force=True)
TEST_DATA_DIR = os.path.join(os.path.dirname(__file__), "test_data")
@pytest.fixture(scope="module")
def force_aligner():
"""Create a ForceAligner instance for testing"""
device = 'cuda' if torch.cuda.is_available() else 'cpu'
aligner = ForceAligner(device=device, frame_length=0.08)
return aligner
@pytest.fixture(scope="module")
def test_cutset_from_audio_file():
"""Create a test cutset from a pre-recorded audio file."""
audio_path = os.path.join(TEST_DATA_DIR, "force_align_test.mp3")
text = "ten companies that let you teach english"
rec = Recording.from_file(audio_path)
cut = rec.to_cut()
cut.supervisions = [
SupervisionSegment(
id=f"{cut.id}-0",
recording_id=cut.recording_id,
start=0.0,
duration=rec.duration,
text=text,
speaker="user",
),
]
return CutSet([cut])
def test_force_align_audio_file(force_aligner, test_cutset_from_audio_file):
"""Test force alignment with a pre-recorded audio file."""
import re
# Store original texts before alignment
original_texts = {}
for cut in test_cutset_from_audio_file:
for sup in cut.supervisions:
if sup.speaker == "user":
original_texts[sup.id] = sup.text
result_cuts = force_aligner.batch_force_align_user_audio(test_cutset_from_audio_file, source_sample_rate=24000)
assert len(result_cuts) == len(test_cutset_from_audio_file)
assert len(result_cuts) == 1
for cut in result_cuts:
user_supervisions = [s for s in cut.supervisions if s.speaker == "user"]
assert len(user_supervisions) > 0
for sup in user_supervisions:
original_text = original_texts.get(sup.id, "")
aligned_text = sup.text
print(f"\n{'='*80}")
print(f"Supervision ID: {sup.id}")
print(f"{'='*80}")
print(f"ORIGINAL TEXT:\n {original_text}")
print(f"\nALIGNED TEXT:\n {aligned_text}")
print(f"{'='*80}")
if "<|" not in aligned_text:
# TODO(kevinhu): Fix CUDA/numpy device mismatch in NeMo aligner utils
# (get_batch_variables returns CUDA tensors that fail on .numpy() calls)
pytest.skip("Force alignment did not produce timestamps (likely CUDA/numpy device mismatch in CI)")
# Extract timestamp-word-timestamp patterns: <|start|> word <|end|>
pattern = r'<\|(\d+)\|>\s+(\S+)\s+<\|(\d+)\|>'
matches = re.findall(pattern, aligned_text)
words_only = re.sub(r'<\|\d+\|>', '', aligned_text).split()
words_only = [w for w in words_only if w]
print(f"\nValidation: Found {len(matches)} timestamped words out of {len(words_only)} total words")
assert len(matches) > 0, "Should have at least one timestamped word"
assert len(matches) == len(
words_only
), f"Every word should have timestamps. Found {len(matches)} timestamped words but {len(words_only)} total words"
for start_frame, word, end_frame in matches:
start_frame = int(start_frame)
end_frame = int(end_frame)
assert (
start_frame <= end_frame
), f"Start frame {start_frame} should be before or equal to end frame {end_frame} for word '{word}'"
assert start_frame >= 0, f"Start frame should be non-negative for word '{word}'"
assert end_frame >= 0, f"End frame should be non-negative for word '{word}'"
def test_force_align_no_user_supervisions(force_aligner):
"""Test with cutset containing no user supervisions"""
cut = dummy_cut(0, recording=dummy_recording(0, with_data=True))
cut.supervisions = [
SupervisionSegment(
id=cut.id,
recording_id=cut.recording_id,
start=0,
duration=0.5,
text='hello',
speaker="assistant",
),
]
cutset = CutSet([cut])
result_cuts = force_aligner.batch_force_align_user_audio(cutset)
assert len(result_cuts) == 1
result_supervisions = list(result_cuts)[0].supervisions
assert result_supervisions[0].text == 'hello'
def test_force_align_empty_cutset(force_aligner):
"""Test with empty cutset"""
empty_cutset = CutSet.from_cuts([])
result_cuts = force_aligner.batch_force_align_user_audio(empty_cutset)
assert len(result_cuts) == 0
def test_strip_timestamps(force_aligner):
"""Test timestamp stripping utility"""
text_with_timestamps = "<|10|> hello <|20|> world <|30|>"
result = force_aligner._strip_timestamps(text_with_timestamps)
assert result == "hello world"
assert "<|" not in result
text_without_timestamps = "hello world"
result = force_aligner._strip_timestamps(text_without_timestamps)
assert result == "hello world"
def test_normalize_transcript(force_aligner):
"""Test transcript normalization"""
assert force_aligner._normalize_transcript("Hello World!") == "hello world"
assert force_aligner._normalize_transcript("don't worry") == "don't worry"
assert force_aligner._normalize_transcript("test123") == "test"
assert force_aligner._normalize_transcript("A,B.C!D?E") == "a b c d e"
@@ -0,0 +1,96 @@
# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import torch.nn
from omegaconf import DictConfig
import nemo.core.optim.lr_scheduler
from nemo.collections.speechlm2.parts.optim_setup import configure_optimizers, freeze_and_subset
class DummyModel(torch.nn.Module):
def __init__(self):
super().__init__()
self.linear = torch.nn.Linear(1, 1)
self.conv = torch.nn.Conv1d(1, 1, 1)
def forward(self, x):
x = self.linear(x)
x = self.conv(x)
return x
def test_freezing_params():
model = DummyModel().train()
assert model.linear.weight.requires_grad
assert model.linear.bias.requires_grad
assert model.conv.weight.requires_grad
assert model.conv.bias.requires_grad
params = freeze_and_subset(model.named_parameters(), exclude_patterns=[r"linear\..+"])
list(params) # execute generator
assert not model.linear.weight.requires_grad
assert not model.linear.bias.requires_grad
assert model.conv.weight.requires_grad
assert model.conv.bias.requires_grad
def test_keeping_unfrozen_params():
model = DummyModel().train()
assert model.linear.weight.requires_grad
assert model.linear.bias.requires_grad
assert model.conv.weight.requires_grad
assert model.conv.bias.requires_grad
params = freeze_and_subset(
model.named_parameters(), exclude_patterns=[r"linear\..+"], keep_patterns=[r"linear.bias"]
)
list(params) # execute generator
assert not model.linear.weight.requires_grad
assert model.linear.bias.requires_grad
assert model.conv.weight.requires_grad
assert model.conv.bias.requires_grad
def test_configure_optimizers():
model = DummyModel()
model.cfg = DictConfig(
{
"optimizer": {"_target_": "torch.optim.adamw.AdamW"},
"freeze_params": [r"conv\..+"],
}
)
ans = configure_optimizers(model)
assert ans.keys() == {"optimizer"}
assert isinstance(ans["optimizer"], torch.optim.AdamW)
parameters = ans["optimizer"].param_groups[0]['params']
assert len(parameters) == 2
assert parameters[0] == model.linear.weight
assert parameters[1] == model.linear.bias
def test_configure_optimizers_with_lr_scheduler():
model = DummyModel()
model.cfg = DictConfig(
{
"optimizer": {"_target_": "torch.optim.adamw.AdamW"},
"lr_scheduler": {
"_target_": "nemo.core.optim.lr_scheduler.CosineAnnealing",
"warmup_steps": 0,
"min_lr": 1e-6,
"max_steps": 100000,
},
}
)
ans = configure_optimizers(model)
assert ans.keys() == {"optimizer", "lr_scheduler"}
assert isinstance(ans["optimizer"], torch.optim.AdamW)
assert isinstance(ans["lr_scheduler"]["scheduler"], nemo.core.optim.lr_scheduler.CosineAnnealing)
@@ -0,0 +1,75 @@
# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from nemo.collections.speechlm2.parts.hf_hub import _inject_local_artifact_paths
def _cached_file_kwargs():
return {
"cache_dir": None,
"force_download": False,
"local_files_only": True,
"token": None,
"revision": None,
"_raise_exceptions_for_gated_repo": False,
"_raise_exceptions_for_missing_entries": False,
"_raise_exceptions_for_connection_errors": False,
}
def _write_local_export_artifacts(tmp_path):
(tmp_path / "tokenizer_config.json").write_text("{}")
(tmp_path / "llm_backbone").mkdir()
(tmp_path / "llm_backbone" / "config.json").write_text("{}")
def test_inject_local_artifact_paths_salm_config(tmp_path):
_write_local_export_artifacts(tmp_path)
cfg = {
"pretrained_llm": "remote-llm",
"pretrained_asr": "remote-asr",
}
_inject_local_artifact_paths(cfg, str(tmp_path), _cached_file_kwargs())
assert cfg["pretrained_llm"] == str(tmp_path / "llm_backbone")
assert cfg["pretrained_asr"] == "remote-asr"
assert cfg["tokenizer_path"] == str(tmp_path)
def test_inject_local_artifact_paths_duplex_eartts_config(tmp_path):
_write_local_export_artifacts(tmp_path)
cfg = {
"pretrained_lm_name": "remote-llm",
"tts_config": {},
}
_inject_local_artifact_paths(cfg, str(tmp_path), _cached_file_kwargs())
assert cfg["pretrained_lm_name"] == str(tmp_path / "llm_backbone")
assert cfg["tokenizer_path"] == str(tmp_path)
def test_inject_local_artifact_paths_no_artifacts_keeps_old_config(tmp_path):
cfg = {
"pretrained_llm": "remote-llm",
"pretrained_weights": True,
}
_inject_local_artifact_paths(cfg, str(tmp_path), _cached_file_kwargs())
assert cfg == {
"pretrained_llm": "remote-llm",
"pretrained_weights": True,
}
@@ -0,0 +1,368 @@
# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Tests for init_from_checkpoint functionality in speechlm2.
Unit tests use simple nn.Module subclasses (no HF downloads, no CUDA).
Integration tests use real SALM / SALMAutomodel (require HF config download;
SALMAutomodel tests also require CUDA).
"""
import os
from unittest.mock import patch
import pytest
import torch
from omegaconf import DictConfig
from safetensors.torch import save_file
from nemo.collections.speechlm2.parts.pretrained import (
_is_dcp_checkpoint,
init_from_training_checkpoint,
maybe_load_pretrained_models,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
class SimpleModel(torch.nn.Module):
"""Tiny model used for fast, self-contained unit tests."""
def __init__(self):
super().__init__()
self.linear = torch.nn.Linear(4, 4, bias=False)
self.norm = torch.nn.LayerNorm(4)
class ConfigurableModel(torch.nn.Module):
"""SimpleModel that carries a ``cfg`` attribute, like SALM/SALMAutomodel."""
def __init__(self, cfg: dict):
super().__init__()
self.cfg = DictConfig(cfg)
self.linear = torch.nn.Linear(4, 4, bias=False)
self.norm = torch.nn.LayerNorm(4)
def _save_ckpt(model, path):
"""Save model state_dict in Lightning checkpoint format."""
torch.save({"state_dict": model.state_dict()}, path)
def _assert_state_dicts_equal(sd1, sd2):
assert set(sd1.keys()) == set(sd2.keys()), f"Key mismatch: {set(sd1.keys()) ^ set(sd2.keys())}"
for key in sd1:
assert torch.equal(sd1[key].cpu(), sd2[key].cpu()), f"Tensor mismatch at key: {key}"
def _assert_state_dicts_not_equal(sd1, sd2):
"""Assert at least one tensor differs (sanity-check before loading)."""
assert set(sd1.keys()) == set(sd2.keys())
any_diff = any(not torch.equal(sd1[k].cpu(), sd2[k].cpu()) for k in sd1)
assert any_diff, "Expected state dicts to differ before checkpoint loading"
# ---------------------------------------------------------------------------
# _is_dcp_checkpoint
# ---------------------------------------------------------------------------
class TestIsDcpCheckpoint:
def test_with_metadata(self, tmp_path):
ckpt_dir = tmp_path / "step=100.ckpt"
ckpt_dir.mkdir()
(ckpt_dir / ".metadata").touch()
assert _is_dcp_checkpoint(str(ckpt_dir))
def test_without_metadata(self, tmp_path):
ckpt_dir = tmp_path / "step=100.ckpt"
ckpt_dir.mkdir()
assert not _is_dcp_checkpoint(str(ckpt_dir))
def test_regular_file(self, tmp_path):
ckpt_file = tmp_path / "step=100.ckpt"
ckpt_file.touch()
assert not _is_dcp_checkpoint(str(ckpt_file))
def test_nonexistent_path(self, tmp_path):
assert not _is_dcp_checkpoint(str(tmp_path / "nonexistent"))
def test_hf_dir_without_metadata(self, tmp_path):
"""HF directory (model.safetensors) should NOT be detected as DCP."""
hf_dir = tmp_path / "hf_model"
hf_dir.mkdir()
(hf_dir / "model.safetensors").touch()
assert not _is_dcp_checkpoint(str(hf_dir))
# ---------------------------------------------------------------------------
# init_from_training_checkpoint — non-DCP paths
# ---------------------------------------------------------------------------
class TestInitFromTrainingCheckpoint:
def test_none_is_noop(self):
model = SimpleModel()
original = model.linear.weight.clone()
init_from_training_checkpoint(model, None)
assert torch.equal(model.linear.weight, original)
def test_single_file_ckpt(self, tmp_path):
source = SimpleModel()
torch.nn.init.ones_(source.linear.weight)
ckpt_path = str(tmp_path / "model.ckpt")
_save_ckpt(source, ckpt_path)
target = SimpleModel() # different random init
_assert_state_dicts_not_equal(source.state_dict(), target.state_dict())
init_from_training_checkpoint(target, ckpt_path)
_assert_state_dicts_equal(target.state_dict(), source.state_dict())
def test_hf_directory(self, tmp_path):
source = SimpleModel()
torch.nn.init.ones_(source.linear.weight)
hf_dir = tmp_path / "hf_model"
hf_dir.mkdir()
save_file(source.state_dict(), str(hf_dir / "model.safetensors"))
target = SimpleModel()
_assert_state_dicts_not_equal(source.state_dict(), target.state_dict())
init_from_training_checkpoint(target, str(hf_dir))
_assert_state_dicts_equal(target.state_dict(), source.state_dict())
# ---------------------------------------------------------------------------
# init_from_training_checkpoint — DCP path (mocked)
# ---------------------------------------------------------------------------
class TestInitFromTrainingCheckpointDCP:
def test_dcp_calls_distributed_load(self, tmp_path):
"""Verify DCP checkpoint triggers torch.distributed.checkpoint.load."""
ckpt_dir = tmp_path / "step=100.ckpt"
ckpt_dir.mkdir()
(ckpt_dir / ".metadata").touch()
model = SimpleModel()
with patch("nemo.collections.speechlm2.parts.pretrained.torch.distributed.checkpoint.load") as mock_load:
init_from_training_checkpoint(model, str(ckpt_dir))
mock_load.assert_called_once()
args, kwargs = mock_load.call_args
state_dict_wrapper = args[0]
assert "state_dict" in state_dict_wrapper
assert kwargs["checkpoint_id"] == str(ckpt_dir)
def test_dcp_state_dict_has_model_keys(self, tmp_path):
"""The state dict passed to dcp.load should contain model parameter keys."""
ckpt_dir = tmp_path / "step=100.ckpt"
ckpt_dir.mkdir()
(ckpt_dir / ".metadata").touch()
model = SimpleModel()
with patch("nemo.collections.speechlm2.parts.pretrained.torch.distributed.checkpoint.load") as mock_load:
init_from_training_checkpoint(model, str(ckpt_dir))
state_dict_wrapper = mock_load.call_args[0][0]
model_sd = state_dict_wrapper["state_dict"]
assert "linear.weight" in model_sd
assert "norm.weight" in model_sd
# ---------------------------------------------------------------------------
# maybe_load_pretrained_models — init_from_checkpoint config key
# ---------------------------------------------------------------------------
class TestMaybeLoadPretrainedModels:
def test_init_from_checkpoint_loads_weights(self, tmp_path):
source = ConfigurableModel(cfg={})
torch.nn.init.ones_(source.linear.weight)
ckpt_path = str(tmp_path / "model.ckpt")
_save_ckpt(source, ckpt_path)
target = ConfigurableModel(cfg={"init_from_checkpoint": ckpt_path})
_assert_state_dicts_not_equal(source.state_dict(), target.state_dict())
maybe_load_pretrained_models(target)
_assert_state_dicts_equal(target.state_dict(), source.state_dict())
def test_init_from_checkpoint_null_is_noop(self):
model = ConfigurableModel(cfg={"init_from_checkpoint": None})
original = model.linear.weight.clone()
maybe_load_pretrained_models(model)
assert torch.equal(model.linear.weight, original)
def test_init_from_checkpoint_key_missing_is_noop(self):
model = ConfigurableModel(cfg={})
original = model.linear.weight.clone()
maybe_load_pretrained_models(model)
assert torch.equal(model.linear.weight, original)
def test_pretrained_s2s_model_still_works(self, tmp_path):
"""Backward compat: pretrained_s2s_model should still be recognized."""
source = ConfigurableModel(cfg={})
torch.nn.init.ones_(source.linear.weight)
ckpt_path = str(tmp_path / "model.ckpt")
_save_ckpt(source, ckpt_path)
target = ConfigurableModel(cfg={"pretrained_s2s_model": ckpt_path})
maybe_load_pretrained_models(target)
_assert_state_dicts_equal(target.state_dict(), source.state_dict())
# ---------------------------------------------------------------------------
# SALM integration test
# ---------------------------------------------------------------------------
AUDIO_LOCATOR_TAG = "<|audioplaceholder|>"
SALM_PERCEPTION_CFG = {
"target": "nemo.collections.speechlm2.modules.perception.AudioPerceptionModule",
"output_dim": 2048,
"encoder": {
"_target_": "nemo.collections.asr.modules.ConformerEncoder",
"att_context_size": [-1, -1],
"causal_downsampling": False,
"conv_context_size": None,
"conv_kernel_size": 9,
"conv_norm_type": "batch_norm",
"d_model": 1024,
"dropout": 0.1,
"dropout_att": 0.1,
"dropout_emb": 0.0,
"dropout_pre_encoder": 0.1,
"feat_in": 128,
"feat_out": -1,
"ff_expansion_factor": 4,
"n_heads": 8,
"n_layers": 2,
"pos_emb_max_len": 5000,
"self_attention_model": "rel_pos",
"subsampling": "dw_striding",
"subsampling_conv_channels": 256,
"subsampling_factor": 8,
},
"modality_adapter": {
"_target_": "nemo.collections.speechlm2.modules.perception.IdentityConnector",
"d_model": 1024,
},
"preprocessor": {
"_target_": "nemo.collections.asr.modules.AudioToMelSpectrogramPreprocessor",
"dither": 1e-05,
"features": 128,
"frame_splicing": 1,
"log": True,
"n_fft": 512,
"normalize": "per_feature",
"pad_to": 0,
"pad_value": 0.0,
"sample_rate": 16000,
"window": "hann",
"window_size": 0.025,
"window_stride": 0.01,
},
}
def _resolve_pretrained_salm():
if os.path.exists("/home/TestData/speechlm/pretrained_models"):
return {
"pretrained_llm": "/home/TestData/speechlm/pretrained_models/TinyLlama--TinyLlama_v1.1",
"pretrained_asr": "/home/TestData/speechlm/pretrained_models/canary-1b-flash.nemo",
}
return {
"pretrained_asr": "nvidia/canary-1b-flash",
"pretrained_llm": "TinyLlama/TinyLlama_v1.1",
}
def _resolve_pretrained_automodel():
if os.path.exists("/home/TestData/speechlm/pretrained_models"):
return {
"pretrained_llm": "/home/TestData/speechlm/pretrained_models/Qwen--Qwen3-1.7B",
"pretrained_asr": "/home/TestData/speechlm/pretrained_models/canary-1b-flash.nemo",
}
return {
"pretrained_asr": "nvidia/canary-1b-flash",
"pretrained_llm": "Qwen/Qwen3-1.7B",
}
def _make_salm_cfg(**overrides):
cfg = {
**_resolve_pretrained_salm(),
"pretrained_weights": False,
"prompt_format": "llama2",
"audio_locator_tag": AUDIO_LOCATOR_TAG,
"perception": SALM_PERCEPTION_CFG,
"optimizer": {"_target_": "torch.optim.AdamW"},
}
cfg.update(overrides)
return cfg
def _make_automodel_cfg(**overrides):
cfg = {
**_resolve_pretrained_automodel(),
"pretrained_weights": False,
"prompt_format": "qwen",
"audio_locator_tag": AUDIO_LOCATOR_TAG,
"perception": SALM_PERCEPTION_CFG,
"optimizer": {"_target_": "torch.optim.AdamW"},
"torch_dtype": "bfloat16",
}
cfg.update(overrides)
return cfg
def test_salm_init_from_checkpoint(tmp_path):
from nemo.collections.speechlm2.models import SALM
# Create source model and save checkpoint
model1 = SALM(_make_salm_cfg())
expected_sd = {k: v.clone().cpu() for k, v in model1.state_dict().items()}
ckpt_path = str(tmp_path / "source.ckpt")
_save_ckpt(model1, ckpt_path)
del model1
# Create target model — init_from_checkpoint overrides the random init
model2 = SALM(_make_salm_cfg(init_from_checkpoint=ckpt_path))
_assert_state_dicts_equal(model2.state_dict(), expected_sd)
@pytest.mark.skipif(not torch.cuda.is_available(), reason="SALMAutomodel requires CUDA")
def test_salm_automodel_init_from_checkpoint(tmp_path):
from nemo.collections.speechlm2.models import SALMAutomodel
# Create source model and save checkpoint
model1 = SALMAutomodel(_make_automodel_cfg())
model1.configure_model()
expected_sd = {k: v.clone().cpu() for k, v in model1.state_dict().items()}
ckpt_path = str(tmp_path / "source.ckpt")
_save_ckpt(model1, ckpt_path)
del model1
if torch.cuda.is_available():
torch.cuda.empty_cache()
# Create target model — configure_model loads checkpoint via maybe_load_pretrained_models
model2 = SALMAutomodel(_make_automodel_cfg(init_from_checkpoint=ckpt_path))
model2.configure_model()
_assert_state_dicts_equal(model2.state_dict(), expected_sd)
@@ -0,0 +1,138 @@
# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import torch
from nemo.collections.speechlm2.parts.label_prep import maybe_prepend_prompt_tokens
def test_maybe_prepend_prompt_tokens_with_source_tokens():
"""Test that prompt tokens are correctly prepended to all sequences and lengths are updated."""
B, T_src, T_tgt, H = 2, 6, 6, 4
PAD = 0
prompt_len_0, prompt_len_1 = 3, 2
# Prompt token IDs (will be passed through embed_fn)
max_prompt_len = max(prompt_len_0, prompt_len_1)
prompt_tokens = torch.full((B, max_prompt_len), PAD, dtype=torch.long)
prompt_tokens[0, :prompt_len_0] = torch.tensor([10, 11, 12])
prompt_tokens[1, :prompt_len_1] = torch.tensor([20, 21])
# Use a simple embedding: token_id -> [token_id, token_id, token_id, token_id]
def embed_fn(token_ids):
return token_ids.unsqueeze(-1).expand(-1, -1, H).float()
# Source encoded (audio features)
source_encoded = torch.arange(B * T_src * H).reshape(B, T_src, H).float()
source_encoded_lens = torch.tensor([5, 4])
# Target tokens
target_tokens = torch.tensor(
[
[1, 100, 101, 102, 2, 0], # BOS, tokens, EOS, PAD
[1, 200, 201, 2, 0, 0],
]
)
target_token_lens = torch.tensor([5, 4])
# Source tokens (for ASR head)
source_tokens = torch.tensor(
[
[1, 50, 51, 52, 2, 0],
[1, 60, 61, 2, 0, 0],
]
)
source_token_lens = torch.tensor([5, 4])
batch = {
"prompt_tokens": prompt_tokens,
"prompt_token_lens": torch.tensor([prompt_len_0, prompt_len_1]),
"target_tokens": target_tokens,
"target_token_lens": target_token_lens,
"source_tokens": source_tokens,
"source_token_lens": source_token_lens,
}
new_source_encoded, new_source_encoded_lens, new_target_tokens = maybe_prepend_prompt_tokens(
batch=batch,
embed_fn=embed_fn,
source_encoded=source_encoded,
source_encoded_lens=source_encoded_lens,
text_pad_id=PAD,
)
# Check output shapes are extended by max_prompt_len
assert new_source_encoded.shape == (B, max_prompt_len + T_src, H)
assert new_target_tokens.shape == (B, max_prompt_len + T_tgt)
assert batch["source_tokens"].shape == (B, max_prompt_len + T_tgt)
# Check lengths are updated: original_len + prompt_len
assert new_source_encoded_lens[0].item() == 5 + prompt_len_0
assert new_source_encoded_lens[1].item() == 4 + prompt_len_1
assert batch["target_token_lens"][0].item() == 5 + prompt_len_0
assert batch["target_token_lens"][1].item() == 4 + prompt_len_1
assert batch["source_token_lens"][0].item() == 5 + prompt_len_0
assert batch["source_token_lens"][1].item() == 4 + prompt_len_1
# Check prompt embeddings are at the beginning of source_encoded
# embed_fn maps token_id -> [token_id]*H, so prompt region should match
for h in range(H):
assert new_source_encoded[0, 0, h].item() == 10.0
assert new_source_encoded[0, 1, h].item() == 11.0
assert new_source_encoded[0, 2, h].item() == 12.0
assert new_source_encoded[1, 0, h].item() == 20.0
assert new_source_encoded[1, 1, h].item() == 21.0
# Check original audio features follow the prompt
for t in range(5): # source_encoded_lens[0] was 5
assert torch.equal(new_source_encoded[0, prompt_len_0 + t], source_encoded[0, t])
for t in range(4): # source_encoded_lens[1] was 4
assert torch.equal(new_source_encoded[1, prompt_len_1 + t], source_encoded[1, t])
# Check target tokens are shifted by prompt_len
assert new_target_tokens[0, :prompt_len_0].tolist() == [PAD] * prompt_len_0
assert new_target_tokens[0, prompt_len_0 : prompt_len_0 + 5].tolist() == [1, 100, 101, 102, 2]
assert new_target_tokens[1, :prompt_len_1].tolist() == [PAD] * prompt_len_1
assert new_target_tokens[1, prompt_len_1 : prompt_len_1 + 4].tolist() == [1, 200, 201, 2]
# Check source tokens are shifted by prompt_len
assert batch["source_tokens"][0, :prompt_len_0].tolist() == [PAD] * prompt_len_0
assert batch["source_tokens"][0, prompt_len_0 : prompt_len_0 + 5].tolist() == [1, 50, 51, 52, 2]
assert batch["source_tokens"][1, :prompt_len_1].tolist() == [PAD] * prompt_len_1
assert batch["source_tokens"][1, prompt_len_1 : prompt_len_1 + 4].tolist() == [1, 60, 61, 2]
def test_maybe_prepend_prompt_tokens_no_prompt():
"""Test that without prompt_tokens in batch, inputs are returned unchanged."""
B, T_src, H = 1, 4, 4
source_encoded = torch.randn(B, T_src, H)
source_encoded_lens = torch.tensor([3])
target_tokens = torch.tensor([[1, 100, 2, 0]])
batch = {
"target_tokens": target_tokens,
"target_token_lens": torch.tensor([3]),
}
out_encoded, out_lens, out_tokens = maybe_prepend_prompt_tokens(
batch=batch,
embed_fn=lambda x: x.unsqueeze(-1).expand(-1, -1, H).float(),
source_encoded=source_encoded,
source_encoded_lens=source_encoded_lens,
text_pad_id=0,
)
assert torch.equal(out_encoded, source_encoded)
assert torch.equal(out_lens, source_encoded_lens)
assert torch.equal(out_tokens, target_tokens)
+392
View File
@@ -0,0 +1,392 @@
# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import pytest
import torch
from nemo.collections.speechlm2.parts.metrics import BLEU, WER, Intelligibility
from nemo.collections.speechlm2.parts.metrics.empty_text import EmptyTextMetric
from nemo.collections.speechlm2.parts.metrics.perplexity import Perplexity
def test_bleu():
metric = BLEU(verbose=False)
metric.update(
name="dataset_1",
refs=["a b c d e f g h i j k l", "m n o p r s t u v"],
hyps=["a b c d e f g h i j k l", "m n o p r s t u v"],
)
metric.update(
name="dataset_2",
refs=["a b c"],
hyps=["a b d"],
)
ans = metric.compute()
assert ans["txt_bleu_dataset_1"] == 100.0
assert ans["txt_bleu_dataset_2"] == 0.0
assert ans["txt_bleu"] == 50.0 # average across datasets
def test_wer():
metric = WER(verbose=False)
metric.update(
name="dataset_1",
refs=["a b c d e f g h i j k l", "m n o p r s t u v"],
hyps=["a b c d e f g h i j k l", "m n o p r s t u v"],
)
metric.update(
name="dataset_2",
refs=["a b c"],
hyps=["a b d"],
)
ans = metric.compute()
assert ans["wer_dataset_1"] == 0.0
assert ans["wer_dataset_2"] == 1 / 3
assert ans["wer"] == 1 / 6 # average across datasets
def test_empty_text_metric():
"""Test EmptyTextMetric for detecting empty hypotheses"""
metric = EmptyTextMetric(verbose=False)
# Test with some empty and non-empty texts
hyps = ["hello world", "", " ", "test", " \n "]
metric.update("test_batch", hyps)
results = metric.compute()
# Should detect 3 empty texts out of 5
assert "empty_text_rate_test_batch" in results
assert results["empty_text_rate_test_batch"].item() == pytest.approx(0.6, abs=0.01) # 3/5 = 0.6
def test_empty_text_metric_reset():
"""Test EmptyTextMetric reset functionality"""
metric = EmptyTextMetric(verbose=False)
# Add some data
metric.update("test", ["hello", ""])
metric.reset()
# After reset, should have no data
results = metric.compute()
assert len(results) == 0
def test_empty_text_all_valid():
"""Test EmptyTextMetric with no empty texts"""
metric = EmptyTextMetric(verbose=False)
metric.update("test", ["hello", "world", "test"])
results = metric.compute()
assert results["empty_text_rate_test"].item() == 0.0
def test_empty_text_all_empty():
"""Test EmptyTextMetric with all empty texts"""
metric = EmptyTextMetric(verbose=False)
metric.update("test", ["", " ", "\n"])
results = metric.compute()
assert results["empty_text_rate_test"].item() == 1.0
def test_perplexity_basic():
"""Test basic perplexity calculation"""
metric = Perplexity(ignore_index=-100, verbose=False)
# Create simple logits and targets
vocab_size = 10
batch_size = 2
seq_len = 3
# Create perfect predictions (all correct)
logits = torch.zeros(batch_size, seq_len, vocab_size)
targets = torch.tensor([[1, 2, 3], [4, 5, 6]])
# Set logits to have high probability for correct tokens
for b in range(batch_size):
for s in range(seq_len):
logits[b, s, targets[b, s]] = 10.0 # High logit for correct token
ppl = metric.update("test", logits, targets)
# With perfect predictions, perplexity should be close to 1
assert ppl < 2.0 # Should be very low
def test_perplexity_with_padding():
"""Test perplexity with padding tokens"""
metric = Perplexity(ignore_index=-100, verbose=False)
vocab_size = 10
batch_size = 2
seq_len = 4
logits = torch.randn(batch_size, seq_len, vocab_size)
# Include padding tokens (ignore_index = -100)
targets = torch.tensor([[1, 2, 3, -100], [4, 5, -100, -100]])
ppl = metric.update("test", logits, targets)
# Should not fail with padding
assert ppl > 0
assert not torch.isnan(torch.tensor(ppl))
def test_perplexity_compute():
"""Test perplexity compute aggregation"""
metric = Perplexity(ignore_index=-100, verbose=False)
vocab_size = 10
logits = torch.randn(2, 3, vocab_size)
targets = torch.tensor([[1, 2, 3], [4, 5, 6]])
metric.update("test", logits, targets)
results = metric.compute()
assert "perplexity_test" in results
assert results["perplexity_test"] > 0
def test_turn_taking_import():
"""Test that turn taking metric function can be imported"""
from nemo.collections.speechlm2.parts.metrics.turn_taking import compute_turn_taking_metrics
# Test with dummy data
source_tokens = torch.tensor([[1, 2, 3, 4]]) # dummy tokens
pred_tokens = torch.tensor([[5, 6, 7, 8]]) # dummy tokens
eos_token_id = 4
bos_token_id = 5
accuracy, latency = compute_turn_taking_metrics(source_tokens, pred_tokens, eos_token_id, bos_token_id)
assert isinstance(accuracy, float)
assert isinstance(latency, float)
def test_mcq_evaluator_import():
"""Test that MCQ evaluator can be imported and initialized"""
import tempfile
from nemo.collections.speechlm2.parts.metrics.mcq_evaluator import MCQEvaluator
with tempfile.TemporaryDirectory() as tmpdir:
evaluator = MCQEvaluator(manifest_dir=tmpdir)
assert evaluator is not None
assert evaluator.manifest_dir == tmpdir
def test_results_logger_import():
"""Test that results logger can be imported"""
import tempfile
from nemo.collections.speechlm2.parts.metrics.results_logger import ResultsLogger
with tempfile.TemporaryDirectory() as tmpdir:
logger = ResultsLogger(save_path=tmpdir)
assert logger is not None
def test_results_logger_single_rank(tmp_path):
"""Test ResultsLogger with single rank (non-distributed)"""
from unittest.mock import patch
from nemo.collections.speechlm2.parts.metrics.results_logger import ResultsLogger
save_path = str(tmp_path / "single_rank")
# Mock distributed functions to simulate single rank
with (
patch('nemo.collections.speechlm2.parts.metrics.results_logger.get_rank', return_value=0),
patch('nemo.collections.speechlm2.parts.metrics.results_logger.get_world_size', return_value=1),
patch('torch.distributed.is_available', return_value=False),
):
logger = ResultsLogger(save_path=save_path)
# Add some test data
logger.update(
name="test_dataset",
refs=["hello world", "goodbye"],
hyps=["hello there", "goodbye"],
asr_hyps=[None, None],
samples_id=["sample1", "sample2"],
pred_audio=None,
pred_audio_sr=16000,
user_audio=None,
user_audio_sr=16000,
src_refs=["user input 1", "user input 2"],
src_hyps=["", ""],
)
# Compute and save
metrics = logger.compute_and_save(special_subset_names=[], mcq_subset_names=[])
# Check that files were created
import os
metadata_path = os.path.join(save_path, "metadatas", "test_dataset_rank0.json")
assert os.path.exists(metadata_path)
# Check final merged file (should be same as rank0 in single-rank case)
final_path = os.path.join(save_path, "metadatas", "test_dataset.json")
assert os.path.exists(final_path)
def test_results_logger_multi_rank(tmp_path):
"""Test ResultsLogger with multiple ranks (simulated distributed training)"""
import json
import os
from unittest.mock import MagicMock, patch
from nemo.collections.speechlm2.parts.metrics.results_logger import ResultsLogger
save_path = str(tmp_path / "multi_rank")
world_size = 4
# Simulate each rank saving its own results
for rank in range(world_size):
with (
patch('nemo.collections.speechlm2.parts.metrics.results_logger.get_rank', return_value=rank),
patch('nemo.collections.speechlm2.parts.metrics.results_logger.get_world_size', return_value=world_size),
patch('torch.distributed.is_available', return_value=True),
patch('torch.distributed.is_initialized', return_value=True),
patch('torch.distributed.barrier'),
): # Mock barrier to avoid actual distributed ops
logger = ResultsLogger(save_path=save_path)
# Each rank processes different samples
start_idx = rank * 2
logger.update(
name="test_dataset",
refs=[f"ref_{start_idx}", f"ref_{start_idx+1}"],
hyps=[f"hyp_{start_idx}", f"hyp_{start_idx+1}"],
asr_hyps=[None, None],
samples_id=[f"sample_{start_idx}", f"sample_{start_idx+1}"],
pred_audio=None,
pred_audio_sr=16000,
user_audio=None,
user_audio_sr=16000,
src_refs=[f"src_{start_idx}", f"src_{start_idx+1}"],
src_hyps=["", ""],
)
# Save rank-specific files (only this part, not the merge)
rank_json_path = os.path.join(save_path, "metadatas", f"test_dataset_rank{rank}.json")
os.makedirs(os.path.dirname(rank_json_path), exist_ok=True)
with open(rank_json_path, 'w', encoding='utf-8') as fout:
for item in logger.cached_results["test_dataset"]:
fout.write(json.dumps(item, ensure_ascii=False) + '\n')
# Now simulate rank 0 merging all results
with (
patch('nemo.collections.speechlm2.parts.metrics.results_logger.get_rank', return_value=0),
patch('nemo.collections.speechlm2.parts.metrics.results_logger.get_world_size', return_value=world_size),
patch('torch.distributed.is_available', return_value=True),
patch('torch.distributed.is_initialized', return_value=True),
patch('torch.distributed.barrier'),
patch('torch.distributed.broadcast_object_list'),
): # Mock broadcast
logger = ResultsLogger(save_path=save_path)
# Manually set cached_results to simulate what rank 0 would have
logger.cached_results["test_dataset"] = []
# Call merge function directly
merged_results = logger._merge_rank_files("test_dataset")
# Verify all samples from all ranks are merged
assert len(merged_results) == world_size * 2 # 4 ranks * 2 samples each
# Verify sample IDs are from all ranks
sample_ids = [item["id"] for item in merged_results]
assert "sample_0" in sample_ids
assert "sample_1" in sample_ids
assert "sample_6" in sample_ids # Last rank's samples
assert "sample_7" in sample_ids
def test_results_logger_rank_file_wait(tmp_path):
"""Test that rank 0 waits for other ranks' files"""
import json
import os
import threading
import time
from unittest.mock import patch
from nemo.collections.speechlm2.parts.metrics.results_logger import ResultsLogger
save_path = str(tmp_path / "wait_test")
os.makedirs(os.path.join(save_path, "metadatas"), exist_ok=True)
world_size = 2
# Create rank 0's file immediately
rank0_file = os.path.join(save_path, "metadatas", "test_dataset_rank0.json")
with open(rank0_file, 'w') as f:
json.dump({"id": "sample_0", "pred_text": "test0"}, f)
f.write('\n')
# Simulate rank 1's file appearing after a delay
def create_rank1_file_delayed():
time.sleep(1) # 1 second delay
rank1_file = os.path.join(save_path, "metadatas", "test_dataset_rank1.json")
with open(rank1_file, 'w') as f:
json.dump({"id": "sample_1", "pred_text": "test1"}, f)
f.write('\n')
thread = threading.Thread(target=create_rank1_file_delayed)
thread.start()
# Rank 0 tries to merge - should wait for rank 1's file
with (
patch('nemo.collections.speechlm2.parts.metrics.results_logger.get_rank', return_value=0),
patch('nemo.collections.speechlm2.parts.metrics.results_logger.get_world_size', return_value=world_size),
):
logger = ResultsLogger(save_path=save_path)
merged_results = logger._merge_rank_files("test_dataset")
# Should have results from both ranks
assert len(merged_results) == 2
sample_ids = [item["id"] for item in merged_results]
assert "sample_0" in sample_ids
assert "sample_1" in sample_ids
thread.join()
def test_intelligibility():
metric = Intelligibility(pretrained_asr=None, verbose=False, reuse_asr_hyps=True)
metric.update(
name="dataset_1",
refs=["a b c d e f g h i j k l", "m n o p r s t u v"],
asr_hyps=["a b c d e f g h i j k l", "m n o p r s t u v"],
pred_audio=None,
)
metric.update(
name="dataset_2",
refs=["a b c"],
asr_hyps=["a b d"],
pred_audio=None,
)
ans = metric.compute()
# wer
assert ans["wer_dataset_1"] == 0.0
assert ans["wer_dataset_2"] == 1 / 3
assert ans["wer"] == 1 / 6 # average across datasets
# cer
assert ans["cer_dataset_1"] == 0.0
assert ans["cer_dataset_2"] == 1 / 5
@@ -0,0 +1,336 @@
# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import os
import pytest
import torch
from lhotse import CutSet, SupervisionSegment
from lhotse.testing.dummies import dummy_cut, dummy_recording
from nemo.collections.common.data.utils import move_data_to_device
from nemo.collections.speechlm2 import DuplexSTTDataset
from nemo.collections.speechlm2.models import NemotronVoiceChat
if torch.cuda.is_available():
torch.set_default_device('cuda')
pretrained_llm = "TinyLlama/TinyLlama_v1.1"
if os.path.exists("/home/TestData/speechlm/pretrained_models"):
pretrained_llm = "/home/TestData/speechlm/pretrained_models/TinyLlama--TinyLlama_v1.1"
# STT sampling rate
source_sample_rate = 16000
# TTS sampling rate
target_sample_rate = 22050
def create_model(
predict_user_text=False,
force_use_noise_augmentation=False,
old_noise_prob=0.0,
old_noise_min_snr=0.0,
old_noise_max_snr=0.0,
):
"""Helper function to create a model with configurable settings."""
test_stt_cfg = {
"model": {
"pretrained_llm": pretrained_llm,
"pretrained_weights": False,
"audio_loss_weight": 1,
"text_loss_weight": 3,
"source_sample_rate": source_sample_rate,
"validation_save_path": "/tmp/test_duplex_stt_logs",
"perception": {
"_target_": "nemo.collections.speechlm2.modules.perception.AudioPerceptionModule",
"preprocessor": {
"_target_": "nemo.collections.asr.modules.AudioToMelSpectrogramPreprocessor",
"features": 80,
},
"encoder": {
"_target_": "nemo.collections.asr.modules.ConformerEncoder",
"feat_in": 80,
"d_model": 512,
"n_heads": 8,
"n_layers": 1,
"subsampling_factor": 8,
},
"modality_adapter": {
"_target_": "nemo.collections.speechlm2.modules.perception.IdentityConnector",
"d_model": 512,
},
"output_dim": 2048,
},
"predict_user_text": predict_user_text,
"force_use_noise_augmentation": force_use_noise_augmentation,
"old_noise_prob": old_noise_prob,
"old_noise_min_snr": old_noise_min_snr,
"old_noise_max_snr": old_noise_max_snr,
"optimizer": {"_target_": "torch.optim.AdamW"},
},
"data": {
"source_sample_rate": 16000,
},
"exp_manager": {
"explicit_log_dir": "/tmp/test_duplex_stt_logs",
},
}
test_tts_config = {
"model": {
"pretrained_lm_name": pretrained_llm,
"pretrained_ae_dir": None,
"pretrained_tts_model": None,
"scoring_asr": "stt_en_fastconformer_transducer_large",
"freeze_params": [
r"^audio_codec\..+$", # Keep audio codec frozen as it only provides supervision for training.
r"^embed_tokens\..+$", # Keep embed_tokens frozen as done in eartts
],
"bos_token": "<s>",
"eos_token": "</s>",
"pad_token": "<SPECIAL_12>",
"audio_codec_run_dtype": "float32",
"prevent_freeze_params": [],
"audio_save_path": "",
"inference_guidance_scale": 0.5,
"inference_noise_scale": 0.8,
"inference_top_p_or_k": 0.8,
"inference_guidance_enabled": False,
"subword_mask_exactly_as_eartts": False,
"context_hidden_mask_exactly_as_eartts": False,
"optimizer": {
"_target_": "torch.optim.AdamW",
"lr": 4e-5,
"betas": [0.9, 0.98],
"weight_decay": 0,
"foreach": True,
},
"lr_scheduler": {
"_target_": "nemo.core.optim.lr_scheduler.InverseSquareRootAnnealing",
"warmup_steps": 2500,
"min_lr": 1e-6,
"max_steps": 100_000_000,
},
"codec_config": {
"latent_size": 512,
"n_fft": 16,
"hop_length": 4,
"base_hidden_size": 384,
"channel_mult": [1, 2, 4],
"rates": [7, 7, 9],
"num_blocks": 3,
"kernel_size": 7,
"groups": 1,
"codebook_size": 1024,
"num_quantizers": 31,
"wav_to_token_ratio": 1764,
},
"tts_config": {
"use_gated_fusion_for_text_audio": True,
"disable_eos_prediction": True,
"use_bos_eos_emb": True,
"use_subword_flag_emb": True,
"num_delay_speech_tokens": 2,
"backbone_type": "gemma3_text",
"backbone_model_class": None,
"backbone_config_class": None,
"backbone_config": {
"hidden_size": 1152,
"intermediate_size": 4608,
"num_hidden_layers": 1,
"num_attention_heads": 16,
"num_key_value_heads": 16,
"head_dim": 72,
"attention_dropout": 0.1,
"use_cache": False,
},
"latent_size": 512,
"codebook_size": 1024,
"num_quantizers": 31,
"context_hidden_size": None,
"cas_config": {
"backbone_type": "t5gemma",
"backbone_model_class": None,
"backbone_config_class": None,
"backbone_config": {
"is_encoder_decoder": False,
"encoder": {
"hidden_size": 1152,
"intermediate_size": 4608,
"num_hidden_layers": 1,
"num_attention_heads": 16,
"num_key_value_heads": 16,
"head_dim": 72,
"use_cache": False,
"attention_dropout": 0.1,
},
},
},
"mog_head_config": {
"intermediate_size": 4608,
"num_layers": 3,
"low_rank": 64,
"num_predictions": 1024,
"min_log_std": -4.0,
"eps": 1e-6,
},
"p_uncond": 0.1,
"label_smoothing": 0.01,
"max_training_rate": 0.8,
"quantizer_dropout": 0.5,
"random_target_masking": False,
"exponent": 3.0,
},
},
"data": {
"add_text_bos_and_eos_in_each_turn": True,
"add_audio_prompt": True,
"audio_prompt_duration": 3.0,
"frame_length": 0.08,
"source_sample_rate": source_sample_rate,
"target_sample_rate": target_sample_rate,
},
"exp_manager": {
"explicit_log_dir": "/tmp/test_duplex_stt_logs",
},
}
test_config = {
"model": {
"scoring_asr": "stt_en_fastconformer_transducer_large",
"stt": test_stt_cfg,
"speech_generation": test_tts_config,
},
"data": {
"frame_length": 0.08,
"source_sample_rate": source_sample_rate,
"target_sample_rate": target_sample_rate,
"input_roles": ["user", "User"],
"output_roles": ["agent", "Assistant", "assistant", "Agent"],
},
"exp_manager": {
"explicit_log_dir": "/tmp/test_nemotron_voicechat_logs",
},
}
model = NemotronVoiceChat(test_config)
if torch.cuda.is_available():
model.to("cuda")
return model
@pytest.fixture(scope="session")
def model():
return create_model(predict_user_text=False)
@pytest.fixture(scope="session")
def dataset(model):
return DuplexSTTDataset(
model.stt_model.tokenizer,
frame_length=0.08,
source_sample_rate=source_sample_rate,
input_roles=["user"],
output_roles=["assistant"],
)
@pytest.fixture(scope="session")
def training_cutset_batch():
cut = dummy_cut(0, recording=dummy_recording(0, with_data=True, duration=1.0, sampling_rate=22050))
cut.target_audio = dummy_recording(1, with_data=True, duration=1.0, sampling_rate=22050)
cut.supervisions = [
SupervisionSegment(
id=cut.id,
recording_id=cut.recording_id,
start=0,
duration=0.1,
text='hi',
speaker="user",
),
SupervisionSegment(
id=cut.id,
recording_id=cut.recording_id,
start=0.3,
duration=0.1,
text='hello',
speaker="assistant",
),
SupervisionSegment(
id=cut.id,
recording_id=cut.recording_id,
start=0.5,
duration=0.1,
text='ok',
speaker="user",
),
SupervisionSegment(
id=cut.id,
recording_id=cut.recording_id,
start=0.6,
duration=0.1,
text='okay',
speaker="assistant",
),
]
return CutSet([cut])
def test_e2e_validation_step(model, dataset, training_cutset_batch):
model.eval()
model.on_validation_epoch_start()
batch = dataset[training_cutset_batch]
batch = move_data_to_device(batch, device=model.device)
results = model.validation_step(
{"dummy_val_set": batch},
batch_idx=0,
speaker_audio=torch.randn(1, 22050, device=model.device),
speaker_audio_lens=torch.tensor([22050], device=model.device),
)
assert results is None # no return value
def test_e2s_offline_generation(model):
model.eval()
# 16000 samples == 1 second == 12.5 frames ~= 14 frames after encoder padding
ans = model.offline_inference(
input_signal=torch.randn(1, 16000, device=model.device),
input_signal_lens=torch.tensor([16000], device=model.device),
speaker_audio=torch.randn(1, 22050, device=model.device),
speaker_audio_lens=torch.tensor([22050], device=model.device),
)
assert ans.keys() == {
'text',
'src_text',
'tokens_text_src',
'tokens_text',
'tokens_len',
'source_audio',
'source_audio_len',
"audio",
"audio_len",
}
assert isinstance(ans["text"], list)
assert isinstance(ans["text"][0], str)
gen_text = ans["tokens_text"]
assert gen_text.shape == (1, 14)
assert gen_text.dtype == torch.long
assert (gen_text >= 0).all()
assert (gen_text < model.stt_model.text_vocab_size).all()
# 14 tokens = 24696 audio frames
gen_audio = ans["audio"]
assert gen_audio.shape == (1, 24696)
@@ -0,0 +1,305 @@
# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import pytest
from lightning.pytorch.strategies.model_parallel import ModelParallelStrategy
from nemo_automodel.components.distributed.config import FSDP2Config
from nemo_automodel.components.moe.config import MoEParallelizerConfig
from omegaconf import DictConfig
from nemo.collections.speechlm2.parts.parallel import AutomodelParallelStrategy
from nemo.utils.trainer_utils import _resolve_automodel_configs, resolve_trainer_cfg
# ---------------------------------------------------------------------------
# AutomodelParallelStrategy
# ---------------------------------------------------------------------------
class TestAutomodelParallelStrategy:
def test_is_subclass_of_model_parallel_strategy(self):
assert issubclass(AutomodelParallelStrategy, ModelParallelStrategy)
def test_default_init(self):
strategy = AutomodelParallelStrategy()
assert strategy._dp_size is None
assert strategy._dp_replicate_size is None
assert strategy._tp_size == 1
assert strategy._pp_size == 1
assert strategy._cp_size == 1
assert strategy._ep_size == 1
assert strategy._distributed_config is None
assert strategy._moe_config is None
assert strategy._moe_mesh is None
assert strategy.activation_checkpointing_llm is False
assert strategy.activation_checkpointing_perception is False
def test_accepts_activation_checkpointing_flags(self):
strategy = AutomodelParallelStrategy(
activation_checkpointing_llm=True,
activation_checkpointing_perception=True,
)
assert strategy.activation_checkpointing_llm is True
assert strategy.activation_checkpointing_perception is True
def test_activation_checkpointing_flags_are_independent(self):
"""Each AC flag can be set without the other."""
llm_only = AutomodelParallelStrategy(activation_checkpointing_llm=True)
assert llm_only.activation_checkpointing_llm is True
assert llm_only.activation_checkpointing_perception is False
perception_only = AutomodelParallelStrategy(activation_checkpointing_perception=True)
assert perception_only.activation_checkpointing_llm is False
assert perception_only.activation_checkpointing_perception is True
def test_custom_parallelism_sizes(self):
strategy = AutomodelParallelStrategy(
dp_size=4,
dp_replicate_size=2,
tp_size=2,
pp_size=2,
cp_size=2,
ep_size=4,
)
assert strategy._dp_size == 4
assert strategy._dp_replicate_size == 2
assert strategy._tp_size == 2
assert strategy._pp_size == 2
assert strategy._cp_size == 2
assert strategy._ep_size == 4
def test_accepts_distributed_config(self):
cfg = FSDP2Config(sequence_parallel=True, defer_fsdp_grad_sync=False)
strategy = AutomodelParallelStrategy(distributed_config=cfg)
assert strategy.distributed_config is cfg
assert strategy.distributed_config.sequence_parallel is True
assert strategy.distributed_config.defer_fsdp_grad_sync is False
def test_accepts_moe_config(self):
cfg = MoEParallelizerConfig()
strategy = AutomodelParallelStrategy(moe_config=cfg)
assert strategy.moe_config is cfg
def test_save_distributed_checkpoint_forwarded(self):
strategy = AutomodelParallelStrategy(save_distributed_checkpoint=False)
assert strategy._save_distributed_checkpoint is False
def test_moe_mesh_initially_none(self):
strategy = AutomodelParallelStrategy()
assert strategy.moe_mesh is None
def test_device_mesh_raises_before_setup(self):
strategy = AutomodelParallelStrategy()
with pytest.raises(RuntimeError):
_ = strategy.device_mesh
def test_distributed_sampler_kwargs_raises_before_setup(self):
strategy = AutomodelParallelStrategy()
with pytest.raises(RuntimeError):
_ = strategy.distributed_sampler_kwargs
# ---------------------------------------------------------------------------
# _resolve_automodel_configs
# ---------------------------------------------------------------------------
class TestResolveAutomodelConfigs:
"""Tests for _resolve_automodel_configs which operates on an instantiated strategy object."""
def test_plain_dict_to_fsdp2_config(self):
strategy = AutomodelParallelStrategy(
distributed_config={"defer_fsdp_grad_sync": False, "sequence_parallel": True},
)
_resolve_automodel_configs(strategy)
assert isinstance(strategy.distributed_config, FSDP2Config)
assert strategy.distributed_config.defer_fsdp_grad_sync is False
assert strategy.distributed_config.sequence_parallel is True
# Check that __post_init__ created the default mp_policy
assert strategy.distributed_config.mp_policy is not None
def test_plain_dict_to_moe_config(self):
strategy = AutomodelParallelStrategy(
moe_config={"reshard_after_forward": True},
)
_resolve_automodel_configs(strategy)
assert isinstance(strategy.moe_config, MoEParallelizerConfig)
assert strategy.moe_config.reshard_after_forward is True
def test_noop_when_no_configs(self):
"""Strategy without configs (e.g. DDPStrategy) is untouched."""
from lightning.pytorch.strategies import DDPStrategy
strategy = DDPStrategy()
_resolve_automodel_configs(strategy) # should not raise
def test_noop_when_already_objects(self):
original_cfg = FSDP2Config()
original_moe = MoEParallelizerConfig()
strategy = AutomodelParallelStrategy(
distributed_config=original_cfg,
moe_config=original_moe,
)
_resolve_automodel_configs(strategy)
assert strategy.distributed_config is original_cfg
assert strategy.moe_config is original_moe
def test_noop_when_configs_are_none(self):
strategy = AutomodelParallelStrategy()
_resolve_automodel_configs(strategy)
assert strategy.distributed_config is None
assert strategy.moe_config is None
def test_empty_dict_creates_default_configs(self):
strategy = AutomodelParallelStrategy(distributed_config={}, moe_config={})
_resolve_automodel_configs(strategy)
assert isinstance(strategy.distributed_config, FSDP2Config)
assert isinstance(strategy.moe_config, MoEParallelizerConfig)
# All defaults should apply
assert strategy.distributed_config.defer_fsdp_grad_sync is True
assert strategy.distributed_config.sequence_parallel is False
def test_nested_target_in_distributed_config(self):
"""A sub-field like mp_policy can use _target_ for Hydra instantiation."""
strategy = AutomodelParallelStrategy(
distributed_config={
"sequence_parallel": True,
"mp_policy": {
"_target_": "torch.distributed.fsdp.MixedPrecisionPolicy",
"param_dtype": "torch.bfloat16",
},
},
)
_resolve_automodel_configs(strategy)
assert isinstance(strategy.distributed_config, FSDP2Config)
assert strategy.distributed_config.sequence_parallel is True
from torch.distributed.fsdp import MixedPrecisionPolicy
assert isinstance(strategy.distributed_config.mp_policy, MixedPrecisionPolicy)
def test_both_configs_resolved_together(self):
strategy = AutomodelParallelStrategy(
distributed_config={"sequence_parallel": False},
moe_config={},
)
_resolve_automodel_configs(strategy)
assert isinstance(strategy.distributed_config, FSDP2Config)
assert isinstance(strategy.moe_config, MoEParallelizerConfig)
# ---------------------------------------------------------------------------
# resolve_trainer_cfg (end-to-end with AutomodelParallelStrategy)
# ---------------------------------------------------------------------------
class TestResolveTrainerCfg:
def test_automodel_strategy_from_yaml(self):
"""End-to-end: YAML dict config -> instantiated AutomodelParallelStrategy."""
trainer_cfg = DictConfig(
{
"devices": 1,
"accelerator": "cpu",
"strategy": {
"_target_": "nemo.collections.speechlm2.parts.parallel.AutomodelParallelStrategy",
"tp_size": 2,
"pp_size": 1,
"distributed_config": {
"sequence_parallel": True,
"defer_fsdp_grad_sync": False,
},
"moe_config": {},
},
}
)
resolved = resolve_trainer_cfg(trainer_cfg)
strategy = resolved["strategy"]
assert isinstance(strategy, AutomodelParallelStrategy)
assert strategy._tp_size == 2
assert strategy._pp_size == 1
assert isinstance(strategy.distributed_config, FSDP2Config)
assert strategy.distributed_config.sequence_parallel is True
assert strategy.distributed_config.defer_fsdp_grad_sync is False
assert isinstance(strategy.moe_config, MoEParallelizerConfig)
def test_non_automodel_strategy_unaffected(self):
"""Other strategies (e.g. DDPStrategy) should pass through unchanged."""
trainer_cfg = DictConfig(
{
"devices": 1,
"accelerator": "cpu",
"strategy": {
"_target_": "lightning.pytorch.strategies.DDPStrategy",
"gradient_as_bucket_view": True,
"find_unused_parameters": True,
},
}
)
resolved = resolve_trainer_cfg(trainer_cfg)
from lightning.pytorch.strategies import DDPStrategy
assert isinstance(resolved["strategy"], DDPStrategy)
def test_string_strategy_unaffected(self):
"""A plain string strategy (e.g. 'ddp') should pass through."""
trainer_cfg = DictConfig(
{
"devices": 1,
"accelerator": "cpu",
"strategy": "ddp",
}
)
resolved = resolve_trainer_cfg(trainer_cfg)
assert resolved["strategy"] == "ddp"
def test_automodel_strategy_with_ac_flags_from_yaml(self):
"""Both AC flags can be set via YAML and survive Hydra instantiation."""
trainer_cfg = DictConfig(
{
"devices": 1,
"accelerator": "cpu",
"strategy": {
"_target_": "nemo.collections.speechlm2.parts.parallel.AutomodelParallelStrategy",
"ep_size": 4,
"activation_checkpointing_llm": True,
"activation_checkpointing_perception": True,
},
}
)
resolved = resolve_trainer_cfg(trainer_cfg)
strategy = resolved["strategy"]
assert isinstance(strategy, AutomodelParallelStrategy)
assert strategy.activation_checkpointing_llm is True
assert strategy.activation_checkpointing_perception is True
def test_automodel_strategy_without_configs(self):
"""AutomodelParallelStrategy can be specified without distributed_config/moe_config."""
trainer_cfg = DictConfig(
{
"devices": 1,
"accelerator": "cpu",
"strategy": {
"_target_": "nemo.collections.speechlm2.parts.parallel.AutomodelParallelStrategy",
"tp_size": 4,
"ep_size": 8,
},
}
)
resolved = resolve_trainer_cfg(trainer_cfg)
strategy = resolved["strategy"]
assert isinstance(strategy, AutomodelParallelStrategy)
assert strategy._tp_size == 4
assert strategy._ep_size == 8
# No configs passed → still None (will be defaulted in setup_environment)
assert strategy._distributed_config is None
assert strategy._moe_config is None
@@ -0,0 +1,241 @@
# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Unit tests for perception-module helpers.
These tests exercise:
* ``_set_encoder_activation_checkpointing`` (the module-level helper that
wraps ``encoder.pre_encode`` and each ``encoder.layers[i]``).
* ``AudioPerceptionModule.set_activation_checkpointing`` and
``AudioTranscriptionPerceptionModule.set_activation_checkpointing`` —
both are thin delegators, so we assert that each class exposes the method
and that it routes through the helper.
"""
import pytest
import torch
from torch import nn
from torch.distributed.algorithms._checkpoint.checkpoint_wrapper import CheckpointWrapper
from nemo.collections.speechlm2.modules.perception import (
AudioPerceptionModule,
AudioTranscriptionPerceptionModule,
_set_encoder_activation_checkpointing,
)
class _FakeConvSubsampling(nn.Module):
"""Stand-in for ``ConvSubsampling``/``StackingSubsampling``: non-Linear, so
the helper should wrap it."""
def __init__(self):
super().__init__()
self.conv = nn.Conv1d(1, 1, kernel_size=1)
def forward(self, x, lengths): # pragma: no cover — forward not exercised
return x, lengths
class _FakeConformerLayer(nn.Module):
def __init__(self, dim: int = 4):
super().__init__()
self.linear = nn.Linear(dim, dim)
def forward(self, x): # pragma: no cover — forward not exercised
return self.linear(x)
class _FakeConformerEncoder(nn.Module):
"""Mimics just the shape the helper inspects: ``.pre_encode`` and
``.layers``. Good enough to test wrapping without pulling the real
``ConformerEncoder``, which needs heavy deps."""
def __init__(self, num_layers: int = 3, pre_encode: nn.Module | None = None):
super().__init__()
if pre_encode is None:
pre_encode = _FakeConvSubsampling()
self.pre_encode = pre_encode
self.layers = nn.ModuleList([_FakeConformerLayer() for _ in range(num_layers)])
class _FakePreprocessor(nn.Module):
def forward(self, input_signal, length):
return input_signal, length
class _FakeUnsupportedEncoder(nn.Module):
def forward(self, audio_signal, length):
return audio_signal, length
class _FakeIdentityAdapter(nn.Module):
def forward(self, audio_signal, length):
return audio_signal, length
# ---------------------------------------------------------------------------
# _set_encoder_activation_checkpointing
# ---------------------------------------------------------------------------
class TestSetEncoderActivationCheckpointing:
def test_noop_when_disabled(self):
encoder = _FakeConformerEncoder(num_layers=3)
original_pre_encode = encoder.pre_encode
original_layers = [encoder.layers[i] for i in range(len(encoder.layers))]
_set_encoder_activation_checkpointing(encoder, enabled=False)
# Nothing should be wrapped.
assert encoder.pre_encode is original_pre_encode
assert not isinstance(encoder.pre_encode, CheckpointWrapper)
for i, layer in enumerate(original_layers):
assert encoder.layers[i] is layer
assert not isinstance(encoder.layers[i], CheckpointWrapper)
def test_wraps_pre_encode_and_all_layers_when_enabled(self):
encoder = _FakeConformerEncoder(num_layers=4)
_set_encoder_activation_checkpointing(encoder, enabled=True)
assert isinstance(encoder.pre_encode, CheckpointWrapper)
assert len(encoder.layers) == 4
for layer in encoder.layers:
assert isinstance(layer, CheckpointWrapper)
def test_skips_linear_pre_encode(self):
"""``ConformerEncoder.forward`` dispatches on ``isinstance(pre_encode,
nn.Linear)`` — wrapping would hide the type and route the call to the
wrong branch. The helper must leave Linear pre_encode untouched even
when ``enabled=True``."""
encoder = _FakeConformerEncoder(num_layers=2, pre_encode=nn.Linear(8, 8))
_set_encoder_activation_checkpointing(encoder, enabled=True)
# Linear pre_encode unchanged …
assert isinstance(encoder.pre_encode, nn.Linear)
assert not isinstance(encoder.pre_encode, CheckpointWrapper)
# … but layers are still wrapped.
for layer in encoder.layers:
assert isinstance(layer, CheckpointWrapper)
def test_noop_when_encoder_has_no_pre_encode(self):
"""Graceful degradation: a non-Conformer encoder with ``.layers`` but
no ``.pre_encode`` should still have its layers wrapped."""
class EncoderNoPreEncode(nn.Module):
def __init__(self):
super().__init__()
self.layers = nn.ModuleList([_FakeConformerLayer() for _ in range(2)])
encoder = EncoderNoPreEncode()
_set_encoder_activation_checkpointing(encoder, enabled=True)
for layer in encoder.layers:
assert isinstance(layer, CheckpointWrapper)
def test_noop_when_encoder_has_no_layers(self):
"""Graceful degradation: an encoder with ``.pre_encode`` but no
``.layers`` should still have its pre_encode wrapped."""
class EncoderNoLayers(nn.Module):
def __init__(self):
super().__init__()
self.pre_encode = _FakeConvSubsampling()
encoder = EncoderNoLayers()
_set_encoder_activation_checkpointing(encoder, enabled=True)
assert isinstance(encoder.pre_encode, CheckpointWrapper)
def test_noop_when_encoder_has_neither(self):
"""Foreign architecture with no recognisable structure: helper is a
no-op and does not raise."""
class UnrelatedEncoder(nn.Module):
def __init__(self):
super().__init__()
self.something = nn.Linear(4, 4)
encoder = UnrelatedEncoder()
_set_encoder_activation_checkpointing(encoder, enabled=True) # must not raise
assert not isinstance(encoder.something, CheckpointWrapper)
# ---------------------------------------------------------------------------
# set_activation_checkpointing method bindings
# ---------------------------------------------------------------------------
class TestPerceptionSetActivationCheckpointingMethod:
"""Verify the method exists and routes through the helper on both
perception classes. We don't instantiate the full module (that requires
loading an ASR checkpoint) — we inspect the class attribute and call it
on a lightweight fake ``self`` that exposes only the bits the method
touches (``self.encoder``)."""
def test_audio_perception_module_has_method(self):
assert callable(getattr(AudioPerceptionModule, "set_activation_checkpointing", None))
def test_audio_transcription_perception_module_has_method(self):
assert callable(getattr(AudioTranscriptionPerceptionModule, "set_activation_checkpointing", None))
def test_method_wraps_encoder_layers(self):
"""Invoke the unbound method against a fake ``self`` that only has
``self.encoder``. This is enough to verify that the method delegates
to ``_set_encoder_activation_checkpointing`` against the encoder
exposed via ``self.encoder``."""
class FakeSelf:
pass
fake = FakeSelf()
fake.encoder = _FakeConformerEncoder(num_layers=2)
AudioPerceptionModule.set_activation_checkpointing(fake, enabled=True)
assert isinstance(fake.encoder.pre_encode, CheckpointWrapper)
for layer in fake.encoder.layers:
assert isinstance(layer, CheckpointWrapper)
def test_method_disabled_is_noop(self):
class FakeSelf:
pass
fake = FakeSelf()
fake.encoder = _FakeConformerEncoder(num_layers=2)
original_pre_encode = fake.encoder.pre_encode
original_layers = list(fake.encoder.layers)
AudioPerceptionModule.set_activation_checkpointing(fake, enabled=False)
assert fake.encoder.pre_encode is original_pre_encode
for i, layer in enumerate(original_layers):
assert fake.encoder.layers[i] is layer
class TestAudioPerceptionSpeakerTargets:
def test_raises_when_spk_targets_are_passed_to_unsupported_encoder(self):
module = AudioPerceptionModule.__new__(AudioPerceptionModule)
nn.Module.__init__(module)
module.preprocessor = _FakePreprocessor()
module.encoder = _FakeUnsupportedEncoder()
module.modality_adapter = _FakeIdentityAdapter()
module.proj = nn.Identity()
module.spec_augmentation = None
with pytest.raises(ValueError, match="spk_targets.*does not support"):
module(
input_signal=torch.zeros(1, 2, 4),
input_signal_length=torch.tensor([4]),
spk_targets=torch.zeros(1, 4, 2),
)
@@ -0,0 +1,182 @@
# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from io import BytesIO
from unittest.mock import MagicMock, PropertyMock
import numpy as np
import pytest
import soundfile as sf
from lhotse import AudioSource, MonoCut, Recording, SupervisionSegment
from nemo.collections.speechlm2.data import DuplexSTTDataset
def _make_in_memory_recording(recording_id, duration, sampling_rate, signal_freq=440.0):
"""Create a lhotse Recording backed by in-memory WAV bytes with a sine wave."""
num_samples = int(duration * sampling_rate)
t = np.linspace(0, duration, num_samples, endpoint=False, dtype=np.float32)
audio = 0.5 * np.sin(2 * np.pi * signal_freq * t)
buf = BytesIO()
sf.write(buf, audio, sampling_rate, format='wav')
buf.seek(0)
return Recording(
id=recording_id,
sampling_rate=sampling_rate,
num_samples=num_samples,
duration=duration,
sources=[AudioSource(type="memory", channels=[0], source=buf.getvalue())],
)
def test_create_role_swapped_cut():
"""
Test _create_role_swapped_cut with a 6-turn conversation.
Original:
Turn 1: User 0.0-1.0 "hello"
Turn 2: Assistant 1.5-2.5 "hi there"
Turn 3: User 3.0-4.0 "how are you"
Turn 4: Assistant 4.5-5.5 "doing well"
Turn 5: User 6.0-7.0 "great"
Turn 6: Assistant 7.5-8.5 "bye"
After swap + filter (remove first Assistant, last User):
User 0.0-1.0 "hi there" (originally Assistant turn 2)
Assistant 1.5-2.5 "how are you" (originally User turn 3)
User 3.0-4.0 "doing well" (originally Assistant turn 4)
Assistant 4.5-5.5 "great" (originally User turn 5)
"""
tokenizer = MagicMock()
type(tokenizer).bos = PropertyMock(return_value=1)
type(tokenizer).eos = PropertyMock(return_value=2)
type(tokenizer).pad = PropertyMock(return_value=0)
type(tokenizer).pad_id = PropertyMock(return_value=0)
type(tokenizer).unk_id = PropertyMock(return_value=None)
tokenizer.text_to_ids = MagicMock(return_value=[1])
dataset = DuplexSTTDataset(
tokenizer=tokenizer,
frame_length=0.08,
source_sample_rate=16000,
input_roles=["User"],
output_roles=["Assistant"],
aug_by_swap_role=True,
cfg={"early_interruption_prob": 0.0},
model_cfg={"predict_user_text": False, "force_align_user_text": False},
)
# Build a 6-turn cut with source (440Hz) and target (880Hz) audio
sampling_rate = 16000
total_duration = 9.0
source_rec = _make_in_memory_recording("src", total_duration, sampling_rate, signal_freq=440.0)
target_rec = _make_in_memory_recording("tgt", total_duration, sampling_rate, signal_freq=880.0)
supervisions = [
SupervisionSegment(id="s1", recording_id="src", start=0.0, duration=1.0, speaker="User", text="hello"),
SupervisionSegment(id="s2", recording_id="src", start=1.5, duration=1.0, speaker="Assistant", text="hi there"),
SupervisionSegment(id="s3", recording_id="src", start=3.0, duration=1.0, speaker="User", text="how are you"),
SupervisionSegment(
id="s4", recording_id="src", start=4.5, duration=1.0, speaker="Assistant", text="doing well"
),
SupervisionSegment(id="s5", recording_id="src", start=6.0, duration=1.0, speaker="User", text="great"),
SupervisionSegment(id="s6", recording_id="src", start=7.5, duration=1.0, speaker="Assistant", text="bye"),
]
cut = MonoCut(
id="test_cut",
start=0,
duration=total_duration,
channel=0,
supervisions=supervisions,
recording=source_rec,
custom={'target_audio': target_rec, 'total_turns': 6},
)
swapped_cut = dataset._create_role_swapped_cut(cut)
assert swapped_cut is not None
sups = swapped_cut.supervisions
# 4 turns remain after removing first Assistant and last User
assert len(sups) == 4
# Roles alternate User, Assistant, User, Assistant
assert [s.speaker for s in sups] == ["User", "Assistant", "User", "Assistant"]
# Texts match the kept turns
assert [s.text for s in sups] == ["hi there", "how are you", "doing well", "great"]
# Timestamps adjusted (first_remaining_start=1.5 subtracted), all start at 0
assert abs(sups[0].start - 0.0) < 1e-6
assert abs(sups[1].start - 1.5) < 1e-6
assert abs(sups[2].start - 3.0) < 1e-6
assert abs(sups[3].start - 4.5) < 1e-6
# Source audio: User regions have signal (from target_audio), Assistant regions are silent
audio = swapped_cut.recording.to_cut().load_audio().squeeze()
for s in sups:
start_sample = int(s.start * sampling_rate)
end_sample = int((s.start + s.duration) * sampling_rate)
rms = np.sqrt(np.mean(audio[start_sample:end_sample] ** 2))
if s.speaker == 'User':
assert rms > 0.1, f"User turn '{s.text}' should have audio from target_audio, RMS={rms:.4f}"
else:
assert rms < 0.01, f"Assistant turn '{s.text}' should be silent in STT source, RMS={rms:.4f}"
# Verify User audio is similar to target audio (880Hz)
# Load the original target audio for comparison
target_audio = target_rec.to_cut().load_audio().squeeze()
for s in sups:
if s.speaker == 'User':
# User turns should be from target_audio (originally Assistant turns)
# The original supervisions had these start times: 1.5 (hi there), 4.5 (doing well)
# which map to indices 0 and 2 in sups (User turns after swap)
start_sample = int(s.start * sampling_rate)
end_sample = int((s.start + s.duration) * sampling_rate)
segment = audio[start_sample:end_sample]
idx = list(sups).index(s)
original_start_times = {0: 1.5, 2: 4.5} # map swapped index to original time
if idx in original_start_times:
orig_start = original_start_times[idx]
orig_start_sample = int(orig_start * sampling_rate)
orig_end_sample = int((orig_start + s.duration) * sampling_rate)
target_segment = target_audio[orig_start_sample:orig_end_sample]
# Verify lengths match exactly
len_diff = abs(len(segment) - len(target_segment))
assert len_diff == 0, (
f"User turn '{s.text}' length must match exactly: "
f"segment={len(segment)} samples, "
f"target_segment={len(target_segment)} samples, "
f"diff={len_diff}"
)
# Compute correlation between user audio and target audio
# Waveforms should be nearly identical
correlation = np.corrcoef(segment, target_segment)[0, 1]
assert correlation > 0.999, (
f"User turn '{s.text}' should have nearly identical waveform to target audio. "
f"Correlation={correlation:.6f}, expected > 0.999"
)
# Metadata
assert swapped_cut.custom['role_swapped'] is True
assert swapped_cut.custom['total_turns'] == 4
if __name__ == "__main__":
pytest.main([__file__, "-v"])
+83
View File
@@ -0,0 +1,83 @@
# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import math
import pytest
import torch
from nemo.collections.speechlm2.modules.rote import RotaryTimeEmbedding, rotate_half
def test_defaults():
"""Defaults follow the Audio Flamingo Next audio-encoder config (theta=1200, partial_rotary_factor=0.2)."""
dim = 100
rote = RotaryTimeEmbedding(dim)
assert rote.theta == 1200.0
assert rote.rotary_fraction == 0.2
assert rote.rotary_dim == 20
assert rote.inv_freq.shape == (10,)
# Rotated width is rounded down to an even number: int(10 * 0.5) = 5 -> 4.
assert RotaryTimeEmbedding(dim=10, rotary_fraction=0.5).rotary_dim == 4
# rotary_fraction=1.0 recovers full-width rotation.
full = RotaryTimeEmbedding(dim, rotary_fraction=1.0)
assert full.rotary_dim == dim
assert full.inv_freq.shape == (dim // 2,)
def test_rotate_half():
"""GPT-J convention: ``[x0, x1, x2, x3] -> [-x1, x0, -x3, x2]``."""
x = torch.tensor([[1.0, 2.0, 3.0, 4.0]])
expected = torch.tensor([[-2.0, 1.0, -4.0, 3.0]])
assert torch.equal(rotate_half(x), expected)
def test_known_value():
"""Check the rotation against an explicit per-pair 2x2 rotation, locking in the
``angle = -tau * 2pi * (1/theta^(2k/rotary_dim))`` formula, the sign, and the GPT-J pairing."""
dim, theta, tau = 4, 100.0, 0.5
rote = RotaryTimeEmbedding(dim, theta=theta, rotary_fraction=1.0)
x = torch.tensor([[[1.0, 2.0, 3.0, 4.0]]]) # (B=1, T=1, C=4)
times = torch.tensor([[tau]])
out = rote(x, times)
# Two channel pairs, each rotated by angle_k = -tau * 2pi / theta^(2k/dim), k in {0, 1}.
angles = [-tau * 2.0 * math.pi / (theta ** (2 * k / dim)) for k in range(dim // 2)]
expected = torch.empty_like(x)
for k, a in enumerate(angles):
xa, xb = x[0, 0, 2 * k], x[0, 0, 2 * k + 1]
c, s = math.cos(a), math.sin(a)
# [[c, -s], [s, c]] @ [xa, xb]
expected[0, 0, 2 * k] = xa * c - xb * s
expected[0, 0, 2 * k + 1] = xa * s + xb * c
assert torch.allclose(out, expected, atol=1e-6)
@pytest.mark.parametrize("dtype", [torch.float32, torch.bfloat16, torch.float16])
def test_shape_dtype_preserved(dtype):
"""Output keeps the input shape and dtype (the internal angle math runs in fp32, so the
passthrough channels round-trip exactly for fp32 and the lower-precision dtypes)."""
dim = 80
rote = RotaryTimeEmbedding(dim)
x = torch.randn(2, 5, dim, dtype=dtype)
times = torch.arange(5, dtype=torch.float32).unsqueeze(0).expand(2, -1)
out = rote(x, times)
assert out.shape == x.shape
assert out.dtype == dtype
# Partial rotation: channels beyond rotary_dim pass through unchanged.
assert torch.equal(out[..., rote.rotary_dim :], x[..., rote.rotary_dim :])
+453
View File
@@ -0,0 +1,453 @@
# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import os
import pytest
import torch
from lhotse import CutSet, SupervisionSegment
from lhotse.testing.dummies import dummy_cut, dummy_recording
from omegaconf import DictConfig
from transformers import GenerationConfig
from nemo.collections.common.data.lhotse import NeMoMultimodalConversation
from nemo.collections.common.data.lhotse.text_adapters import AudioTurn, TextTurn
from nemo.collections.common.data.utils import move_data_to_device
from nemo.collections.common.prompts import PromptFormatter
from nemo.collections.speechlm2.data import SALMDataset
from nemo.collections.speechlm2.models import SALM
from tests.collections.speechlm2._chunking_helpers import (
ChunkingTestPerception,
ChunkingTestTokenizer,
chunking_test_devices,
)
if torch.cuda.is_available():
torch.set_default_device('cuda')
def resolve_pretrained_models():
if os.path.exists("/home/TestData/speechlm/pretrained_models"):
# CI pre-cached paths:
return {
"pretrained_llm": "/home/TestData/speechlm/pretrained_models/TinyLlama--TinyLlama_v1.1",
"pretrained_asr": "/home/TestData/speechlm/pretrained_models/canary-1b-flash.nemo",
}
else:
# HF URLs:
return {
"pretrained_asr": "nvidia/canary-1b-flash",
"pretrained_llm": "TinyLlama/TinyLlama_v1.1",
}
AUDIO_LOCATOR_TAG = "<|audioplaceholder|>"
PROMPT = "llama2"
@pytest.fixture(scope="session")
def model():
cfg = {
**resolve_pretrained_models(),
"pretrained_weights": False,
"prompt_format": PROMPT,
"audio_locator_tag": AUDIO_LOCATOR_TAG,
"perception": {
"target": "nemo.collections.speechlm2.modules.perception.AudioPerceptionModule",
"output_dim": 2048,
"encoder": {
"_target_": "nemo.collections.asr.modules.ConformerEncoder",
"att_context_size": [-1, -1],
"causal_downsampling": False,
"conv_context_size": None,
"conv_kernel_size": 9,
"conv_norm_type": "batch_norm",
"d_model": 1024,
"dropout": 0.1,
"dropout_att": 0.1,
"dropout_emb": 0.0,
"dropout_pre_encoder": 0.1,
"feat_in": 128,
"feat_out": -1,
"ff_expansion_factor": 4,
"n_heads": 8,
"n_layers": 2,
"pos_emb_max_len": 5000,
"self_attention_model": "rel_pos",
"subsampling": "dw_striding",
"subsampling_conv_channels": 256,
"subsampling_factor": 8,
},
"modality_adapter": {
"_target_": "nemo.collections.speechlm2.modules.perception.IdentityConnector",
"d_model": 1024,
},
"preprocessor": {
"_target_": "nemo.collections.asr.modules.AudioToMelSpectrogramPreprocessor",
"dither": 1e-05,
"features": 128,
"frame_splicing": 1,
"log": True,
"n_fft": 512,
"normalize": "per_feature",
"pad_to": 0,
"pad_value": 0.0,
"sample_rate": 16000,
"window": "hann",
"window_size": 0.025,
"window_stride": 0.01,
},
},
"optimizer": {"_target_": "torch.optim.AdamW"},
}
model = SALM(cfg)
if torch.cuda.is_available():
model.to("cuda")
return model
@pytest.fixture(scope="session")
def dataset(model):
return SALMDataset(model.tokenizer)
@pytest.fixture(scope="session")
def prompt_formatter(model):
return PromptFormatter.resolve(PROMPT)(model.tokenizer)
@pytest.fixture(scope="session")
def training_cutset_batch():
cut = dummy_cut(0, recording=dummy_recording(0, with_data=True))
cut.supervisions = [
SupervisionSegment(
id=cut.id, recording_id=cut.recording_id, start=0, duration=1.0, text='Some text transcription.'
)
]
return CutSet(
[
NeMoMultimodalConversation(
id="example-0",
turns=[
TextTurn(role="user", value="Repeat after me:"),
AudioTurn(role="user", cut=cut, audio_locator_tag=AUDIO_LOCATOR_TAG),
TextTurn(role="assistant", value=cut.supervisions[0].text),
],
token_equivalent_duration=0.08,
)
]
)
def test_salm_dataset(dataset, prompt_formatter, training_cutset_batch):
# This first step pre-tokenizes the examples, usually handled within `get_lhotse_dataloder_from_config`.
training_cutset_batch = training_cutset_batch.map(lambda c: c.apply_prompt_format(prompt_formatter), apply_fn=None)
# fmt: off
tokenized = training_cutset_batch[0].input_ids
assert (
prompt_formatter.tokenizer.tokenizer.decode(tokenized) ==
f"<s> [INST] Repeat after me: {AUDIO_LOCATOR_TAG} [/INST] Some text transcription. </s>"
)
# fmt: on
batch = dataset[training_cutset_batch]
for key in ("audios", "audio_lens", "input_ids", "loss_mask"):
assert key in batch
assert torch.is_tensor(batch[key])
def test_salm_training_step(model, dataset, prompt_formatter, training_cutset_batch):
training_cutset_batch = training_cutset_batch.map(lambda c: c.apply_prompt_format(prompt_formatter), apply_fn=None)
batch = dataset[training_cutset_batch]
batch = move_data_to_device(batch, device=model.device)
results = model.training_step(batch, batch_idx=0)
assert torch.is_tensor(results["loss"])
assert not torch.isnan(results["loss"])
assert results["loss"] > 0
def test_salm_validation_step(model, dataset, prompt_formatter, training_cutset_batch):
model.on_validation_epoch_start()
training_cutset_batch = training_cutset_batch.map(lambda c: c.apply_prompt_format(prompt_formatter), apply_fn=None)
batch = dataset[training_cutset_batch]
batch = move_data_to_device(batch, device=model.device)
results = model.validation_step({"dummy_val_set": batch}, batch_idx=0)
assert results is None
def test_salm_generation(model):
answer = model.generate(
prompts=[
[
{"role": "user", "slots": {"message": f"Repeat after me: {AUDIO_LOCATOR_TAG}"}},
]
],
audios=torch.randn(1, 16000),
audio_lens=torch.tensor([16000]),
max_new_tokens=4,
)
assert answer.shape == (1, 4)
assert answer.dtype == torch.long
assert (answer >= 0).all()
assert (answer < model.text_vocab_size).all()
@pytest.mark.parametrize(
("enable_thinking", "expected_formatter_kwargs"),
[
(False, {"enable_thinking": False}),
(None, {}),
],
)
def test_salm_generation_passes_enable_thinking(model, monkeypatch, enable_thinking, expected_formatter_kwargs):
seen = {}
class _FakeFormatter:
def __init__(self, tokenizer):
pass
def encode_dialog(self, turns, **kwargs):
seen["turns"] = turns
seen["formatter_kwargs"] = kwargs
return {"input_ids": torch.tensor([1, 2], dtype=torch.long)}
def fake_generate(*, input_ids, attention_mask, generation_config, **kwargs):
seen["input_ids"] = input_ids
seen["attention_mask"] = attention_mask
max_new_tokens = kwargs["max_new_tokens"]
return torch.zeros((input_ids.shape[0], max_new_tokens), dtype=torch.long, device=input_ids.device)
monkeypatch.setattr(PromptFormatter, "resolve", staticmethod(lambda name: _FakeFormatter))
monkeypatch.setattr(model.llm, "generate", fake_generate, raising=False)
answer = model.generate(
prompts=[[{"role": "user", "slots": {"message": "test"}}]],
enable_thinking=enable_thinking,
max_new_tokens=3,
)
assert seen["formatter_kwargs"] == expected_formatter_kwargs
assert seen["turns"] == [{"role": "user", "slots": {"message": "test"}}]
assert seen["input_ids"].shape == (1, 2)
assert torch.equal(seen["attention_mask"], torch.ones_like(seen["input_ids"], dtype=torch.bool))
assert answer.shape == (1, 3)
def test_salm_generation_audios_via_prompt(model, tmp_path):
audio_path = tmp_path / "audio.wav"
dummy_cut(0, with_data=True).save_audio(audio_path)
answer = model.generate(
prompts=[
[{"role": "user", "content": f"Repeat after me: {AUDIO_LOCATOR_TAG}", "audio": [audio_path]}],
[
{
"role": "user",
"content": f"Repeat after me: {AUDIO_LOCATOR_TAG} and {AUDIO_LOCATOR_TAG}",
"audio": [audio_path, audio_path],
}
],
],
generation_config=GenerationConfig(max_new_tokens=4),
)
assert answer.shape == (2, 4)
assert answer.dtype == torch.long
assert (answer >= 0).all()
assert (answer < model.text_vocab_size).all()
def test_salm_generation_prompts_as_tensor(model):
answer = model.generate(
prompts=torch.tensor([[1, 2, 3, 4, 5, 6, 7, model.audio_locator_tag_id]]),
audios=torch.randn(1, 16000),
audio_lens=torch.tensor([16000]),
max_new_tokens=4,
)
assert answer.shape == (1, 4)
assert answer.dtype == torch.long
assert (answer >= 0).all()
assert (answer < model.text_vocab_size).all()
@pytest.mark.parametrize("device", chunking_test_devices())
def test_salm_prepare_inputs_chunks_long_audio(device):
model = _make_chunking_test_model(encoder_chunk_size_seconds=1.0, sampling_rate=2, device=device)
batch = {
"audios": torch.tensor([[1.0, 2.0, 3.0, 4.0, 5.0]], device=device),
"audio_lens": torch.tensor([5], dtype=torch.long, device=device),
"input_ids": torch.tensor([[model.audio_locator_tag_id, 10]], dtype=torch.long, device=device),
"loss_mask": torch.tensor([[False, True]], dtype=torch.bool, device=device),
}
inputs = model.prepare_inputs(batch)
chunked_signal, chunked_lens = model.perception.calls[0]
assert chunked_signal.shape == (2, 3)
assert torch.equal(chunked_lens, torch.tensor([2, 3], dtype=torch.long, device=device))
assert torch.equal(inputs["input_embeds"][0, :, 0], torch.tensor([1.0, 2.0, 3.0, 4.0, 5.0], device=device))
assert torch.equal(inputs["attention_mask"], torch.ones((1, 5), dtype=torch.bool, device=device))
@pytest.mark.parametrize("device", chunking_test_devices())
def test_salm_prepare_inputs_passes_chunk_time_offsets(device):
# sampling_rate=8, chunk=0.5s -> 4 samples/chunk; a 12-sample audio splits cleanly into
# 3 chunks starting at samples 0, 4, 8, i.e. time offsets 0.0, 0.5, 1.0 seconds.
model = _make_chunking_test_model(encoder_chunk_size_seconds=0.5, sampling_rate=8, hop_length=2, device=device)
batch = {
"audios": torch.arange(1.0, 13.0, device=device).unsqueeze(0),
"audio_lens": torch.tensor([12], dtype=torch.long, device=device),
"input_ids": torch.tensor([[model.audio_locator_tag_id, 10]], dtype=torch.long, device=device),
"loss_mask": torch.tensor([[False, True]], dtype=torch.bool, device=device),
}
model.prepare_inputs(batch)
chunked_signal, chunked_lens = model.perception.calls[0]
assert chunked_signal.shape == (3, 4)
assert torch.equal(chunked_lens, torch.tensor([4, 4, 4], dtype=torch.long, device=device))
time_offset = model.perception.time_offsets[0]
assert time_offset is not None
# chunk N start_sample / sampling_rate: [0/8, 4/8, 8/8]
assert torch.equal(time_offset, torch.tensor([0.0, 0.5, 1.0], device=device))
@pytest.mark.parametrize("device", chunking_test_devices())
def test_salm_prepare_inputs_merges_short_tail_chunk(device):
model = _make_chunking_test_model(encoder_chunk_size_seconds=0.5, sampling_rate=8, hop_length=2, device=device)
batch = {
"audios": torch.tensor([[1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0]], device=device),
"audio_lens": torch.tensor([9], dtype=torch.long, device=device),
"input_ids": torch.tensor([[model.audio_locator_tag_id, 10]], dtype=torch.long, device=device),
"loss_mask": torch.tensor([[False, True]], dtype=torch.bool, device=device),
}
inputs = model.prepare_inputs(batch)
chunked_signal, chunked_lens = model.perception.calls[0]
assert chunked_signal.shape == (2, 5)
assert torch.equal(chunked_lens, torch.tensor([4, 5], dtype=torch.long, device=device))
assert torch.equal(
inputs["input_embeds"][0, :, 0],
torch.tensor([1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0], device=device),
)
@pytest.mark.parametrize("device", chunking_test_devices())
def test_salm_prepare_inputs_skips_chunking_when_size_is_null(device):
model = _make_chunking_test_model(encoder_chunk_size_seconds=None, sampling_rate=2, device=device)
batch = {
"audios": torch.tensor([[1.0, 2.0, 3.0, 4.0, 5.0]], device=device),
"audio_lens": torch.tensor([5], dtype=torch.long, device=device),
"input_ids": torch.tensor([[model.audio_locator_tag_id, 10]], dtype=torch.long, device=device),
"loss_mask": torch.tensor([[False, True]], dtype=torch.bool, device=device),
}
model.prepare_inputs(batch)
input_signal, input_signal_lens = model.perception.calls[0]
assert input_signal.shape == (1, 5)
assert torch.equal(input_signal_lens, torch.tensor([5], dtype=torch.long, device=device))
@pytest.mark.parametrize("device", chunking_test_devices())
def test_salm_prepare_inputs_skips_chunking_when_key_absent(device):
"""Backwards compat: missing encoder_chunk_size_seconds key matches legacy non-chunking path."""
model = _make_chunking_test_model(encoder_chunk_size_seconds=None, sampling_rate=2, device=device)
model.cfg = DictConfig({})
batch = {
"audios": torch.tensor([[1.0, 2.0, 3.0, 4.0, 5.0]], device=device),
"audio_lens": torch.tensor([5], dtype=torch.long, device=device),
"input_ids": torch.tensor([[model.audio_locator_tag_id, 10]], dtype=torch.long, device=device),
"loss_mask": torch.tensor([[False, True]], dtype=torch.bool, device=device),
}
model.prepare_inputs(batch)
input_signal, input_signal_lens = model.perception.calls[0]
assert input_signal.shape == (1, 5)
assert torch.equal(input_signal_lens, torch.tensor([5], dtype=torch.long, device=device))
@pytest.mark.parametrize("device", chunking_test_devices())
def test_salm_prepare_inputs_preserves_chunked_audio_order(device):
model = _make_chunking_test_model(encoder_chunk_size_seconds=1.0, sampling_rate=2, device=device)
batch = {
"audios": torch.tensor(
[
[1.0, 2.0, 3.0, 0.0, 0.0],
[10.0, 11.0, 12.0, 13.0, 14.0],
],
device=device,
),
"audio_lens": torch.tensor([3, 5], dtype=torch.long, device=device),
"input_ids": torch.tensor(
[[model.audio_locator_tag_id, model.audio_locator_tag_id, 10]], dtype=torch.long, device=device
),
"loss_mask": torch.tensor([[False, False, True]], dtype=torch.bool, device=device),
}
inputs = model.prepare_inputs(batch)
assert torch.equal(
inputs["input_embeds"][0, :, 0],
torch.tensor([1.0, 2.0, 3.0, 10.0, 11.0, 12.0, 13.0, 14.0], device=device),
)
@pytest.mark.parametrize("device", chunking_test_devices())
def test_salm_generate_chunks_audio_before_llm(device):
model = _make_chunking_test_model(encoder_chunk_size_seconds=1.0, sampling_rate=2, device=device)
answer = model.generate(
prompts=torch.tensor([[model.audio_locator_tag_id, 10]], dtype=torch.long, device=device),
audios=torch.tensor([[1.0, 2.0, 3.0, 4.0, 5.0]], device=device),
audio_lens=torch.tensor([5], dtype=torch.long, device=device),
max_new_tokens=3,
)
chunked_signal, chunked_lens = model.perception.calls[0]
assert chunked_signal.shape == (2, 3)
assert torch.equal(chunked_lens, torch.tensor([2, 3], dtype=torch.long, device=device))
assert torch.equal(
model.llm.generate_kwargs["inputs_embeds"][0, :5, 0],
torch.tensor([1.0, 2.0, 3.0, 4.0, 5.0], device=device),
)
assert answer.shape == (1, 3)
def _make_chunking_test_model(encoder_chunk_size_seconds, sampling_rate, device, hop_length=1):
model = SALM.__new__(SALM)
torch.nn.Module.__init__(model)
model.cfg = DictConfig({"encoder_chunk_size_seconds": encoder_chunk_size_seconds})
model.audio_locator_tag = AUDIO_LOCATOR_TAG
model.tokenizer = ChunkingTestTokenizer(AUDIO_LOCATOR_TAG)
model.embed_tokens = torch.nn.Embedding(128, 1, device=device)
with torch.no_grad():
model.embed_tokens.weight.zero_()
model.llm = _SalmChunkingTestLLM()
model.perception = ChunkingTestPerception(sampling_rate=sampling_rate, hop_length=hop_length)
model._use_tp = False
return model
class _SalmChunkingTestLLM(torch.nn.Module):
def __init__(self):
super().__init__()
# move_embedding() writes model.embed_tokens into llm.model.embed_tokens then deletes it.
self.model = torch.nn.Module()
self.generate_kwargs = None
def generate(self, **kwargs):
self.generate_kwargs = kwargs
batch_size = kwargs["inputs_embeds"].shape[0]
max_new_tokens = kwargs["max_new_tokens"]
return torch.zeros((batch_size, max_new_tokens), dtype=torch.long, device=kwargs["inputs_embeds"].device)
@@ -0,0 +1,274 @@
# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import os
import pytest
import torch
from lhotse import CutSet, SupervisionSegment
from lhotse.testing.dummies import dummy_cut, dummy_recording
from transformers import GenerationConfig
from nemo.collections.common.data.lhotse import NeMoMultimodalConversation
from nemo.collections.common.data.lhotse.text_adapters import AudioTurn, TextTurn
from nemo.collections.common.data.utils import move_data_to_device
from nemo.collections.common.prompts import PromptFormatter
from nemo.collections.speechlm2.data import SALMDataset
from nemo.collections.speechlm2.models.salm_asr_decoder import SALMWithAsrDecoder
if torch.cuda.is_available():
torch.set_default_device('cuda')
def resolve_pretrained_models():
if os.path.exists("/home/TestData/speechlm/pretrained_models"):
# CI pre-cached paths:
return {
"pretrained_llm": "/home/TestData/speechlm/pretrained_models/TinyLlama--TinyLlama_v1.1",
"pretrained_asr": "/home/TestData/speechlm/pretrained_models/parakeet-tdt-0.6b-v2.nemo",
}
else:
# HF URLs:
return {
"pretrained_asr": "nvidia/parakeet-tdt-0.6b-v2",
"pretrained_llm": "TinyLlama/TinyLlama_v1.1",
}
AUDIO_LOCATOR_TAG = "<|audioplaceholder|>"
PROMPT = "llama2"
@pytest.fixture(scope="session")
def model():
cfg = {
**resolve_pretrained_models(),
"pretrained_weights": True,
"prompt_format": PROMPT,
"audio_locator_tag": AUDIO_LOCATOR_TAG,
"perception": {
"target": "nemo.collections.speechlm2.modules.perception.AudioTranscriptionPerceptionModule",
"output_dim": 2048,
"asr": {
"encoder": {
"_target_": "nemo.collections.asr.modules.ConformerEncoder",
"att_context_size": [-1, -1],
"causal_downsampling": False,
"conv_context_size": None,
"conv_kernel_size": 9,
"conv_norm_type": "batch_norm",
"d_model": 1024,
"dropout": 0.1,
"dropout_att": 0.1,
"dropout_emb": 0.0,
"dropout_pre_encoder": 0.1,
"feat_in": 128,
"feat_out": -1,
"ff_expansion_factor": 4,
"n_heads": 8,
"n_layers": 2,
"pos_emb_max_len": 5000,
"self_attention_model": "rel_pos",
"subsampling": "dw_striding",
"subsampling_conv_channels": 256,
"subsampling_factor": 8,
},
"preprocessor": {
"_target_": "nemo.collections.asr.modules.AudioToMelSpectrogramPreprocessor",
"dither": 1e-05,
"features": 128,
"frame_splicing": 1,
"log": True,
"n_fft": 512,
"normalize": "per_feature",
"pad_to": 0,
"pad_value": 0.0,
"sample_rate": 16000,
"window": "hann",
"window_size": 0.025,
"window_stride": 0.01,
},
},
"modality_adapter": {
"_target_": "nemo.collections.speechlm2.modules.perception.IdentityConnector",
"d_model": 1024,
},
},
"optimizer": {"_target_": "torch.optim.AdamW"},
}
model = SALMWithAsrDecoder(cfg)
if torch.cuda.is_available():
model.to("cuda")
return model
@pytest.fixture(scope="session")
def dataset(model):
return SALMDataset(model.tokenizer)
@pytest.fixture(scope="session")
def prompt_formatter(model):
return PromptFormatter.resolve(PROMPT)(model.tokenizer)
@pytest.fixture(scope="session")
def training_cutset_batch():
cut = dummy_cut(0, recording=dummy_recording(0, with_data=True))
cut.supervisions = [
SupervisionSegment(
id=cut.id, recording_id=cut.recording_id, start=0, duration=1.0, text='Some text transcription.'
)
]
return CutSet(
[
NeMoMultimodalConversation(
id="example-0",
turns=[
TextTurn(role="user", value="Repeat after me:"),
AudioTurn(role="user", cut=cut, audio_locator_tag=AUDIO_LOCATOR_TAG),
TextTurn(role="assistant", value=cut.supervisions[0].text),
],
token_equivalent_duration=0.08,
)
]
)
def test_salm_dataset(dataset, prompt_formatter, training_cutset_batch):
# This first step pre-tokenizes the examples, usually handled within `get_lhotse_dataloder_from_config`.
training_cutset_batch = training_cutset_batch.map(lambda c: c.apply_prompt_format(prompt_formatter), apply_fn=None)
# fmt: off
tokenized = training_cutset_batch[0].input_ids
assert (
prompt_formatter.tokenizer.tokenizer.decode(tokenized) ==
f"<s> [INST] Repeat after me: {AUDIO_LOCATOR_TAG} [/INST] Some text transcription. </s>"
)
# fmt: on
batch = dataset[training_cutset_batch]
for key in ("audios", "audio_lens", "input_ids", "loss_mask"):
assert key in batch
assert torch.is_tensor(batch[key])
def test_salm_training_step(model, dataset, prompt_formatter, training_cutset_batch):
training_cutset_batch = training_cutset_batch.map(lambda c: c.apply_prompt_format(prompt_formatter), apply_fn=None)
batch = dataset[training_cutset_batch]
batch = move_data_to_device(batch, device=model.device)
results = model.training_step(batch, batch_idx=0)
assert torch.is_tensor(results["loss"])
assert not torch.isnan(results["loss"])
assert results["loss"] > 0
def test_salm_validation_step(model, dataset, prompt_formatter, training_cutset_batch):
model.on_validation_epoch_start()
training_cutset_batch = training_cutset_batch.map(lambda c: c.apply_prompt_format(prompt_formatter), apply_fn=None)
batch = dataset[training_cutset_batch]
batch = move_data_to_device(batch, device=model.device)
results = model.validation_step({"dummy_val_set": batch}, batch_idx=0)
assert results is None
def test_salm_generation(model):
answer = model.generate(
prompts=[
[
{"role": "user", "slots": {"message": f"Repeat after me: {AUDIO_LOCATOR_TAG}"}},
]
],
audios=torch.randn(1, 16000),
audio_lens=torch.tensor([16000]),
max_new_tokens=4,
)
assert answer.shape == (1, 4)
assert answer.dtype == torch.long
assert (answer >= 0).all()
assert (answer < model.text_vocab_size).all()
@pytest.mark.parametrize(
("enable_thinking", "expected_formatter_kwargs"),
[
(False, {"enable_thinking": False}),
(None, {}),
],
)
def test_salm_generation_passes_enable_thinking(model, monkeypatch, enable_thinking, expected_formatter_kwargs):
seen = {}
class _FakeFormatter:
def __init__(self, tokenizer):
pass
def encode_dialog(self, turns, **kwargs):
seen["turns"] = turns
seen["formatter_kwargs"] = kwargs
return {"input_ids": torch.tensor([1, 2], dtype=torch.long)}
def fake_generate(*, inputs_embeds, attention_mask, generation_config, **kwargs):
seen["inputs_embeds"] = inputs_embeds
seen["attention_mask"] = attention_mask
max_new_tokens = kwargs["max_new_tokens"]
return torch.zeros((inputs_embeds.shape[0], max_new_tokens), dtype=torch.long, device=inputs_embeds.device)
monkeypatch.setattr(PromptFormatter, "resolve", staticmethod(lambda name: _FakeFormatter))
monkeypatch.setattr(model.llm, "generate", fake_generate, raising=False)
answer = model.generate(
prompts=[[{"role": "user", "slots": {"message": "test"}}]],
enable_thinking=enable_thinking,
max_new_tokens=3,
)
assert seen["formatter_kwargs"] == expected_formatter_kwargs
assert seen["turns"] == [{"role": "user", "slots": {"message": "test"}}]
assert seen["inputs_embeds"].shape[:2] == (1, 2)
assert torch.equal(seen["attention_mask"], torch.ones((1, 2), dtype=torch.bool, device=model.device))
assert answer.shape == (1, 3)
def test_salm_generation_audios_via_prompt(model, tmp_path):
audio_path = tmp_path / "audio.wav"
dummy_cut(0, with_data=True).save_audio(audio_path)
answer = model.generate(
prompts=[
[{"role": "user", "content": f"Repeat after me: {AUDIO_LOCATOR_TAG}", "audio": [audio_path]}],
[
{
"role": "user",
"content": f"Repeat after me: {AUDIO_LOCATOR_TAG} and {AUDIO_LOCATOR_TAG}",
"audio": [audio_path, audio_path],
}
],
],
generation_config=GenerationConfig(max_new_tokens=4),
)
assert answer.shape == (2, 4)
assert answer.dtype == torch.long
assert (answer >= 0).all()
assert (answer < model.text_vocab_size).all()
def test_salm_generation_prompts_as_tensor(model):
answer = model.generate(
prompts=torch.tensor([[1, 2, 3, 4, 5, 6, 7, model.audio_locator_tag_id]]),
audios=torch.randn(1, 16000),
audio_lens=torch.tensor([16000]),
max_new_tokens=4,
)
assert answer.shape == (1, 4)
assert answer.dtype == torch.long
assert (answer >= 0).all()
assert (answer < model.text_vocab_size).all()
@@ -0,0 +1,235 @@
# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import os
import pytest
import torch
from lhotse import CutSet, SupervisionSegment
from lhotse.testing.dummies import dummy_cut, dummy_recording
from transformers import GenerationConfig
from nemo.collections.common.data.lhotse import NeMoMultimodalConversation
from nemo.collections.common.data.lhotse.text_adapters import AudioTurn, TextTurn
from nemo.collections.common.data.utils import move_data_to_device
from nemo.collections.common.prompts import PromptFormatter
from nemo.collections.speechlm2.data import SALMDataset
from nemo.collections.speechlm2.models.salm_asr_decoder import SALMWithAsrDecoder
if torch.cuda.is_available():
torch.set_default_device('cuda')
def resolve_pretrained_models():
if os.path.exists("/home/TestData/speechlm/pretrained_models"):
# CI pre-cached paths:
return {
"pretrained_llm": "/home/TestData/speechlm/pretrained_models/TinyLlama--TinyLlama_v1.1",
"pretrained_asr": "/home/TestData/speechlm/pretrained_models/parakeet-tdt-0.6b-v2.nemo",
}
else:
# HF URLs:
return {
"pretrained_asr": "nvidia/parakeet-tdt-0.6b-v2",
"pretrained_llm": "TinyLlama/TinyLlama_v1.1",
}
AUDIO_LOCATOR_TAG = "<|audioplaceholder|>"
PROMPT = "llama2"
@pytest.fixture(scope="session")
def model():
cfg = {
**resolve_pretrained_models(),
"pretrained_weights": True,
"prompt_format": PROMPT,
"audio_locator_tag": AUDIO_LOCATOR_TAG,
"perception": {
"target": "nemo.collections.speechlm2.modules.perception.AudioTranscriptionPerceptionModule",
"output_dim": 2048,
"asr": {
"encoder": {
"_target_": "nemo.collections.asr.modules.ConformerEncoder",
"att_context_size": [-1, -1],
"causal_downsampling": False,
"conv_context_size": None,
"conv_kernel_size": 9,
"conv_norm_type": "batch_norm",
"d_model": 1024,
"dropout": 0.1,
"dropout_att": 0.1,
"dropout_emb": 0.0,
"dropout_pre_encoder": 0.1,
"feat_in": 128,
"feat_out": -1,
"ff_expansion_factor": 4,
"n_heads": 8,
"n_layers": 2,
"pos_emb_max_len": 5000,
"self_attention_model": "rel_pos",
"subsampling": "dw_striding",
"subsampling_conv_channels": 256,
"subsampling_factor": 8,
},
"preprocessor": {
"_target_": "nemo.collections.asr.modules.AudioToMelSpectrogramPreprocessor",
"dither": 1e-05,
"features": 128,
"frame_splicing": 1,
"log": True,
"n_fft": 512,
"normalize": "per_feature",
"pad_to": 0,
"pad_value": 0.0,
"sample_rate": 16000,
"window": "hann",
"window_size": 0.025,
"window_stride": 0.01,
},
},
"modality_adapter": {
"_target_": "nemo.collections.speechlm2.modules.perception.MultiLayerProjectionConnector",
"target_layer_ids": [7, 15, 19, 23],
"input_dim": 1024,
"output_dim": 2048,
},
},
"optimizer": {"_target_": "torch.optim.AdamW"},
}
model = SALMWithAsrDecoder(cfg)
if torch.cuda.is_available():
model.to("cuda")
return model
@pytest.fixture(scope="session")
def dataset(model):
return SALMDataset(model.tokenizer)
@pytest.fixture(scope="session")
def prompt_formatter(model):
return PromptFormatter.resolve(PROMPT)(model.tokenizer)
@pytest.fixture(scope="session")
def training_cutset_batch():
cut = dummy_cut(0, recording=dummy_recording(0, with_data=True))
cut.supervisions = [
SupervisionSegment(
id=cut.id, recording_id=cut.recording_id, start=0, duration=1.0, text='Some text transcription.'
)
]
return CutSet(
[
NeMoMultimodalConversation(
id="example-0",
turns=[
TextTurn(role="user", value="Repeat after me:"),
AudioTurn(role="user", cut=cut, audio_locator_tag=AUDIO_LOCATOR_TAG),
TextTurn(role="assistant", value=cut.supervisions[0].text),
],
token_equivalent_duration=0.08,
)
]
)
def test_salm_dataset(dataset, prompt_formatter, training_cutset_batch):
# This first step pre-tokenizes the examples, usually handled within `get_lhotse_dataloder_from_config`.
training_cutset_batch = training_cutset_batch.map(lambda c: c.apply_prompt_format(prompt_formatter), apply_fn=None)
# fmt: off
tokenized = training_cutset_batch[0].input_ids
assert (
prompt_formatter.tokenizer.tokenizer.decode(tokenized) ==
f"<s> [INST] Repeat after me: {AUDIO_LOCATOR_TAG} [/INST] Some text transcription. </s>"
)
# fmt: on
batch = dataset[training_cutset_batch]
for key in ("audios", "audio_lens", "input_ids", "loss_mask"):
assert key in batch
assert torch.is_tensor(batch[key])
def test_salm_training_step(model, dataset, prompt_formatter, training_cutset_batch):
training_cutset_batch = training_cutset_batch.map(lambda c: c.apply_prompt_format(prompt_formatter), apply_fn=None)
batch = dataset[training_cutset_batch]
batch = move_data_to_device(batch, device=model.device)
results = model.training_step(batch, batch_idx=0)
assert torch.is_tensor(results["loss"])
assert not torch.isnan(results["loss"])
assert results["loss"] > 0
def test_salm_validation_step(model, dataset, prompt_formatter, training_cutset_batch):
model.on_validation_epoch_start()
training_cutset_batch = training_cutset_batch.map(lambda c: c.apply_prompt_format(prompt_formatter), apply_fn=None)
batch = dataset[training_cutset_batch]
batch = move_data_to_device(batch, device=model.device)
results = model.validation_step({"dummy_val_set": batch}, batch_idx=0)
assert results is None
def test_salm_generation(model):
answer = model.generate(
prompts=[
[
{"role": "user", "slots": {"message": f"Repeat after me: {AUDIO_LOCATOR_TAG}"}},
]
],
audios=torch.randn(1, 16000),
audio_lens=torch.tensor([16000]),
max_new_tokens=4,
)
assert answer.shape == (1, 4)
assert answer.dtype == torch.long
assert (answer >= 0).all()
assert (answer < model.text_vocab_size).all()
def test_salm_generation_audios_via_prompt(model, tmp_path):
audio_path = tmp_path / "audio.wav"
dummy_cut(0, with_data=True).save_audio(audio_path)
answer = model.generate(
prompts=[
[{"role": "user", "content": f"Repeat after me: {AUDIO_LOCATOR_TAG}", "audio": [audio_path]}],
[
{
"role": "user",
"content": f"Repeat after me: {AUDIO_LOCATOR_TAG} and {AUDIO_LOCATOR_TAG}",
"audio": [audio_path, audio_path],
}
],
],
generation_config=GenerationConfig(max_new_tokens=4),
)
assert answer.shape == (2, 4)
assert answer.dtype == torch.long
assert (answer >= 0).all()
assert (answer < model.text_vocab_size).all()
def test_salm_generation_prompts_as_tensor(model):
answer = model.generate(
prompts=torch.tensor([[1, 2, 3, 4, 5, 6, 7, model.audio_locator_tag_id]]),
audios=torch.randn(1, 16000),
audio_lens=torch.tensor([16000]),
max_new_tokens=4,
)
assert answer.shape == (1, 4)
assert answer.dtype == torch.long
assert (answer >= 0).all()
assert (answer < model.text_vocab_size).all()
@@ -0,0 +1,480 @@
# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import os
import pytest
import torch
from lhotse import CutSet, SupervisionSegment
from lhotse.testing.dummies import dummy_cut, dummy_recording
from transformers import GenerationConfig
from nemo.collections.common.data.lhotse import NeMoMultimodalConversation
from nemo.collections.common.data.lhotse.text_adapters import AudioTurn, TextTurn
from nemo.collections.common.data.utils import move_data_to_device
from nemo.collections.common.prompts import PromptFormatter
from nemo.collections.speechlm2.data import SALMDataset
from nemo.collections.speechlm2.models import SALMAutomodel
from tests.collections.speechlm2._chunking_helpers import (
ChunkingTestPerception,
ChunkingTestTokenizer,
chunking_test_devices,
)
requires_cuda = pytest.mark.skipif(not torch.cuda.is_available(), reason="SALMAutomodel requires CUDA")
@pytest.fixture(autouse=True, scope="module")
def _default_device_cuda():
"""Run this module's tests on CUDA by default, but scope the change so it does
not leak. ``torch.set_default_device`` is a global, process-wide mutation; setting
it at import time bleeds into other modules collected in the same pytest session
(e.g. the CPU tests in tests/collections/asr/test_parallel_expert_encoder.py),
causing spurious cuda/cpu device-mismatch failures. The previous default device
is always restored on teardown.
"""
if not torch.cuda.is_available():
yield
return
prev = torch.get_default_device()
torch.set_default_device('cuda')
try:
yield
finally:
torch.set_default_device(prev)
def resolve_pretrained_models():
if os.path.exists("/home/TestData/speechlm/pretrained_models"):
# CI pre-cached paths:
return {
"pretrained_llm": "/home/TestData/speechlm/pretrained_models/Qwen--Qwen3-1.7B",
"pretrained_asr": "/home/TestData/speechlm/pretrained_models/canary-1b-flash.nemo",
}
else:
# HF URLs:
return {
"pretrained_asr": "nvidia/canary-1b-flash",
"pretrained_llm": "Qwen/Qwen3-1.7B",
}
AUDIO_LOCATOR_TAG = "<|audioplaceholder|>"
PROMPT = "qwen"
@pytest.fixture(scope="session")
def model():
if not torch.cuda.is_available():
pytest.skip("SALMAutomodel requires CUDA")
cfg = {
**resolve_pretrained_models(),
"pretrained_weights": False,
"prompt_format": PROMPT,
"audio_locator_tag": AUDIO_LOCATOR_TAG,
"perception": {
"target": "nemo.collections.speechlm2.modules.perception.AudioPerceptionModule",
"output_dim": 2048,
"encoder": {
"_target_": "nemo.collections.asr.modules.ConformerEncoder",
"att_context_size": [-1, -1],
"causal_downsampling": False,
"conv_context_size": None,
"conv_kernel_size": 9,
"conv_norm_type": "batch_norm",
"d_model": 1024,
"dropout": 0.1,
"dropout_att": 0.1,
"dropout_emb": 0.0,
"dropout_pre_encoder": 0.1,
"feat_in": 128,
"feat_out": -1,
"ff_expansion_factor": 4,
"n_heads": 8,
"n_layers": 2,
"pos_emb_max_len": 5000,
"self_attention_model": "rel_pos",
"subsampling": "dw_striding",
"subsampling_conv_channels": 256,
"subsampling_factor": 8,
},
"modality_adapter": {
"_target_": "nemo.collections.speechlm2.modules.perception.IdentityConnector",
"d_model": 1024,
},
"preprocessor": {
"_target_": "nemo.collections.asr.modules.AudioToMelSpectrogramPreprocessor",
"dither": 1e-05,
"features": 128,
"frame_splicing": 1,
"log": True,
"n_fft": 512,
"normalize": "per_feature",
"pad_to": 0,
"pad_value": 0.0,
"sample_rate": 16000,
"window": "hann",
"window_size": 0.025,
"window_stride": 0.01,
},
},
"optimizer": {"_target_": "torch.optim.AdamW"},
"torch_dtype": "bfloat16",
}
model = SALMAutomodel(cfg)
model.configure_model()
model.to("cuda")
return model
@pytest.fixture(scope="session")
def dataset(model):
return SALMDataset(model.tokenizer)
@pytest.fixture(scope="session")
def prompt_formatter(model):
return PromptFormatter.resolve(PROMPT)(model.tokenizer)
@pytest.fixture(scope="session")
def training_cutset_batch():
cut = dummy_cut(0, recording=dummy_recording(0, with_data=True))
cut.supervisions = [
SupervisionSegment(
id=cut.id, recording_id=cut.recording_id, start=0, duration=1.0, text='Some text transcription.'
)
]
return CutSet(
[
NeMoMultimodalConversation(
id="example-0",
turns=[
TextTurn(role="user", value="Repeat after me:"),
AudioTurn(role="user", cut=cut, audio_locator_tag=AUDIO_LOCATOR_TAG),
TextTurn(role="assistant", value=cut.supervisions[0].text),
],
token_equivalent_duration=0.08,
)
]
)
@requires_cuda
def test_salm_automodel_dataset(dataset, prompt_formatter, training_cutset_batch):
# This first step pre-tokenizes the examples, usually handled within `get_lhotse_dataloder_from_config`.
training_cutset_batch = training_cutset_batch.map(lambda c: c.apply_prompt_format(prompt_formatter), apply_fn=None)
# fmt: off
tokenized = training_cutset_batch[0].input_ids
assert (
prompt_formatter.tokenizer.tokenizer.decode(tokenized) ==
f"<|im_start|>user\nRepeat after me: {AUDIO_LOCATOR_TAG}<|im_end|>\n<|im_start|>assistant\nSome text transcription.<|im_end|>\n"
)
# fmt: on
batch = dataset[training_cutset_batch]
for key in ("audios", "audio_lens", "input_ids", "loss_mask"):
assert key in batch
assert torch.is_tensor(batch[key])
@requires_cuda
def test_salm_automodel_training_step(model, dataset, prompt_formatter, training_cutset_batch):
training_cutset_batch = training_cutset_batch.map(lambda c: c.apply_prompt_format(prompt_formatter), apply_fn=None)
batch = dataset[training_cutset_batch]
batch = move_data_to_device(batch, device=model.device)
results = model.training_step(batch, batch_idx=0)
assert torch.is_tensor(results["loss"])
assert not torch.isnan(results["loss"])
assert results["loss"] > 0
@requires_cuda
def test_salm_automodel_validation_step(model, dataset, prompt_formatter, training_cutset_batch):
model.on_validation_epoch_start()
training_cutset_batch = training_cutset_batch.map(lambda c: c.apply_prompt_format(prompt_formatter), apply_fn=None)
batch = dataset[training_cutset_batch]
batch = move_data_to_device(batch, device=model.device)
results = model.validation_step({"dummy_val_set": batch}, batch_idx=0)
assert results is None
def test_salm_automodel_validation_epoch_end_uses_token_weighted_metrics():
model = SALMAutomodel.__new__(SALMAutomodel)
torch.nn.Module.__init__(model)
model.on_validation_epoch_start()
model._get_moe_dp_group = lambda: None
model._partial_val_loss_sums["dummy"].extend([torch.tensor(2.0), torch.tensor(18.0)])
model._partial_val_corrects["dummy"].extend([torch.tensor(1.0), torch.tensor(3.0)])
model._partial_val_num_frames["dummy"].extend([torch.tensor(1.0), torch.tensor(9.0)])
logged = {}
def fake_log(name, value, **kwargs):
logged[name] = value.detach().cpu()
model.log = fake_log
model.on_validation_epoch_end()
assert logged["val_loss_dummy"].item() == pytest.approx(2.0)
assert logged["val_acc_dummy"].item() == pytest.approx(0.4)
assert logged["val_loss"].item() == pytest.approx(2.0)
assert logged["val_acc"].item() == pytest.approx(0.4)
assert not model._partial_val_loss_sums
assert not model._partial_val_corrects
assert not model._partial_val_num_frames
@requires_cuda
def test_salm_automodel_generation(model):
answer = model.generate(
prompts=[
[
{"role": "user", "slots": {"message": f"Repeat after me: {AUDIO_LOCATOR_TAG}"}},
]
],
audios=torch.randn(1, 16000),
audio_lens=torch.tensor([16000]),
max_new_tokens=4,
)
assert answer.shape == (1, 4)
assert answer.dtype == torch.long
assert (answer >= 0).all()
assert (answer < model.text_vocab_size).all()
@requires_cuda
@pytest.mark.parametrize(
("enable_thinking", "expected_formatter_kwargs"),
[
(False, {"enable_thinking": False}),
(None, {}),
],
)
def test_salm_automodel_generation_passes_enable_thinking(
model, monkeypatch, enable_thinking, expected_formatter_kwargs
):
seen = {}
class _FakeFormatter:
def __init__(self, tokenizer):
pass
def encode_dialog(self, turns, **kwargs):
seen["turns"] = turns
seen["formatter_kwargs"] = kwargs
return {"input_ids": torch.tensor([1, 2], dtype=torch.long)}
def fake_generate(*, input_ids, attention_mask, generation_config, **kwargs):
seen["input_ids"] = input_ids
seen["attention_mask"] = attention_mask
max_new_tokens = kwargs["max_new_tokens"]
return torch.zeros((input_ids.shape[0], max_new_tokens), dtype=torch.long, device=input_ids.device)
monkeypatch.setattr(PromptFormatter, "resolve", staticmethod(lambda name: _FakeFormatter))
monkeypatch.setattr(model.llm, "generate", fake_generate, raising=False)
answer = model.generate(
prompts=[[{"role": "user", "slots": {"message": "test"}}]],
enable_thinking=enable_thinking,
max_new_tokens=3,
)
assert seen["formatter_kwargs"] == expected_formatter_kwargs
assert seen["turns"] == [{"role": "user", "slots": {"message": "test"}}]
assert seen["input_ids"].shape == (1, 2)
assert torch.equal(seen["attention_mask"], torch.ones_like(seen["input_ids"], dtype=torch.bool))
assert answer.shape == (1, 3)
@requires_cuda
def test_salm_automodel_generation_audios_via_prompt(model, tmp_path):
audio_path = tmp_path / "audio.wav"
dummy_cut(0, with_data=True).save_audio(audio_path)
answer = model.generate(
prompts=[
[{"role": "user", "content": f"Repeat after me: {AUDIO_LOCATOR_TAG}", "audio": [audio_path]}],
[
{
"role": "user",
"content": f"Repeat after me: {AUDIO_LOCATOR_TAG} and {AUDIO_LOCATOR_TAG}",
"audio": [audio_path, audio_path],
}
],
],
generation_config=GenerationConfig(max_new_tokens=4),
)
assert answer.shape == (2, 4)
assert answer.dtype == torch.long
assert (answer >= 0).all()
assert (answer < model.text_vocab_size).all()
@requires_cuda
def test_salm_automodel_generation_prompts_as_tensor(model):
answer = model.generate(
prompts=torch.tensor([[1, 2, 3, 4, 5, 6, 7, model.audio_locator_tag_id]]),
audios=torch.randn(1, 16000),
audio_lens=torch.tensor([16000]),
max_new_tokens=4,
)
assert answer.shape == (1, 4)
assert answer.dtype == torch.long
assert (answer >= 0).all()
assert (answer < model.text_vocab_size).all()
@pytest.mark.parametrize("device", chunking_test_devices())
def test_salm_automodel_prepare_inputs_chunks_long_audio(device):
model = _make_chunking_test_model(encoder_chunk_size_seconds=1.0, sampling_rate=2, device=device)
spk_targets = torch.arange(10, dtype=torch.float32, device=device).reshape(1, 5, 2)
batch = {
"audios": torch.tensor([[1.0, 2.0, 3.0, 4.0, 5.0]], device=device),
"audio_lens": torch.tensor([5], dtype=torch.long, device=device),
"input_ids": torch.tensor([[model.audio_locator_tag_id, 10]], dtype=torch.long, device=device),
"loss_mask": torch.tensor([[False, True]], dtype=torch.bool, device=device),
"spk_targets": spk_targets,
}
inputs = model.prepare_inputs(batch)
chunked_signal, chunked_lens = model.perception.calls[0]
assert chunked_signal.shape == (2, 3)
assert torch.equal(chunked_lens, torch.tensor([2, 3], dtype=torch.long, device=device))
assert torch.equal(
model.perception.spk_targets_calls[0],
torch.stack([torch.cat([spk_targets[0, :2], spk_targets[0, 1:2]]), spk_targets[0, 2:5]]),
)
assert torch.equal(inputs["input_embeds"][0, :, 0], torch.tensor([1.0, 2.0, 3.0, 4.0, 5.0], device=device))
assert torch.equal(inputs["attention_mask"], torch.ones((1, 5), dtype=torch.bool, device=device))
@pytest.mark.parametrize("device", chunking_test_devices())
def test_salm_automodel_prepare_inputs_merges_short_tail_chunk(device):
model = _make_chunking_test_model(encoder_chunk_size_seconds=0.5, sampling_rate=8, hop_length=2, device=device)
batch = {
"audios": torch.tensor([[1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0]], device=device),
"audio_lens": torch.tensor([9], dtype=torch.long, device=device),
"input_ids": torch.tensor([[model.audio_locator_tag_id, 10]], dtype=torch.long, device=device),
"loss_mask": torch.tensor([[False, True]], dtype=torch.bool, device=device),
}
inputs = model.prepare_inputs(batch)
chunked_signal, chunked_lens = model.perception.calls[0]
assert chunked_signal.shape == (2, 5)
assert torch.equal(chunked_lens, torch.tensor([4, 5], dtype=torch.long, device=device))
assert model.perception.spk_targets_calls[0] is None
assert torch.equal(
inputs["input_embeds"][0, :, 0],
torch.tensor([1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0], device=device),
)
@pytest.mark.parametrize("device", chunking_test_devices())
def test_salm_automodel_prepare_inputs_skips_chunking_when_size_is_null(device):
model = _make_chunking_test_model(encoder_chunk_size_seconds=None, sampling_rate=2, device=device)
batch = {
"audios": torch.tensor([[1.0, 2.0, 3.0, 4.0, 5.0]], device=device),
"audio_lens": torch.tensor([5], dtype=torch.long, device=device),
"input_ids": torch.tensor([[model.audio_locator_tag_id, 10]], dtype=torch.long, device=device),
"loss_mask": torch.tensor([[False, True]], dtype=torch.bool, device=device),
}
model.prepare_inputs(batch)
input_signal, input_signal_lens = model.perception.calls[0]
assert input_signal.shape == (1, 5)
assert torch.equal(input_signal_lens, torch.tensor([5], dtype=torch.long, device=device))
@pytest.mark.parametrize("device", chunking_test_devices())
def test_salm_automodel_prepare_inputs_preserves_chunked_audio_order(device):
model = _make_chunking_test_model(encoder_chunk_size_seconds=1.0, sampling_rate=2, device=device)
batch = {
"audios": torch.tensor(
[
[1.0, 2.0, 3.0, 0.0, 0.0],
[10.0, 11.0, 12.0, 13.0, 14.0],
],
device=device,
),
"audio_lens": torch.tensor([3, 5], dtype=torch.long, device=device),
"input_ids": torch.tensor(
[[model.audio_locator_tag_id, model.audio_locator_tag_id, 10]], dtype=torch.long, device=device
),
"loss_mask": torch.tensor([[False, False, True]], dtype=torch.bool, device=device),
}
inputs = model.prepare_inputs(batch)
assert torch.equal(
inputs["input_embeds"][0, :, 0],
torch.tensor([1.0, 2.0, 3.0, 10.0, 11.0, 12.0, 13.0, 14.0], device=device),
)
@pytest.mark.parametrize("device", chunking_test_devices())
def test_salm_automodel_generate_chunks_audio_before_llm(device):
model = _make_chunking_test_model(encoder_chunk_size_seconds=1.0, sampling_rate=2, device=device)
spk_targets = torch.arange(10, dtype=torch.float32, device=device).reshape(1, 5, 2)
answer = model.generate(
prompts=torch.tensor([[model.audio_locator_tag_id, 10]], dtype=torch.long, device=device),
audios=torch.tensor([[1.0, 2.0, 3.0, 4.0, 5.0]], device=device),
audio_lens=torch.tensor([5], dtype=torch.long, device=device),
spk_targets=spk_targets,
max_new_tokens=3,
)
chunked_signal, chunked_lens = model.perception.calls[0]
assert chunked_signal.shape == (2, 3)
assert torch.equal(chunked_lens, torch.tensor([2, 3], dtype=torch.long, device=device))
assert torch.equal(
model.perception.spk_targets_calls[0],
torch.stack([torch.cat([spk_targets[0, :2], spk_targets[0, 1:2]]), spk_targets[0, 2:5]]),
)
assert torch.equal(
model.llm.generate_kwargs["inputs_embeds"][0, :5, 0],
torch.tensor([1.0, 2.0, 3.0, 4.0, 5.0], device=device),
)
assert answer.shape == (1, 3)
def _make_chunking_test_model(encoder_chunk_size_seconds, sampling_rate, device, hop_length=1):
model = SALMAutomodel.__new__(SALMAutomodel)
torch.nn.Module.__init__(model)
model.cfg = {"encoder_chunk_size_seconds": encoder_chunk_size_seconds}
model.audio_locator_tag = AUDIO_LOCATOR_TAG
model.tokenizer = ChunkingTestTokenizer(AUDIO_LOCATOR_TAG)
model.llm = _AutomodelChunkingTestLLM(device=device)
model.perception = ChunkingTestPerception(sampling_rate=sampling_rate, hop_length=hop_length)
model._use_tp = False
return model
class _AutomodelChunkingTestLLM(torch.nn.Module):
def __init__(self, device):
super().__init__()
self.model = torch.nn.Module()
self.model.embed_tokens = torch.nn.Embedding(128, 1, device=device)
self.generate_kwargs = None
with torch.no_grad():
self.model.embed_tokens.weight.zero_()
def generate(self, **kwargs):
self.generate_kwargs = kwargs
batch_size = kwargs["inputs_embeds"].shape[0]
max_new_tokens = kwargs["max_new_tokens"]
return torch.zeros((batch_size, max_new_tokens), dtype=torch.long, device=kwargs["inputs_embeds"].device)
@@ -0,0 +1,369 @@
# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Tests for SALM + Automodel LoRA integration."""
import os
import re
import pytest
import torch
from lhotse import CutSet, SupervisionSegment
from lhotse.testing.dummies import dummy_cut, dummy_recording
from omegaconf import DictConfig, OmegaConf
from nemo.collections.common.data.lhotse import NeMoMultimodalConversation
from nemo.collections.common.data.lhotse.text_adapters import AudioTurn, TextTurn
from nemo.collections.common.data.utils import move_data_to_device
from nemo.collections.common.prompts import PromptFormatter
from nemo.collections.speechlm2.data import SALMDataset
from nemo.collections.speechlm2.models import SALMAutomodel
from nemo.collections.speechlm2.parts.automodel_lora import (
LORA_PARAM_PATTERN,
ensure_lora_trainable,
make_peft_config,
maybe_install_lora,
)
if torch.cuda.is_available():
torch.set_default_device('cuda')
def resolve_pretrained_models():
if os.path.exists("/home/TestData/speechlm/pretrained_models"):
return {
"pretrained_llm": "/home/TestData/speechlm/pretrained_models/Qwen--Qwen3-1.7B",
"pretrained_asr": "/home/TestData/speechlm/pretrained_models/canary-1b-flash.nemo",
}
else:
return {
"pretrained_asr": "nvidia/canary-1b-flash",
"pretrained_llm": "Qwen/Qwen3-1.7B",
}
AUDIO_LOCATOR_TAG = "<|audioplaceholder|>"
PROMPT = "qwen"
LORA_CFG = {
"dim": 8,
"alpha": 16,
"dropout": 0.0,
"target_modules": ["q_proj", "v_proj"],
}
BASE_CFG = {
**resolve_pretrained_models(),
"pretrained_weights": False,
"prompt_format": PROMPT,
"audio_locator_tag": AUDIO_LOCATOR_TAG,
"freeze_params": ["^llm\\..+$", "^perception\\.preprocessor\\..+$", "^perception\\.encoder\\..+$"],
"prevent_freeze_params": [],
"lora": LORA_CFG,
"perception": {
"target": "nemo.collections.speechlm2.modules.perception.AudioPerceptionModule",
"output_dim": 2048,
"encoder": {
"_target_": "nemo.collections.asr.modules.ConformerEncoder",
"att_context_size": [-1, -1],
"causal_downsampling": False,
"conv_context_size": None,
"conv_kernel_size": 9,
"conv_norm_type": "batch_norm",
"d_model": 1024,
"dropout": 0.1,
"dropout_att": 0.1,
"dropout_emb": 0.0,
"dropout_pre_encoder": 0.1,
"feat_in": 128,
"feat_out": -1,
"ff_expansion_factor": 4,
"n_heads": 8,
"n_layers": 2,
"pos_emb_max_len": 5000,
"self_attention_model": "rel_pos",
"subsampling": "dw_striding",
"subsampling_conv_channels": 256,
"subsampling_factor": 8,
},
"modality_adapter": {
"_target_": "nemo.collections.speechlm2.modules.perception.IdentityConnector",
"d_model": 1024,
},
"preprocessor": {
"_target_": "nemo.collections.asr.modules.AudioToMelSpectrogramPreprocessor",
"dither": 1e-05,
"features": 128,
"frame_splicing": 1,
"log": True,
"n_fft": 512,
"normalize": "per_feature",
"pad_to": 0,
"pad_value": 0.0,
"sample_rate": 16000,
"window": "hann",
"window_size": 0.025,
"window_stride": 0.01,
},
},
"optimizer": {"_target_": "torch.optim.AdamW"},
"torch_dtype": "bfloat16",
}
# ---------------------------------------------------------------------------
# Unit tests for automodel_lora helpers (no model needed)
# ---------------------------------------------------------------------------
class TestMakePeftConfig:
def test_basic(self):
cfg = DictConfig({"dim": 32, "alpha": 64, "target_modules": ["q_proj"]})
pc = make_peft_config(cfg)
assert pc.dim == 32
assert pc.alpha == 64
assert pc.target_modules == ["*.q_proj"]
def test_defaults(self):
cfg = DictConfig({})
pc = make_peft_config(cfg)
assert pc is None
def test_all_fields(self):
cfg = DictConfig(
{
"dim": 16,
"alpha": 32,
"dropout": 0.1,
"target_modules": ["q_proj", "v_proj"],
"exclude_modules": ["lm_head"],
"match_all_linear": False,
"use_dora": True,
"dropout_position": "pre",
"lora_A_init": "kaiming_uniform",
"use_triton": False,
}
)
pc = make_peft_config(cfg)
assert pc.dim == 16
assert pc.use_dora is True
assert pc.dropout_position == "pre"
assert pc.lora_A_init == "kaiming_uniform"
assert pc.exclude_modules == ["*.lm_head"]
def test_short_names_get_wildcard_prefix(self):
"""Short leaf names like 'q_proj' should be auto-prefixed with '*.' so
that automodel's ModuleMatcher matches them against full dotted paths."""
cfg = DictConfig({"target_modules": ["q_proj", "v_proj"], "exclude_modules": ["lm_head"]})
pc = make_peft_config(cfg)
assert pc.target_modules == ["*.q_proj", "*.v_proj"]
assert pc.exclude_modules == ["*.lm_head"]
def test_already_qualified_names_unchanged(self):
"""Names that already contain '*' or '.' should not be double-prefixed."""
cfg = DictConfig({"target_modules": ["*.q_proj", "model.layers.*.self_attn.v_proj"]})
pc = make_peft_config(cfg)
assert pc.target_modules == ["*.q_proj", "model.layers.*.self_attn.v_proj"]
class TestEnsureLoraTrainable:
def _model_stub(self, cfg_dict):
class Stub:
pass
s = Stub()
s.cfg = DictConfig(cfg_dict)
return s
def test_creates_prevent_freeze_params(self):
model = self._model_stub({})
ensure_lora_trainable(model)
assert LORA_PARAM_PATTERN in model.cfg.prevent_freeze_params
def test_appends_to_existing(self):
model = self._model_stub({"prevent_freeze_params": ["^some_other$"]})
ensure_lora_trainable(model)
assert len(model.cfg.prevent_freeze_params) == 2
assert LORA_PARAM_PATTERN in model.cfg.prevent_freeze_params
def test_idempotent(self):
model = self._model_stub({"prevent_freeze_params": []})
ensure_lora_trainable(model)
ensure_lora_trainable(model)
assert model.cfg.prevent_freeze_params.count(LORA_PARAM_PATTERN) == 1
class TestLoraParamPattern:
"""Verify the regex matches typical LoRA parameter names."""
@pytest.mark.parametrize(
"name",
[
"llm.model.layers.0.self_attn.q_proj.lora_A.weight",
"llm.model.layers.0.self_attn.v_proj.lora_B.weight",
"llm.model.layers.31.mlp.gate_proj.lora_A.weight",
],
)
def test_matches_lora_params(self, name):
assert re.compile(LORA_PARAM_PATTERN).match(name)
@pytest.mark.parametrize(
"name",
[
"llm.model.layers.0.self_attn.q_proj.weight",
"perception.encoder.layers.0.weight",
"llm.lm_head.weight",
],
)
def test_does_not_match_base_params(self, name):
assert re.compile(LORA_PARAM_PATTERN).match(name) is None
# ---------------------------------------------------------------------------
# Integration tests (require CUDA — Automodel's from_config needs a GPU)
# ---------------------------------------------------------------------------
requires_cuda = pytest.mark.skipif(not torch.cuda.is_available(), reason="Automodel requires CUDA")
@pytest.fixture(scope="module")
def salm_with_lora():
if not torch.cuda.is_available():
pytest.skip("Automodel requires CUDA")
model = SALMAutomodel(BASE_CFG)
model.configure_model()
model.to("cuda")
return model
@pytest.fixture(scope="module")
def salm_without_lora():
if not torch.cuda.is_available():
pytest.skip("Automodel requires CUDA")
cfg = {k: v for k, v in BASE_CFG.items() if k != "lora"}
model = SALMAutomodel(cfg)
model.configure_model()
model.to("cuda")
return model
@pytest.fixture(scope="module")
def dataset(salm_with_lora):
return SALMDataset(salm_with_lora.tokenizer)
@pytest.fixture(scope="module")
def prompt_formatter(salm_with_lora):
return PromptFormatter.resolve(PROMPT)(salm_with_lora.tokenizer)
@pytest.fixture(scope="module")
def training_cutset_batch():
cut = dummy_cut(0, recording=dummy_recording(0, with_data=True))
cut.supervisions = [
SupervisionSegment(
id=cut.id, recording_id=cut.recording_id, start=0, duration=1.0, text='Some text transcription.'
)
]
return CutSet(
[
NeMoMultimodalConversation(
id="example-0",
turns=[
TextTurn(role="user", value="Repeat after me:"),
AudioTurn(role="user", cut=cut, audio_locator_tag=AUDIO_LOCATOR_TAG),
TextTurn(role="assistant", value=cut.supervisions[0].text),
],
token_equivalent_duration=0.08,
)
]
)
@requires_cuda
def test_lora_params_exist(salm_with_lora):
"""After configure_model with lora config, LLM should have lora_A/lora_B parameters."""
lora_params = {n for n, _ in salm_with_lora.named_parameters() if ".lora_" in n}
assert len(lora_params) > 0, "No LoRA parameters found"
# We targeted q_proj and v_proj, so each should have lora_A.weight and lora_B.weight
assert any("q_proj.lora_A" in n for n in lora_params)
assert any("q_proj.lora_B" in n for n in lora_params)
assert any("v_proj.lora_A" in n for n in lora_params)
assert any("v_proj.lora_B" in n for n in lora_params)
@requires_cuda
def test_no_lora_params_without_config(salm_without_lora):
"""Without lora config, no LoRA parameters should exist."""
lora_params = [n for n, _ in salm_without_lora.named_parameters() if ".lora_" in n]
assert len(lora_params) == 0
@requires_cuda
def test_lora_prevent_freeze_pattern_set(salm_with_lora):
"""The prevent_freeze_params list should include the LoRA pattern."""
assert LORA_PARAM_PATTERN in salm_with_lora.cfg.prevent_freeze_params
@requires_cuda
def test_lora_params_are_trainable(salm_with_lora):
"""LoRA parameters should have requires_grad=True."""
for name, param in salm_with_lora.named_parameters():
if ".lora_" in name:
assert param.requires_grad, f"LoRA param {name} is not trainable"
@requires_cuda
def test_base_llm_params_not_patched_elsewhere(salm_with_lora):
"""Only targeted modules (q_proj, v_proj) should have LoRA, not others like k_proj."""
lora_params = {n for n, _ in salm_with_lora.named_parameters() if ".lora_" in n}
for n in lora_params:
assert "q_proj" in n or "v_proj" in n, f"Unexpected LoRA param on non-targeted module: {n}"
@requires_cuda
def test_lora_training_step(salm_with_lora, dataset, prompt_formatter, training_cutset_batch):
training_cutset_batch = training_cutset_batch.map(lambda c: c.apply_prompt_format(prompt_formatter), apply_fn=None)
batch = dataset[training_cutset_batch]
batch = move_data_to_device(batch, device=salm_with_lora.device)
results = salm_with_lora.training_step(batch, batch_idx=0)
assert torch.is_tensor(results["loss"])
assert not torch.isnan(results["loss"])
assert results["loss"] > 0
@requires_cuda
def test_lora_generation(salm_with_lora):
answer = salm_with_lora.generate(
prompts=[
[{"role": "user", "slots": {"message": f"Repeat after me: {AUDIO_LOCATOR_TAG}"}}],
],
audios=torch.randn(1, 16000),
audio_lens=torch.tensor([16000]),
max_new_tokens=4,
)
assert answer.shape == (1, 4)
assert answer.dtype == torch.long
@requires_cuda
def test_lora_match_all_linear():
"""Test match_all_linear=True applies LoRA to all linear layers in the LLM."""
cfg = {**BASE_CFG}
cfg["lora"] = {"dim": 8, "alpha": 16, "match_all_linear": True}
model = SALMAutomodel(cfg)
model.configure_model()
lora_params = {n for n, _ in model.named_parameters() if ".lora_" in n}
# Should have LoRA on more than just q_proj/v_proj
modules_with_lora = {n.rsplit(".lora_", 1)[0] for n in lora_params}
assert len(modules_with_lora) > 2, f"match_all_linear should patch many modules, got: {modules_with_lora}"
@@ -0,0 +1,391 @@
# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""SALMAutomodel tests for the Parallel Expert Encoder (PEE) recipe.
Mirrors ``test_salm_automodel.py`` but swaps the plain ConformerEncoder for a
``ParallelExpertEncoder`` (streaming Sortformer diarizer + ASR Conformer, fused).
The tiny-but-real PE encoder is the same dummy bundle built in
``tests/collections/asr/test_parallel_expert_encoder.py`` (``build_toy_pe_encoder``);
this file mounts it onto ``model.perception.encoder`` exactly the way
``nemo.collections.speechlm2.parts.pretrained.setup_parallel_expert_encoder`` does
and exercises the SALM training / validation / generation path end to end, plus the
``spk_targets -> spk_targets`` routing that is unique to the PEE recipe.
"""
import importlib.util
import os
from types import SimpleNamespace
import pytest
import torch
from lhotse import CutSet, SupervisionSegment
from lhotse.testing.dummies import dummy_cut, dummy_recording
from omegaconf import DictConfig
from nemo.collections.asr.modules.parallel_expert_encoder import ParallelExpertEncoder
from nemo.collections.common.data.lhotse import NeMoMultimodalConversation
from nemo.collections.common.data.lhotse.text_adapters import AudioTurn, TextTurn
from nemo.collections.common.data.utils import move_data_to_device
from nemo.collections.common.prompts import PromptFormatter
from nemo.collections.speechlm2.data import SALMDataset
from nemo.collections.speechlm2.models import SALMAutomodel
# Reuse the toy PE encoder (and its dimensions) defined for the standalone
# ParallelExpertEncoder tests, so both suites share one dummy bundle definition.
from tests.collections.asr.test_parallel_expert_encoder import (
_ASR_D_MODEL,
_MEL_FEATURES,
_N_SPK,
_SUBSAMPLING_FACTOR,
build_toy_pe_encoder,
)
# SALMAutomodel.configure_model() pulls in the (gitignored) nemo_automodel package,
# so the full-model tests need both CUDA and that dependency to be importable.
# Build a precise skip reason that names only the piece that is actually missing
# (so a CUDA-equipped box without nemo_automodel doesn't get a misleading "requires CUDA").
_HAS_CUDA = torch.cuda.is_available()
_HAS_AUTOMODEL = importlib.util.find_spec("nemo_automodel") is not None
_MISSING = [name for name, ok in (("CUDA", _HAS_CUDA), ("the nemo_automodel package", _HAS_AUTOMODEL)) if not ok]
requires_cuda = pytest.mark.skipif(
bool(_MISSING),
reason="SALMAutomodel needs " + " and ".join(_MISSING) if _MISSING else "",
)
# NOTE: deliberately do NOT call torch.set_default_device('cuda') here. It is a
# global, process-wide mutation that leaks into other test modules collected in
# the same pytest session (e.g. the device-agnostic CPU tests in
# tests/collections/asr/test_parallel_expert_encoder.py), causing spurious
# cuda/cpu device-mismatch failures. CUDA tests below place their tensors on
# model.device explicitly instead.
def resolve_pretrained_models():
if os.path.exists("/home/TestData/speechlm/pretrained_models"):
# CI pre-cached paths:
return {
"pretrained_llm": "/home/TestData/speechlm/pretrained_models/Qwen--Qwen3-1.7B",
"pretrained_asr": "/home/TestData/speechlm/pretrained_models/canary-1b-flash.nemo",
}
else:
# HF URLs:
return {
"pretrained_asr": "nvidia/canary-1b-flash",
"pretrained_llm": "Qwen/Qwen3-1.7B",
}
AUDIO_LOCATOR_TAG = "<|audioplaceholder|>"
PROMPT = "qwen"
# Speaker-activity (SOT) target settings; num_speakers matches the PE encoder's n_spk.
SOT_CFG = {
"num_speakers": _N_SPK,
"sample_rate": 16000,
"window_stride": 0.01,
"subsampling_factor": _SUBSAMPLING_FACTOR,
"no_rttm_to_ones": True,
}
def mount_dummy_pe_encoder(model: SALMAutomodel) -> ParallelExpertEncoder:
"""Mount a dummy ParallelExpertEncoder onto ``model.perception.encoder``.
Replicates ``setup_parallel_expert_encoder`` without needing an on-disk
``.nemo`` bundle: it swaps the perception encoder for a tiny-but-real PE
encoder, disables the outer preprocessor normalization (the PE encoder
re-applies ASR normalization internally), and matches the model dtype/device.
"""
pe_encoder = build_toy_pe_encoder()
# The PE encoder consumes un-normalised mels; turn off the perception
# preprocessor's normalization just like the real mounting helper does.
model.perception.preprocessor.featurizer.normalize = None
model.perception.encoder = pe_encoder
target_dtype = next(model.llm.parameters()).dtype
model.perception.to(device=model.device, dtype=target_dtype)
return pe_encoder
@pytest.fixture(scope="session")
def model():
if _MISSING:
pytest.skip("SALMAutomodel needs " + " and ".join(_MISSING))
cfg = {
**resolve_pretrained_models(),
"pretrained_weights": False,
"prompt_format": PROMPT,
"audio_locator_tag": AUDIO_LOCATOR_TAG,
"perception": {
"target": "nemo.collections.speechlm2.modules.perception.AudioPerceptionModule",
"output_dim": 2048,
# Placeholder encoder; its only job is to define a d_model that matches
# the dummy PE encoder we mount in its place (mount validation checks this).
"encoder": {
"_target_": "nemo.collections.asr.modules.ConformerEncoder",
"att_context_size": [-1, -1],
"causal_downsampling": False,
"conv_context_size": None,
"conv_kernel_size": 9,
"conv_norm_type": "batch_norm",
"d_model": _ASR_D_MODEL,
"dropout": 0.0,
"dropout_att": 0.0,
"dropout_emb": 0.0,
"dropout_pre_encoder": 0.0,
"feat_in": _MEL_FEATURES,
"feat_out": -1,
"ff_expansion_factor": 4,
"n_heads": 4,
"n_layers": 1,
"pos_emb_max_len": 5000,
"self_attention_model": "rel_pos",
"subsampling": "dw_striding",
"subsampling_conv_channels": 16,
"subsampling_factor": _SUBSAMPLING_FACTOR,
},
"modality_adapter": {
"_target_": "nemo.collections.speechlm2.modules.perception.IdentityConnector",
"d_model": _ASR_D_MODEL,
},
"preprocessor": {
"_target_": "nemo.collections.asr.modules.AudioToMelSpectrogramPreprocessor",
"dither": 1e-05,
"features": _MEL_FEATURES,
"frame_splicing": 1,
"log": True,
"n_fft": 512,
"normalize": "per_feature",
"pad_to": 0,
"pad_value": 0.0,
"sample_rate": 16000,
"window": "hann",
"window_size": 0.025,
"window_stride": 0.01,
},
},
"optimizer": {"_target_": "torch.optim.AdamW"},
"torch_dtype": "bfloat16",
}
model = SALMAutomodel(cfg)
model.configure_model()
model.to("cuda")
mount_dummy_pe_encoder(model)
return model
@pytest.fixture(scope="session")
def dataset(model):
# SOT mode: each batch additionally carries RTTM-derived `spk_targets`.
return SALMDataset(model.tokenizer, multispeaker_cfg=SOT_CFG)
@pytest.fixture(scope="session")
def prompt_formatter(model):
return PromptFormatter.resolve(PROMPT)(model.tokenizer)
@pytest.fixture(scope="session")
def training_cutset_batch():
cut = dummy_cut(0, recording=dummy_recording(0, with_data=True))
cut.supervisions = [
SupervisionSegment(
id=cut.id, recording_id=cut.recording_id, start=0, duration=1.0, text='Some text transcription.'
)
]
return CutSet(
[
NeMoMultimodalConversation(
id="example-0",
turns=[
TextTurn(role="user", value="Repeat after me:"),
AudioTurn(role="user", cut=cut, audio_locator_tag=AUDIO_LOCATOR_TAG),
TextTurn(role="assistant", value=cut.supervisions[0].text),
],
token_equivalent_duration=0.08,
)
]
)
@requires_cuda
def test_salm_automodel_pee_uses_parallel_expert_encoder(model):
assert isinstance(model.perception.encoder, ParallelExpertEncoder)
assert model._uses_parallel_expert_encoder()
# The mounted PE encoder is a drop-in: its d_model drives the perception output projection.
assert model.perception.encoder.d_model == _ASR_D_MODEL
assert model.perception.encoder.n_spk == _N_SPK
# PE encoder consumes un-normalised mels; the outer preprocessor normalization is disabled.
assert model.perception.preprocessor.featurizer.normalize is None
@requires_cuda
def test_salm_automodel_pee_dataset_emits_speaker_targets(dataset, prompt_formatter, training_cutset_batch):
training_cutset_batch = training_cutset_batch.map(lambda c: c.apply_prompt_format(prompt_formatter), apply_fn=None)
batch = dataset[training_cutset_batch]
for key in ("audios", "audio_lens", "input_ids", "loss_mask", "spk_targets"):
assert key in batch
assert torch.is_tensor(batch[key])
# spk_targets: (B, T_target, num_speakers) -> last dim must match the PE encoder n_spk.
assert batch["spk_targets"].ndim == 3
assert batch["spk_targets"].shape[0] == batch["audios"].shape[0]
assert batch["spk_targets"].shape[-1] == _N_SPK
@requires_cuda
def test_salm_automodel_pee_training_step(model, dataset, prompt_formatter, training_cutset_batch):
training_cutset_batch = training_cutset_batch.map(lambda c: c.apply_prompt_format(prompt_formatter), apply_fn=None)
batch = dataset[training_cutset_batch]
assert "spk_targets" in batch # injected as spk_targets into the PE encoder during training
batch = move_data_to_device(batch, device=model.device)
results = model.training_step(batch, batch_idx=0)
assert torch.is_tensor(results["loss"])
assert not torch.isnan(results["loss"])
assert results["loss"] > 0
@requires_cuda
def test_salm_automodel_pee_validation_step(model, dataset, prompt_formatter, training_cutset_batch):
model.on_validation_epoch_start()
training_cutset_batch = training_cutset_batch.map(lambda c: c.apply_prompt_format(prompt_formatter), apply_fn=None)
batch = dataset[training_cutset_batch]
batch = move_data_to_device(batch, device=model.device)
# Validation ignores spk_targets and lets the PE encoder run its embedded Sortformer.
results = model.validation_step({"dummy_val_set": batch}, batch_idx=0)
assert results is None
@requires_cuda
def test_salm_automodel_pee_generation(model):
# No spk_targets at inference -> the PE encoder predicts diarization internally.
answer = model.generate(
prompts=[
[
{"role": "user", "slots": {"message": f"Repeat after me: {AUDIO_LOCATOR_TAG}"}},
]
],
audios=torch.randn(1, 16000, device=model.device),
audio_lens=torch.tensor([16000], device=model.device),
max_new_tokens=4,
)
assert answer.shape == (1, 4)
assert answer.dtype == torch.long
assert (answer >= 0).all()
assert (answer < model.text_vocab_size).all()
# ----------------------------------------------------------------------------- #
# CPU-only: spk_targets -> spk_targets routing in prepare_inputs.
#
# Uses a bare SALMAutomodel (no LLM/ASR download) with a stub perception whose
# `.encoder` is the real dummy ParallelExpertEncoder, so `_uses_parallel_expert_encoder`
# takes the PEE branch and we can assert how speaker targets are forwarded.
# ----------------------------------------------------------------------------- #
class _PEETestTokenizer:
pad = 0
unk_id = None
bos_id = 1
eos_id = 2
def __init__(self, audio_locator_tag):
self._audio_locator_tag = audio_locator_tag
def token_to_id(self, token):
assert token == self._audio_locator_tag
return 99
class _PEETestLLM(torch.nn.Module):
def __init__(self):
super().__init__()
self.model = torch.nn.Module()
self.model.embed_tokens = torch.nn.Embedding(128, 1)
with torch.no_grad():
self.model.embed_tokens.weight.zero_()
class _PEETestPerception(torch.nn.Module):
"""Stub perception that exposes a real PE encoder and records the spk_targets it receives."""
def __init__(self, pe_encoder: ParallelExpertEncoder):
super().__init__()
self.encoder = pe_encoder # real ParallelExpertEncoder -> drives the PEE branch
self.preprocessor = SimpleNamespace(featurizer=SimpleNamespace(sample_rate=16000, hop_length=160))
self.spk_targets_calls = []
def forward(self, input_signal=None, input_signal_length=None, spk_targets=None):
self.spk_targets_calls.append(spk_targets)
max_len = int(input_signal_length.max().item())
return input_signal[:, :max_len].unsqueeze(-1), input_signal_length.clone()
def _make_pee_routing_test_model(pe_encoder, cfg=None):
model = SALMAutomodel.__new__(SALMAutomodel)
torch.nn.Module.__init__(model)
model.cfg = DictConfig(cfg or {"encoder_chunk_size_seconds": None})
model.audio_locator_tag = AUDIO_LOCATOR_TAG
model.tokenizer = _PEETestTokenizer(AUDIO_LOCATOR_TAG)
model.llm = _PEETestLLM()
model.perception = _PEETestPerception(pe_encoder)
model._use_tp = False
return model
@pytest.fixture(scope="module")
def dummy_pe_encoder():
return build_toy_pe_encoder().eval()
@pytest.mark.unit
def test_pee_prepare_inputs_detects_parallel_expert_encoder(dummy_pe_encoder):
model = _make_pee_routing_test_model(dummy_pe_encoder)
assert model._uses_parallel_expert_encoder()
@pytest.mark.unit
def test_pee_prepare_inputs_routes_spk_targets_as_spk_targets(dummy_pe_encoder):
model = _make_pee_routing_test_model(dummy_pe_encoder)
spk_targets = torch.rand(1, 4, _N_SPK)
batch = {
"audios": torch.tensor([[1.0, 2.0, 3.0, 4.0, 5.0]]),
"audio_lens": torch.tensor([5], dtype=torch.long),
"input_ids": torch.tensor([[model.audio_locator_tag_id, 10]], dtype=torch.long),
"loss_mask": torch.tensor([[False, True]], dtype=torch.bool),
"spk_targets": spk_targets,
}
# RTTM-derived spk_targets are forwarded when present in the batch.
model.prepare_inputs(batch)
assert model.perception.spk_targets_calls[-1] is spk_targets
# No spk_targets key means the embedded Sortformer predicts speaker activity.
batch_without_spk_targets = {k: v for k, v in batch.items() if k != "spk_targets"}
model.prepare_inputs(batch_without_spk_targets)
assert model.perception.spk_targets_calls[-1] is None
@pytest.mark.unit
def test_pee_prepare_inputs_warns_for_experimental_inference_options(dummy_pe_encoder):
model = _make_pee_routing_test_model(
dummy_pe_encoder,
cfg={"pe_encoder_path": "/tmp/pee.nemo", "encoder_chunk_size_seconds": 30.0},
)
batch = {
"audios": torch.tensor([[1.0, 2.0, 3.0, 4.0, 5.0]]),
"audio_lens": torch.tensor([5], dtype=torch.long),
"input_ids": torch.tensor([[model.audio_locator_tag_id, 10]], dtype=torch.long),
"loss_mask": torch.tensor([[False, True]], dtype=torch.bool),
}
with pytest.warns(UserWarning, match="ParallelExpertEncoder inference path.*encoder_chunk_size_seconds"):
model.prepare_inputs(batch)
assert model.perception.spk_targets_calls[-1] is None
@@ -0,0 +1,201 @@
# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""CPU-only tests for the CP-helper module.
The ``cp_size > 1`` path in ``encode_audio_with_cp_distribution`` requires
a real ``torch.distributed`` process group; it's exercised by the 2-GPU
smoke. These tests cover the fallback contracts that run on every machine
(``cp_mesh is None``, ``B_aud == 0``).
"""
import pytest
import torch
from nemo.collections.speechlm2.parts.cp_helpers import encode_audio_with_cp_distribution, get_cp_mesh
def test_get_cp_mesh_none():
assert get_cp_mesh(None) == (None, 1, 0)
class _DummyCpDim:
"""Stand-in for ``device_mesh['cp']`` whose ``.size()`` is 1 (CP inactive)."""
def size(self):
return 1
class _DummyDeviceMesh:
"""Minimal ``DeviceMesh``-like object exposing only the bits ``get_cp_mesh`` reads."""
def __init__(self, cp_size: int = 1, has_cp: bool = True):
self.mesh_dim_names = ("dp", "cp", "tp") if has_cp else ("dp", "tp")
self._cp_size = cp_size
def __getitem__(self, key):
if key == "cp":
class _Dim:
def __init__(self, size):
self._size = size
def size(self):
return self._size
return _Dim(self._cp_size)
raise KeyError(key)
def test_get_cp_mesh_cp_size_one():
assert get_cp_mesh(_DummyDeviceMesh(cp_size=1)) == (None, 1, 0)
def test_get_cp_mesh_no_cp_dim():
assert get_cp_mesh(_DummyDeviceMesh(has_cp=False)) == (None, 1, 0)
class _PerceptionStub:
"""Stand-in for ``self.perception``: returns a deterministic embedding per audio."""
def __init__(self, hidden_size: int = 4):
self.hidden_size = hidden_size
self.spk_targets_calls = []
def __call__(self, *, input_signal, input_signal_length, spk_targets=None):
self.spk_targets_calls.append(None if spk_targets is None else spk_targets.detach().clone())
# Pretend each audio of length L produces L // 2 frames of embeddings;
# encode the row index into the first column so we can verify ordering.
B, T = input_signal.shape
if B == 0:
return torch.zeros(0, 0, self.hidden_size, dtype=torch.float32), input_signal_length
# Frame count per row scales with audio_lens.
out_lens = (input_signal_length // 2).clamp(min=1)
max_out = int(out_lens.max().item())
embs = torch.zeros(B, max_out, self.hidden_size, dtype=torch.float32)
for i in range(B):
embs[i, : int(out_lens[i].item()), 0] = float(i) # marker
return embs, out_lens
def test_encode_audio_no_cp_returns_unpadded_list():
perception = _PerceptionStub(hidden_size=4)
audios = torch.zeros(3, 1600, dtype=torch.float32)
audio_lens = torch.tensor([800, 1200, 1600], dtype=torch.long)
embs = encode_audio_with_cp_distribution(
perception,
audios,
audio_lens,
chunk_size_seconds=None,
sampling_rate=16000,
cp_mesh=None,
)
# 3 audios → 3 embedding tensors with row-specific lengths.
assert len(embs) == 3
assert perception.spk_targets_calls[-1] is None
expected_lens = [400, 600, 800]
for i, e in enumerate(embs):
assert e.shape == (expected_lens[i], 4)
# Marker preserved.
assert torch.all(e[:, 0] == float(i))
def test_encode_audio_empty_batch_returns_empty():
perception = _PerceptionStub()
audios = torch.zeros(0, 1600, dtype=torch.float32)
audio_lens = torch.zeros(0, dtype=torch.long)
embs = encode_audio_with_cp_distribution(
perception,
audios,
audio_lens,
chunk_size_seconds=None,
sampling_rate=16000,
cp_mesh=None,
)
assert embs == []
def test_encode_audio_no_cp_forwards_spk_targets():
perception = _PerceptionStub(hidden_size=4)
audios = torch.zeros(2, 1600, dtype=torch.float32)
audio_lens = torch.tensor([800, 1600], dtype=torch.long)
spk_targets = torch.arange(16, dtype=torch.float32).reshape(2, 4, 2)
encode_audio_with_cp_distribution(
perception,
audios,
audio_lens,
chunk_size_seconds=None,
sampling_rate=16000,
cp_mesh=None,
spk_targets=spk_targets,
)
assert torch.equal(perception.spk_targets_calls[-1], spk_targets)
class _FakeCpMesh:
def size(self):
return 2
def get_group(self):
return "fake-cp-group"
class _TrainablePerceptionStub(torch.nn.Module):
def __init__(self):
super().__init__()
self.scale = torch.nn.Parameter(torch.tensor(2.0))
self.spk_targets_calls = []
def forward(self, *, input_signal, input_signal_length, spk_targets=None):
self.spk_targets_calls.append(None if spk_targets is None else spk_targets.detach().clone())
B = input_signal.shape[0]
embs = input_signal[:, :2].unsqueeze(-1) * self.scale
lens = torch.full((B,), 2, dtype=input_signal_length.dtype, device=input_signal_length.device)
return embs, lens
def test_encode_audio_cp_distribution_preserves_local_autograd(monkeypatch):
perception = _TrainablePerceptionStub()
audios = torch.tensor([[1.0, 2.0, 0.0], [3.0, 4.0, 0.0]])
audio_lens = torch.tensor([3, 3], dtype=torch.long)
def fake_all_gather(local_stack, group):
assert group == "fake-cp-group"
remote_stack = torch.zeros_like(local_stack)
return (local_stack, remote_stack)
def fake_lens_all_gather(gathered_lens, local_lens, group):
assert group == "fake-cp-group"
gathered_lens[0].copy_(local_lens)
gathered_lens[1].fill_(2)
monkeypatch.setattr("nemo.collections.speechlm2.parts.cp_helpers.dist.get_rank", lambda group: 0)
monkeypatch.setattr("nemo.collections.speechlm2.parts.cp_helpers.dist.all_reduce", lambda *args, **kwargs: None)
monkeypatch.setattr("nemo.collections.speechlm2.parts.cp_helpers.dist.all_gather", fake_lens_all_gather)
monkeypatch.setattr("nemo.collections.speechlm2.parts.cp_helpers.differentiable_all_gather", fake_all_gather)
spk_targets = torch.arange(8, dtype=torch.float32).reshape(2, 2, 2)
embs = encode_audio_with_cp_distribution(
perception,
audios,
audio_lens,
chunk_size_seconds=None,
sampling_rate=16000,
cp_mesh=_FakeCpMesh(),
spk_targets=spk_targets,
)
assert torch.equal(perception.spk_targets_calls[-1], spk_targets[:1])
assert embs[0].requires_grad
embs[0].sum().backward()
assert perception.scale.grad is not None
assert perception.scale.grad.item() == pytest.approx(3.0)
@@ -0,0 +1,98 @@
# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import pytest
import torch
from lhotse import CutSet, SupervisionSegment
from lhotse.testing.dummies import dummy_cut, dummy_recording
import nemo.collections.speechlm2.data.salm_dataset as salm_dataset_module
from nemo.collections.common.data.lhotse import NeMoMultimodalConversation
from nemo.collections.common.data.lhotse.text_adapters import AudioTurn, TextTurn
class _Tokenizer:
pad = 0
unk_id = 1
@pytest.mark.unit
def test_salm_dataset_builds_aligned_sot_targets(monkeypatch):
text = "<spk:0> hello world <spk:1> yes now"
cut = dummy_cut(0, duration=0.04, recording=dummy_recording(0, duration=0.04, with_data=True))
cut.custom = {}
cut.supervisions = [
SupervisionSegment(id=cut.id, recording_id=cut.recording_id, start=0.0, duration=0.04, text=text)
]
conversation = NeMoMultimodalConversation(
id="example-0",
turns=[
AudioTurn(role="user", cut=cut, audio_locator_tag="<|audio|>", text=text),
TextTurn(role="assistant", value=text),
],
token_equivalent_duration=0.01,
)
conversation.input_ids = torch.tensor([7, 8, 9], dtype=torch.long)
conversation.mask = torch.tensor([False, True, True])
conversations = CutSet([conversation])
def fake_audio_collate(conversations, *args, **kwargs):
return torch.zeros(1, 640), torch.tensor([640], dtype=torch.long), conversations
def fake_speaker_activity_from_cut(cut, **kwargs):
assert cut.supervisions[0].text == text
return torch.tensor(
[
[0.0, 1.0],
[0.0, 1.0],
[1.0, 0.0],
[1.0, 0.0],
]
)
monkeypatch.setattr(salm_dataset_module, "collate_conversation_audio_fault_tolerant", fake_audio_collate)
monkeypatch.setattr(salm_dataset_module, "speaker_activity_from_cut", fake_speaker_activity_from_cut)
dataset = salm_dataset_module.SALMDataset(
tokenizer=_Tokenizer(),
multispeaker_cfg={
"num_speakers": 2,
"sample_rate": 16000,
"window_stride": 0.01,
"subsampling_factor": 1,
},
)
assert dataset.multispeaker_cfg == salm_dataset_module.MultiSpeakerConfig(
num_speakers=2,
num_sample_per_mel_frame=160,
num_mel_frame_per_target_frame=1,
)
assert dataset.multispeaker_cfg.num_sample_per_mel_frame == 160
batch = dataset[conversations]
assert torch.equal(
batch["spk_targets"],
torch.tensor(
[
[
[1.0, 0.0],
[1.0, 0.0],
[0.0, 1.0],
[0.0, 1.0],
]
]
),
)
assert torch.equal(batch["spk_target_length"], torch.tensor([4]))
@@ -0,0 +1,74 @@
# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from types import SimpleNamespace
import pytest
from omegaconf import DictConfig
from transformers import Qwen3Config, Qwen3ForCausalLM
def _make_qwen3_stub():
llm = Qwen3ForCausalLM(
Qwen3Config(
vocab_size=32,
hidden_size=16,
intermediate_size=32,
num_hidden_layers=1,
num_attention_heads=2,
num_key_value_heads=2,
max_position_embeddings=32,
)
)
return SimpleNamespace(
cfg=DictConfig(
{
"prevent_freeze_params": [],
"lora": {
"r": 4,
"lora_alpha": 8,
"lora_dropout": 0.0,
"target_modules": ["q_proj", "v_proj"],
"task_type": "CAUSAL_LM",
},
}
),
llm=llm,
embed_tokens=llm.model.embed_tokens,
)
def _import_peft_lora_dependencies():
"""Import peft-dependent symbols, skipping when CI has an older torchao stack."""
try:
from peft import PeftModel
from peft.tuners.lora.torchao import dispatch_torchao # noqa: F401
from nemo.collections.speechlm2.parts.lora import maybe_install_lora
except ImportError as e:
if "torchao" in str(e).lower():
pytest.skip(f"peft requires a newer torchao: {e}")
raise
return PeftModel, maybe_install_lora
def test_maybe_install_lora_restores_qwen3_input_embeddings_temporarily():
PeftModel, maybe_install_lora = _import_peft_lora_dependencies()
model = _make_qwen3_stub()
del model.llm.model.embed_tokens
maybe_install_lora(model)
assert isinstance(model.llm, PeftModel)
assert hasattr(model.llm.base_model.model.model.layers[0].self_attn.q_proj, "lora_A")
assert model.cfg.prevent_freeze_params == [r"^.+\.lora_.+$"]
assert not hasattr(model.llm.base_model.model.model, "embed_tokens")
@@ -0,0 +1,552 @@
# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import ast
from pathlib import Path
import pytest
import torch
from nemo.collections.speechlm2.parts.packed_sequences import pack_audio_into_text_embeds, prepare_packed_llm_inputs
PAD = 0
AUDIO = 100
REPO_ROOT = Path(__file__).parents[3]
def test_packed_sequences_does_not_import_speechlm2_models_globally():
source = REPO_ROOT / "nemo/collections/speechlm2/parts/packed_sequences.py"
tree = ast.parse(source.read_text())
bad_imports = []
for node in tree.body:
if (
isinstance(node, ast.ImportFrom)
and node.module
and node.module.startswith("nemo.collections.speechlm2.models")
):
bad_imports.append((node.lineno, node.module))
elif isinstance(node, ast.Import):
for alias in node.names:
if alias.name.startswith("nemo.collections.speechlm2.models"):
bad_imports.append((node.lineno, alias.name))
assert bad_imports == []
def _basic_batch():
"""Mirrors `test_audio_placeholders.py::test_replace_placeholders`.
Two utterances, three audio replacements of length 4, 3, 2.
"""
input_ids = torch.tensor(
[
[7, AUDIO, 1, 2, AUDIO, 1],
[PAD, PAD, 3, AUDIO, 4, 5], # left-padded
]
)
loss_mask = torch.tensor(
[
[False, False, False, False, False, True],
[False, False, False, False, True, True],
]
)
embeds = torch.ones(2, 6, 2)
embeds[1, :2] = 0 # zero left-pad slots
replacements = [
torch.full((4, 2), fill_value=2.0),
torch.full((3, 2), fill_value=3.0),
torch.full((2, 2), fill_value=4.0),
]
target_ids = input_ids.where(loss_mask, -100)
return input_ids, embeds, target_ids, replacements
def test_basic_pack_shapes_and_cu_seqlens():
input_ids, embeds, target_ids, replacements = _basic_batch()
out = pack_audio_into_text_embeds(
input_ids=input_ids,
embeds=embeds,
target_ids=target_ids,
replacements=replacements,
padding_id=PAD,
placeholder_id=AUDIO,
)
# Real per-utterance flat lengths:
# utt0: 1 text + 4 audio + 2 text + 3 audio + 1 text = 11
# utt1: 1 text + 2 audio + 2 text = 5 (left-pad of 2 stripped)
assert out["seq_lens"].squeeze(-1).tolist() == [11, 5]
# cp_size=1, tp_size=1 ⇒ no rounding
assert out["seq_lens_padded"].squeeze(-1).tolist() == [11, 5]
# cu_seqlens = [0] + cumsum(seq_lens_padded)
assert out["cu_seqlens"].dtype == torch.int32
assert out["cu_seqlens"].tolist() == [0, 11, 16]
assert out["max_seqlen"].item() == 11
assert out["qkv_format"] == "thd"
T_total = 11 + 5
assert out["inputs_embeds"].shape == (T_total, 2)
assert out["labels"].shape == (T_total,)
assert out["position_ids"].shape == (T_total,)
def test_position_ids_reset_per_utt():
input_ids, embeds, target_ids, replacements = _basic_batch()
out = pack_audio_into_text_embeds(
input_ids=input_ids,
embeds=embeds,
target_ids=target_ids,
replacements=replacements,
padding_id=PAD,
placeholder_id=AUDIO,
)
pos = out["position_ids"]
cu = out["cu_seqlens"].tolist()
for start, end in zip(cu[:-1], cu[1:]):
assert pos[start].item() == 0
assert torch.equal(pos[start:end], torch.arange(end - start, dtype=torch.long))
def test_audio_frame_labels_are_ignored():
"""Audio-frame slots must be -100 in `labels` regardless of loss_mask."""
input_ids, embeds, target_ids, replacements = _basic_batch()
out = pack_audio_into_text_embeds(
input_ids=input_ids,
embeds=embeds,
target_ids=target_ids,
replacements=replacements,
padding_id=PAD,
placeholder_id=AUDIO,
)
labels = out["labels"]
cu = out["cu_seqlens"].tolist()
# Utterance 0 layout: [t, a, a, a, a, t, t, a, a, a, t]
# pos 0 1 2 3 4 5 6 7 8 9 10
# Audio slots before shift: 1..4 and 7..9. After per-utt next-token shift,
# the *previous* slot's label becomes the audio target → also ignored once
# the original slot's label was -100. Verify all original audio slots map
# to -100 in the shifted output (audio at t means lab_shift[t-1] gets
# what was at t, which is -100 from the audio fill).
utt0 = labels[cu[0] : cu[1]]
# Shifted: original audio at positions 1-4 → label[0..3] should be -100;
# audio at 7-9 → label[6..8] should be -100; last slot (10) is -100.
assert (utt0[0:4] == -100).all()
assert (utt0[6:9] == -100).all()
assert utt0[-1].item() == -100
def test_labels_shifted_per_utt():
"""`labels[t]` should equal the original `target_ids` at position t+1
*within the utterance*, with the last slot set to -100."""
input_ids, embeds, target_ids, replacements = _basic_batch()
out = pack_audio_into_text_embeds(
input_ids=input_ids,
embeds=embeds,
target_ids=target_ids,
replacements=replacements,
padding_id=PAD,
placeholder_id=AUDIO,
)
labels = out["labels"]
cu = out["cu_seqlens"].tolist()
# Utt 0 last slot is the trailing text "1" with loss_mask=True (target=1),
# which after per-utt shift becomes the label of position 9 (the last
# audio frame). Since the shift puts orig[t+1] at slot t, the second-to-
# last slot of utt0 holds target_ids of the trailing "1".
utt0 = labels[cu[0] : cu[1]]
assert utt0[-2].item() == 1 # trailing text token "1" with loss_mask=True
assert utt0[-1].item() == -100 # last position of every utterance is -100
# Utt 1 last two text tokens (4, 5) had loss_mask=True. After per-utt
# shift, label[L-3] = 4, label[L-2] = 5, label[L-1] = -100.
utt1 = labels[cu[1] : cu[2]]
assert utt1[-3].item() == 4
assert utt1[-2].item() == 5
assert utt1[-1].item() == -100
def test_no_audio_utterance():
"""Utterance without any audio placeholders still packs correctly."""
input_ids = torch.tensor([[1, 2, 3, 4, 5]])
loss_mask = torch.tensor([[False, False, True, True, True]])
embeds = torch.full((1, 5, 2), 1.0)
target_ids = input_ids.where(loss_mask, -100)
out = pack_audio_into_text_embeds(
input_ids=input_ids,
embeds=embeds,
target_ids=target_ids,
replacements=[],
padding_id=PAD,
placeholder_id=AUDIO,
)
assert out["seq_lens"].squeeze(-1).tolist() == [5]
assert out["seq_lens_padded"].squeeze(-1).tolist() == [5]
assert out["cu_seqlens"].tolist() == [0, 5]
labels = out["labels"]
# Original target_ids = [-100, -100, 3, 4, 5]; after per-utt shift:
# [-100, 3, 4, 5, -100]
assert labels.tolist() == [-100, 3, 4, 5, -100]
def test_b_one():
"""Single-utterance batch produces valid `cu_seqlens=[0, L]`."""
input_ids = torch.tensor([[1, AUDIO, 2]])
loss_mask = torch.tensor([[False, False, True]])
embeds = torch.full((1, 3, 2), 1.0)
target_ids = input_ids.where(loss_mask, -100)
replacements = [torch.full((3, 2), 7.0)]
out = pack_audio_into_text_embeds(
input_ids=input_ids,
embeds=embeds,
target_ids=target_ids,
replacements=replacements,
padding_id=PAD,
placeholder_id=AUDIO,
)
assert out["seq_lens"].squeeze(-1).tolist() == [5] # 1 + 3 audio + 1
assert out["cu_seqlens"].tolist() == [0, 5]
assert out["inputs_embeds"].shape == (5, 2)
@pytest.mark.parametrize("cp_size", [2, 4])
def test_cp_divisibility(cp_size):
"""Each per-utt padded length is a multiple of 2*cp_size."""
input_ids, embeds, target_ids, replacements = _basic_batch()
out = pack_audio_into_text_embeds(
input_ids=input_ids,
embeds=embeds,
target_ids=target_ids,
replacements=replacements,
padding_id=PAD,
placeholder_id=AUDIO,
cp_size=cp_size,
)
mult = 2 * cp_size
for L in out["seq_lens_padded"].squeeze(-1).tolist():
assert L % mult == 0
def test_tp_divisibility():
"""`T_total` is a multiple of tp_size (last utterance gets the bump)."""
input_ids, embeds, target_ids, replacements = _basic_batch()
# real_lens = [11, 5], total = 16; pick tp_size=3 to force a bump.
out = pack_audio_into_text_embeds(
input_ids=input_ids,
embeds=embeds,
target_ids=target_ids,
replacements=replacements,
padding_id=PAD,
placeholder_id=AUDIO,
tp_size=3,
)
T_total = out["seq_lens_padded"].sum().item()
assert T_total % 3 == 0
# First utterance untouched (only the last gets the TP bump).
assert out["seq_lens_padded"].squeeze(-1).tolist()[0] == 11
# Last utterance bumped from 5 → 7 (next multiple of 3 after 16 is 18).
assert out["seq_lens_padded"].squeeze(-1).tolist()[1] == 7
def test_tp_and_cp_combined():
input_ids, embeds, target_ids, replacements = _basic_batch()
out = pack_audio_into_text_embeds(
input_ids=input_ids,
embeds=embeds,
target_ids=target_ids,
replacements=replacements,
padding_id=PAD,
placeholder_id=AUDIO,
cp_size=2,
tp_size=8,
)
padded = out["seq_lens_padded"].squeeze(-1).tolist()
real = out["seq_lens"].squeeze(-1).tolist()
# Every padded length ≥ real and divisible by 4.
for r, p in zip(real, padded):
assert p >= r
assert p % 4 == 0
# Total divisible by tp_size.
assert sum(padded) % 8 == 0
def test_tp_bump_preserves_cp_alignment_when_tp_is_not_cp_multiple():
input_ids = torch.tensor([[1, 2, 3, 4, 5], [6, 7, 8, 9, 10]])
loss_mask = torch.ones_like(input_ids, dtype=torch.bool)
embeds = torch.ones(2, 5, 2)
target_ids = input_ids.where(loss_mask, -100)
out = pack_audio_into_text_embeds(
input_ids=input_ids,
embeds=embeds,
target_ids=target_ids,
replacements=[],
padding_id=PAD,
placeholder_id=AUDIO,
cp_size=2,
tp_size=6,
)
padded = out["seq_lens_padded"].squeeze(-1).tolist()
assert padded == [8, 16]
for L in padded:
assert L % 4 == 0
assert sum(padded) % 6 == 0
def test_cu_seqlens_matches_padded_cumsum():
input_ids, embeds, target_ids, replacements = _basic_batch()
out = pack_audio_into_text_embeds(
input_ids=input_ids,
embeds=embeds,
target_ids=target_ids,
replacements=replacements,
padding_id=PAD,
placeholder_id=AUDIO,
cp_size=2,
tp_size=8,
)
expected = [0]
for L in out["seq_lens_padded"].squeeze(-1).tolist():
expected.append(expected[-1] + L)
assert out["cu_seqlens"].tolist() == expected
assert out["max_seqlen"].item() == max(out["seq_lens_padded"].squeeze(-1).tolist())
def test_loss_mask_propagates_to_minus_100():
"""Positions where loss_mask=False end up as -100 in the shifted labels."""
input_ids = torch.tensor([[1, 2, 3, 4]])
loss_mask = torch.tensor([[False, False, False, False]]) # nothing supervised
embeds = torch.full((1, 4, 2), 1.0)
target_ids = input_ids.where(loss_mask, -100)
out = pack_audio_into_text_embeds(
input_ids=input_ids,
embeds=embeds,
target_ids=target_ids,
replacements=[],
padding_id=PAD,
placeholder_id=AUDIO,
)
assert (out["labels"] == -100).all()
def test_prepare_packed_llm_inputs_attention_kwargs_reach_te_preprocessor():
"""End-to-end contract: ``prepare_packed_llm_inputs`` → Automodel's TE
attention preprocessor must yield ``max_seqlen_q``/``max_seqlen_kv`` and
``cu_seqlens_q``/``cu_seqlens_kv`` populated for the THD path.
Regression for: ``prepare_packed_llm_inputs`` previously emitted
``max_seqlen_q``/``max_seqlen_kv`` (plural), but the preprocessor only
inspects the singular ``max_seqlen`` key in the ``cu_seqlens`` branch
(``Automodel/.../attention/utils.py``). The plural keys were silently
dropped, TE fell back to a degenerate varlen path, and step-1 backward
produced NaN gradients that poisoned every subsequent step.
"""
automodel_attn = pytest.importorskip(
"nemo_automodel.components.attention.utils",
reason="Automodel attention preprocessor required for the contract test.",
)
input_ids, embeds, target_ids, replacements = _basic_batch()
out = prepare_packed_llm_inputs(
input_ids=input_ids,
text_embs=embeds,
audio_embs=replacements,
target_ids=target_ids,
padding_id=PAD,
placeholder_id=AUDIO,
device_mesh=None, # CP=1, TP=1 path
)
llm_kwargs = out["llm_kwargs"]
assert out["attention_mask"] is None
assert llm_kwargs["qkv_format"] == "thd"
assert "cu_seqlens" in llm_kwargs
assert "max_seqlen" in llm_kwargs, (
"Automodel's preprocessor only checks the singular `max_seqlen` key in the "
"cu_seqlens THD branch; pre-split `max_seqlen_q`/`max_seqlen_kv` would be dropped."
)
assert "cu_seqlens_padded" not in llm_kwargs, (
"Standard Automodel pipeline emits only `cu_seqlens`, never both `cu_seqlens` "
"and `cu_seqlens_padded`. Passing both activates the `pad_between_seqs=True` "
"branch in Automodel/.../attention/utils.py, routing TE down a different path."
)
# Run the LLM kwargs through the preprocessor exactly as the attention
# layer does: 4D BSHD-shaped Q/K/V plus attention_mask=None plus the
# llm_kwargs splatted in. ``input_embeds`` is now 2D ``[T, H]`` per the
# canonical Automodel THD shape contract.
B, T, H = 1, int(out["input_embeds"].shape[0]), 2
nh, hd = 2, 4
q = torch.zeros(B, T, nh, hd)
k = torch.zeros(B, T, nh, hd)
v = torch.zeros(B, T, nh, hd)
_, _, _, te_attn_kwargs = automodel_attn.preprocess_args_and_kwargs_for_attn(
q, k, v, attention_mask=None, attn_impl="te", **llm_kwargs
)
assert te_attn_kwargs.get("qkv_format") == "thd"
assert te_attn_kwargs.get("attn_mask_type") == "padding_causal"
assert "cu_seqlens_q" in te_attn_kwargs and "cu_seqlens_kv" in te_attn_kwargs
assert "max_seqlen_q" in te_attn_kwargs and "max_seqlen_kv" in te_attn_kwargs, (
"TE DotProductAttention requires max_seqlen_q/kv for qkv_format='thd'; "
"missing keys cause silent degenerate-path fallback and NaN gradients."
)
assert te_attn_kwargs["max_seqlen_q"] == llm_kwargs["max_seqlen"]
assert te_attn_kwargs["max_seqlen_kv"] == llm_kwargs["max_seqlen"]
def _bshd_supervised_pairs(input_ids, embeds, target_ids, replacements):
"""Run the BSHD path (``replace_placeholders_and_build_targets`` + the
``[:-1] / [1:]`` next-token shift used in
``SALMAutomodel.prepare_inputs``) and return the ordered list of
supervised ``(input_embedding, target_token_id)`` pairs.
"""
from nemo.collections.speechlm2.models.salm import replace_placeholders_and_build_targets
bshd_embs, bshd_targets, _ = replace_placeholders_and_build_targets(
input_ids=input_ids,
embeds=embeds,
padding_id=PAD,
placeholder_id=AUDIO,
replacements=[r.clone() for r in replacements],
target_ids=target_ids,
)
bshd_embs = bshd_embs[:, :-1]
bshd_targets = bshd_targets[:, 1:]
pairs = []
B, T = bshd_targets.shape
for b in range(B):
for t in range(T):
tgt = bshd_targets[b, t].item()
if tgt != -100:
pairs.append((bshd_embs[b, t].clone(), tgt))
return pairs
def _thd_supervised_pairs(input_ids, embeds, target_ids, replacements):
"""Run the THD path (``pack_audio_into_text_embeds`` with the per-utt
next-token shift) and return the ordered list of supervised
``(input_embedding, target_token_id)`` pairs.
"""
out = pack_audio_into_text_embeds(
input_ids=input_ids,
embeds=embeds,
target_ids=target_ids,
replacements=[r.clone() for r in replacements],
padding_id=PAD,
placeholder_id=AUDIO,
)
embs = out["inputs_embeds"] # [T_total, H]
labels = out["labels"] # [T_total]
pairs = []
for t in range(labels.shape[0]):
tgt = labels[t].item()
if tgt != -100:
pairs.append((embs[t].clone(), tgt))
return pairs
def _assert_pairs_equivalent(bshd_pairs, thd_pairs, *, atol=1e-6):
assert len(bshd_pairs) == len(thd_pairs), (
f"BSHD has {len(bshd_pairs)} supervised pairs, THD has {len(thd_pairs)}. "
f"Both paths must yield the same ordered set of (input, target) pairs."
)
for i, ((e_b, t_b), (e_t, t_t)) in enumerate(zip(bshd_pairs, thd_pairs)):
assert t_b == t_t, (
f"Pair {i}: target_id mismatch — BSHD={t_b}, THD={t_t}. "
f"Per-utt next-token shift must align with global [:-1]/[1:] shift."
)
assert torch.allclose(e_b, e_t, atol=atol), (
f"Pair {i} (target={t_b}): input embedding mismatch between BSHD and THD. " f"BSHD={e_b}, THD={e_t}"
)
def test_thd_and_bshd_supervised_pairs_match_basic():
"""First-principles invariant: BSHD and THD are different *layouts* of the
same data. The set of supervised ``(input_embedding, target_token_id)``
pairs that contribute to the cross-entropy loss must be identical between
paths. Any divergence in this set means the THD path is feeding the model
something the BSHD path is not (or vice-versa).
"""
input_ids, embeds, target_ids, replacements = _basic_batch()
bshd_pairs = _bshd_supervised_pairs(input_ids, embeds, target_ids, replacements)
thd_pairs = _thd_supervised_pairs(input_ids, embeds, target_ids, replacements)
_assert_pairs_equivalent(bshd_pairs, thd_pairs)
def test_thd_and_bshd_supervised_pairs_match_no_audio_utt():
"""Pure-text utterance (no audio_locator)."""
input_ids = torch.tensor([[1, 2, 3, 4, 5]])
loss_mask = torch.tensor([[False, False, True, True, True]])
embeds = torch.randn(1, 5, 4)
target_ids = input_ids.where(loss_mask, -100)
bshd_pairs = _bshd_supervised_pairs(input_ids, embeds, target_ids, replacements=[])
thd_pairs = _thd_supervised_pairs(input_ids, embeds, target_ids, replacements=[])
_assert_pairs_equivalent(bshd_pairs, thd_pairs)
def test_thd_and_bshd_supervised_pairs_match_left_padded():
"""Left-padded utterances must yield the same supervised pairs."""
input_ids = torch.tensor(
[
[PAD, PAD, PAD, 1, 2, AUDIO, 3, 4],
[PAD, 5, 6, AUDIO, 7, AUDIO, 8, 9],
]
)
loss_mask = torch.tensor(
[
[False, False, False, False, False, True, True, True],
[False, False, False, True, True, True, True, True],
]
)
embeds = torch.randn(2, 8, 4)
embeds[0, :3] = 0 # zero left-pad slots
embeds[1, :1] = 0
target_ids = input_ids.where(loss_mask, -100)
replacements = [
torch.randn(3, 4), # utt0 audio
torch.randn(2, 4), # utt1 first audio
torch.randn(4, 4), # utt1 second audio
]
bshd_pairs = _bshd_supervised_pairs(input_ids, embeds, target_ids, replacements)
thd_pairs = _thd_supervised_pairs(input_ids, embeds, target_ids, replacements)
_assert_pairs_equivalent(bshd_pairs, thd_pairs)
def test_thd_and_bshd_supervised_pairs_match_b1():
"""Single-utterance batch."""
input_ids = torch.tensor([[1, AUDIO, 2, 3, AUDIO, 4]])
loss_mask = torch.tensor([[False, False, True, True, True, True]])
embeds = torch.randn(1, 6, 4)
target_ids = input_ids.where(loss_mask, -100)
replacements = [torch.randn(2, 4), torch.randn(5, 4)]
bshd_pairs = _bshd_supervised_pairs(input_ids, embeds, target_ids, replacements)
thd_pairs = _thd_supervised_pairs(input_ids, embeds, target_ids, replacements)
_assert_pairs_equivalent(bshd_pairs, thd_pairs)
def test_padded_slots_have_zero_embed_and_ignored_label():
"""Inter-utt padding (added for cp_size rounding) gets zero embedding,
-100 label, and contiguous position_ids."""
input_ids, embeds, target_ids, replacements = _basic_batch()
out = pack_audio_into_text_embeds(
input_ids=input_ids,
embeds=embeds,
target_ids=target_ids,
replacements=replacements,
padding_id=PAD,
placeholder_id=AUDIO,
cp_size=2, # rounds 11→12 and 6→6
)
embs = out["inputs_embeds"]
labels = out["labels"]
pos = out["position_ids"]
# Utt 0 had real_len=11 and padded to 12 (next multiple of 4). Slot 11 is
# the pad slot.
assert torch.equal(embs[11], torch.zeros(2))
assert labels[11].item() == -100
assert pos[11].item() == 11 # contiguous with the utt's real positions
@@ -0,0 +1,154 @@
# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Unit tests for ``validate_parallelism_compatibility``.
Pure-function tests — no Lightning, no model, no device mesh required.
"""
import warnings
import pytest
from nemo.collections.speechlm2.parts.parallel import validate_parallelism_compatibility
# Combinations that must pass without raising or warning.
def test_bshd_cp1_te_passes():
validate_parallelism_compatibility(
packed_sequences=False,
cp_size=1,
attn_backend="te",
nvte_fused_attn=None,
device_capability=(9, 0), # H100
)
def test_bshd_cp1_sdpa_passes():
validate_parallelism_compatibility(
packed_sequences=False,
cp_size=1,
attn_backend="sdpa",
nvte_fused_attn=None,
device_capability=(9, 0),
)
def test_thd_cp1_te_with_fused_attn_off_passes():
validate_parallelism_compatibility(
packed_sequences=True,
cp_size=1,
attn_backend="te",
nvte_fused_attn="0",
device_capability=(12, 0), # sm_120 — still fine because env is set
)
def test_thd_cp2_te_with_fused_attn_off_passes():
validate_parallelism_compatibility(
packed_sequences=True,
cp_size=2,
attn_backend="te",
nvte_fused_attn="0",
device_capability=(12, 0),
)
# BSHD + CP > 1 — hard error regardless of other knobs.
@pytest.mark.parametrize("cp_size", [2, 4, 8])
def test_bshd_with_cp_raises(cp_size):
with pytest.raises(ValueError, match="BSHD .* incompatible with cp_size > 1"):
validate_parallelism_compatibility(
packed_sequences=False,
cp_size=cp_size,
attn_backend="te",
nvte_fused_attn="0",
device_capability=(9, 0),
)
# THD + non-TE attention — hard error.
@pytest.mark.parametrize("attn_backend", ["sdpa", "flex"])
def test_thd_with_non_te_attn_raises(attn_backend):
with pytest.raises(ValueError, match=r"THD.*requires.*attn=te"):
validate_parallelism_compatibility(
packed_sequences=True,
cp_size=1,
attn_backend=attn_backend,
nvte_fused_attn="0",
device_capability=(9, 0),
)
# THD + TE + NVTE_FUSED_ATTN unset — warns on non-sm_120, raises on sm_120.
@pytest.mark.parametrize("nvte_fused_attn", [None, "", "1", "true"])
def test_thd_te_without_fused_attn_off_warns_on_other_archs(nvte_fused_attn):
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
validate_parallelism_compatibility(
packed_sequences=True,
cp_size=1,
attn_backend="te",
nvte_fused_attn=nvte_fused_attn,
device_capability=(9, 0), # H100
)
assert len(caught) == 1
assert "NVTE_FUSED_ATTN" in str(caught[0].message)
@pytest.mark.parametrize("nvte_fused_attn", [None, "", "1", "true"])
def test_thd_te_without_fused_attn_off_raises_on_sm120(nvte_fused_attn):
with pytest.raises(ValueError, match="NVTE_FUSED_ATTN"):
validate_parallelism_compatibility(
packed_sequences=True,
cp_size=1,
attn_backend="te",
nvte_fused_attn=nvte_fused_attn,
device_capability=(12, 0), # sm_120
)
def test_thd_te_with_fused_attn_off_does_not_warn_on_sm120():
"""The escape-hatch case: user has the env set, no warning fires."""
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
validate_parallelism_compatibility(
packed_sequences=True,
cp_size=1,
attn_backend="te",
nvte_fused_attn="0",
device_capability=(12, 0),
)
assert len(caught) == 0
def test_unknown_device_capability_warns_not_raises():
"""``device_capability=None`` (CPU-only env) treats the THD/TE check as a warning."""
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
validate_parallelism_compatibility(
packed_sequences=True,
cp_size=1,
attn_backend="te",
nvte_fused_attn=None,
device_capability=None,
)
assert len(caught) == 1
assert "NVTE_FUSED_ATTN" in str(caught[0].message)
+260
View File
@@ -0,0 +1,260 @@
# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Unit tests for ``examples/speechlm2/to_hf.py::prepare_for_vllm``.
The script lives under ``examples/`` (not an importable package), so we load
it via ``importlib`` and patch ``AutoTokenizer`` / ``_detect_vllm_architecture``
to avoid any network or real-model dependencies.
"""
import importlib.util
import json
from pathlib import Path
from unittest.mock import patch
import pytest
import torch
from safetensors.torch import load_file
_TO_HF_PATH = Path(__file__).parents[3] / "examples" / "speechlm2" / "to_hf.py"
_spec = importlib.util.spec_from_file_location("to_hf_for_test", _TO_HF_PATH)
to_hf = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(to_hf)
AUDIO_TOKEN = "<|audio|>"
CHAT_TEMPLATE_INLINE = "{% for msg in messages %}{{msg.content}}{% endfor %}"
CHAT_TEMPLATE_LARGE = "{% for msg in messages %}" + "X" * 4096 + "{{msg.content}}{% endfor %}"
class _FakeTokenizer:
"""Minimal stand-in for an HF ``AutoTokenizer`` instance.
Mimics only the surface used by ``prepare_for_vllm``:
* ``get_vocab`` / ``add_special_tokens``
* ``save_pretrained`` (writes tokenizer_config.json, optionally
splitting a large chat_template into a separate .jinja file)
* ``eos_token_id``
"""
def __init__(
self,
*,
vocab_tokens=(),
chat_template=CHAT_TEMPLATE_INLINE,
split_chat_template=False,
tokenizer_class="Qwen2Tokenizer",
eos_token_id=42,
):
self._vocab = {tok: i for i, tok in enumerate(vocab_tokens)}
self._chat_template = chat_template
self._split_chat_template = split_chat_template
self._tokenizer_class = tokenizer_class
self.eos_token_id = eos_token_id
self.add_special_tokens_calls = []
def get_vocab(self):
return dict(self._vocab)
def add_special_tokens(self, special_tokens_dict):
added = special_tokens_dict.get("additional_special_tokens", [])
for tok in added:
if tok not in self._vocab:
self._vocab[tok] = len(self._vocab)
self.add_special_tokens_calls.append(added)
return len(added)
def save_pretrained(self, output_dir):
output_dir = Path(output_dir)
# Transformers writes extra_special_tokens as a LIST (not dict); mimic
# that here so the dict-form coercion in to_hf.py is exercised.
tok_cfg = {
"tokenizer_class": self._tokenizer_class,
"extra_special_tokens": [AUDIO_TOKEN],
}
if self._split_chat_template:
(output_dir / "chat_template.jinja").write_text(self._chat_template)
else:
tok_cfg["chat_template"] = self._chat_template
(output_dir / "tokenizer_config.json").write_text(json.dumps(tok_cfg))
(output_dir / "tokenizer.json").write_text('{"fake": true}')
def _seed_output_dir(tmp_path, llm_arch="Qwen2ForCausalLM"):
"""Pre-populate ``output_dir`` with what ``save_hf_checkpoint`` would write."""
(tmp_path / "config.json").write_text(
json.dumps(
{
"architectures": [llm_arch],
"hidden_size": 2048,
"num_hidden_layers": 24,
}
)
)
return tmp_path
class _FakeLLMConfig:
def save_pretrained(self, output_dir):
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
(output_dir / "config.json").write_text(
json.dumps(
{
"model_type": "qwen2",
"architectures": ["Qwen2ForCausalLM"],
"hidden_size": 2048,
}
)
)
class _FakeExportModel:
cfg = {
"pretrained_llm": "fake-model",
"pretrained_asr": "fake-asr",
"pretrained_weights": False,
"dtype": "bf16",
"torch_dtype": "bf16",
"audio_locator_tag": AUDIO_TOKEN,
}
llm = type("_FakeLLM", (), {"config": _FakeLLMConfig()})()
def test_save_hf_checkpoint_writes_llm_backbone_config(tmp_path):
cfg = to_hf.HfExportConfig(
class_path="fake.Class",
ckpt_path="fake.ckpt",
ckpt_config="fake.yaml",
output_dir=str(tmp_path),
dtype="bfloat16",
)
to_hf.save_hf_checkpoint(_FakeExportModel(), {"weight": torch.zeros(1)}, cfg)
root_cfg = json.loads((tmp_path / "config.json").read_text())
llm_cfg = json.loads((tmp_path / "llm_backbone" / "config.json").read_text())
assert "llm_config" not in root_cfg
assert root_cfg["pretrained_llm"] == "fake-model"
assert root_cfg["dtype"] == "bfloat16"
assert root_cfg["torch_dtype"] == "bfloat16"
assert llm_cfg["model_type"] == "qwen2"
assert llm_cfg["architectures"] == ["Qwen2ForCausalLM"]
assert _FakeExportModel.cfg["dtype"] == "bf16"
def test_save_hf_checkpoint_accepts_bf16_export_dtype(tmp_path):
cfg = to_hf.HfExportConfig(
class_path="fake.Class",
ckpt_path="fake.ckpt",
ckpt_config="fake.yaml",
output_dir=str(tmp_path),
dtype="bf16",
)
to_hf.save_hf_checkpoint(_FakeExportModel(), {"weight": torch.zeros(1)}, cfg)
root_cfg = json.loads((tmp_path / "config.json").read_text())
state_dict = load_file(tmp_path / "model.safetensors")
assert root_cfg["dtype"] == "bfloat16"
assert root_cfg["torch_dtype"] == "bfloat16"
assert state_dict["weight"].dtype == torch.bfloat16
# ──────────────────────────────────────────────────────────────────────
# Error paths (no mocking required — checks run before any HF calls)
# ──────────────────────────────────────────────────────────────────────
def test_prepare_for_vllm_missing_pretrained_llm(tmp_path):
with pytest.raises(ValueError, match="pretrained_llm"):
to_hf.prepare_for_vllm(str(tmp_path), {"audio_locator_tag": AUDIO_TOKEN})
def test_prepare_for_vllm_missing_audio_locator_tag(tmp_path):
with pytest.raises(ValueError, match="audio_locator_tag"):
to_hf.prepare_for_vllm(str(tmp_path), {"pretrained_llm": "fake-model"})
# ──────────────────────────────────────────────────────────────────────
# Happy paths (mock AutoTokenizer + _detect_vllm_architecture)
# ──────────────────────────────────────────────────────────────────────
def _run_prepare(tmp_path, fake_tok, arch="NeMoSpeechLMForConditionalGeneration", llm_arch="Qwen2ForCausalLM"):
output_dir = _seed_output_dir(tmp_path, llm_arch=llm_arch)
with (
patch.object(to_hf, "_detect_vllm_architecture", return_value=arch),
patch("transformers.AutoTokenizer.from_pretrained", return_value=fake_tok),
):
to_hf.prepare_for_vllm(
str(output_dir),
{"pretrained_llm": "fake-model", "audio_locator_tag": AUDIO_TOKEN},
)
return output_dir
def test_prepare_for_vllm_patches_config_json(tmp_path):
"""config.json gets model_type, architectures, and audio_locator_tag."""
output_dir = _run_prepare(tmp_path, _FakeTokenizer())
cfg = json.loads((output_dir / "config.json").read_text())
assert cfg["model_type"] == "nemo_speechlm"
assert cfg["architectures"] == ["NeMoSpeechLMForConditionalGeneration"]
assert cfg["audio_locator_tag"] == AUDIO_TOKEN
# Original LLM fields are preserved.
assert cfg["hidden_size"] == 2048
def test_prepare_for_vllm_adds_audio_token_to_vocab(tmp_path):
"""Audio token is registered via add_special_tokens when not already in vocab."""
fake_tok = _FakeTokenizer(vocab_tokens=["<|im_start|>", "<|im_end|>"])
_run_prepare(tmp_path, fake_tok)
assert fake_tok.add_special_tokens_calls == [[AUDIO_TOKEN]]
assert AUDIO_TOKEN in fake_tok.get_vocab()
def test_prepare_for_vllm_skips_add_if_audio_token_already_in_vocab(tmp_path):
"""Avoid re-adding a token that was already present in the backbone vocab."""
fake_tok = _FakeTokenizer(vocab_tokens=["<|im_start|>", "<|im_end|>", AUDIO_TOKEN])
_run_prepare(tmp_path, fake_tok)
assert fake_tok.add_special_tokens_calls == []
def test_prepare_for_vllm_tokenizer_config_normalized(tmp_path):
"""tokenizer_config.json has dict-form extra_special_tokens + forced tokenizer_class."""
output_dir = _run_prepare(tmp_path, _FakeTokenizer(tokenizer_class="TokenizersBackend"))
tok_cfg = json.loads((output_dir / "tokenizer_config.json").read_text())
assert tok_cfg["tokenizer_class"] == "PreTrainedTokenizerFast"
assert tok_cfg["extra_special_tokens"] == {"audio_token": AUDIO_TOKEN}
def test_prepare_for_vllm_preserves_inline_chat_template_verbatim(tmp_path):
"""No enable_thinking patching: chat_template is byte-identical after prep."""
output_dir = _run_prepare(tmp_path, _FakeTokenizer(chat_template=CHAT_TEMPLATE_INLINE))
tok_cfg = json.loads((output_dir / "tokenizer_config.json").read_text())
assert tok_cfg["chat_template"] == CHAT_TEMPLATE_INLINE
def test_prepare_for_vllm_rescues_chat_template_jinja_file(tmp_path):
"""Qwen3-style: chat_template split to .jinja file → inlined + file removed."""
fake_tok = _FakeTokenizer(chat_template=CHAT_TEMPLATE_LARGE, split_chat_template=True)
output_dir = _run_prepare(tmp_path, fake_tok)
tok_cfg = json.loads((output_dir / "tokenizer_config.json").read_text())
assert tok_cfg["chat_template"] == CHAT_TEMPLATE_LARGE
assert not (output_dir / "chat_template.jinja").exists()
def test_prepare_for_vllm_generation_config(tmp_path):
"""generation_config.json gets written with the tokenizer's eos_token_id."""
output_dir = _run_prepare(tmp_path, _FakeTokenizer(eos_token_id=99))
gen_cfg = json.loads((output_dir / "generation_config.json").read_text())
assert gen_cfg == {"eos_token_id": [99]}
@@ -0,0 +1,169 @@
# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Drift check for the vLLM plugin's audio-token estimator.
The plugin hand-rolls
``NeMoSpeechLMProcessingInfo._estimate_audio_tokens`` in pure Python for
speed (~90x faster than the equivalent tensor-ops path). Accuracy
matters: the result drives how many ``<|audio|>`` placeholders the
prompt processor inserts AND the encoder's real output frame count at
forward time; a mismatch breaks placeholder-to-feature alignment.
This test locks the hand-rolled math to NeMo's ``calc_length`` on a
canonical set of audio lengths. If FastConformer's subsampling ever
changes upstream (different kernel/stride/repeat), this test fails and
forces an update to the estimator.
"""
import pytest
import torch
pytest.importorskip("vllm")
from nemo.collections.asr.parts.submodules.subsampling import calc_length
from nemo.collections.speechlm2.vllm.salm.audio import (
_DUMMY_AUDIO_MAX_DURATION_S,
_MIN_CHUNK_SIZE_SAMPLES,
_SAMPLING_RATE,
NeMoSpeechLMProcessingInfo,
)
def _reference(audio_length_samples: int) -> int:
"""Reference impl using NeMo's calc_length for the conv chain."""
n_fft = 512
hop_length = 160
stft_pad = n_fft // 2
fbank_len = (audio_length_samples + 2 * stft_pad - n_fft) // hop_length
out = calc_length(
torch.tensor([fbank_len], dtype=torch.float),
all_paddings=2,
kernel_size=3,
stride=2,
ceil_mode=False,
repeat_num=3,
)
return max(1, int(out.item()))
@pytest.mark.parametrize(
"samples",
[
1_600, # 0.1 s
16_000, # 1 s
80_000, # 5 s
160_000, # 10 s
320_000, # 20 s
640_000, # 40 s, the typical max
12_345, # arbitrary small
54_321, # arbitrary mid
100_001, # arbitrary (odd)
],
)
def test_estimator_matches_calc_length(samples: int) -> None:
ours = NeMoSpeechLMProcessingInfo._estimate_audio_tokens(samples)
ref = _reference(samples)
assert ours == ref, (
f"audio_token estimator diverged from NeMo calc_length for "
f"samples={samples}: ours={ours}, ref={ref}. "
f"Check if FastConformer's subsampling stack changed upstream."
)
def test_estimator_min_one() -> None:
"""Even for very short audio the estimator must return at least 1."""
assert NeMoSpeechLMProcessingInfo._estimate_audio_tokens(1) >= 1
def test_estimator_chunking_disabled_matches_single_pass() -> None:
"""``chunk_size_seconds=None`` must match the legacy single-pass estimate."""
samples = 30 * 16_000
assert NeMoSpeechLMProcessingInfo._estimate_audio_tokens(
samples, chunk_size_seconds=None
) == NeMoSpeechLMProcessingInfo._estimate_audio_tokens_single_pass(samples)
def test_estimator_short_audio_falls_back_to_single_pass() -> None:
"""Audio shorter than the chunk size collapses to a single forward."""
samples = 5 * 16_000
assert NeMoSpeechLMProcessingInfo._estimate_audio_tokens(
samples, chunk_size_seconds=30.0
) == NeMoSpeechLMProcessingInfo._estimate_audio_tokens_single_pass(samples)
def test_estimator_chunked_sums_per_chunk_frames() -> None:
"""Long audio is split into chunks and per-chunk frame counts are summed,
matching ``encode_audio_with_optional_chunking``'s concat behavior."""
samples = 90 * 16_000
chunk_size_seconds = 30.0
chunk_samples = int(round(chunk_size_seconds * 16_000))
expected = sum(
NeMoSpeechLMProcessingInfo._estimate_audio_tokens_single_pass(min(chunk_samples, samples - i))
for i in range(0, samples, chunk_samples)
)
assert (
NeMoSpeechLMProcessingInfo._estimate_audio_tokens(samples, chunk_size_seconds=chunk_size_seconds) == expected
)
def test_estimator_chunked_tail_folded_into_previous_chunk() -> None:
"""A tiny tail (< min chunk size) is folded into the previous chunk so
the total token count matches the runtime helper instead of producing a
spurious single-frame chunk that the audio preprocessor would reject."""
chunk_size_seconds = 30.0
chunk_samples = int(round(chunk_size_seconds * 16_000))
samples = chunk_samples + 100 # 100 sample tail < min_chunk_size_samples (320)
# Folded: one chunk of `samples` samples (no split).
expected = NeMoSpeechLMProcessingInfo._estimate_audio_tokens_single_pass(samples)
assert (
NeMoSpeechLMProcessingInfo._estimate_audio_tokens(samples, chunk_size_seconds=chunk_size_seconds) == expected
)
def test_estimator_clamps_tiny_chunk_size_to_min_samples() -> None:
assert _MIN_CHUNK_SIZE_SAMPLES == 320
chunk_size_seconds = 1 / _SAMPLING_RATE
samples = 2 * _MIN_CHUNK_SIZE_SAMPLES + 100
expected = NeMoSpeechLMProcessingInfo._estimate_audio_tokens_single_pass(
_MIN_CHUNK_SIZE_SAMPLES
) + NeMoSpeechLMProcessingInfo._estimate_audio_tokens_single_pass(_MIN_CHUNK_SIZE_SAMPLES + 100)
assert (
NeMoSpeechLMProcessingInfo._estimate_audio_tokens(samples, chunk_size_seconds=chunk_size_seconds) == expected
)
def test_estimator_negative_chunk_size_raises() -> None:
with pytest.raises(ValueError, match="encoder_chunk_size_seconds"):
NeMoSpeechLMProcessingInfo._estimate_audio_tokens(16_000, chunk_size_seconds=-1.0)
@pytest.mark.parametrize("chunk_size_seconds", [None, 30.0])
def test_samples_for_audio_tokens_returns_minimum_sample_count(chunk_size_seconds: float | None) -> None:
target_tokens = 17
samples = NeMoSpeechLMProcessingInfo._samples_for_audio_tokens(target_tokens, chunk_size_seconds)
assert NeMoSpeechLMProcessingInfo._estimate_audio_tokens(samples, chunk_size_seconds) >= target_tokens
assert NeMoSpeechLMProcessingInfo._estimate_audio_tokens(samples - 1, chunk_size_seconds) < target_tokens
def test_samples_for_audio_tokens_rejects_unreachable_target() -> None:
max_samples = int(_DUMMY_AUDIO_MAX_DURATION_S * _SAMPLING_RATE)
max_tokens = NeMoSpeechLMProcessingInfo._estimate_audio_tokens(max_samples)
with pytest.raises(ValueError, match="Cannot produce"):
NeMoSpeechLMProcessingInfo._samples_for_audio_tokens(max_tokens + 1)
@@ -0,0 +1,605 @@
# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Unit tests for the vLLM NeMo Speech LM (SALM) plugin.
Covers plugin registration, config loading + escape-hatch wiring, special
token handling, and backend selection -- without requiring GPU or model
weights.
"""
import importlib.util
from types import SimpleNamespace
import pytest
try:
from nemo.collections.speechlm2.vllm.salm import config as _config_module
NeMoSpeechLMConfig = _config_module.NeMoSpeechLMConfig
_HAS_CONFIG = True
except (ImportError, RuntimeError):
_HAS_CONFIG = False
_HAS_VLLM = importlib.util.find_spec("vllm") is not None
_DEFAULT_CONFIG_KWARGS = {
"pretrained_llm": "nvidia/NVIDIA-Nemotron-3-Nano-30B-A3B-BF16",
"pretrained_asr": "nvidia/canary-1b-v2",
"audio_locator_tag": "<|audio|>",
"prompt_format": "nemotron-nano-v3",
"pretrained_weights": True,
}
@pytest.mark.skipif(not _HAS_CONFIG, reason="NeMoSpeechLMConfig not available")
class TestNeMoSpeechLMConfig:
"""Tests for NeMoSpeechLMConfig."""
@pytest.fixture(autouse=True)
def mock_backbone_config(self, monkeypatch):
def from_pretrained(model_name: str, trust_remote_code: bool = True):
if "Nemotron" in model_name:
return SimpleNamespace(
architectures=["NemotronHybridForCausalLM"],
hidden_size=2048,
vocab_size=131072,
num_hidden_layers=4,
num_key_value_heads=2,
layer_norm_epsilon=1e-5,
)
return SimpleNamespace(
architectures=["Qwen3ForCausalLM"],
hidden_size=2048,
vocab_size=151936,
num_hidden_layers=4,
rms_norm_eps=1e-6,
)
monkeypatch.setattr(_config_module.AutoConfig, "from_pretrained", from_pretrained)
def test_model_type(self):
assert NeMoSpeechLMConfig.model_type == "nemo_speechlm"
def test_default_construction_for_hf_serialization(self):
"""HF internally constructs a no-arg config when serializing configs."""
cfg = NeMoSpeechLMConfig()
assert cfg.pretrained_llm is None
assert cfg.pretrained_asr is None
assert cfg.audio_locator_tag is None
assert cfg.prompt_format is None
assert cfg.pretrained_weights is None
assert cfg.llm_architectures == []
assert cfg.get_text_config() is cfg.text_config
def test_loads_text_config(self):
"""Config should load a text_config from the pretrained LLM."""
cfg = NeMoSpeechLMConfig(**_DEFAULT_CONFIG_KWARGS)
assert cfg.text_config is not None
assert hasattr(cfg.text_config, "hidden_size")
assert cfg.get_text_config() is cfg.text_config
def test_hybrid_backbone_aliases_for_vllm(self):
cfg = NeMoSpeechLMConfig(**_DEFAULT_CONFIG_KWARGS)
assert cfg.is_hybrid is True
assert cfg.llm_architectures == ["NemotronHForCausalLM"]
assert cfg.text_config.total_num_kv_heads == cfg.text_config.num_key_value_heads
assert cfg.text_config.rms_norm_eps == cfg.text_config.layer_norm_epsilon
@pytest.mark.parametrize(
"architectures, expected_is_hybrid",
[
(["NemotronHForCausalLM"], True),
(["NemotronHybridForCausalLM"], True),
(["Qwen3ForCausalLM"], False),
(["LlamaForCausalLM"], False),
(["Qwen2ForCausalLM"], False),
],
)
def test_is_hybrid_backend_helper(self, architectures, expected_is_hybrid):
"""``_is_hybrid_backend`` should match the documented hybrid allow-list."""
from nemo.collections.speechlm2.vllm.salm.config import _is_hybrid_backend
assert _is_hybrid_backend(architectures) is expected_is_hybrid
@pytest.mark.parametrize(
"backbone_archs, expected_is_hybrid",
[
(["NemotronHForCausalLM"], True),
(["NemotronHybridForCausalLM"], True),
(["Qwen3ForCausalLM"], False),
],
)
def test_is_hybrid_set_from_backbone_architectures(self, monkeypatch, backbone_archs, expected_is_hybrid):
"""``cfg.is_hybrid`` is driven by the backbone HF config's ``architectures``."""
def from_pretrained(model_name: str, trust_remote_code: bool = True):
kwargs = dict(
architectures=backbone_archs,
hidden_size=2048,
vocab_size=131072,
num_hidden_layers=4,
)
if expected_is_hybrid:
kwargs.update(num_key_value_heads=2, layer_norm_epsilon=1e-5)
else:
kwargs.update(rms_norm_eps=1e-6)
return SimpleNamespace(**kwargs)
monkeypatch.setattr(_config_module.AutoConfig, "from_pretrained", from_pretrained)
cfg = NeMoSpeechLMConfig(**_DEFAULT_CONFIG_KWARGS)
assert cfg.is_hybrid is expected_is_hybrid
def test_hybrid_backbone_does_not_set_layer_types_shim(self):
"""Hybrid backbones must NOT have layer_types overridden -- the runtime
is_hybrid escape hatch only fires when every layer is 'attention'."""
cfg = NeMoSpeechLMConfig(**_DEFAULT_CONFIG_KWARGS)
assert cfg.is_hybrid is True
assert getattr(cfg.text_config, "layer_types", None) is None
def test_transformer_backbone_engages_layer_types_shim(self):
"""Non-hybrid backbones get layer_types=['attention']*N so vLLM's
ModelConfig.is_hybrid property returns False at runtime even though
the model class declares IsHybrid (needed for NemotronH path)."""
cfg = NeMoSpeechLMConfig(
**{
**_DEFAULT_CONFIG_KWARGS,
"pretrained_llm": "Qwen/Qwen3-1.7B",
}
)
assert cfg.is_hybrid is False
assert cfg.text_config.layer_types == ["attention"] * 4
def test_custom_pretrained_llm(self):
"""Config should accept different LLM backbones."""
cfg = NeMoSpeechLMConfig(
**{
**_DEFAULT_CONFIG_KWARGS,
"pretrained_llm": "Qwen/Qwen3-1.7B",
}
)
assert cfg.pretrained_llm == "Qwen/Qwen3-1.7B"
assert cfg.text_config is not None
assert cfg.llm_architectures == ["Qwen3ForCausalLM"]
def test_audio_locator_tag_default_accepted(self):
cfg = NeMoSpeechLMConfig(**_DEFAULT_CONFIG_KWARGS)
assert cfg.audio_locator_tag == "<|audio|>"
def test_audio_locator_tag_custom_rejected(self):
"""Plugin only supports ``<|audio|>``; mismatched checkpoints fail at load time."""
with pytest.raises(ValueError, match="audio_locator_tag"):
NeMoSpeechLMConfig(
**{
**_DEFAULT_CONFIG_KWARGS,
"audio_locator_tag": "<|custom_audio|>",
}
)
@pytest.mark.parametrize(
"field",
[
"pretrained_llm",
"pretrained_asr",
"audio_locator_tag",
"prompt_format",
"pretrained_weights",
],
)
def test_required_exported_fields(self, field):
kwargs = dict(_DEFAULT_CONFIG_KWARGS)
kwargs.pop(field)
with pytest.raises(ValueError, match=field):
NeMoSpeechLMConfig(**kwargs)
def test_unknown_attr_raises(self):
cfg = NeMoSpeechLMConfig(**_DEFAULT_CONFIG_KWARGS)
with pytest.raises(AttributeError):
_ = cfg.nonexistent_attribute_xyz
def test_encoder_chunk_size_seconds_default_none(self):
"""Legacy checkpoints without a chunk size keep the single-pass encoder path."""
cfg = NeMoSpeechLMConfig(**_DEFAULT_CONFIG_KWARGS)
assert cfg.encoder_chunk_size_seconds is None
def test_encoder_chunk_size_seconds_round_trips(self):
"""Chunk size set in config.json (e.g. SALMAutomodel default 30 s) survives load."""
cfg = NeMoSpeechLMConfig(
**{
**_DEFAULT_CONFIG_KWARGS,
"encoder_chunk_size_seconds": 30.0,
}
)
assert cfg.encoder_chunk_size_seconds == 30.0
def test_encoder_chunk_size_seconds_default_init_inert(self):
"""No-arg default init must still expose ``encoder_chunk_size_seconds=None``."""
cfg = NeMoSpeechLMConfig()
assert cfg.encoder_chunk_size_seconds is None
@pytest.mark.skipif(not (_HAS_CONFIG and _HAS_VLLM), reason="NeMoSpeechLMConfig or vLLM not available")
class TestBackendSelection:
"""Tests for ``backends.make_backend`` dispatch on hybrid/transformer configs."""
@pytest.fixture(autouse=True)
def mock_backbone_config(self, monkeypatch):
def from_pretrained(model_name: str, trust_remote_code: bool = True):
if "Nemotron" in model_name:
return SimpleNamespace(
architectures=["NemotronHybridForCausalLM"],
hidden_size=2048,
vocab_size=131072,
num_hidden_layers=4,
num_key_value_heads=2,
layer_norm_epsilon=1e-5,
)
return SimpleNamespace(
architectures=["Qwen3ForCausalLM"],
hidden_size=2048,
vocab_size=151936,
num_hidden_layers=4,
rms_norm_eps=1e-6,
)
monkeypatch.setattr(_config_module.AutoConfig, "from_pretrained", from_pretrained)
def test_hybrid_config_picks_hybrid_backend(self):
from nemo.collections.speechlm2.vllm.salm.backends import HybridBackend, make_backend
cfg = NeMoSpeechLMConfig(**_DEFAULT_CONFIG_KWARGS)
backend = make_backend(cfg)
assert isinstance(backend, HybridBackend)
assert backend.architectures() == ["NemotronHForCausalLM"]
def test_transformer_config_picks_transformer_backend(self):
from nemo.collections.speechlm2.vllm.salm.backends import TransformerBackend, make_backend
cfg = NeMoSpeechLMConfig(
**{
**_DEFAULT_CONFIG_KWARGS,
"pretrained_llm": "Qwen/Qwen3-1.7B",
}
)
backend = make_backend(cfg)
assert isinstance(backend, TransformerBackend)
assert backend.architectures() == ["Qwen3ForCausalLM"]
@pytest.mark.skipif(not _HAS_VLLM, reason="vLLM not installed")
class TestSpecialTokens:
"""Tests for special token handling."""
def test_adds_missing_token(self):
from unittest.mock import MagicMock
from nemo.collections.speechlm2.vllm.salm.audio import _ensure_special_tokens
tokenizer = MagicMock()
tokenizer.get_vocab.return_value = {}
_ensure_special_tokens(tokenizer)
tokenizer.add_special_tokens.assert_called_once()
def test_skips_existing_token(self):
from unittest.mock import MagicMock
from nemo.collections.speechlm2.vllm.salm.audio import _ensure_special_tokens
tokenizer = MagicMock()
tokenizer.get_vocab.return_value = {"<|audio|>": 99}
_ensure_special_tokens(tokenizer)
tokenizer.add_special_tokens.assert_not_called()
def test_placeholder_str(self):
from nemo.collections.speechlm2.vllm.salm.model import NeMoSpeechLMForConditionalGeneration
assert NeMoSpeechLMForConditionalGeneration.get_placeholder_str("audio", 0) == "<|audio|>"
assert NeMoSpeechLMForConditionalGeneration.get_placeholder_str("image", 0) is None
@pytest.mark.skipif(not _HAS_VLLM, reason="vLLM not installed")
class TestAudioProcessing:
"""Tests for audio encoding with a tiny perception module."""
def test_data_parser_normalizes_audio(self, monkeypatch):
from nemo.collections.speechlm2.vllm.salm.audio import NeMoSpeechLMProcessingInfo
info = object.__new__(NeMoSpeechLMProcessingInfo)
monkeypatch.setattr(info, "_get_expected_hidden_size", lambda: 2048)
parser = info.get_data_parser()
assert parser.audio_resampler.target_sr == 16000
assert parser.target_channels == 1
def test_processing_info_has_no_audio_duration_limit(self):
from nemo.collections.speechlm2.vllm.salm.audio import NeMoSpeechLMProcessingInfo
info = object.__new__(NeMoSpeechLMProcessingInfo)
assert not hasattr(info, "get_max_audio_len")
assert not hasattr(info, "get_max_audio_tokens")
def test_dummy_inputs_use_profiling_audio_length(self):
from nemo.collections.speechlm2.vllm.salm.audio import (
NeMoSpeechLMDummyInputsBuilder,
NeMoSpeechLMProcessingInfo,
)
info = object.__new__(NeMoSpeechLMProcessingInfo)
builder = object.__new__(NeMoSpeechLMDummyInputsBuilder)
builder.info = info
result = builder.get_dummy_mm_data(seq_len=0, mm_counts={"audio": 1}, mm_options={})
assert result["audio"][0].shape[-1] == 40 * 16000
def test_dummy_inputs_use_requested_audio_length(self, monkeypatch):
from nemo.collections.speechlm2.vllm.salm.audio import NeMoSpeechLMDummyInputsBuilder
builder = object.__new__(NeMoSpeechLMDummyInputsBuilder)
builder.info = SimpleNamespace(_get_encoder_chunk_size_seconds=lambda: None)
monkeypatch.setattr(
builder,
"_get_dummy_audios",
lambda length, num_audios: [SimpleNamespace(length=length) for _ in range(num_audios)],
)
result = builder.get_dummy_mm_data(
seq_len=0,
mm_counts={"audio": 1},
mm_options={"audio": SimpleNamespace(length=12345)},
)
assert result["audio"][0].length == 12345
def test_dummy_inputs_cap_requested_audio_length_to_text_budget(self, monkeypatch):
from nemo.collections.speechlm2.vllm.salm.audio import (
_DUMMY_AUDIO_TEXT_TOKEN_RESERVE,
NeMoSpeechLMDummyInputsBuilder,
NeMoSpeechLMProcessingInfo,
)
target_audio_tokens = 4
max_audio_len = NeMoSpeechLMProcessingInfo._samples_for_audio_tokens(target_audio_tokens)
builder = object.__new__(NeMoSpeechLMDummyInputsBuilder)
builder.info = SimpleNamespace(_get_encoder_chunk_size_seconds=lambda: None)
monkeypatch.setattr(
builder,
"_get_dummy_audios",
lambda length, num_audios: [SimpleNamespace(length=length) for _ in range(num_audios)],
)
result = builder.get_dummy_mm_data(
seq_len=_DUMMY_AUDIO_TEXT_TOKEN_RESERVE + target_audio_tokens,
mm_counts={"audio": 1},
mm_options={"audio": SimpleNamespace(length=max_audio_len + 16000)},
)
assert result["audio"][0].length == max_audio_len
def test_dummy_inputs_large_seq_len_uses_max_audio_cap(self, monkeypatch):
from nemo.collections.speechlm2.vllm.salm.audio import (
_DUMMY_AUDIO_MAX_DURATION_S,
_SAMPLING_RATE,
NeMoSpeechLMDummyInputsBuilder,
)
max_audio_len = int(_DUMMY_AUDIO_MAX_DURATION_S * _SAMPLING_RATE)
builder = object.__new__(NeMoSpeechLMDummyInputsBuilder)
builder.info = SimpleNamespace(_get_encoder_chunk_size_seconds=lambda: None)
monkeypatch.setattr(
builder,
"_get_dummy_audios",
lambda length, num_audios: [SimpleNamespace(length=length) for _ in range(num_audios)],
)
result = builder.get_dummy_mm_data(
seq_len=10_000_000,
mm_counts={"audio": 1},
mm_options={"audio": SimpleNamespace(length=max_audio_len + 16000)},
)
assert result["audio"][0].length == max_audio_len
def test_call_hf_processor_requires_matching_placeholder_count(self):
from nemo.collections.speechlm2.vllm.salm.audio import NeMoSpeechLMMultiModalProcessor
processor = object.__new__(NeMoSpeechLMMultiModalProcessor)
processor.info = SimpleNamespace(
get_tokenizer=_FakeTokenizer,
_estimate_audio_tokens=lambda samples, chunk_size_seconds=None: 2,
_get_encoder_chunk_size_seconds=lambda: None,
)
with pytest.raises(ValueError, match="placeholders"):
processor._call_hf_processor(
prompt="Transcribe this audio",
mm_data={"audios": [[0.0] * 16000]},
mm_kwargs={},
tok_kwargs={},
)
def test_call_hf_processor_emits_true_audio_lengths(self):
import torch
from nemo.collections.speechlm2.vllm.salm.audio import NeMoSpeechLMMultiModalProcessor
processor = object.__new__(NeMoSpeechLMMultiModalProcessor)
processor.info = SimpleNamespace(
get_tokenizer=_FakeTokenizer,
_estimate_audio_tokens=lambda samples, chunk_size_seconds=None: 2,
_get_encoder_chunk_size_seconds=lambda: None,
)
result = processor._call_hf_processor(
prompt="Transcribe: <|audio|>",
mm_data={"audios": [[0.0] * 12345]},
mm_kwargs={},
tok_kwargs={},
)
assert len(result["audio_signal"]) == 1
assert result["audio_signal"][0].shape[-1] == 12345
assert torch.equal(result["audio_signal_length"], torch.tensor([12345]))
def test_perception_forward(self):
"""A small NeMo perception module should encode dummy audio to embeddings."""
import torch
if not torch.cuda.is_available():
pytest.skip("CUDA required")
from nemo.collections.speechlm2.vllm.salm.audio import _load_nemo_perception
perception_cfg = {
"output_dim": 256,
"encoder": {
"_target_": "nemo.collections.asr.modules.ConformerEncoder",
"feat_in": 128,
"feat_out": -1,
"n_layers": 2,
"d_model": 256,
"subsampling": "dw_striding",
"subsampling_factor": 8,
"subsampling_conv_channels": 64,
"ff_expansion_factor": 4,
"self_attention_model": "rel_pos",
"n_heads": 4,
"conv_kernel_size": 9,
"conv_norm_type": "batch_norm",
"dropout": 0.0,
"dropout_pre_encoder": 0.0,
"dropout_emb": 0.0,
"dropout_att": 0.0,
},
"modality_adapter": {
"_target_": "nemo.collections.speechlm2.modules.perception.IdentityConnector",
"d_model": 256,
},
"preprocessor": {
"_target_": "nemo.collections.asr.modules.AudioToMelSpectrogramPreprocessor",
"sample_rate": 16000,
"normalize": "per_feature",
"window_size": 0.025,
"window_stride": 0.01,
"window": "hann",
"features": 128,
"n_fft": 512,
"log": True,
"frame_splicing": 1,
"dither": 0.0,
"pad_to": 0,
"pad_value": 0.0,
},
}
perception = _load_nemo_perception(perception_cfg)
perception = perception.to("cuda", dtype=torch.float32)
dummy_audio = torch.randn(1, 16000, device="cuda")
audio_len = torch.tensor([16000], device="cuda")
with torch.no_grad():
embeds, embed_lens = perception(input_signal=dummy_audio, input_signal_length=audio_len)
assert embeds.ndim == 3
assert embeds.shape[0] == 1
assert embeds.shape[2] == 256
assert embed_lens[0] > 0
@pytest.mark.skipif(not _HAS_VLLM, reason="vLLM not installed")
class TestPluginRegistration:
"""Tests for plugin registration with vLLM."""
def test_register_config(self, monkeypatch):
"""register() should add nemo_speechlm to vLLM's config registry."""
from transformers import AutoConfig
from nemo.collections.speechlm2.vllm.salm import register
monkeypatch.setattr(
AutoConfig, "from_pretrained", lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError())
)
register()
from vllm.transformers_utils.config import _CONFIG_REGISTRY
assert "nemo_speechlm" in _CONFIG_REGISTRY
def test_register_model(self, monkeypatch):
"""register() should make NeMoSpeechLMForConditionalGeneration importable.
The plugin now registers a single architecture name; the obsolete
``NeMoSpeechLMHybridForConditionalGeneration`` no longer appears.
"""
from transformers import AutoConfig
from nemo.collections.speechlm2.vllm.salm import register
monkeypatch.setattr(
AutoConfig, "from_pretrained", lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError())
)
register()
from vllm.model_executor.models.registry import ModelRegistry
from nemo.collections.speechlm2.vllm.salm.model import NeMoSpeechLMForConditionalGeneration
assert "NeMoSpeechLMForConditionalGeneration" in ModelRegistry.get_supported_archs()
assert NeMoSpeechLMForConditionalGeneration is not None
def test_register_does_not_patch_fast_tokenizer(self, monkeypatch):
from transformers import AutoConfig, PreTrainedTokenizerFast
from nemo.collections.speechlm2.vllm.salm import register
monkeypatch.setattr(
AutoConfig, "from_pretrained", lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError())
)
assert "_orig_batch_encode_plus" not in PreTrainedTokenizerFast.__dict__
register()
assert "_orig_batch_encode_plus" not in PreTrainedTokenizerFast.__dict__
def test_register_does_not_load_backbone_config(self, monkeypatch):
from unittest.mock import Mock
from transformers import AutoConfig
from nemo.collections.speechlm2.vllm.salm import register
from_pretrained = Mock(side_effect=AssertionError("register() must not load remote backbone configs"))
monkeypatch.setattr(AutoConfig, "from_pretrained", from_pretrained)
register()
from_pretrained.assert_not_called()
class _FakeTokenizer:
def __init__(self):
self.added_special_tokens = None
def get_vocab(self):
return {}
def add_special_tokens(self, tokens):
self.added_special_tokens = tokens
def encode(self, prompt, add_special_tokens=True):
return list(range(max(1, len(prompt.split()))))