Skip to content

Commit 6db3edb

Browse files
committed
Add Nemotron 3.5 speech prompt support
1 parent 1e4bcc5 commit 6db3edb

5 files changed

Lines changed: 147 additions & 1 deletion

File tree

nemo/collections/common/prompts/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from nemo.collections.common.prompts.mistral import MistralPromptFormatter
2121
from nemo.collections.common.prompts.nemotron_h import NemotronHPromptFormatter
2222
from nemo.collections.common.prompts.nemotron_nano_v3 import NemotronNanoV3PromptFormatter
23+
from nemo.collections.common.prompts.nemotron3p5 import Nemotron3p5PromptFormatter
2324
from nemo.collections.common.prompts.phi2 import (
2425
Phi2ChatPromptFormatter,
2526
Phi2CodePromptFormatter,
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
from lhotse.cut import Cut
15+
16+
from nemo.collections.common.data.prompt_fn import registered_prompt_format_fn
17+
from nemo.collections.common.prompts.nemotron_nano_v3 import (
18+
NemotronNanoV3PromptFormatter,
19+
nemotron_nano_v3,
20+
)
21+
22+
23+
class Nemotron3p5PromptFormatter(NemotronNanoV3PromptFormatter):
24+
"""Speech prompt formatter for NVIDIA Nemotron 3.5 chat checkpoints.
25+
26+
Nemotron 3.5 and Nemotron 3 Nano use the same wire format for the
27+
system/user/plain-assistant turns supported by SpeechLM. They have
28+
separate upstream chat templates, however, so keep a distinct registered
29+
name rather than making recipes claim to use the Nano model family.
30+
31+
The upstream templates differ for structured ``reasoning_content`` and
32+
tool-call messages. Those fields are outside the SpeechLM formatter's
33+
current ``message``-slot schema and are intentionally not approximated
34+
here.
35+
"""
36+
37+
NAME = "nemotron3p5"
38+
39+
40+
@registered_prompt_format_fn(Cut, Nemotron3p5PromptFormatter)
41+
def nemotron3p5(cut: Cut, prompt: Nemotron3p5PromptFormatter):
42+
return nemotron_nano_v3(cut, prompt)

nemo/collections/speechlm2/models/salm_automodel.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,10 @@ def __init__(self, cfg) -> None:
6060

6161
tokenizer_src = self.cfg.get("tokenizer_path", None) or self.cfg.pretrained_llm
6262
self.tokenizer = AutoTokenizer(
63-
tokenizer_src, use_fast=True, trust_remote_code=self.cfg.get("trust_remote_code", False)
63+
tokenizer_src,
64+
use_fast=True,
65+
trust_remote_code=self.cfg.get("trust_remote_code", False),
66+
pad_token=self.cfg.get("pad_token", None),
6467
)
6568
self.tokenizer.add_special_tokens({"additional_special_tokens": [self.audio_locator_tag]})
6669
self.speaker_token_ids = build_speaker_tokens(self.cfg.get("speaker_tokens", None), self.tokenizer)
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
from nemo.collections.common.prompts import PromptFormatter
15+
from nemo.collections.common.prompts.nemotron3p5 import Nemotron3p5PromptFormatter
16+
17+
18+
def test_nemotron3p5_is_registered():
19+
assert PromptFormatter.resolve("nemotron3p5") is Nemotron3p5PromptFormatter
20+
21+
22+
def test_nemotron3p5_training_basic(bpe_tokenizer_with_think):
23+
formatter = Nemotron3p5PromptFormatter(bpe_tokenizer_with_think)
24+
ans = formatter.encode_dialog(
25+
[
26+
{"role": "user", "slots": {"message": "TEST"}},
27+
{"role": "assistant", "slots": {"message": "TEST"}},
28+
]
29+
)
30+
31+
assert set(ans) == {"input_ids", "context_ids", "answer_ids", "mask"}
32+
assert (
33+
bpe_tokenizer_with_think.ids_to_text(ans["input_ids"].tolist())
34+
== "<|im_start|>system\n<|im_end|>\n <|im_start|>user\nTEST<|im_end|>\n "
35+
"<|im_start|>assistant\n<think></think>TEST<|im_end|>\n"
36+
)
37+
assert ans["mask"].tolist() == [False] * len(ans["context_ids"]) + [True] * len(ans["answer_ids"])
38+
39+
40+
def test_nemotron3p5_inference_generation_prompt(bpe_tokenizer_with_think):
41+
formatter = Nemotron3p5PromptFormatter(bpe_tokenizer_with_think)
42+
thinking = formatter.encode_dialog(
43+
[{"role": "user", "slots": {"message": "TEST"}}], enable_thinking=True
44+
)
45+
no_thinking = formatter.encode_dialog(
46+
[{"role": "user", "slots": {"message": "TEST"}}], enable_thinking=False
47+
)
48+
49+
assert (
50+
bpe_tokenizer_with_think.ids_to_text(thinking["input_ids"].tolist())
51+
== "<|im_start|>system\n<|im_end|>\n <|im_start|>user\nTEST<|im_end|>\n "
52+
"<|im_start|>assistant\n<think>\n"
53+
)
54+
assert (
55+
bpe_tokenizer_with_think.ids_to_text(no_thinking["input_ids"].tolist())
56+
== "<|im_start|>system\n<|im_end|>\n <|im_start|>user\nTEST<|im_end|>\n "
57+
"<|im_start|>assistant\n<think></think>"
58+
)

tests/collections/speechlm2/test_salm_automodel.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,48 @@ def test_salm_automodel_training_step_uses_dataloader_iter_signature():
206206
assert list(inspect.signature(SALMAutomodel.training_step).parameters) == ["self", "dataloader_iter"]
207207

208208

209+
def test_salm_automodel_pad_token_override_preserves_eot_labels(monkeypatch):
210+
seen = {}
211+
212+
class FakeTokenizer:
213+
def __init__(self, _src, *, use_fast, trust_remote_code, pad_token):
214+
seen["pad_token"] = pad_token
215+
self.pad = 0 if pad_token == "<unk>" else 11
216+
self.unk_id = 0
217+
218+
def add_special_tokens(self, _tokens):
219+
return 0
220+
221+
salm_module = __import__(
222+
"nemo.collections.speechlm2.models.salm_automodel", fromlist=["AutoTokenizer"]
223+
)
224+
monkeypatch.setattr(salm_module, "AutoTokenizer", FakeTokenizer)
225+
model = SALMAutomodel(
226+
{
227+
"pretrained_llm": "unused",
228+
"audio_locator_tag": "<|audio|>",
229+
"pad_token": "<unk>",
230+
}
231+
)
232+
233+
assert seen["pad_token"] == "<unk>"
234+
assert model.text_pad_id == 0
235+
236+
from nemo.collections.speechlm2.parts.packed_sequences import (
237+
prepare_packed_llm_inputs,
238+
)
239+
240+
packed = prepare_packed_llm_inputs(
241+
input_ids=torch.tensor([[0, 10, 11, 10, 42, 11]]),
242+
text_embs=torch.randn(1, 6, 2),
243+
audio_embs=[],
244+
target_ids=torch.tensor([[-100, -100, -100, -100, 42, 11]]),
245+
padding_id=model.text_pad_id,
246+
placeholder_id=999,
247+
)
248+
assert packed["target_ids"].tolist() == [-100, -100, 42, 11, -100]
249+
250+
209251
def test_salm_automodel_fused_linear_forward_keeps_hidden_states_without_logits():
210252
class FakeLLM(torch.nn.Module):
211253
def __init__(self):

0 commit comments

Comments
 (0)