ba4be087d5
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
261 lines
11 KiB
Python
261 lines
11 KiB
Python
# 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]}
|