NVIDIA-NeMo/Speech · #16388

Add Riva Translate v2 support

briyan-nvidia · merged Oct 16, 20263 files · 379 + / 10 −
nemo/collections/asr/inference/nmt/llm_translator.py90 + / 10 −
@@ -28,6 +28,7 @@     PromptTemplate,     QwenReasoningTranslatorPromptTemplate,     RivaTranslatorPromptTemplate,+    RivaV2TranslatorPromptTemplate, )  try:@@ -37,6 +38,18 @@  from nemo.utils import logging +RIVA_V2_MODEL_NAME: Final[str] = "nvidia/Riva-Translate-4B-Instruct-v2"+CHINESE_LANGUAGE_NAMES: Final[frozenset[str]] = frozenset(+    {+        "chinese",+        "simplified chinese",+        "traditional chinese",+        "zh",+        "zh-cn",+        "zh-tw",+    }+)+ TRANSLATION_MODELS_BY_SERIES: Final[dict[str, tuple[str, ...]]] = {     "eurollm": (         "utter-project/EuroLLM-1.7B-Instruct",@@ -52,7 +65,10 @@         "Qwen/Qwen3.5-27B",         "Qwen/Qwen3.5-35B-A3B",     ),-    "riva": ("nvidia/Riva-Translate-4B-Instruct",),+    "riva": (+        "nvidia/Riva-Translate-4B-Instruct",+        RIVA_V2_MODEL_NAME,+    ), } SUPPORTED_TRANSLATION_MODELS: Final[tuple[str, ...]] = tuple(     model for series_models in TRANSLATION_MODELS_BY_SERIES.values() for model in series_models@@ -109,11 +125,14 @@ def __init__(         self.split_batch = self.batch_size > 0          self.nmt_model = self.load_model(llm_params)+        self.is_riva_v2 = model_name == RIVA_V2_MODEL_NAME+        self.prefix_tokenizer = self.nmt_model.get_tokenizer() if self.is_riva_v2 else None         self.sampling_params = SamplingParams(**sampling_params)          self.source_language = source_language         self.target_language = target_language         self.prompt_template = self.get_prompt_template(model_name)+        self.use_token_prefix_boundaries = self.is_riva_v2 and self._is_chinese(target_language)         self.waitk = waitk      @staticmethod@@ -131,6 +150,10 @@ def convert_to_dict(params: dict | DictConfig | None) -> dict:             return OmegaConf.to_container(params)         return params +    @staticmethod+    def _is_chinese(language: str) -> bool:+        return language.lower() in CHINESE_LANGUAGE_NAMES+     @staticmethod     def setup_device(device: str, device_id: int) -> tuple[str, int]:         """@@ -189,6 +212,9 @@ def get_prompt_template(model_name: str) -> PromptTemplate:         ):             return QwenReasoningTranslatorPromptTemplate +        if model_name == RIVA_V2_MODEL_NAME:+            return RivaV2TranslatorPromptTemplate+         if model_name in TRANSLATION_MODELS_BY_SERIES["riva"]:             return RivaTranslatorPromptTemplate @@ -204,8 +230,10 @@ def load_model(self, llm_params: dict) -> LLM:             RuntimeError: If model loading fails.         """         try:-            os.environ["CUDA_VISIBLE_DEVICES"] = str(self.device_id)-            local_path = self._get_local_model_path(self.model_name)+            # Respect process-level GPU isolation. Overwriting this after ASR initializes+            # CUDA can move the vLLM worker onto a different physical device.+            os.environ.setdefault("CUDA_VISIBLE_DEVICES", str(self.device_id))+            local_path = self._get_local_model_path(self.model_name, revision=llm_params.get("revision"))             if local_path is not None and os.path.exists(local_path):                 logging.info(f"Loading LLM from local cache path: {local_path}")                 model_name = local_path@@ -217,11 +245,12 @@ def load_model(self, llm_params: dict) -> LLM:         except Exception as e:             raise RuntimeError(f"Model loading failed: {str(e)}") from e -    def _get_local_model_path(self, repo_id):+    def _get_local_model_path(self, repo_id: str, revision: str | None = None):         """         Get local model path from HuggingFace model hub.         Args:             repo_id: (str) repository ID of the model+            revision: (str | None) model revision to resolve in the local cache         Returns:             local_path: (str) local path of the model         Raises:@@ -230,6 +259,7 @@ def _get_local_model_path(self, repo_id):         try:             return snapshot_download(                 repo_id=repo_id,+                revision=revision,                 local_files_only=True,             )         except LocalEntryNotFoundError:@@ -263,18 +293,46 @@ def translate_batch(         for src_lang, tgt_lang, src_prefix, tgt_prefix, src_context, tgt_context in zip(             src_langs, tgt_langs, asr_transcripts, prefixes, src_contexts, tgt_contexts         ):-            text = self.prompt_template.format(src_lang, tgt_lang, src_prefix, tgt_prefix, src_context, tgt_context)+            if self.is_riva_v2:+                messages = self.prompt_template.messages(+                    src_lang, tgt_lang, src_prefix, tgt_prefix, src_context, tgt_context+                )+                has_target_prefix = bool(tgt_prefix.strip())+                text = self.prefix_tokenizer.apply_chat_template(+                    messages,+                    tokenize=False,+                    add_generation_prompt=not has_target_prefix,+                    continue_final_message=has_target_prefix,+                )+            else:+                text = self.prompt_template.format(+                    src_lang, tgt_lang, src_prefix, tgt_prefix, src_context, tgt_context+                )             input_texts.append(text)          outputs = self.nmt_model.generate(input_texts, self.sampling_params, use_tqdm=False)         translations = []-        for tgt_prefix, output in zip(prefixes, outputs):+        for tgt_prefix, tgt_lang, output in zip(prefixes, tgt_langs, outputs):             output_text = output.outputs[0].text-            output_text = self.prompt_template.extract(output_text).strip()+            output_text = self.prompt_template.extract(output_text)             if tgt_prefix:-                translations.append(f"{tgt_prefix} {output_text}")+                if self.is_riva_v2:+                    if self._is_chinese(tgt_lang):+                        separator = ""+                    else:+                        output_text = output_text.strip()+                        separator = ""+                        if (+                            output_text+                            and output_text[0] not in string.punctuation+                            and tgt_prefix[-1] not in "([{'\"«„“"+                        ):+                            separator = " "+                    translations.append(f"{tgt_prefix}{separator}{output_text}")+                else:+                    translations.append(f"{tgt_prefix} {output_text.strip()}")             else:-                translations.append(output_text)+                translations.append(output_text.strip())         return translations      def translate(@@ -314,6 +372,26 @@ def translate(             )         return all_translations +    @staticmethod+    def _token_boundaries(tokenizer, text: str) -> tuple[set[int], list[int]]:+        encoded = tokenizer(text, add_special_tokens=False, return_offsets_mapping=True)+        return {end for _, end in encoded["offset_mapping"]}, encoded["input_ids"]++    def _trim_lcp_to_token_boundary(self, previous: str, current: str, lcp: str) -> str:+        """Retreat an LCP to a boundary shared by both model tokenizations."""++        if not lcp:+            return ""+        previous_boundaries, previous_ids = self._token_boundaries(self.prefix_tokenizer, previous)+        current_boundaries, current_ids = self._token_boundaries(self.prefix_tokenizer, current)+        shared_boundaries = previous_boundaries & current_boundaries+        for boundary in sorted((offset for offset in shared_boundaries if offset <= len(lcp)), reverse=True):+            prefix = lcp[:boundary]+            prefix_ids = self.prefix_tokenizer.encode(prefix, add_special_tokens=False)+            if previous_ids[: len(prefix_ids)] == prefix_ids and current_ids[: len(prefix_ids)] == prefix_ids:+                return prefix+        return ""+     def get_prefixes(         self,         asr_transcripts: list[str],@@ -337,8 +415,10 @@ def get_prefixes(             lcp = os.path.commonprefix([prev_trans, trans])             had_leading_space = lcp.startswith(" ") +            if self.use_token_prefix_boundaries:+                lcp = self._trim_lcp_to_token_boundary(prev_trans, trans, lcp)             # If lcp happens mid-word, remove generated ending up to the first full word-            if (len(lcp) > 0) and (lcp[-1] not in f"{string.punctuation} "):+            elif (len(lcp) > 0) and (lcp[-1] not in f"{string.punctuation} "):                 lcp = " ".join(lcp.split()[:-1])              # Remove trailing whitespaces
nemo/collections/asr/inference/nmt/prompts.py98 + / 0 −
@@ -153,6 +153,104 @@ def extract(cls, response: str) -> str:         return response.split('\n')[0]  +class RivaV2TranslatorPromptTemplate(PromptTemplate):+    """Message builder for the Riva-Translate-4B-Instruct-v2 chat template."""++    LANGUAGE_CODES = {+        "arabic": "ar",+        "brazilian portuguese": "pt-br",+        "bulgarian": "bg",+        "chinese": "zh",+        "croatian": "hr",+        "czech": "cs",+        "danish": "da",+        "dutch": "nl",+        "english": "en",+        "estonian": "et",+        "european portuguese": "pt-pt",+        "european spanish": "es-es",+        "finnish": "fi",+        "french": "fr",+        "german": "de",+        "greek": "el",+        "hindi": "hi",+        "hungarian": "hu",+        "indonesian": "id",+        "italian": "it",+        "japanese": "ja",+        "korean": "ko",+        "latin american spanish": "es-us",+        "latam spanish": "es-us",+        "latvian": "lv",+        "lithuanian": "lt",+        "norwegian": "no",+        "polish": "pl",+        "portuguese": "pt",+        "romanian": "ro",+        "russian": "ru",+        "simplified chinese": "zh",+        "slovak": "sk",+        "slovenian": "sl",+        "spanish": "es",+        "swedish": "sv",+        "thai": "th",+        "traditional chinese": "zh-tw",+        "turkish": "tr",+        "ukrainian": "uk",+        "vietnamese": "vi",+    }++    @classmethod+    def messages(+        cls,+        src_lang: str,+        tgt_lang: str,+        src_prefix: str,+        tgt_prefix: str,+        src_context: str = "",+        tgt_context: str = "",+    ) -> list[dict[str, str]]:+        """Build messages to be rendered by the model-provided chat template."""++        source_code = cls.LANGUAGE_CODES.get(src_lang.lower(), src_lang.lower())+        target_code = cls.LANGUAGE_CODES.get(tgt_lang.lower(), tgt_lang.lower())+        messages = [{"role": "system", "content": f"{source_code}-{target_code}"}]++        source_context = re.sub(r"\s+", " ", src_context).strip()+        target_context = re.sub(r"\s+", " ", tgt_context).strip()+        if bool(source_context) != bool(target_context):+            raise ValueError("Riva v2 source and target context must either both be set or both be empty")+        if source_context:+            messages.extend(+                [+                    {"role": "user", "content": source_context},+                    {"role": "assistant", "content": target_context},+                ]+            )++        messages.append({"role": "user", "content": re.sub(r"\s+", " ", src_prefix).strip()})+        target_prefix = re.sub(r"\s+", " ", tgt_prefix).strip()+        if target_prefix:+            messages.append({"role": "assistant", "content": target_prefix})+        return messages++    @classmethod+    def format(+        cls,+        src_lang: str,+        tgt_lang: str,+        src_prefix: str,+        tgt_prefix: str,+        src_context: str = "",+        tgt_context: str = "",+    ) -> str:+        raise RuntimeError("Riva v2 prompts must be rendered with the model tokenizer")++    @classmethod+    def extract(cls, response: str) -> str:+        return response.split('\n')[0]++ class QwenReasoningTranslatorPromptTemplate(PromptTemplate):     """     Chat-style prompt template for Qwen Reasoning model to perform translation.
tests/collections/asr/inference/nmt/test_riva_v2.pyadded191 + / 0 −
@@ -0,0 +1,191 @@+# 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 types import SimpleNamespace+from unittest.mock import patch++import pytest++pytest.importorskip("vllm", reason="Riva Translate v2 tests require vLLM")++from nemo.collections.asr.inference.nmt import llm_translator+from nemo.collections.asr.inference.nmt.llm_translator import RIVA_V2_MODEL_NAME, LLMTranslator+from nemo.collections.asr.inference.nmt.prompts import RivaV2TranslatorPromptTemplate+++class _PairTokenizer:+    def __call__(self, text, add_special_tokens=False, return_offsets_mapping=False):+        offsets = [(start, min(start + 2, len(text))) for start in range(0, len(text), 2)]+        result = {"input_ids": [hash(text[start:end]) for start, end in offsets]}+        if return_offsets_mapping:+            result["offset_mapping"] = offsets+        return result++    def encode(self, text, add_special_tokens=False):+        return self(text, add_special_tokens=add_special_tokens)["input_ids"]+++def test_riva_v2_is_a_supported_translation_model():+    assert LLMTranslator.get_prompt_template(RIVA_V2_MODEL_NAME) is RivaV2TranslatorPromptTemplate+++def test_local_model_resolution_honors_revision():+    calls = []++    def _snapshot_download(**kwargs):+        calls.append(kwargs)+        return "/cached/revision"++    translator = LLMTranslator.__new__(LLMTranslator)++    with patch.object(llm_translator, "snapshot_download", _snapshot_download):+        path = translator._get_local_model_path("nvidia/model", revision="abc123")++    assert path == "/cached/revision"+    assert calls == [{"repo_id": "nvidia/model", "revision": "abc123", "local_files_only": True}]+++def test_model_loading_preserves_process_level_gpu_isolation():+    loaded = []++    translator = LLMTranslator.__new__(LLMTranslator)+    translator.model_name = "nvidia/model"+    translator.device_id = 0++    with (+        patch.dict(llm_translator.os.environ, {"CUDA_VISIBLE_DEVICES": "1"}),+        patch.object(llm_translator, "LLM", lambda **kwargs: loaded.append(kwargs) or object()),+        patch.object(translator, "_get_local_model_path", lambda *args, **kwargs: None),+    ):+        translator.load_model({})++        assert llm_translator.os.environ["CUDA_VISIBLE_DEVICES"] == "1"+    assert loaded == [{"model": "nvidia/model"}]+++def test_riva_v2_messages_use_language_pair_and_aligned_context():+    messages = RivaV2TranslatorPromptTemplate.messages(+        "English", "German", "Current source", "Aktuelle", "Earlier source", "Fruehere Ausgabe"+    )++    assert messages == [+        {"role": "system", "content": "en-de"},+        {"role": "user", "content": "Earlier source"},+        {"role": "assistant", "content": "Fruehere Ausgabe"},+        {"role": "user", "content": "Current source"},+        {"role": "assistant", "content": "Aktuelle"},+    ]+++def test_riva_v2_uses_model_chat_template_for_prefix_continuation():+    calls = []++    class _Tokenizer:+        def apply_chat_template(self, messages, **kwargs):+            calls.append((messages, kwargs))+            return "tokenizer-rendered-prompt"++    class _Model:+        def generate(self, prompts, sampling_params, use_tqdm):+            assert prompts == ["tokenizer-rendered-prompt"]+            return [SimpleNamespace(outputs=[SimpleNamespace(text=".")])]++    translator = LLMTranslator.__new__(LLMTranslator)+    translator.is_riva_v2 = True+    translator.prompt_template = RivaV2TranslatorPromptTemplate+    translator.prefix_tokenizer = _Tokenizer()+    translator.nmt_model = _Model()+    translator.sampling_params = object()+    translator.use_token_prefix_boundaries = False++    result = translator.translate_batch(["Current source"], ["Die Uebersetzung"], ["en"], ["de"], [""], [""])++    assert result == ["Die Uebersetzung."]+    assert calls[0][1] == {+        "tokenize": False,+        "add_generation_prompt": False,+        "continue_final_message": True,+    }+++def test_riva_v2_opens_assistant_turn_without_target_prefix():+    calls = []++    class _Tokenizer:+        def apply_chat_template(self, messages, **kwargs):+            calls.append((messages, kwargs))+            return "tokenizer-rendered-prompt"++    class _Model:+        def generate(self, prompts, sampling_params, use_tqdm):+            return [SimpleNamespace(outputs=[SimpleNamespace(text="Uebersetzung")])]++    translator = LLMTranslator.__new__(LLMTranslator)+    translator.is_riva_v2 = True+    translator.prompt_template = RivaV2TranslatorPromptTemplate+    translator.prefix_tokenizer = _Tokenizer()+    translator.nmt_model = _Model()+    translator.sampling_params = object()+    translator.use_token_prefix_boundaries = False++    result = translator.translate_batch(["Current source"], [""], ["en"], ["de"], [""], [""])++    assert result == ["Uebersetzung"]+    assert calls[0][1] == {+        "tokenize": False,+        "add_generation_prompt": True,+        "continue_final_message": False,+    }+++def test_riva_v2_joins_prefixes_using_each_request_target_language():+    class _Tokenizer:+        def apply_chat_template(self, messages, **kwargs):+            return "tokenizer-rendered-prompt"++    class _Model:+        def generate(self, prompts, sampling_params, use_tqdm):+            return [+                SimpleNamespace(outputs=[SimpleNamespace(text="开始")]),+                SimpleNamespace(outputs=[SimpleNamespace(text="Konferenz")]),+            ]++    translator = LLMTranslator.__new__(LLMTranslator)+    translator.is_riva_v2 = True+    translator.prompt_template = RivaV2TranslatorPromptTemplate+    translator.prefix_tokenizer = _Tokenizer()+    translator.nmt_model = _Model()+    translator.sampling_params = object()++    result = translator.translate_batch(+        ["Current source", "Current source"],+        ["会议", "Die"],+        ["en", "en"],+        ["zh", "de"],+        ["", ""],+        ["", ""],+    )++    assert result == ["会议开始", "Die Konferenz"]+++def test_riva_v2_token_lcp_retreats_to_shared_model_token_boundary():+    translator = LLMTranslator.__new__(LLMTranslator)+    translator.prefix_tokenizer = _PairTokenizer()+    translator.use_token_prefix_boundaries = True+    translator.waitk = -1++    prefix = translator.get_prefixes(["source"], ["会议开幕"], ["会议开始"])[0]++    assert prefix == "会议"