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
281 lines
11 KiB
Python
281 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.
|
|
import json
|
|
from dataclasses import dataclass
|
|
from functools import partial
|
|
from time import perf_counter
|
|
from typing import Optional
|
|
|
|
import lhotse.dataset
|
|
import torch
|
|
from lhotse import CutSet, fastcopy
|
|
from lhotse.dataset import IterableDatasetWrapper
|
|
from lhotse.serialization import SequentialJsonlWriter
|
|
from omegaconf import OmegaConf
|
|
from transformers import GenerationConfig
|
|
|
|
from nemo.collections.common.data.lhotse import NeMoMultimodalConversation
|
|
from nemo.collections.common.data.lhotse.cutset import cut_to_conversation, guess_parse_cutset
|
|
from nemo.collections.common.data.lhotse.dataloader import tokenize_with_prompt
|
|
from nemo.collections.common.data.lhotse.text_adapters import AudioTurn, TextTurn
|
|
from nemo.collections.speechlm2 import SALM, SALMDataset
|
|
from nemo.collections.speechlm2.models.salm_asr_decoder import SALMWithAsrDecoder
|
|
from nemo.core.config import hydra_runner
|
|
from nemo.utils import logging
|
|
from nemo.utils.get_rank import is_global_rank_zero
|
|
|
|
|
|
def _resolve_model_cls(pretrained_name: str, use_asr_decoder: bool, use_nemo_automodel: bool | None):
|
|
"""Pick model class. Auto-detects from config.json when use_nemo_automodel is None."""
|
|
if use_asr_decoder:
|
|
return SALMWithAsrDecoder
|
|
if use_nemo_automodel is None:
|
|
# Auto-detect: peek at config.json
|
|
from transformers.utils import cached_file
|
|
|
|
config_path = cached_file(
|
|
pretrained_name,
|
|
"config.json",
|
|
_raise_exceptions_for_missing_entries=False,
|
|
_raise_exceptions_for_connection_errors=False,
|
|
)
|
|
if config_path is not None:
|
|
with open(config_path) as f:
|
|
use_nemo_automodel = json.load(f).get("use_nemo_automodel", False)
|
|
else:
|
|
use_nemo_automodel = False
|
|
if use_nemo_automodel:
|
|
from nemo.collections.speechlm2.models import SALMAutomodel
|
|
|
|
return SALMAutomodel
|
|
return SALM
|
|
|
|
|
|
@dataclass
|
|
class SalmEvalConfig:
|
|
pretrained_name: str
|
|
inputs: str
|
|
batch_size: int = 64
|
|
max_new_tokens: int = 128
|
|
output_manifest: str = "generations.jsonl"
|
|
verbose: bool = True
|
|
device: str = "cuda"
|
|
dtype: str = "bfloat16"
|
|
extra_eos_tokens: Optional[list[str]] = None
|
|
system_prompt: Optional[str] = None
|
|
user_prompt: Optional[str] = None
|
|
enable_thinking: Optional[bool] = None
|
|
use_asr_decoder: bool = False # set this to True if using SALMWithAsrDecoder
|
|
use_nemo_automodel: Optional[bool] = None # None = auto-detect from config.json
|
|
# Parallelism sizes for distributed inference (launch with torchrun)
|
|
tp_size: int = 1
|
|
ep_size: int = 1
|
|
pp_size: int = 1
|
|
cp_size: int = 1
|
|
|
|
|
|
@hydra_runner(config_name="SalmEvalConfig", schema=SalmEvalConfig)
|
|
def main(cfg: SalmEvalConfig):
|
|
logging.info(f"Hydra config:\n{OmegaConf.to_yaml(cfg)}")
|
|
|
|
is_distributed = any(s > 1 for s in [cfg.tp_size, cfg.ep_size, cfg.pp_size, cfg.cp_size])
|
|
model_cls = _resolve_model_cls(cfg.pretrained_name, cfg.use_asr_decoder, cfg.use_nemo_automodel)
|
|
|
|
if is_distributed and model_cls is SALM:
|
|
raise RuntimeError(
|
|
"Distributed inference requires SALMAutomodel. Set use_nemo_automodel=true or use a checkpoint "
|
|
"exported from SALMAutomodel."
|
|
)
|
|
|
|
if is_distributed:
|
|
from nemo.collections.speechlm2.parts.parallel import setup_distributed
|
|
|
|
strategy = setup_distributed(
|
|
tp_size=cfg.tp_size, ep_size=cfg.ep_size, pp_size=cfg.pp_size, cp_size=cfg.cp_size
|
|
)
|
|
model = model_cls.from_pretrained(
|
|
cfg.pretrained_name,
|
|
device_mesh=strategy.device_mesh,
|
|
distributed_config=strategy.distributed_config,
|
|
moe_config=strategy.moe_config,
|
|
moe_mesh=strategy.moe_mesh,
|
|
torch_dtype=cfg.dtype,
|
|
)
|
|
else:
|
|
model = model_cls.from_pretrained(cfg.pretrained_name)
|
|
model = model.to(getattr(torch, cfg.dtype)).to(cfg.device)
|
|
model = model.eval()
|
|
|
|
conversations = (
|
|
guess_parse_cutset(cfg.inputs)
|
|
.map(
|
|
partial(
|
|
cut_to_conversation,
|
|
audio_locator_tag=model.audio_locator_tag,
|
|
token_equivalent_duration=model.token_equivalent_duration,
|
|
)
|
|
)
|
|
.map(
|
|
partial(replace_audio_locator_tag, audio_locator_tag=model.audio_locator_tag),
|
|
apply_fn=None,
|
|
)
|
|
.map(
|
|
partial(set_token_equivalent_duration, token_equivalent_duration=model.token_equivalent_duration),
|
|
apply_fn=None,
|
|
)
|
|
.map(
|
|
partial(attach_system_and_user_turns, system_prompt=cfg.system_prompt, user_prompt=cfg.user_prompt),
|
|
apply_fn=None,
|
|
)
|
|
.map(strip_response_if_any, apply_fn=None)
|
|
.map(
|
|
partial(
|
|
tokenize_with_prompt,
|
|
tokenizer=model.tokenizer,
|
|
prompt_format=model.cfg.prompt_format,
|
|
enable_thinking=cfg.enable_thinking,
|
|
),
|
|
apply_fn=None,
|
|
)
|
|
)
|
|
conversations = sort_by_length(conversations)
|
|
dloader = torch.utils.data.DataLoader(
|
|
dataset=IterableDatasetWrapper(
|
|
dataset=SALMDataset(model.tokenizer),
|
|
# rank=0 world_size=1 hardcoded so lhotse doesn't accidentally auto-split batches in model parallel settings
|
|
sampler=lhotse.dataset.DynamicCutSampler(conversations, max_cuts=cfg.batch_size, rank=0, world_size=1),
|
|
),
|
|
num_workers=1,
|
|
batch_size=None,
|
|
)
|
|
|
|
eos_tokens = [model.text_eos_id]
|
|
if cfg.extra_eos_tokens is not None:
|
|
for t in cfg.extra_eos_tokens:
|
|
tid = model.tokenizer.token_to_id(t)
|
|
assert tid is not None, f"Token '{t}' is not in the model's vocabulary."
|
|
eos_tokens.append(tid)
|
|
|
|
num_answer_tokens = []
|
|
infer_durations = []
|
|
with _create_output_writer(cfg.output_manifest) as writer:
|
|
for batch_idx, batch in enumerate(dloader):
|
|
ts = perf_counter()
|
|
answer_ids = model.generate(
|
|
prompts=batch["input_ids"].to(model.device, non_blocking=True),
|
|
audios=batch["audios"].to(model.device, non_blocking=True),
|
|
audio_lens=batch["audio_lens"].to(model.device, non_blocking=True),
|
|
generation_config=GenerationConfig(
|
|
max_new_tokens=cfg.max_new_tokens,
|
|
bos_token_id=model.text_bos_id,
|
|
eos_token_id=eos_tokens,
|
|
pad_token_id=model.text_pad_id,
|
|
),
|
|
)
|
|
answer_ids = answer_ids.cpu()
|
|
batch_infer_duration = perf_counter() - ts
|
|
|
|
batch_contexts = [model.tokenizer.ids_to_text(example) for example in batch["input_ids"]]
|
|
answer_ids = [parse_hyp(ans, eos_tokens) for ans in answer_ids]
|
|
batch_num_answer_tokens = [len(ans) for ans in answer_ids]
|
|
batch_answers = [model.tokenizer.ids_to_text(ans) for ans in answer_ids]
|
|
for conv, ctx, ans in zip(batch["conversations"], batch_contexts, batch_answers):
|
|
conv.turns.append(TextTurn(role="assistant", value=ans))
|
|
for k, v in list(conv.custom.items()):
|
|
if isinstance(v, torch.Tensor):
|
|
del conv.custom[k]
|
|
writer.write(conv.to_dict())
|
|
|
|
num_answer_tokens.extend(batch_num_answer_tokens)
|
|
infer_durations.append(batch_infer_duration)
|
|
if cfg.verbose:
|
|
batch_token_per_second = sum(batch_num_answer_tokens) / batch_infer_duration
|
|
logging.info(f"Batch {batch_idx}: TPS={batch_token_per_second:.2f}")
|
|
|
|
rtfx = sum(num_answer_tokens) / sum(infer_durations)
|
|
logging.info(f"TPS: {rtfx:.2f}")
|
|
|
|
|
|
def replace_audio_locator_tag(
|
|
conversation: NeMoMultimodalConversation, audio_locator_tag: str
|
|
) -> NeMoMultimodalConversation:
|
|
for turn in conversation.turns:
|
|
if isinstance(turn, AudioTurn):
|
|
turn.audio_locator_tag = audio_locator_tag
|
|
return conversation
|
|
|
|
|
|
def set_token_equivalent_duration(
|
|
conversation: NeMoMultimodalConversation, token_equivalent_duration: float
|
|
) -> NeMoMultimodalConversation:
|
|
conversation.token_equivalent_duration = token_equivalent_duration
|
|
return conversation
|
|
|
|
|
|
def attach_system_and_user_turns(
|
|
conversation: NeMoMultimodalConversation, system_prompt: str | None = None, user_prompt: str | None = None
|
|
) -> NeMoMultimodalConversation:
|
|
if system_prompt is None and user_prompt is None:
|
|
return conversation
|
|
turns = conversation.turns
|
|
# Attach user prompt only when no user turn with a text prompt exists.
|
|
if user_prompt is not None and not any(isinstance(t, TextTurn) and t.role == "user" for t in turns):
|
|
turns = [TextTurn(role="user", value=user_prompt)] + turns
|
|
# Attach system prompt only when no system prompt already exists.
|
|
if system_prompt is not None and not any(t.role == "system" for t in turns):
|
|
turns = [TextTurn(role="system", value=system_prompt)] + turns
|
|
return fastcopy(conversation, turns=turns)
|
|
|
|
|
|
def strip_response_if_any(
|
|
conversation: NeMoMultimodalConversation,
|
|
) -> NeMoMultimodalConversation:
|
|
turns = conversation.turns
|
|
while turns[-1].role == "assistant":
|
|
turns = turns[:-1]
|
|
return fastcopy(conversation, turns=turns)
|
|
|
|
|
|
def sort_by_length(conversations: CutSet) -> CutSet:
|
|
return CutSet(sorted(conversations, key=lambda c: c.total_length, reverse=True))
|
|
|
|
|
|
def parse_hyp(answer: torch.Tensor, eos_tokens: list[int]):
|
|
end = torch.isin(answer, torch.tensor(eos_tokens)).nonzero(as_tuple=True)[0]
|
|
if end.numel() == 0:
|
|
return answer
|
|
end = end[0]
|
|
return answer[:end]
|
|
|
|
|
|
class _NullWriter:
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
return False
|
|
|
|
def write(self, data):
|
|
pass
|
|
|
|
|
|
def _create_output_writer(output_manifest: Optional[str]):
|
|
if output_manifest is None or not is_global_rank_zero():
|
|
return _NullWriter()
|
|
return SequentialJsonlWriter(output_manifest)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|