diff --git a/.github/actions/test-template/action.yml b/.github/actions/test-template/action.yml index ae58280727c2..6f8ea688b6fb 100644 --- a/.github/actions/test-template/action.yml +++ b/.github/actions/test-template/action.yml @@ -56,6 +56,14 @@ inputs: description: "Directory under tests/ containing the test scripts" required: false default: "functional_tests" + download-artifact-name: + description: "Optional workflow artifact to place in the test checkout" + required: false + default: "" + download-artifact-path: + description: "Destination path relative to the test checkout" + required: false + default: ".ci-artifacts" runs: using: "composite" steps: @@ -92,6 +100,14 @@ runs: with: path: ${{ github.run_id }}/${{steps.uuid.outputs.id }}/NeMo + - name: Download input artifact + if: ${{ inputs.download-artifact-name != '' }} + uses: actions/download-artifact@v7 + with: + name: ${{ inputs.download-artifact-name }} + path: >- + ${{ github.run_id }}/${{ steps.uuid.outputs.id }}/NeMo/${{ inputs.download-artifact-path }} + - name: Start container shell: bash env: diff --git a/.github/workflows/cicd-main-speech.yml b/.github/workflows/cicd-main-speech.yml index 392226051627..b61c9c47980b 100644 --- a/.github/workflows/cicd-main-speech.yml +++ b/.github/workflows/cicd-main-speech.yml @@ -47,6 +47,92 @@ jobs: dockerfile: docker/Dockerfile runner: ${{ inputs.runner }} + easymagpie-vllm-serving-changes: + runs-on: ubuntu-latest + outputs: + run: ${{ steps.decision.outputs.run }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + - name: Get PR info + id: get-pr-info + if: startsWith(github.ref, 'refs/heads/pull-request/') + uses: nv-gha-runners/get-pr-info@main + - name: Get changed files + id: changed-files + uses: step-security/changed-files@v45.0.1 + with: + sha: ${{ github.sha }} + base_sha: ${{ fromJSON(steps.get-pr-info.outputs.pr-info || '{}').base.sha || github.event.pull_request.base.sha }} + files: | + .github/actions/test-template/action.yml + .github/workflows/cicd-main-speech.yml + tools/easymagpie_vllm_omni/** + tests/collections/tts/easymagpie_vllm_omni/conversion/** + tests/collections/tts/easymagpie_vllm_omni/serving/** + tests/functional_tests/L0_Unit_Tests_GPU_TTS_EasyMagpie_vLLM.sh + - name: Decide whether to run serving tests + id: decision + env: + ANY_CHANGED: ${{ steps.changed-files.outputs.any_changed }} + EVENT_NAME: ${{ github.event_name }} + TESTS_TO_RUN: ${{ inputs.test_to_run }} + run: | + run="$ANY_CHANGED" + + if [[ "$EVENT_NAME" == "schedule" ]]; then + run=false + elif [[ "$EVENT_NAME" == "workflow_dispatch" ]] && \ + jq -e 'index("all") != null or index("L0_Unit_Tests_GPU_TTS_EasyMagpie_vLLM") != null' \ + <<<"$TESTS_TO_RUN" >/dev/null; then + run=true + fi + + echo "run=$run" | tee -a "$GITHUB_OUTPUT" + + easymagpie-vllm-serving-build: + needs: [easymagpie-vllm-serving-changes] + if: needs.easymagpie-vllm-serving-changes.outputs.run == 'true' + uses: ./.github/workflows/_build_container.yml + with: + image-name: nemo_container_easymagpie_vllm + dockerfile: tools/easymagpie_vllm_omni/Dockerfile.ci + runner: ${{ inputs.runner }} + + easymagpie-codec-contract: + needs: [build, easymagpie-vllm-serving-changes] + if: needs.easymagpie-vllm-serving-changes.outputs.run == 'true' + runs-on: ${{ inputs.runner }} + name: EasyMagpie codec parity contract + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Pull Speech image + run: docker pull ${{ needs.build.outputs.image }} + - name: Generate contract with Speech decoder + run: | + mkdir -p .ci-artifacts/easymagpie-codec-contract + docker run \ + --rm \ + --shm-size=8g \ + --env NEMO_HOME=/tmp/nemo_home \ + --volume "$(pwd):/workspace" \ + --workdir /workspace \ + ${{ needs.build.outputs.image }} \ + bash -c "python tests/collections/tts/easymagpie_vllm_omni/conversion/generate_codec_contract.py \ + .ci-artifacts/easymagpie-codec-contract" + du -h .ci-artifacts/easymagpie-codec-contract/* + - name: Upload codec contract + uses: actions/upload-artifact@v6 + with: + name: easymagpie-codec-contract + path: .ci-artifacts/easymagpie-codec-contract + if-no-files-found: error + retention-days: 1 + compression-level: 0 + unit-tests: strategy: fail-fast: false @@ -152,6 +238,30 @@ jobs: cpu-only: ${{ matrix.cpu-only || false }} is_optional: ${{ matrix.is-optional || false }} + easymagpie-vllm-serving-tests: + needs: [easymagpie-vllm-serving-build, easymagpie-codec-contract] + runs-on: ${{ inputs.runner }} + name: L0_Unit_Tests_GPU_TTS_EasyMagpie_vLLM + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Checkout NeMo + uses: actions/checkout@v6 + with: + path: ${{ github.run_id }} + - name: main + uses: ./.github/actions/test-template + with: + runner: ${{ runner.name }} + script: L0_Unit_Tests_GPU_TTS_EasyMagpie_vLLM + is_unit_test: true + tests_to_run: ${{ inputs.test_to_run }} + image: ${{ needs.easymagpie-vllm-serving-build.outputs.image }} + test-data-path: ${{ vars.DEFAULT_TEST_DATA_PATH || '/mnt/datadrive/TestData' }} + timeout: 15 + download-artifact-name: easymagpie-codec-contract + download-artifact-path: .ci_artifacts/easymagpie-codec-contract + e2e-tests: strategy: fail-fast: false diff --git a/tests/collections/tts/easymagpie_vllm_omni/conversion/conftest.py b/tests/collections/tts/easymagpie_vllm_omni/conversion/conftest.py new file mode 100644 index 000000000000..2c69a1f606cd --- /dev/null +++ b/tests/collections/tts/easymagpie_vllm_omni/conversion/conftest.py @@ -0,0 +1,25 @@ +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. 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. +"""Path setup for Speech-side EasyMagpie checkpoint conversion tests.""" +from __future__ import annotations + +import sys +from pathlib import Path + +EASYMAGPIE_ROOT = Path(__file__).resolve().parents[5] / "tools" / "easymagpie_vllm_omni" +SCRIPTS_DIR = EASYMAGPIE_ROOT / "scripts" + +# Conversion uses Speech and pure PyTorch helpers, never the vLLM runtime. +sys.path.insert(0, str(EASYMAGPIE_ROOT)) +sys.path.insert(0, str(SCRIPTS_DIR)) diff --git a/tests/collections/tts/easymagpie_vllm_omni/conversion/generate_codec_contract.py b/tests/collections/tts/easymagpie_vllm_omni/conversion/generate_codec_contract.py new file mode 100755 index 000000000000..89b25af357d8 --- /dev/null +++ b/tests/collections/tts/easymagpie_vllm_omni/conversion/generate_codec_contract.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. 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. +"""Generate a tiny Speech-to-vLLM codec parity contract for the serving CI job.""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +import torch +from hydra.utils import instantiate +from safetensors.torch import save_file + +ROOT = Path(__file__).resolve().parents[5] +EASYMAGPIE_ROOT = ROOT / "tools" / "easymagpie_vllm_omni" +sys.path.insert(0, str(EASYMAGPIE_ROOT)) +sys.path.insert(0, str(EASYMAGPIE_ROOT / "scripts")) + +import convert_codec as converter # noqa: E402 +from easymagpie_vllm_omni.codec.config import EasyMagpieCodecConfig # noqa: E402 +from easymagpie_vllm_omni.codec.weight_conversion import convert_decoder_state_dict # noqa: E402 + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("output", type=Path) + return parser.parse_args() + + +def speech_decoder_config() -> dict: + return { + "_target_": "nemo.collections.tts.modules.audio_codec_modules.ResNetDecoder", + "input_dim": 4, + "input_filters": 8, + "pre_up_sample_rates": [2], + "pre_up_sample_filters": [8], + "n_hidden_layers": 2, + "hidden_filters": 16, + "resblock_up_sample_rates": [2], + "resblock_up_sample_filters": [4], + "resblock_up_sample_kernel_size": 7, + "kernel_size": 3, + "activation": "half_snake", + "is_causal": True, + "pad_mode": "replicate", + } + + +def serving_config() -> EasyMagpieCodecConfig: + return EasyMagpieCodecConfig( + input_dim=4, + input_filters=8, + hidden_filters=16, + num_hidden_layers=2, + pre_upsample_rates=[2], + pre_upsample_filters=[8], + resblock_upsample_rates=[2], + resblock_upsample_filters=[4], + kernel_size=3, + resblock_kernel_size=7, + activation="half_snake", + num_codebooks=2, + codebook_size=4, + num_levels_per_group=[2, 2], + frame_stacking_factor=2, + output_sample_rate=22050, + ) + + +def generate_contract(output: Path) -> None: + torch.manual_seed(20260730) + decoder_config = speech_decoder_config() + source_decoder = instantiate(decoder_config) + source_state = { + f"audio_decoder.{name}": tensor.detach().cpu() for name, tensor in source_decoder.state_dict().items() + } + decoder = converter.restore_speech_decoder(decoder_config, source_state) + + num_frames = 7 + latent = torch.linspace(-1.0, 1.0, steps=num_frames * decoder_config["input_dim"], dtype=torch.float32) + latent = latent.view(num_frames, decoder_config["input_dim"]) + input_len = torch.tensor([num_frames], dtype=torch.long) + with torch.no_grad(): + audio, audio_len = decoder(inputs=latent.T.unsqueeze(0).contiguous(), input_len=input_len) + + config = serving_config() + expected_audio_len = num_frames * config.samples_per_codec_frame + if int(audio_len.item()) != expected_audio_len: + raise AssertionError(f"Speech decoder returned {int(audio_len.item())} samples, expected {expected_audio_len}") + + output.mkdir(parents=True, exist_ok=True) + config.save_pretrained(output) + save_file(convert_decoder_state_dict(source_state), output / "model.safetensors") + save_file( + { + "latent": latent.contiguous(), + "expected_audio": audio.squeeze(0).contiguous(), + "expected_audio_len": audio_len.cpu().contiguous(), + }, + output / "contract.safetensors", + ) + + +if __name__ == "__main__": + generate_contract(parse_args().output) diff --git a/tests/collections/tts/easymagpie_vllm_omni/conversion/test_convert_codec.py b/tests/collections/tts/easymagpie_vllm_omni/conversion/test_convert_codec.py new file mode 100644 index 000000000000..82bd1ffc2e3b --- /dev/null +++ b/tests/collections/tts/easymagpie_vllm_omni/conversion/test_convert_codec.py @@ -0,0 +1,40 @@ +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. 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 convert_codec as converter +import pytest + + +def _valid_decoder_config() -> dict: + return { + "_target_": "nemo.collections.tts.modules.audio_codec_modules.ResNetDecoder", + "is_causal": True, + "activation": "half_snake", + } + + +@pytest.mark.parametrize( + ("field", "value", "message"), + [ + ("_target_", "some.OtherDecoder", "ResNetDecoder"), + ("is_causal", False, "causal"), + ("activation", "snake", "half_snake"), + ], +) +def test_validate_decoder_config_rejects_unsupported_codec(field, value, message): + config = _valid_decoder_config() + config[field] = value + + with pytest.raises(ValueError, match=message): + converter.validate_decoder_config(config) diff --git a/tests/collections/tts/easymagpie_vllm_omni/conversion/test_convert_to_vllm.py b/tests/collections/tts/easymagpie_vllm_omni/conversion/test_convert_to_vllm.py new file mode 100644 index 000000000000..902b6d94f57b --- /dev/null +++ b/tests/collections/tts/easymagpie_vllm_omni/conversion/test_convert_to_vllm.py @@ -0,0 +1,136 @@ +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. 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 __future__ import annotations + +import sys +import types + +import convert_to_vllm as converter # noqa: E402 +import pytest +import torch +from omegaconf import OmegaConf + + +class _FakeEmbeddingModel(torch.nn.Module): + def __init__(self): + super().__init__() + self.anchor = torch.nn.Parameter(torch.zeros(1)) + self.text_embedding = None + self.cfg_unk_token_id = 12 + self.interruption_token_id = 13 + self.cfg = types.SimpleNamespace(embedding_dim=2) + + def embed_text_tokens(self, ids, text_lens, disable_cas_embedding): + del text_lens, disable_cas_embedding + return ids.unsqueeze(-1).expand(-1, -1, self.cfg.embedding_dim).float() + + +def test_precompute_text_embeddings_includes_multiturn_interruption_token(): + table = converter.precompute_text_embeddings(_FakeEmbeddingModel(), batch_size=8) + + assert table.shape == (14, 2) + torch.testing.assert_close(table[-1], torch.tensor([13.0, 13.0])) + + +def test_build_config_exports_multiturn_text_metadata(monkeypatch): + class _FakeNemotronHConfig: + def __init__(self, **kwargs): + self.__dict__.update(kwargs) + + monkeypatch.setitem( + sys.modules, + "nemo.collections.tts.modules.nemotron_h_decoder", + types.SimpleNamespace(NemotronHConfig=_FakeNemotronHConfig), + ) + mode = types.SimpleNamespace( + text_input_mode="streaming", + streaming_phonemes_delay=3, + streaming_speech_delay=5, + ) + model = types.SimpleNamespace( + cfg=OmegaConf.create( + { + "decoder_type": "nemotron_h", + "hidden_dim": 4, + "embedding_dim": 4, + "nemotron_h_config": {"hidden_size": 4}, + "local_transformer_type": "ar", + "use_multiturn_dataset": True, + } + ), + eos_id=101, + interruption_token_id=103, + num_audio_codebooks=2, + codebook_size=32, + frame_stacking_factor=1, + phoneme_tokenizer=None, + mode_name_to_mode={"default": mode}, + default_inference_mode="default", + training_modes=[], + task_embedding=None, + audio_bos_id=32, + audio_eos_id=33, + mask_token_id=36, + ) + + config = converter.build_config(model, vocab_size=104, torch_dtype="float32") + + assert config["text_eos_id"] == 101 + assert config["text_interruption_id"] == 103 + assert config["use_multiturn_dataset"] is True + + +def _validation_model(): + mode = types.SimpleNamespace( + text_input_mode="streaming", + streaming_phonemes_delay=3, + streaming_speech_delay=5, + ) + return types.SimpleNamespace( + cfg=OmegaConf.create( + { + "decoder_type": "nemotron_h", + "hidden_dim": 4, + "embedding_dim": 4, + "nemotron_h_config": {"hidden_size": 4}, + "local_transformer_type": "ar", + } + ), + mode_name_to_mode={"default": mode}, + default_inference_mode="default", + ) + + +@pytest.mark.parametrize( + ("field", "value", "message"), + [ + ("decoder_type", "huggingface", "Nemotron-H"), + ("local_transformer_type", "none", "local_transformer_type='ar'"), + ("hidden_dim", 8, "hidden_dim.*embedding_dim"), + ], +) +def test_validate_model_config_rejects_unsupported_model(field, value, message): + model = _validation_model() + model.cfg[field] = value + + with pytest.raises(ValueError, match=message): + converter.validate_model_config(model) + + +def test_validate_model_config_rejects_non_streaming_default_mode(): + model = _validation_model() + model.mode_name_to_mode["default"].text_input_mode = "full" + + with pytest.raises(ValueError, match="text_input_mode='streaming'"): + converter.validate_model_config(model) diff --git a/tests/collections/tts/easymagpie_vllm_omni/serving/conftest.py b/tests/collections/tts/easymagpie_vllm_omni/serving/conftest.py new file mode 100644 index 000000000000..56beeddf566f --- /dev/null +++ b/tests/collections/tts/easymagpie_vllm_omni/serving/conftest.py @@ -0,0 +1,69 @@ +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. 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. +"""Fixtures and dependency boundary for EasyMagpie vLLM serving tests.""" +from __future__ import annotations + +import importlib.util +import sys +import types +from pathlib import Path + +import pytest + +_SERVING_DEPENDENCIES = ("vllm", "vllm_omni") +_HAS_SERVING_DEPENDENCIES = all(importlib.util.find_spec(name) is not None for name in _SERVING_DEPENDENCIES) + +# Ordinary Speech test environments do not install the pinned serving stack. +# The dedicated serving job provides both dependencies and collects this suite. +collect_ignore_glob = [] if _HAS_SERVING_DEPENDENCIES else ["test_*.py"] + +EASYMAGPIE_ROOT = Path(__file__).resolve().parents[5] / "tools" / "easymagpie_vllm_omni" +SCRIPTS_DIR = EASYMAGPIE_ROOT / "scripts" + +# Load the standalone serving package from this checkout without installing Speech. +sys.path.insert(0, str(EASYMAGPIE_ROOT)) +sys.path.insert(0, str(SCRIPTS_DIR)) + +# Equal dimensions exercise the identity projection path. +_DEFAULT_ARCH: dict = dict( + hidden_dim=64, + embedding_dim=64, + audio_embedding_dim=64, + num_audio_codebooks=2, + codebook_size=32, + frame_stacking_factor=2, + local_transformer_n_layers=2, + local_transformer_n_heads=4, + local_transformer_hidden_dim=64, +) + + +def build_vllm_config(**arch_overrides): + """Build a minimal eager ``VllmConfig`` for the code predictor.""" + import torch + from vllm.config import CompilationMode + + arch = {**_DEFAULT_ARCH, **arch_overrides} + hf_config = types.SimpleNamespace(**arch) + return types.SimpleNamespace( + model_config=types.SimpleNamespace(hf_config=hf_config, dtype=torch.float32), + scheduler_config=types.SimpleNamespace(max_num_batched_tokens=128), + compilation_config=types.SimpleNamespace(mode=CompilationMode.NONE), + ) + + +@pytest.fixture +def vllm_config_factory(): + """Fixture returning the :func:`build_vllm_config` factory.""" + return build_vllm_config diff --git a/tests/collections/tts/easymagpie_vllm_omni/serving/test_audio_output.py b/tests/collections/tts/easymagpie_vllm_omni/serving/test_audio_output.py new file mode 100644 index 000000000000..cf0f45588628 --- /dev/null +++ b/tests/collections/tts/easymagpie_vllm_omni/serving/test_audio_output.py @@ -0,0 +1,40 @@ +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. 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 __future__ import annotations + +from collections import UserDict +from types import SimpleNamespace + +import pytest + +torch = pytest.importorskip("torch") + +from easymagpie_vllm_omni.audio_output import extract_audio_from_stage_output + + +def test_extract_audio_unwraps_list_and_mapping_payload(): + payload = UserDict( + { + "audio": torch.tensor([0.25, -0.5], dtype=torch.float32), + "sr": torch.tensor(22050, dtype=torch.int32), + } + ) + completion = SimpleNamespace(multimodal_output=payload) + request_output = SimpleNamespace(outputs=[completion]) + stage_output = SimpleNamespace(request_output=request_output) + + waveform, sample_rate = extract_audio_from_stage_output([stage_output]) + + torch.testing.assert_close(torch.from_numpy(waveform), payload["audio"]) + assert sample_rate == 22050 diff --git a/tests/collections/tts/easymagpie_vllm_omni/serving/test_backbone_patches.py b/tests/collections/tts/easymagpie_vllm_omni/serving/test_backbone_patches.py new file mode 100644 index 000000000000..98e1a8c2a4b8 --- /dev/null +++ b/tests/collections/tts/easymagpie_vllm_omni/serving/test_backbone_patches.py @@ -0,0 +1,59 @@ +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. 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 narrow vLLM Nemotron-H compatibility patches.""" +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +torch = pytest.importorskip("torch") +pytest.importorskip("vllm") + +from easymagpie_vllm_omni.backbone_patches import patch_shared_expert_activation # noqa: E402 +from vllm.model_executor.layers.activation import ReLUSquaredActivation # noqa: E402 + + +class NemotronHMoE: + def __init__(self, activation): + self.shared_experts = SimpleNamespace(act_fn=activation) + + +def _backbone(activation: str, current_activation): + layer = SimpleNamespace(mixer=NemotronHMoE(current_activation)) + return SimpleNamespace(config=SimpleNamespace(mlp_hidden_act=activation), layers=[layer]) + + +def _relu_squared_without_vllm_context(): + activation = ReLUSquaredActivation.__new__(ReLUSquaredActivation) + torch.nn.Module.__init__(activation) + return activation + + +def test_shared_expert_activation_is_read_from_config(): + backbone = _backbone("silu", _relu_squared_without_vllm_context()) + + assert patch_shared_expert_activation(backbone) == 1 + torch.testing.assert_close( + backbone.layers[0].mixer.shared_experts.act_fn(torch.tensor([-1.0, 0.0, 1.0])), + torch.nn.functional.silu(torch.tensor([-1.0, 0.0, 1.0])), + ) + assert patch_shared_expert_activation(backbone) == 0 + + +def test_shared_expert_patch_rejects_unknown_upstream_implementation(): + backbone = _backbone("silu", torch.nn.Identity()) + + with pytest.raises(RuntimeError, match="implementation changed"): + patch_shared_expert_activation(backbone) diff --git a/tests/collections/tts/easymagpie_vllm_omni/serving/test_benchmark_incremental_server.py b/tests/collections/tts/easymagpie_vllm_omni/serving/test_benchmark_incremental_server.py new file mode 100644 index 000000000000..e89f4e40bde5 --- /dev/null +++ b/tests/collections/tts/easymagpie_vllm_omni/serving/test_benchmark_incremental_server.py @@ -0,0 +1,114 @@ +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. 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 __future__ import annotations + +import json + +import pytest + +pytest.importorskip("numpy") +pytest.importorskip("websockets") + +import benchmark_incremental_server as benchmark # noqa: E402 + + +class _FakeWebSocket: + def __init__(self): + self.sent = [] + self.responses = iter( + [ + json.dumps({"type": "audio.start", "sample_rate": 22050}), + b"\x00\x00\x01\x00", + json.dumps({"type": "audio.done", "error": False}), + json.dumps({"type": "session.done", "total_sentences": 1}), + ] + ) + + async def __aenter__(self): + return self + + async def __aexit__(self, *_): + return False + + async def send(self, message): + self.sent.append(json.loads(message)) + + async def recv(self): + return next(self.responses) + + +def test_request_sends_token_chunks_and_collects_streaming_pcm(monkeypatch): + websocket = _FakeWebSocket() + monkeypatch.setattr(benchmark.websockets, "connect", lambda *_args, **_kwargs: websocket) + + result = benchmark._do_request( + { + "url": "http://localhost:8091", + "uttid": "test-0", + "token_ids": [10, 11, 12, 13, 14], + "tokens_per_chunk": 2, + "send_delay_s": 0.0, + "speaker_id": "eng", + "max_new_tokens": 128, + "sample_rate": 22050, + "timeout": 10.0, + "output_dir": None, + } + ) + + assert websocket.sent[0]["type"] == "session.config" + assert websocket.sent[1:4] == [ + {"type": "input.tokens", "tokens": [10, 11]}, + {"type": "input.tokens", "tokens": [12, 13]}, + {"type": "input.tokens", "tokens": [14]}, + ] + assert websocket.sent[4] == {"type": "input.done"} + assert result.error is None + assert result.num_samples == 2 + assert result.num_text_tokens == 5 + assert result.num_input_chunks == 3 + + +def test_make_tasks_preserves_reference_ids_and_avoids_filename_collisions(): + tasks = benchmark._make_tasks( + [("utt-1", "one", [1]), ("utt-2", "two", [2])], + 2, + url="http://localhost:8091", + speaker_id="eng", + max_new_tokens=128, + sample_rate=22050, + timeout=10, + output_dir="wavs", + tokens_per_chunk=5, + send_delay_s=0.0, + ) + + assert {task["uttid"] for task in tasks} == {"utt-1", "utt-2"} + + +def test_make_tasks_still_supports_more_requests_than_corpus_entries(): + tasks = benchmark._make_tasks( + [("utt-1", "one", [1])], + 2, + url="url", + speaker_id=None, + max_new_tokens=128, + sample_rate=22050, + timeout=10, + output_dir=None, + tokens_per_chunk=5, + send_delay_s=0.0, + ) + + assert len(tasks) == 2 diff --git a/tests/collections/tts/easymagpie_vllm_omni/serving/test_benchmark_model.py b/tests/collections/tts/easymagpie_vllm_omni/serving/test_benchmark_model.py new file mode 100644 index 000000000000..1c9c9883a46e --- /dev/null +++ b/tests/collections/tts/easymagpie_vllm_omni/serving/test_benchmark_model.py @@ -0,0 +1,61 @@ +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. 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 __future__ import annotations + +from types import SimpleNamespace + +import benchmark_model as benchmark # noqa: E402 +import pytest +from vllm.sampling_params import RequestOutputKind, SamplingParams + + +@pytest.mark.asyncio +async def test_model_benchmark_uses_service_protocol_for_one_token_chunks(): + meta = SimpleNamespace( + tokenizer=SimpleNamespace(encode=lambda _text, add_special_tokens=False: [10, 11, 12]), + speaker_id="eng", + speaker_embedding=None, + prompt_len=2, + text_eos_id=99, + ) + params = SamplingParams(max_tokens=32, output_kind=RequestOutputKind.DELTA) + inputs, observe_output = benchmark.build_streaming_request( + "ignored", + meta, + params, + max_new_tokens=32, + tokens_per_chunk=1, + ) + + expected = [ + ({"text_token": [10], "text_token_start": 0}, 1), + ({"text_token": [11], "text_token_start": 1}, 1), + ({"text_token": [12], "text_token_start": 2}, 1), + ({"text_token": [99], "text_token_start": 3}, 1), + ] + for info, max_tokens in expected: + engine_input = await anext(inputs) + assert engine_input.prompt["additional_information"]["text_token"] == info["text_token"] + assert engine_input.prompt["additional_information"]["text_token_start"] == info["text_token_start"] + assert engine_input.sampling_params.max_tokens == max_tokens + observe_output( + SimpleNamespace( + stage_id=0, + outputs=[SimpleNamespace(token_ids=[1] * max_tokens, finish_reason="length")], + ) + ) + + tail = await anext(inputs) + assert tail.prompt["additional_information"] == {"text_token": []} + assert tail.sampling_params.max_tokens == 28 diff --git a/tests/collections/tts/easymagpie_vllm_omni/serving/test_benchmark_server.py b/tests/collections/tts/easymagpie_vllm_omni/serving/test_benchmark_server.py new file mode 100644 index 000000000000..c87205955c9d --- /dev/null +++ b/tests/collections/tts/easymagpie_vllm_omni/serving/test_benchmark_server.py @@ -0,0 +1,112 @@ +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. 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 __future__ import annotations + + +import pytest + +pytest.importorskip("numpy") +pytest.importorskip("requests") + +import benchmark_server as benchmark # noqa: E402 + + +class _StreamingResponse: + def __enter__(self): + return self + + def __exit__(self, *_): + return False + + def raise_for_status(self): + return None + + def iter_content(self, chunk_size=None): + assert chunk_size is None + yield b"\x00" + yield b"\x40\xff" + yield b"\x7f" + + +def test_request_uses_openai_speech_endpoint_and_decodes_streaming_pcm(monkeypatch): + sent = {} + + def post(url, **kwargs): + sent["url"] = url + sent.update(kwargs) + return _StreamingResponse() + + monkeypatch.setattr(benchmark.requests, "post", post) + result = benchmark._do_request( + { + "url": "http://localhost:8091", + "uttid": "test", + "text": "Hello", + "speaker_id": "eng", + "max_new_tokens": 128, + "sample_rate": 22050, + "timeout": 10, + "output_dir": None, + } + ) + + assert sent["url"] == "http://localhost:8091/v1/audio/speech" + assert sent["json"] == { + "input": "Hello", + "voice": "eng", + "response_format": "pcm", + "stream": True, + "stream_format": "audio", + "max_new_tokens": 128, + } + assert result.error is None + assert result.num_samples == 2 + assert result.sr == 22050 + + +def test_make_tasks_avoids_output_filename_collisions_when_corpus_is_large_enough(): + tasks = benchmark._make_tasks( + [("utt-1", "one"), ("utt-2", "two"), ("utt-3", "three")], + 3, + url="http://localhost:8091", + speaker_id="eng", + max_new_tokens=128, + sample_rate=22050, + timeout=10, + output_dir="wavs", + ) + + assert {task["uttid"] for task in tasks} == {"utt-1", "utt-2", "utt-3"} + + +def test_make_tasks_still_supports_more_requests_than_corpus_entries(): + tasks = benchmark._make_tasks([("utt-1", "one")], 2, "url", None, 128, 22050, 10, None) + + assert len(tasks) == 2 + + +def test_playback_metrics_compare_gap_to_preceding_chunk_duration(): + metrics = benchmark._playback_metrics( + arrivals=[0.0, 0.10, 0.25], + durations=[0.08, 0.20, 0.20], + ) + + assert metrics["deadline_misses"] == 1 + assert metrics["underruns"] == 1 + assert metrics["chunk_rtfs"] == pytest.approx([0.8, 4.0 / 3.0]) + assert metrics["headrooms"] == pytest.approx([-0.02, 0.05]) + assert metrics["transitions"] == [ + pytest.approx((0.08, 0.10, True, True)), + pytest.approx((0.20, 0.15, False, False)), + ] diff --git a/tests/collections/tts/easymagpie_vllm_omni/serving/test_codec_contract.py b/tests/collections/tts/easymagpie_vllm_omni/serving/test_codec_contract.py new file mode 100644 index 000000000000..1b1fb6a430ca --- /dev/null +++ b/tests/collections/tts/easymagpie_vllm_omni/serving/test_codec_contract.py @@ -0,0 +1,61 @@ +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. 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. +"""Cross-environment numerical contract with the canonical Speech decoder.""" + +from __future__ import annotations + +import os +from pathlib import Path + +import pytest +import torch +from easymagpie_vllm_omni.codec.config import EasyMagpieCodecConfig +from easymagpie_vllm_omni.codec.packed import PackedEasyMagpieCodec +from safetensors.torch import load_file +from vllm.config import VllmConfig, set_current_vllm_config +from vllm.forward_context import set_forward_context + +CONTRACT_ENV = "EASYMAGPIE_CODEC_CONTRACT" + + +def contract_dir() -> Path: + value = os.environ.get(CONTRACT_ENV) + if not value: + pytest.skip(f"{CONTRACT_ENV} is set only by the cross-environment CI job") + path = Path(value) + required = (path / "config.json", path / "model.safetensors", path / "contract.safetensors") + missing = [str(item) for item in required if not item.is_file()] + if missing: + pytest.fail(f"incomplete codec contract; missing {missing}") + return path + + +def test_packed_decoder_matches_speech_contract() -> None: + path = contract_dir() + config = EasyMagpieCodecConfig.from_pretrained(path) + weights = load_file(path / "model.safetensors", device="cpu") + contract = load_file(path / "contract.safetensors", device="cpu") + + vllm_config = VllmConfig() + with set_current_vllm_config(vllm_config): + packed = PackedEasyMagpieCodec(config, dtype=torch.float32).eval() + packed.load_state_dict(weights, strict=True) + + with torch.no_grad(), set_forward_context(None, vllm_config): + actual = packed.audio_decoder(contract["latent"]) + + expected = contract["expected_audio"] + assert actual.shape == expected.shape + assert actual.numel() == int(contract["expected_audio_len"].item()) + torch.testing.assert_close(actual, expected, atol=2e-5, rtol=2e-5) diff --git a/tests/collections/tts/easymagpie_vllm_omni/serving/test_codec_kernels.py b/tests/collections/tts/easymagpie_vllm_omni/serving/test_codec_kernels.py new file mode 100644 index 000000000000..bba6c551831e --- /dev/null +++ b/tests/collections/tts/easymagpie_vllm_omni/serving/test_codec_kernels.py @@ -0,0 +1,109 @@ +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. 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 +import torch.nn.functional as F + +from easymagpie_vllm_omni.codec.kernels import packed_causal_conv1d, packed_causal_conv_transpose1d, packed_half_snake +from easymagpie_vllm_omni.codec.packed import CODEC_STATE_ELEMENTS + + +pytestmark = pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA is required for Triton kernel tests") + + +def test_packed_half_snake() -> None: + torch.manual_seed(17) + inputs = torch.randn(113, 32, device="cuda") + alpha = torch.rand(1, 16, 1, device="cuda") + 0.25 + actual = packed_half_snake(inputs, alpha) + snake_in = inputs[:, :16] + scale = alpha.reshape(1, -1) + expected = torch.cat( + (snake_in + torch.sin(scale * snake_in).square() / (scale + 1e-9), F.leaky_relu(inputs[:, 16:])), + dim=-1, + ) + torch.testing.assert_close(actual, expected, atol=2e-5, rtol=2e-5) + + +def test_packed_causal_conv1d() -> None: + torch.manual_seed(19) + device = torch.device("cuda") + factor = 2 + lengths = [3, 2] + sequences = [torch.randn(length * factor, 4, device=device) for length in lengths] + packed = torch.cat(sequences) + weight = torch.randn(8, 4, 3, device=device) + bias = torch.randn(8, device=device) + state = torch.zeros(2, CODEC_STATE_ELEMENTS, device=device) + query_start_loc = torch.tensor([0, 3, 5], dtype=torch.int32, device=device) + cache_indices = torch.tensor([0, 1], dtype=torch.int32, device=device) + has_initial = torch.zeros(2, dtype=torch.bool, device=device) + + actual = packed_causal_conv1d( + packed, + weight, + bias, + state, + query_start_loc, + cache_indices, + has_initial, + time_factor=factor, + ) + # Triton uses IEEE dot products; compare against a float64 CPU oracle rather than CUDA TF32. + expected = torch.cat( + [ + F.conv1d( + F.pad(sequence.T[None].double().cpu(), (2, 0)), + weight.double().cpu(), + bias.double().cpu(), + )[0].T + for sequence in sequences + ] + ).to(device=device, dtype=actual.dtype) + torch.testing.assert_close(actual, expected, atol=2e-5, rtol=2e-5) + for index, sequence in enumerate(sequences): + torch.testing.assert_close(state[index, : 2 * 4].view(2, 4), sequence[-2:]) + + +def test_packed_causal_conv_transpose1d() -> None: + torch.manual_seed(23) + device = torch.device("cuda") + factor = 2 + stride = 2 + lengths = [3, 2] + sequences = [torch.randn(length * factor, 8, device=device) for length in lengths] + packed = torch.cat(sequences) + conv = torch.nn.ConvTranspose1d(8, 4, 2 * stride, stride=stride, groups=4).to(device) + state = torch.zeros(2, CODEC_STATE_ELEMENTS, device=device) + query_start_loc = torch.tensor([0, 3, 5], dtype=torch.int32, device=device) + cache_indices = torch.tensor([0, 1], dtype=torch.int32, device=device) + has_initial = torch.zeros(2, dtype=torch.bool, device=device) + + actual = packed_causal_conv_transpose1d( + packed, + conv.weight, + conv.bias, + state, + query_start_loc, + cache_indices, + has_initial, + stride=stride, + time_factor=factor, + output_channels=4, + ) + expected = torch.cat([conv(sequence.T[None])[0, :, :-stride].T for sequence in sequences]) + torch.testing.assert_close(actual, expected, atol=2e-4, rtol=2e-4) + for index, sequence in enumerate(sequences): + torch.testing.assert_close(state[index, :8], sequence[-1]) diff --git a/tests/collections/tts/easymagpie_vllm_omni/serving/test_codec_model.py b/tests/collections/tts/easymagpie_vllm_omni/serving/test_codec_model.py new file mode 100644 index 000000000000..eb74b690e460 --- /dev/null +++ b/tests/collections/tts/easymagpie_vllm_omni/serving/test_codec_model.py @@ -0,0 +1,45 @@ +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. 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 +import torch +from easymagpie_vllm_omni.codec.model import EasyMagpieCodecForConditionalGeneration + + +def _payload(frames: int, codebooks: int = 16) -> dict: + return {"codes": {"audio": torch.arange(frames * codebooks).view(frames, codebooks)}} + + +def test_payload_codes_skips_active_but_unscheduled_requests(): + model = SimpleNamespace(config=SimpleNamespace(num_stacked_codebooks=16)) + infos = [_payload(2), _payload(4), _payload(1)] + spans = [(0, 2), (2, 2), (2, 3)] + + codes, frame_counts = EasyMagpieCodecForConditionalGeneration._payload_codes( + model, infos, torch.device("cpu"), spans + ) + + assert codes.shape == (3, 16) + assert frame_counts == [2, 1] + assert torch.equal(codes[:2], infos[0]["codes"]["audio"]) + assert torch.equal(codes[2:], infos[2]["codes"]["audio"]) + + +def test_payload_codes_rejects_scheduled_frame_mismatch(): + model = SimpleNamespace(config=SimpleNamespace(num_stacked_codebooks=16)) + + with pytest.raises(ValueError, match="scheduled 1 placeholders.*2 frames"): + EasyMagpieCodecForConditionalGeneration._payload_codes(model, [_payload(2)], torch.device("cpu"), [(0, 1)]) diff --git a/tests/collections/tts/easymagpie_vllm_omni/serving/test_codec_packed.py b/tests/collections/tts/easymagpie_vllm_omni/serving/test_codec_packed.py new file mode 100644 index 000000000000..7492f5948bb2 --- /dev/null +++ b/tests/collections/tts/easymagpie_vllm_omni/serving/test_codec_packed.py @@ -0,0 +1,259 @@ +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. 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 easymagpie_vllm_omni.codec.config import EasyMagpieCodecConfig +from easymagpie_vllm_omni.codec.packed import CODEC_STATE_ELEMENTS, CodecStateLayer, PackedEasyMagpieCodec +from vllm.config import VllmConfig, set_current_vllm_config +from vllm.forward_context import set_forward_context +from vllm.v1.attention.backends.mamba1_attn import Mamba1AttentionMetadata + + +def tiny_config() -> EasyMagpieCodecConfig: + return EasyMagpieCodecConfig( + input_dim=4, + input_filters=8, + hidden_filters=16, + num_hidden_layers=2, + pre_upsample_rates=[2], + pre_upsample_filters=[8], + resblock_upsample_rates=[2], + resblock_upsample_filters=[4], + num_codebooks=2, + codebook_size=4, + num_levels_per_group=[2, 2], + frame_stacking_factor=2, + ) + + +def metadata(frames: int, *, has_initial: bool, device: torch.device | str = "cpu") -> Mamba1AttentionMetadata: + result = Mamba1AttentionMetadata( + num_prefills=1, + num_prefill_tokens=frames, + num_decodes=0, + num_decode_tokens=0, + num_reqs=1, + has_initial_states_p=torch.tensor([has_initial], device=device), + query_start_loc_p=torch.tensor([0, frames], dtype=torch.int32, device=device), + num_computed_tokens_p=None, + state_indices_tensor_p=torch.tensor([0], dtype=torch.int32, device=device), + state_indices_tensor_d=torch.empty((0, 1), dtype=torch.int32, device=device), + query_start_loc_d=None, + num_accepted_tokens=None, + block_idx_last_scheduled_token=None, + block_idx_first_scheduled_token_p=None, + block_idx_last_computed_token=None, + block_idx_last_scheduled_token_prev_step=None, + seq_lens=torch.tensor([frames], dtype=torch.int32, device=device), + ) + result.codec_max_query_len = frames + result.codec_uniform = True + return result + + +def decode_metadata( + *, + pages: tuple[int, ...] = (0,), + device: torch.device | str = "cpu", +) -> Mamba1AttentionMetadata: + num_decodes = len(pages) + result = Mamba1AttentionMetadata( + num_prefills=0, + num_prefill_tokens=0, + num_decodes=num_decodes, + num_decode_tokens=num_decodes, + num_reqs=num_decodes, + has_initial_states_p=None, + query_start_loc_p=None, + num_computed_tokens_p=None, + state_indices_tensor_p=None, + state_indices_tensor_d=torch.tensor(pages, dtype=torch.int32, device=device).unsqueeze(1), + query_start_loc_d=None, + num_accepted_tokens=None, + block_idx_last_scheduled_token=None, + block_idx_first_scheduled_token_p=None, + block_idx_last_computed_token=None, + block_idx_last_scheduled_token_prev_step=None, + seq_lens=torch.full((num_decodes,), 2, dtype=torch.int32, device=device), + ) + result.codec_uniform = True + return result + + +def mixed_metadata(prefill_frames: int, *, device: torch.device | str = "cpu") -> Mamba1AttentionMetadata: + result = Mamba1AttentionMetadata( + num_prefills=1, + num_prefill_tokens=prefill_frames, + num_decodes=1, + num_decode_tokens=1, + num_reqs=2, + has_initial_states_p=torch.tensor([False], device=device), + query_start_loc_p=torch.tensor([0, prefill_frames], dtype=torch.int32, device=device), + num_computed_tokens_p=None, + state_indices_tensor_p=torch.tensor([1], dtype=torch.int32, device=device), + state_indices_tensor_d=torch.tensor([[0]], dtype=torch.int32, device=device), + query_start_loc_d=None, + num_accepted_tokens=None, + block_idx_last_scheduled_token=None, + block_idx_first_scheduled_token_p=None, + block_idx_last_computed_token=None, + block_idx_last_scheduled_token_prev_step=None, + seq_lens=torch.tensor([2, prefill_frames], dtype=torch.int32, device=device), + ) + result.codec_max_query_len = prefill_frames + return result + + +def test_packed_profile_path_registers_state_layers() -> None: + torch.manual_seed(11) + config = tiny_config() + vllm_config = VllmConfig() + with set_current_vllm_config(vllm_config): + packed = PackedEasyMagpieCodec(config, dtype=torch.float32).eval() + state_layers = [module for module in packed.modules() if isinstance(module, CodecStateLayer)] + assert [layer.prefix for layer in state_layers] == [ + f"easymagpie_codec_state.{index}" for index in range(len(state_layers)) + ] + + codes = torch.randint(0, config.codebook_size, (1, 5, config.num_stacked_codebooks)) + with set_forward_context(None, vllm_config): + actual = packed(codes.squeeze(0)) + assert actual.shape == (5 * config.samples_per_frame,) + + +def test_vllm_state_pages_match_full_decode() -> None: + torch.manual_seed(17) + config = tiny_config() + vllm_config = VllmConfig() + with set_current_vllm_config(vllm_config): + full = PackedEasyMagpieCodec(config, dtype=torch.float32, prefix="full").eval() + packed = PackedEasyMagpieCodec(config, dtype=torch.float32).eval() + packed.load_state_dict(full.state_dict(), strict=True) + + state_layers = [module for module in packed.modules() if isinstance(module, CodecStateLayer)] + for layer in state_layers: + layer.kv_cache = [torch.zeros((1, CODEC_STATE_ELEMENTS))] + + codes = torch.randint(0, config.codebook_size, (1, 6, config.num_stacked_codebooks)) + pieces = [] + offset = 0 + for chunk_index, chunk_size in enumerate((1, 2, 3)): + layer_metadata = {layer.prefix: metadata(chunk_size, has_initial=chunk_index > 0) for layer in state_layers} + with set_forward_context(layer_metadata, vllm_config): + pieces.append(packed(codes[0, offset : offset + chunk_size])) + offset += chunk_size + + actual = torch.cat(pieces) + with set_forward_context(None, vllm_config): + expected = full(codes.squeeze(0)) + torch.testing.assert_close(actual, expected, atol=2e-5, rtol=2e-5) + + +def test_one_frame_decode_metadata_matches_full_decode() -> None: + torch.manual_seed(23) + config = tiny_config() + vllm_config = VllmConfig() + with set_current_vllm_config(vllm_config): + full = PackedEasyMagpieCodec(config, dtype=torch.float32, prefix="full").eval() + packed = PackedEasyMagpieCodec(config, dtype=torch.float32, prefix="one_frame").eval() + packed.load_state_dict(full.state_dict(), strict=True) + + state_layers = [module for module in packed.modules() if isinstance(module, CodecStateLayer)] + for layer in state_layers: + layer.kv_cache = [torch.zeros((1, CODEC_STATE_ELEMENTS))] + + codes = torch.randint(0, config.codebook_size, (1, 5, config.num_stacked_codebooks)) + pieces = [] + for frame in range(codes.shape[1]): + layer_metadata = { + layer.prefix: (metadata(1, has_initial=False) if frame == 0 else decode_metadata()) + for layer in state_layers + } + with set_forward_context(layer_metadata, vllm_config): + pieces.append(packed(codes[0, frame : frame + 1])) + + with set_forward_context(None, vllm_config): + expected = full(codes.squeeze(0)) + torch.testing.assert_close(torch.cat(pieces), expected, atol=2e-5, rtol=2e-5) + + +def test_mixed_decode_and_prefill_batch() -> None: + torch.manual_seed(27) + config = tiny_config() + vllm_config = VllmConfig() + with set_current_vllm_config(vllm_config): + full = PackedEasyMagpieCodec(config, dtype=torch.float32, prefix="full").eval() + packed = PackedEasyMagpieCodec(config, dtype=torch.float32, prefix="mixed").eval() + packed.load_state_dict(full.state_dict(), strict=True) + + state_layers = [module for module in packed.modules() if isinstance(module, CodecStateLayer)] + for layer in state_layers: + layer.kv_cache = [torch.zeros((2, CODEC_STATE_ELEMENTS))] + + decode_codes = torch.randint(0, config.codebook_size, (1, 2, config.num_stacked_codebooks)) + prefill_codes = torch.randint(0, config.codebook_size, (1, 2, config.num_stacked_codebooks)) + initial_metadata = {layer.prefix: metadata(1, has_initial=False) for layer in state_layers} + with set_forward_context(initial_metadata, vllm_config): + packed(decode_codes[0, :1]) + + batch_codes = torch.cat((decode_codes[0, 1:], prefill_codes[0]), dim=0) + layer_metadata = {layer.prefix: mixed_metadata(2) for layer in state_layers} + with set_forward_context(layer_metadata, vllm_config): + actual = packed(batch_codes) + + with set_forward_context(None, vllm_config): + decode_expected = full(decode_codes.squeeze(0))[config.samples_per_frame :] + prefill_expected = full(prefill_codes.squeeze(0)) + torch.testing.assert_close(actual, torch.cat((decode_expected, prefill_expected)), atol=2e-5, rtol=2e-5) + + +@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA is required for Triton integration") +def test_cuda_packed_kernels_preserve_state_across_chunks() -> None: + torch.manual_seed(29) + device = torch.device("cuda") + config = tiny_config() + vllm_config = VllmConfig() + with set_current_vllm_config(vllm_config): + full = PackedEasyMagpieCodec(config, dtype=torch.float32, prefix="full").to(device).eval() + streamed = PackedEasyMagpieCodec(config, dtype=torch.float32, prefix="streamed").to(device).eval() + streamed.load_state_dict(full.state_dict(), strict=True) + + full_layers = [module for module in full.modules() if isinstance(module, CodecStateLayer)] + streamed_layers = [module for module in streamed.modules() if isinstance(module, CodecStateLayer)] + for layer in full_layers + streamed_layers: + layer.kv_cache = [torch.zeros((1, CODEC_STATE_ELEMENTS), device=device)] + + codes = torch.randint( + 0, + config.codebook_size, + (1, 5, config.num_stacked_codebooks), + device=device, + ) + full_metadata = {layer.prefix: metadata(5, has_initial=False, device=device) for layer in full_layers} + with set_forward_context(full_metadata, vllm_config): + expected = full(codes[0]) + + pieces = [] + for frame in range(codes.shape[1]): + chunk_metadata = { + layer.prefix: ( + metadata(1, has_initial=False, device=device) if frame == 0 else decode_metadata(device=device) + ) + for layer in streamed_layers + } + with set_forward_context(chunk_metadata, vllm_config): + pieces.append(streamed(codes[0, frame : frame + 1])) + + torch.testing.assert_close(torch.cat(pieces), expected, atol=3e-5, rtol=3e-5) diff --git a/tests/collections/tts/easymagpie_vllm_omni/serving/test_codec_utils.py b/tests/collections/tts/easymagpie_vllm_omni/serving/test_codec_utils.py new file mode 100644 index 000000000000..7eb544348de5 --- /dev/null +++ b/tests/collections/tts/easymagpie_vllm_omni/serving/test_codec_utils.py @@ -0,0 +1,102 @@ +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. 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 easymagpie_vllm_omni.codec.config import EasyMagpieCodecConfig +from easymagpie_vllm_omni.codec.packed import PackedFiniteScalarDequantizer +from easymagpie_vllm_omni.codec.packing import unstack_acoustic_codes +from easymagpie_vllm_omni.codec.weight_conversion import fold_weight_norm + + +def tiny_config() -> EasyMagpieCodecConfig: + return EasyMagpieCodecConfig( + input_dim=4, + input_filters=8, + hidden_filters=16, + num_hidden_layers=2, + pre_upsample_rates=[2], + pre_upsample_filters=[8], + resblock_upsample_rates=[2], + resblock_upsample_filters=[4], + num_codebooks=2, + codebook_size=4, + num_levels_per_group=[2, 2], + frame_stacking_factor=2, + ) + + +def test_fsq_decode() -> None: + decode = PackedFiniteScalarDequantizer(tiny_config()) + indices = torch.tensor([[[0, 3]]]) + actual = decode(indices.squeeze(0)).unsqueeze(0) + expected = torch.tensor([[[-1.0, -1.0, 0.0, 0.0]]]) + torch.testing.assert_close(actual, expected) + + +def test_unstack_predictor_codes_preserves_codebook_and_time_order() -> None: + # Each predictor row is [c0_t0, c0_t1, c1_t0, c1_t1, ...]. + stacked = torch.tensor( + [ + [0, 1, 10, 11, 20, 21], + [100, 101, 110, 111, 120, 121], + ] + ) + actual = unstack_acoustic_codes(stacked, num_codebooks=3, frame_stacking_factor=2) + expected = torch.tensor( + [ + [0, 10, 20], + [1, 11, 21], + [100, 110, 120], + [101, 111, 121], + ] + ) + torch.testing.assert_close(actual, expected) + + +def test_fold_weight_norm_matches_torch() -> None: + conv = torch.nn.Conv1d(3, 5, 3) + torch.nn.utils.parametrizations.weight_norm(conv) + g = conv.parametrizations.weight.original0.detach() + v = conv.parametrizations.weight.original1.detach() + torch.testing.assert_close(fold_weight_norm(g, v), conv.weight.detach()) + + +@pytest.mark.parametrize( + ("kwargs", "message"), + [ + ({"activation": "snake"}, "half_snake"), + ({"codebook_size": 8}, "product of num_levels_per_group"), + ({"pre_upsample_filters": [3]}, "divisible"), + ], +) +def test_codec_config_rejects_unsupported_topologies(kwargs, message) -> None: + config_kwargs = { + "input_dim": 4, + "input_filters": 8, + "hidden_filters": 16, + "num_hidden_layers": 2, + "pre_upsample_rates": [2], + "pre_upsample_filters": [8], + "resblock_upsample_rates": [2], + "resblock_upsample_filters": [4], + "num_codebooks": 2, + "codebook_size": 4, + "num_levels_per_group": [2, 2], + "frame_stacking_factor": 2, + } + config_kwargs.update(kwargs) + + with pytest.raises(ValueError, match=message): + EasyMagpieCodecConfig(**config_kwargs) diff --git a/tests/collections/tts/easymagpie_vllm_omni/serving/test_config.py b/tests/collections/tts/easymagpie_vllm_omni/serving/test_config.py new file mode 100644 index 000000000000..45c53a01b84c --- /dev/null +++ b/tests/collections/tts/easymagpie_vllm_omni/serving/test_config.py @@ -0,0 +1,132 @@ +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. 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. +"""Pure-Python tests for :class:`EasyMagpieOmniArch`. + +These have no heavy dependencies (no torch / vllm) and validate the derived +quantities and the ``from_hf_config`` merge logic that the rest of the model +relies on for correct vocab sizes and special-token ids. +""" +from __future__ import annotations + +import types + +import pytest + +from easymagpie_vllm_omni.config import ( + EASYMAGPIE_SMALLMAMBA, + NUM_SPECIAL_AUDIO_TOKENS, + SPECIAL_AUDIO_EOS, + SPECIAL_AUDIO_MASK, + EasyMagpieOmniArch, +) + + +def test_derived_codebook_counts(): + arch = EasyMagpieOmniArch(num_audio_codebooks=8, frame_stacking_factor=2, codebook_size=1024) + assert arch.num_stacked_codebooks == 16 + assert arch.num_all_tokens_per_codebook == 1024 + NUM_SPECIAL_AUDIO_TOKENS + + +def test_special_token_ids_default_to_codebook_offsets(): + arch = EasyMagpieOmniArch(codebook_size=1024) + assert arch.audio_eos_id == 1024 + SPECIAL_AUDIO_EOS + assert arch.mask_token_id == 1024 + SPECIAL_AUDIO_MASK + # EOS must remain inside the per-codebook vocab so it stays sampleable. + assert arch.audio_eos_id < arch.num_all_tokens_per_codebook + + +def test_forced_special_token_ids_override_defaults(): + arch = EasyMagpieOmniArch( + codebook_size=1024, + forced_audio_bos_id=1024, + forced_audio_eos_id=1025, + forced_mask_token_id=1028, + ) + assert arch.audio_bos_id == 1024 + assert arch.audio_eos_id == 1025 + assert arch.mask_token_id == 1028 + + +def test_phoneme_ids_fall_back_to_tokenizer_convention(): + arch = EasyMagpieOmniArch(phoneme_vocab_size=2051) + assert arch.resolved_phoneme_bos_id == 2048 + assert arch.resolved_phoneme_eos_id == 2049 + assert arch.resolved_phoneme_unk_id == 2050 + + +def test_text_eos_id_supports_appended_multiturn_token(): + assert EasyMagpieOmniArch().resolved_text_eos_id(text_vocab_size=103) == 101 + arch = EasyMagpieOmniArch(text_eos_id=101, use_multiturn_dataset=True) + assert arch.resolved_text_eos_id(text_vocab_size=104) == 101 + + +def test_from_hf_config_overrides_and_ignores_unknown(): + hf_config = types.SimpleNamespace( + num_audio_codebooks=4, + codebook_size=2048, + frame_stacking_factor=1, + text_eos_id=32001, + use_multiturn_dataset=True, + local_transformer_n_layers=5, + some_unrelated_field="ignored", + ) + arch = EasyMagpieOmniArch.from_hf_config(hf_config) + assert arch.num_audio_codebooks == 4 + assert arch.codebook_size == 2048 + assert arch.frame_stacking_factor == 1 + assert arch.text_eos_id == 32001 + assert arch.use_multiturn_dataset is True + assert arch.local_transformer_n_layers == 5 + # Untouched fields keep the default profile. + assert arch.audio_embedding_dim == EASYMAGPIE_SMALLMAMBA.audio_embedding_dim + + +def test_from_hf_config_hidden_size_fallback(): + hf_config = types.SimpleNamespace(hidden_size=999) + arch = EasyMagpieOmniArch.from_hf_config(hf_config) + assert arch.hidden_dim == 999 + # embedding_dim defaults to the same backbone width when not given explicitly. + assert arch.embedding_dim == 999 + + +def test_text_prefill_includes_first_phoneme_bos_position(): + arch = EasyMagpieOmniArch( + streaming_phonemes_delay=3, + streaming_speech_delay=5, + ) + + assert arch.text_prefill_num == 4 + + +@pytest.mark.parametrize( + ("kwargs", "message"), + [ + ({"hidden_dim": 1024, "embedding_dim": 1536}, "hidden_dim.*embedding_dim"), + ({"local_transformer_n_heads": 7}, "local_transformer_hidden_dim.*divisible"), + ({"phoneme_stacking_factor": 0, "phoneme_vocab_size": 2051}, "both enabled or both disabled"), + ( + {"streaming_phonemes_delay": 3, "streaming_speech_delay": 3}, + "streaming_speech_delay.*greater", + ), + ({"forced_audio_eos_id": 7}, "forced_audio_eos_id"), + ], +) +def test_validate_rejects_unsupported_architectures(kwargs, message): + with pytest.raises(ValueError, match=message): + EasyMagpieOmniArch(**kwargs).validate() + + +def test_from_hf_config_validates_text_eos_id(): + with pytest.raises(ValueError, match="text_eos_id"): + EasyMagpieOmniArch.from_hf_config(types.SimpleNamespace(text_vocab_size=10, text_eos_id=10)) diff --git a/tests/collections/tts/easymagpie_vllm_omni/serving/test_local_transformer.py b/tests/collections/tts/easymagpie_vllm_omni/serving/test_local_transformer.py new file mode 100644 index 000000000000..915e74336508 --- /dev/null +++ b/tests/collections/tts/easymagpie_vllm_omni/serving/test_local_transformer.py @@ -0,0 +1,95 @@ +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. 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 local-transformer sampling contracts.""" +from __future__ import annotations + +import pytest + +torch = pytest.importorskip("torch") +pytest.importorskip("vllm") + +from conftest import build_vllm_config # noqa: E402 +from easymagpie_vllm_omni.config import EasyMagpieOmniArch # noqa: E402 +from easymagpie_vllm_omni.local_transformer import EasyMagpieCodePredictor # noqa: E402 + +# Cover identity and linear projection paths. +ARCH_PROFILES = { + "equal_dims": dict( + hidden_dim=64, + embedding_dim=64, + audio_embedding_dim=64, + local_transformer_hidden_dim=64, + local_transformer_n_heads=4, + ), + "mixed_dims": dict( + hidden_dim=64, + embedding_dim=64, + audio_embedding_dim=48, + local_transformer_hidden_dim=80, + local_transformer_n_heads=4, + ), +} + + +def _build_predictor(profile_kwargs: dict): + """Build an initialized code predictor and its derived architecture.""" + cfg = build_vllm_config(**profile_kwargs) + arch = EasyMagpieOmniArch.from_hf_config(cfg.model_config.hf_config) + + cp = EasyMagpieCodePredictor(vllm_config=cfg, prefix="code_predictor").eval() + cp.init_forbidden_mask() + return cp, arch + + +@pytest.mark.unit +def test_generate_codes_shape_dtype_and_range(): + """``generate_codes`` returns valid (num_tokens, num_codebooks) int64 codes within vocab.""" + cp, arch = _build_predictor(ARCH_PROFILES["equal_dims"]) + num_tokens = 5 + + torch.manual_seed(0) + codes = cp.generate_codes(torch.randn(num_tokens, arch.hidden_dim)) + + assert codes.shape == (num_tokens, arch.num_stacked_codebooks) + assert codes.dtype == torch.long + assert codes.min().item() >= 0 + assert codes.max().item() < arch.num_all_tokens_per_codebook + + +@pytest.mark.unit +def test_generate_codes_respects_forbidden_mask(): + """With argmax sampling, forbidden special tokens are never emitted (only EOS stays reachable).""" + cp, arch = _build_predictor(ARCH_PROFILES["equal_dims"]) + cp.temperature = 0.0 # argmax over masked logits + + torch.manual_seed(0) + codes = cp.generate_codes(torch.randn(7, arch.hidden_dim)) + + # Allowed = real codebook tokens [0, codebook_size) plus the audio EOS id. + allowed = (codes < arch.codebook_size) | (codes == arch.audio_eos_id) + assert allowed.all(), f"sampled forbidden tokens: {sorted(set(codes[~allowed].tolist()))}" + + +@pytest.mark.unit +def test_generate_codes_deterministic_with_seed(): + """Same seed + same input ⇒ identical sampled codes (sampler is RNG-driven, no host state).""" + cp, arch = _build_predictor(ARCH_PROFILES["equal_dims"]) + dec_hidden = torch.randn(4, arch.hidden_dim) + + torch.manual_seed(7) + first = cp.generate_codes(dec_hidden) + torch.manual_seed(7) + second = cp.generate_codes(dec_hidden) + + assert torch.equal(first, second) diff --git a/tests/collections/tts/easymagpie_vllm_omni/serving/test_model_prefill.py b/tests/collections/tts/easymagpie_vllm_omni/serving/test_model_prefill.py new file mode 100644 index 000000000000..dc64fc11a613 --- /dev/null +++ b/tests/collections/tts/easymagpie_vllm_omni/serving/test_model_prefill.py @@ -0,0 +1,48 @@ +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. 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 torch +from easymagpie_vllm_omni.easymagpie import EasyMagpieTTSForConditionalGeneration +from torch import nn + + +def test_text_prefill_embeddings_add_phoneme_bos_at_position_three(): + model = EasyMagpieTTSForConditionalGeneration.__new__(EasyMagpieTTSForConditionalGeneration) + nn.Module.__init__(model) + model.arch = SimpleNamespace(text_prefill_num=4, phoneme_stacking_factor=1) + model.embedding_dim = 3 + model.has_phoneme = True + model.phonemes_delay = 3 + model.phoneme_bos_id = 7 + model.text_embedding = nn.Embedding(32, 3) + model.phoneme_embeddings = nn.ModuleList([nn.Embedding(16, 3)]) + + with torch.no_grad(): + model.text_embedding.weight.zero_() + model.phoneme_embeddings[0].weight.zero_() + for index, token_id in enumerate((10, 11, 12, 13), start=1): + model.text_embedding.weight[token_id] = torch.tensor([index, 0, 0]) + model.phoneme_embeddings[0].weight[7] = torch.tensor([0, 0, 10]) + + rows = model._build_text_prefill_embeds( + torch.device("cpu"), + torch.float32, + {"text_prefill_num": 4, "prefill_text_tokens": [10, 11, 12, 13]}, + ) + + torch.testing.assert_close( + rows, + torch.tensor([[1, 0, 0], [2, 0, 0], [3, 0, 0], [4, 0, 10]], dtype=torch.float32), + ) diff --git a/tests/collections/tts/easymagpie_vllm_omni/serving/test_pipeline.py b/tests/collections/tts/easymagpie_vllm_omni/serving/test_pipeline.py new file mode 100644 index 000000000000..849884fb1b96 --- /dev/null +++ b/tests/collections/tts/easymagpie_vllm_omni/serving/test_pipeline.py @@ -0,0 +1,41 @@ +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. 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 the standalone EasyMagpie LM pipeline topology.""" +from __future__ import annotations + +import pytest + +pytest.importorskip("vllm_omni") + +from easymagpie_vllm_omni.pipeline import EASYMAGPIE_LM_PIPELINE, EASYMAGPIE_PIPELINE # noqa: E402 + + +def test_lm_pipeline_is_single_stage(): + assert EASYMAGPIE_LM_PIPELINE.model_type == "easymagpie_lm" + assert len(EASYMAGPIE_LM_PIPELINE.stages) == 1 + stage = EASYMAGPIE_LM_PIPELINE.stages[0] + assert stage.stage_id == 0 + assert stage.model_stage == "easymagpie" + assert stage.final_output is True + assert stage.final_output_type == "audio" + assert stage.engine_output_type == "audio" + assert stage.custom_process_next_stage_input_func is None + assert stage.async_chunk_process_next_stage_input_func is None + + +def test_two_stage_pipeline_unchanged(): + assert EASYMAGPIE_PIPELINE.model_type == "easymagpie" + assert len(EASYMAGPIE_PIPELINE.stages) == 2 + assert EASYMAGPIE_PIPELINE.stages[1].final_output is True + assert EASYMAGPIE_PIPELINE.stages[1].final_output_type == "audio" diff --git a/tests/collections/tts/easymagpie_vllm_omni/serving/test_runner.py b/tests/collections/tts/easymagpie_vllm_omni/serving/test_runner.py new file mode 100644 index 000000000000..44148a135b48 --- /dev/null +++ b/tests/collections/tts/easymagpie_vllm_omni/serving/test_runner.py @@ -0,0 +1,60 @@ +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. 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 the vLLM-Omni 0.24 streaming runner compatibility layer.""" +from __future__ import annotations + +import torch +import yaml + +from conftest import EASYMAGPIE_ROOT +from easymagpie_vllm_omni.runner import merge_streaming_additional_information + +WORKER_CLS = "easymagpie_vllm_omni.runner.EasyMagpieGPUARWorker" + + +def test_streaming_update_preserves_model_state_and_replaces_latest_chunk(): + cached = { + "decode_offset": 7, + "text_tokens": [10, 20], + "text_token": [20], + "meta": {"num_processed_tokens": 3}, + } + + merged = merge_streaming_additional_information(cached, {"text_token": [30]}) + + assert merged["decode_offset"] == 7 + assert merged["text_tokens"] == [10, 20] + assert merged["text_token"] == [30] + assert merged["meta"]["num_processed_tokens"] == 0 + assert merged["meta"]["resumable"] is True + + +def test_streaming_update_accumulates_declared_tensor_keys(): + cached = {"hidden_states": {"output": torch.tensor([[1.0]])}} + incoming = {"hidden_states": {"output": torch.tensor([[2.0]])}} + + merged = merge_streaming_additional_information( + cached, + incoming, + accumulated_keys={("hidden_states", "output")}, + ) + + torch.testing.assert_close(merged["hidden_states"]["output"], torch.tensor([[1.0], [2.0]])) + + +def test_deploy_configs_select_compatibility_worker_for_lm(): + for filename in ("easymagpie_lm.yaml", "easymagpie.yaml"): + deploy = yaml.safe_load((EASYMAGPIE_ROOT / "deploy" / filename).read_text()) + lm_stage = next(stage for stage in deploy["stages"] if stage["stage_id"] == 0) + assert lm_stage["engine_extras"]["worker_cls"] == WORKER_CLS diff --git a/tests/collections/tts/easymagpie_vllm_omni/serving/test_scheduler.py b/tests/collections/tts/easymagpie_vllm_omni/serving/test_scheduler.py new file mode 100644 index 000000000000..baa425054420 --- /dev/null +++ b/tests/collections/tts/easymagpie_vllm_omni/serving/test_scheduler.py @@ -0,0 +1,341 @@ +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. 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 the vLLM-Omni 0.24 async scheduler compatibility layer.""" +from __future__ import annotations + +import threading +from collections import deque +from types import SimpleNamespace + +import pytest +import torch +from easymagpie_vllm_omni.scheduler import ( + EasyMagpieARAsyncScheduler, + EasyMagpieCodecScheduler, + _poll_native_codec_chunk, +) +from vllm.v1.request import RequestStatus +from vllm_omni.core.sched.omni_ar_scheduler import OmniARAsyncScheduler +from vllm_omni.distributed.omni_connectors.transfer_adapter.chunk_transfer_adapter import OmniChunkTransferAdapter + + +def test_no_stop_is_inert_for_non_resumable_requests(monkeypatch): + """A plain HTTP request never hits a segment stop, so the override must pass + ``super()`` through unchanged and leave request accounting untouched.""" + scheduler = object.__new__(EasyMagpieARAsyncScheduler) + request = SimpleNamespace( + async_tokens_to_discard=0, + num_computed_tokens=20, + num_output_placeholders=0, + ) + + def fake_update_request_with_output(self, req, new_token_ids): + return new_token_ids, False # no stop this step + + def fake_update_from_output(self, scheduler_output, model_runner_output): + self._update_request_with_output(request, [7]) + return "outputs" + + monkeypatch.setattr(OmniARAsyncScheduler, "_update_request_with_output", fake_update_request_with_output) + monkeypatch.setattr(OmniARAsyncScheduler, "update_from_output", fake_update_from_output) + + outputs = scheduler.update_from_output(None, None) + + assert outputs == "outputs" + assert request.async_tokens_to_discard == 0 + assert request.num_computed_tokens == 20 + assert request.num_output_placeholders == 0 + + +def test_terminal_stop_without_discard_is_inert(monkeypatch): + """HTTP requests end on a normal audio-EOS stop (not a resumable segment + stop), so omni arms no discard and the override must not roll anything back.""" + scheduler = object.__new__(EasyMagpieARAsyncScheduler) + request = SimpleNamespace( + async_tokens_to_discard=0, + num_computed_tokens=20, + num_output_placeholders=0, + ) + + def fake_update_request_with_output(self, req, new_token_ids): + req.num_output_placeholders = 0 # terminal stop, nothing else in flight + return new_token_ids, True + + def fake_update_from_output(self, scheduler_output, model_runner_output): + self._update_request_with_output(request, [7]) + return "outputs" # omni does not arm a discard for a terminal stop + + monkeypatch.setattr(OmniARAsyncScheduler, "_update_request_with_output", fake_update_request_with_output) + monkeypatch.setattr(OmniARAsyncScheduler, "update_from_output", fake_update_from_output) + + outputs = scheduler.update_from_output(None, None) + + assert outputs == "outputs" + assert request.async_tokens_to_discard == 0 + assert request.num_computed_tokens == 20 + + +@pytest.mark.parametrize("remaining_placeholders", [0, 1, 2]) +def test_segment_stop_discards_and_rolls_back_exact_inflight_count(monkeypatch, remaining_placeholders): + scheduler = object.__new__(EasyMagpieARAsyncScheduler) + request = SimpleNamespace( + async_tokens_to_discard=0, + num_computed_tokens=20, + num_output_placeholders=remaining_placeholders + 1, + ) + + def fake_update_request_with_output(self, req, new_token_ids): + req.num_output_placeholders -= 1 # stopping token returned + return new_token_ids, True + + def fake_update_from_output(self, scheduler_output, model_runner_output): + self._update_request_with_output(request, [7]) + request.async_tokens_to_discard = 1 + request.num_output_placeholders = 0 + return "outputs" + + monkeypatch.setattr(OmniARAsyncScheduler, "_update_request_with_output", fake_update_request_with_output) + monkeypatch.setattr(OmniARAsyncScheduler, "update_from_output", fake_update_from_output) + + outputs = scheduler.update_from_output(None, None) + + assert outputs == "outputs" + assert request.async_tokens_to_discard == remaining_placeholders + assert request.num_computed_tokens == 20 - remaining_placeholders + + +def test_final_streaming_sentinel_marks_session_non_resumable(monkeypatch): + scheduler = object.__new__(EasyMagpieARAsyncScheduler) + request = SimpleNamespace(resumable=True, streaming_queue=deque([None])) + + def fake_handle_stopped_request(self, req): + assert req.resumable is False + return True + + monkeypatch.setattr(OmniARAsyncScheduler, "_handle_stopped_request", fake_handle_stopped_request) + + assert scheduler._handle_stopped_request(request) is True + assert request.resumable is False + + +def test_empty_streaming_queue_remains_resumable_while_waiting(monkeypatch): + scheduler = object.__new__(EasyMagpieARAsyncScheduler) + request = SimpleNamespace(resumable=True, streaming_queue=deque()) + monkeypatch.setattr(OmniARAsyncScheduler, "_handle_stopped_request", lambda self, req: False) + + assert scheduler._handle_stopped_request(request) is False + assert request.resumable is True + + +def test_resume_uses_exact_discard_count_and_forwards_chunk_metadata(monkeypatch): + scheduler = object.__new__(EasyMagpieARAsyncScheduler) + scheduler.vllm_config = SimpleNamespace(model_config=SimpleNamespace(stage_id=0)) + session = SimpleNamespace( + async_tokens_to_discard=0, + num_computed_tokens=20, + num_output_placeholders=2, + num_tokens=23, + max_tokens=1, + additional_information={"text_token": [1]}, + ) + update = SimpleNamespace(max_tokens=5, additional_information={"text_token": [2, 3]}) + + def fake_update_request_as_session(self, req, streaming_update): + req.async_tokens_to_discard = 1 + req.num_computed_tokens -= req.num_output_placeholders + req.num_output_placeholders = 0 + + monkeypatch.setattr(OmniARAsyncScheduler, "_update_request_as_session", fake_update_request_as_session) + + scheduler._update_request_as_session(session, update) + + assert session.async_tokens_to_discard == 2 + assert session.num_computed_tokens == 18 + assert session.max_tokens == 5 + assert session.additional_information == {"text_token": [2, 3]} + + +def test_native_codec_chunk_appends_prompt_without_resetting_state(monkeypatch): + adapter = object.__new__(OmniChunkTransferAdapter) + adapter._easymagpie_num_quantizers = 2 + adapter._easymagpie_chunk_lock = threading.Lock() + adapter.get_req_chunk = {"request": 1} + request = SimpleNamespace( + prompt_token_ids=[0, 0], + request_id="request", + _all_token_ids=[0, 0], + num_computed_tokens=2, + num_prompt_tokens=2, + additional_information=None, + update_block_hashes=lambda: None, + ) + + def fake_poll(self, req): + req.prompt_token_ids = [0] + req._all_token_ids[:] = [] + req.num_computed_tokens = 0 + req.additional_information = {"codes": {"audio": torch.ones((3, 2), dtype=torch.long)}} + return True + + monkeypatch.setattr(OmniChunkTransferAdapter, "_poll_single_request", fake_poll) + + assert _poll_native_codec_chunk(adapter, request) is True + assert request.prompt_token_ids == [0, 0, 0, 0, 0] + assert request._all_token_ids == [0, 0, 0, 0, 0] + assert request.num_prompt_tokens == 5 + assert request.num_computed_tokens == 2 + assert request.additional_information["codes"]["audio"].shape == (3, 2) + + prewarm_request = SimpleNamespace( + prompt_token_ids=[0], + request_id="request", + _all_token_ids=[0], + num_computed_tokens=0, + num_prompt_tokens=1, + additional_information=None, + update_block_hashes=lambda: None, + ) + assert _poll_native_codec_chunk(adapter, prewarm_request) is True + assert prewarm_request.prompt_token_ids == [0, 0, 0] + assert prewarm_request._all_token_ids == [0, 0, 0] + assert prewarm_request.num_computed_tokens == 0 + + +def test_native_codec_empty_segment_marker_does_not_reset_state(monkeypatch): + adapter = object.__new__(OmniChunkTransferAdapter) + adapter._easymagpie_num_quantizers = 2 + adapter._easymagpie_chunk_lock = threading.Lock() + request = SimpleNamespace( + prompt_token_ids=[0, 0, 0], + request_id="request", + _all_token_ids=[0, 0, 0], + num_computed_tokens=3, + num_prompt_tokens=3, + additional_information={"codes": {"audio": torch.ones((3, 2), dtype=torch.long)}}, + update_block_hashes=lambda: None, + ) + + def fake_poll(self, req): + # The base adapter resets these fields before deciding that an empty + # non-terminal segment marker is not a schedulable codec chunk. + req.prompt_token_ids = [] + req._all_token_ids[:] = [] + req.num_prompt_tokens = 0 + req.num_computed_tokens = 0 + req.additional_information = {"meta": {"is_segment_finished": True}} + return False + + monkeypatch.setattr(OmniChunkTransferAdapter, "_poll_single_request", fake_poll) + + assert _poll_native_codec_chunk(adapter, request) is False + assert request.prompt_token_ids == [0, 0, 0] + assert request._all_token_ids == [0, 0, 0] + assert request.num_prompt_tokens == 3 + assert request.num_computed_tokens == 3 + + +def test_native_codec_streaming_update_preserves_state_and_resumes_polling(): + scheduler = object.__new__(EasyMagpieCodecScheduler) + scheduler.chunk_transfer_adapter = SimpleNamespace(segment_finished_requests={"request"}) + scheduler.num_waiting_for_streaming_input = 1 + scheduler.log_stats = False + + class _SkippedWaiting(list): + def remove_requests(self, requests): + for request in requests: + self.remove(request) + + original_prompt = [0, 0, 0, 0, 0] + original_all_tokens = [0, 0, 0, 0, 0] + session = SimpleNamespace( + request_id="request", + prompt_token_ids=original_prompt, + _all_token_ids=original_all_tokens, + num_prompt_tokens=5, + num_computed_tokens=5, + additional_information={"codes": {"audio": torch.ones((2, 2), dtype=torch.long)}}, + arrival_time=1.0, + sampling_params="old", + _output_token_ids=[], + update_block_hashes=lambda: None, + status=RequestStatus.WAITING_FOR_STREAMING_REQ, + ) + scheduler.skipped_waiting = _SkippedWaiting([session]) + enqueued = [] + scheduler._enqueue_waiting_request = enqueued.append + update = SimpleNamespace( + prompt_token_ids=[0], + additional_information=None, + arrival_time=2.0, + sampling_params="new", + ) + + scheduler._update_request_as_session(session, update) + + assert session.prompt_token_ids is original_prompt + assert session._all_token_ids is original_all_tokens + assert session.num_prompt_tokens == 5 + assert session.num_computed_tokens == 5 + assert session.additional_information["codes"]["audio"].shape == (2, 2) + assert session.arrival_time == 2.0 + assert session.sampling_params == "new" + assert session.status == RequestStatus.WAITING + assert scheduler.num_waiting_for_streaming_input == 0 + assert scheduler.chunk_transfer_adapter.segment_finished_requests == set() + assert scheduler.skipped_waiting == [] + assert enqueued == [session] + + +@pytest.mark.parametrize( + ("status", "queue_name", "num_waiting"), + [ + (RequestStatus.WAITING, "waiting", 0), + (RequestStatus.WAITING_FOR_STREAMING_REQ, "skipped_waiting", 1), + ], +) +def test_native_codec_segment_resume_stays_on_cached_request_path(status, queue_name, num_waiting): + scheduler = object.__new__(EasyMagpieCodecScheduler) + scheduler.chunk_transfer_adapter = SimpleNamespace(segment_finished_requests={"request"}) + scheduler.num_waiting_for_streaming_input = num_waiting + scheduler.running = [] + + class _RequestQueue(list): + def remove_requests(self, requests): + for request in requests: + self.remove(request) + + session = SimpleNamespace( + request_id="request", + prompt_token_ids=[0] * 6, + _all_token_ids=[0] * 6, + num_prompt_tokens=6, + num_computed_tokens=6, + status=status, + ) + scheduler.waiting = _RequestQueue() + scheduler.skipped_waiting = _RequestQueue() + getattr(scheduler, queue_name).append(session) + + scheduler._resume_codec_after_segment(session) + + assert scheduler.waiting == [] + assert scheduler.skipped_waiting == [] + assert scheduler.running == [session] + assert session.status == RequestStatus.RUNNING + assert session.prompt_token_ids == [0] * 6 + assert session._all_token_ids == [0] * 6 + assert session.num_prompt_tokens == 6 + assert session.num_computed_tokens == 6 + assert scheduler.num_waiting_for_streaming_input == 0 + assert scheduler.chunk_transfer_adapter.segment_finished_requests == set() diff --git a/tests/collections/tts/easymagpie_vllm_omni/serving/test_serving_stream.py b/tests/collections/tts/easymagpie_vllm_omni/serving/test_serving_stream.py new file mode 100644 index 000000000000..e58dd128f063 --- /dev/null +++ b/tests/collections/tts/easymagpie_vllm_omni/serving/test_serving_stream.py @@ -0,0 +1,358 @@ +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. 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 __future__ import annotations + +import asyncio +import json +from types import SimpleNamespace + +import pytest +from easymagpie_vllm_omni.serving_adapter import _build_adapter_cls +from easymagpie_vllm_omni.serving_stream import EasyMagpieInputStream, EasyMagpieStreamingSpeechHandler +from vllm.sampling_params import RequestOutputKind, SamplingParams + + +def test_input_stream_default_pacing_tolerates_loaded_service_queueing(): + stream = EasyMagpieInputStream( + prefill_prompt={"prompt_token_ids": [0]}, + sampling_params=SamplingParams(max_tokens=32), + text_eos_id=99, + max_new_tokens=32, + ) + + # Segment completions took 2-3 seconds with 32 concurrent service requests. + # The watchdog must distinguish that normal queueing from a stalled engine. + assert stream.pace_timeout_s >= 30.0 + + +@pytest.mark.asyncio +async def test_input_stream_builds_one_resumable_request_from_token_chunks(): + params = SamplingParams( + temperature=0.0, + max_tokens=2048, + detokenize=False, + ignore_eos=True, + stop_token_ids=[1], + output_kind=RequestOutputKind.DELTA, + ) + stream = EasyMagpieInputStream( + prefill_prompt={"prompt_token_ids": [0, 0], "additional_information": {"speaker_id": "eng"}}, + sampling_params=params, + text_eos_id=99, + max_new_tokens=20, + pace_timeout_s=0.0, + ) + + await stream.put_tokens([10, 11, 12]) + await stream.put_tokens([13, 14]) + await stream.finish() + chunks = [chunk async for chunk in stream.inputs()] + + assert chunks[0].prompt["prompt_token_ids"] == [0, 0] + assert chunks[0].prompt["additional_information"] == { + "speaker_id": "eng", + "text_token": [10, 11, 12], + "text_token_start": 0, + } + assert chunks[0].sampling_params.max_tokens == 3 + assert chunks[1].prompt["additional_information"] == {"text_token": [13, 14, 99], "text_token_start": 3} + assert chunks[1].sampling_params.max_tokens == 3 + assert chunks[2].prompt["additional_information"] == {"text_token": []} + assert chunks[2].sampling_params.max_tokens == 20 + + +@pytest.mark.asyncio +async def test_input_stream_prefills_four_tokens_and_requires_them_in_first_update(): + params = SamplingParams(max_tokens=32, output_kind=RequestOutputKind.DELTA) + stream = EasyMagpieInputStream( + prefill_prompt={ + "prompt_token_ids": [0] * 6, + "additional_information": {"speaker_id": "eng", "text_prefill_num": 4}, + }, + sampling_params=params, + text_eos_id=99, + max_new_tokens=32, + text_prefill_num=4, + pace_timeout_s=0.0, + ) + + with pytest.raises(ValueError, match="first input update must contain at least 4"): + await stream.put_tokens([10, 11, 12]) + + stream = EasyMagpieInputStream( + prefill_prompt={ + "prompt_token_ids": [0] * 6, + "additional_information": {"speaker_id": "eng", "text_prefill_num": 4}, + }, + sampling_params=params, + text_eos_id=99, + max_new_tokens=32, + text_prefill_num=4, + pace_timeout_s=0.0, + ) + await stream.put_tokens([10, 11, 12, 13, 14]) + await stream.finish() + chunks = [chunk async for chunk in stream.inputs()] + + assert chunks[0].prompt["additional_information"] == { + "speaker_id": "eng", + "text_prefill_num": 4, + "prefill_text_tokens": [10, 11, 12, 13], + "text_token": [10, 11, 12, 13, 14], + "text_token_start": 0, + } + assert chunks[0].sampling_params.max_tokens == 2 + + +@pytest.mark.asyncio +async def test_http_adapter_folds_four_text_positions_into_prefill(): + adapter = _build_adapter_cls()(SimpleNamespace(engine_client=None)) + adapter._prompt_len = lambda _speaker_id: 2 + adapter._text_stream_metadata = lambda: (99, 4) + adapter._model_tokenizer = lambda: SimpleNamespace(encode=lambda *_args, **_kwargs: [10, 11, 12, 13, 14]) + request = SimpleNamespace( + input="hello", + voice="eng", + extra_params=None, + ) + + prepared = await adapter.build( + request, + sampling_params_list=[], + has_inline_ref_audio=False, + ) + + prompt = prepared.prompt + info = prompt["additional_information"] + assert len(prompt["prompt_token_ids"]) == 6 + assert info["text_tokens"] == [10, 11, 12, 13, 14, 99] + assert info["prefill_text_tokens"] == [10, 11, 12, 13] + assert info["text_prefill_num"] == 4 + + +def test_adapter_treats_null_text_eos_as_legacy_vocab_offset(): + adapter = _build_adapter_cls()(SimpleNamespace(engine_client=None)) + adapter._model_config_cache = { + "text_vocab_size": 100, + "text_eos_id": None, + "streaming_phonemes_delay": 3, + "streaming_speech_delay": 5, + } + + assert adapter._text_stream_metadata() == (98, 4) + + +@pytest.mark.asyncio +async def test_input_stream_preserves_one_token_chunks_with_absolute_offsets(): + params = SamplingParams(max_tokens=32, output_kind=RequestOutputKind.DELTA) + stream = EasyMagpieInputStream( + prefill_prompt={"prompt_token_ids": [0]}, + sampling_params=params, + text_eos_id=99, + max_new_tokens=32, + pace_timeout_s=0.0, + queue_depth=4, + coalesce_queued_tokens=False, + ) + await stream.put_tokens([10]) + await stream.put_tokens([11]) + await stream.put_tokens([12]) + await stream.finish() + + chunks = [chunk async for chunk in stream.inputs()] + + assert [chunk.prompt["additional_information"] for chunk in chunks] == [ + {"text_token": [10], "text_token_start": 0}, + {"text_token": [11], "text_token_start": 1}, + {"text_token": [12], "text_token_start": 2}, + {"text_token": [99], "text_token_start": 3}, + {"text_token": []}, + ] + + +@pytest.mark.asyncio +async def test_input_stream_counts_stage_zero_delta_tokens(): + params = SamplingParams(max_tokens=32, output_kind=RequestOutputKind.DELTA) + stream = EasyMagpieInputStream( + prefill_prompt={"prompt_token_ids": [0]}, + sampling_params=params, + text_eos_id=99, + max_new_tokens=32, + pace_timeout_s=0.01, + ) + + stream.observe_output(SimpleNamespace(stage_id=0, outputs=[SimpleNamespace(token_ids=[1, 1, 1])])) + + assert stream.observed_output_frames == 3 + + +@pytest.mark.asyncio +async def test_input_stream_times_out_before_replacing_segment_conditioning(): + params = SamplingParams(max_tokens=32, output_kind=RequestOutputKind.DELTA) + stream = EasyMagpieInputStream( + prefill_prompt={"prompt_token_ids": [0]}, + sampling_params=params, + text_eos_id=99, + max_new_tokens=32, + pace_timeout_s=0.01, + ) + await stream.put_tokens([10, 11]) + await stream.put_tokens([12]) + inputs = stream.inputs() + + first = await anext(inputs) + assert first.sampling_params.max_tokens == 2 + + stream.observe_output(SimpleNamespace(stage_id=0, outputs=[SimpleNamespace(token_ids=[1])])) + with pytest.raises(TimeoutError, match="waiting for stage-0 segment completion"): + await anext(inputs) + + +@pytest.mark.asyncio +async def test_input_stream_waits_for_segment_completion_after_all_frames(): + params = SamplingParams(max_tokens=32, output_kind=RequestOutputKind.DELTA) + stream = EasyMagpieInputStream( + prefill_prompt={"prompt_token_ids": [0]}, + sampling_params=params, + text_eos_id=99, + max_new_tokens=32, + pace_timeout_s=1.0, + ) + await stream.put_tokens([10, 11]) + await stream.put_tokens([12]) + inputs = stream.inputs() + await anext(inputs) + + stream.observe_output(SimpleNamespace(stage_id=0, outputs=[SimpleNamespace(token_ids=[1, 1], finish_reason=None)])) + next_input = asyncio.create_task(anext(inputs)) + await asyncio.sleep(0) + assert not next_input.done() + + stream.observe_output(SimpleNamespace(stage_id=0, outputs=[SimpleNamespace(token_ids=[], finish_reason="length")])) + assert (await next_input).prompt["additional_information"] == {"text_token": [12], "text_token_start": 2} + + +@pytest.mark.asyncio +async def test_input_stream_accepts_segment_completion_with_a_dropped_output_frame(): + params = SamplingParams(max_tokens=32, output_kind=RequestOutputKind.DELTA) + stream = EasyMagpieInputStream( + prefill_prompt={"prompt_token_ids": [0]}, + sampling_params=params, + text_eos_id=99, + max_new_tokens=32, + pace_timeout_s=1.0, + ) + await stream.put_tokens([10, 11]) + await stream.put_tokens([12]) + inputs = stream.inputs() + await anext(inputs) + + # Async segment lookahead may discard one in-flight frame. The segment's + # finish event is authoritative and must allow the next text chunk through. + stream.observe_output( + SimpleNamespace(stage_id=0, outputs=[SimpleNamespace(token_ids=[1], finish_reason="length")]) + ) + + next_input = await anext(inputs) + assert next_input.prompt["additional_information"] == {"text_token": [12], "text_token_start": 2} + + +def test_two_stage_pipeline_exposes_lm_progress_for_stream_pacing(): + from easymagpie_vllm_omni.pipeline import EASYMAGPIE_PIPELINE + + lm_stage = EASYMAGPIE_PIPELINE.stages[0] + assert lm_stage.final_output is True + assert lm_stage.final_output_type == "latent" + assert lm_stage.scheduler_cls == "easymagpie_vllm_omni.scheduler.EasyMagpieARAsyncScheduler" + + +@pytest.mark.asyncio +async def test_handler_accepts_token_events_and_streams_pcm(): + class FakeWebSocket: + def __init__(self): + self.received = iter( + [ + {"type": "session.config", "voice": "eng", "stream_audio": True, "response_format": "pcm"}, + {"type": "input.tokens", "tokens": [10, 11]}, + {"type": "input.done"}, + ] + ) + self.json_messages = [] + self.audio_chunks = [] + self.accepted = False + + async def accept(self): + self.accepted = True + + async def receive_text(self): + return json.dumps(next(self.received)) + + async def send_json(self, payload): + self.json_messages.append(payload) + + async def send_bytes(self, payload): + self.audio_chunks.append(payload) + + class FakeEngine: + default_sampling_params_list = [ + SamplingParams(max_tokens=20, output_kind=RequestOutputKind.DELTA), + SamplingParams(max_tokens=20, output_kind=RequestOutputKind.DELTA), + ] + + def generate(self, *, prompt, **_kwargs): + async def outputs(): + async for chunk in prompt: + count = chunk.sampling_params.max_tokens + yield SimpleNamespace( + stage_id=0, + outputs=[SimpleNamespace(token_ids=[1] * count, finish_reason="length")], + ) + + return outputs() + + async def abort(self, _request_id): + raise AssertionError("completed streams must not be aborted") + + class FakeAdapter: + @staticmethod + def build_streaming_spec(_request): + return SimpleNamespace( + prefill_prompt={"prompt_token_ids": [0]}, + tokenizer=SimpleNamespace(encode=lambda text, add_special_tokens=False: [len(text)]), + text_eos_id=99, + sample_rate=22050, + ) + + class FakeSpeechService: + _tts_model_type = "easymagpie" + engine_client = FakeEngine() + + @staticmethod + def _get_tts_adapter(): + return FakeAdapter() + + @staticmethod + async def _generate_pcm_chunks(generator, _request_id): + async for _output in generator: + yield b"\x01\x02" + + websocket = FakeWebSocket() + handler = EasyMagpieStreamingSpeechHandler(FakeSpeechService()) + await handler.handle_session(websocket) + + assert websocket.accepted + assert websocket.audio_chunks + assert websocket.json_messages[0]["type"] == "audio.start" + assert websocket.json_messages[-2]["type"] == "audio.done" + assert websocket.json_messages[-1] == {"type": "session.done", "total_sentences": 1} diff --git a/tests/collections/tts/easymagpie_vllm_omni/serving/test_stage_processors.py b/tests/collections/tts/easymagpie_vllm_omni/serving/test_stage_processors.py new file mode 100644 index 000000000000..4fd5b1a8aa97 --- /dev/null +++ b/tests/collections/tts/easymagpie_vllm_omni/serving/test_stage_processors.py @@ -0,0 +1,256 @@ +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. 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 __future__ import annotations + +from collections import defaultdict +from types import SimpleNamespace + +import pytest +import torch +from easymagpie_vllm_omni.stage_processors import talker2code2wav_async_chunk + + +class _Request: + external_req_id = "request-0" + + def __init__(self): + self.finished = False + self.resumable = True + self.output_token_ids = [] + + def is_finished(self): + return self.finished + + +def _manager(): + return SimpleNamespace( + config=SimpleNamespace(hf_config=SimpleNamespace(streaming_speech_delay=2)), + connector=SimpleNamespace( + config={ + "extra": { + "codec_chunk_frames": 2, + } + } + ), + code_prompt_token_ids=defaultdict(list), + ) + + +def _output(value: int): + return {"audio_codes": torch.tensor([[value, value + 100]], dtype=torch.long)} + + +def test_async_codec_state_stays_continuous_across_resumable_segments(): + manager = _manager() + request = _Request() + + # Warm-up is counted over the whole request, including segment boundaries. + request.output_token_ids = [0] + assert talker2code2wav_async_chunk(manager, _output(1), request) is None + request.output_token_ids = [0, 0] + request.finished = True + warmup_flush = talker2code2wav_async_chunk(manager, _output(2), request, is_finished=True) + assert warmup_flush.codes.audio.numel() == 0 + manager.code_prompt_token_ids.pop(request.external_req_id, None) + + # The framework buffer is reset per segment, but the processor's request + # state retains the real acoustic frames and its emission high-water mark. + request.finished = False + request.output_token_ids = [0] + assert talker2code2wav_async_chunk(manager, _output(3), request) is None + request.output_token_ids = [0, 0] + request.finished = True + first = talker2code2wav_async_chunk(manager, _output(4), request, is_finished=True) + torch.testing.assert_close(first.codes.audio, torch.tensor([[3, 103], [4, 104]])) + assert first.meta.left_context_size == 0 + manager.code_prompt_token_ids.pop(request.external_req_id, None) + + request.finished = False + request.output_token_ids = [0] + assert talker2code2wav_async_chunk(manager, _output(5), request) is None + request.output_token_ids = [0, 0] + second = talker2code2wav_async_chunk(manager, _output(6), request) + torch.testing.assert_close(second.codes.audio, torch.tensor([[5, 105], [6, 106]])) + assert second.meta.left_context_size == 0 + + # Repeated segment flushes at the same length must not duplicate audio. + request.finished = True + assert talker2code2wav_async_chunk(manager, None, request, is_finished=True) is None + + # Terminal completion releases the request-persistent state. + request.resumable = False + assert talker2code2wav_async_chunk(manager, None, request, is_finished=True) is None + assert request.external_req_id not in manager._emp_seen_frames + assert request.external_req_id not in manager._emp_request_speech_delay + assert request.external_req_id not in manager._emp_emitted_frames + assert request.external_req_id not in manager._emp_emitted_chunks + assert request.external_req_id not in manager._emp_frame_buffer_base + assert request.external_req_id not in manager._emp_frame_buffer + + +def test_resumable_segment_stop_does_not_flush_partial_codec_chunk(): + manager = _manager() + manager.config.hf_config.streaming_speech_delay = 0 + manager.connector.config["extra"].update({"codec_chunk_frames": 4}) + request = _Request() + + assert talker2code2wav_async_chunk(manager, _output(1), request) is None + request.finished = True + assert talker2code2wav_async_chunk(manager, _output(2), request, is_finished=True) is None + + request.finished = False + assert talker2code2wav_async_chunk(manager, _output(3), request) is None + full = talker2code2wav_async_chunk(manager, _output(4), request) + assert full is not None + torch.testing.assert_close(full.codes.audio, torch.tensor([[1, 101], [2, 102], [3, 103], [4, 104]])) + + +def test_async_codec_drops_only_warmup_not_moved_into_prefill(): + manager = _manager() + manager.config.hf_config = SimpleNamespace( + streaming_phonemes_delay=3, + streaming_speech_delay=5, + ) + manager.connector.config["extra"]["codec_chunk_frames"] = 1 + request = _Request() + request.additional_information = {"text_prefill_num": 4} + + # The prefill callback carries no generated acoustic frame and must not + # consume the one remaining warm-up slot. + assert ( + talker2code2wav_async_chunk( + manager, + {"audio_codes": torch.zeros(1, 2, dtype=torch.long)}, + request, + ) + is None + ) + assert manager._emp_seen_frames[request.external_req_id] == 0 + + assert talker2code2wav_async_chunk(manager, _output(1), request) is None + first_audio = talker2code2wav_async_chunk(manager, _output(2), request) + torch.testing.assert_close(first_audio.codes.audio, torch.tensor([[2, 102]])) + + +def test_async_codec_drops_terminal_audio_eos_row(): + manager = _manager() + manager.config.hf_config = SimpleNamespace( + streaming_speech_delay=0, + forced_audio_eos_id=1025, + codebook_size=1024, + ) + manager.connector.config["extra"]["codec_chunk_frames"] = 4 + request = _Request() + request.resumable = False + + assert talker2code2wav_async_chunk(manager, _output(1), request) is None + assert talker2code2wav_async_chunk(manager, _output(2), request) is None + + request.finished = True + terminal = talker2code2wav_async_chunk( + manager, + {"audio_codes": torch.tensor([[1025, 777]], dtype=torch.long)}, + request, + is_finished=True, + ) + + assert bool(terminal.meta.finished) + torch.testing.assert_close(terminal.codes.audio, torch.tensor([[1, 101], [2, 102]])) + + +def test_async_codec_buffer_drops_emitted_rows(): + manager = _manager() + manager.config.hf_config.streaming_speech_delay = 0 + request = _Request() + + last = None + for value in range(1, 11): + last = talker2code2wav_async_chunk(manager, _output(value), request) + + assert last is not None + torch.testing.assert_close(last.codes.audio, torch.tensor([[9, 109], [10, 110]])) + assert last.meta.left_context_size == 0 + buffer = manager._emp_frame_buffer[request.external_req_id] + assert buffer == [] + assert manager._emp_frame_buffer_base[request.external_req_id] == 10 + assert manager._emp_emitted_frames[request.external_req_id] == 10 + + +def test_async_codec_uses_configured_startup_chunk_ramp(): + manager = _manager() + manager.config.hf_config.streaming_speech_delay = 0 + manager.connector.config["extra"].update({"codec_chunk_frames": 4, "codec_startup_chunk_frames": [1, 2]}) + request = _Request() + + first = talker2code2wav_async_chunk(manager, _output(1), request) + assert first is not None + torch.testing.assert_close(first.codes.audio, torch.tensor([[1, 101]])) + + assert talker2code2wav_async_chunk(manager, _output(2), request) is None + second = talker2code2wav_async_chunk(manager, _output(3), request) + assert second is not None + torch.testing.assert_close(second.codes.audio, torch.tensor([[2, 102], [3, 103]])) + assert second.meta.left_context_size == 0 + + for value in range(4, 7): + assert talker2code2wav_async_chunk(manager, _output(value), request) is None + steady = talker2code2wav_async_chunk(manager, _output(7), request) + assert steady is not None + torch.testing.assert_close( + steady.codes.audio, + torch.tensor([[4, 104], [5, 105], [6, 106], [7, 107]]), + ) + assert steady.meta.left_context_size == 0 + assert manager._emp_emitted_chunks[request.external_req_id] == 3 + + +def test_async_codec_allows_larger_first_chunk_than_steady_state(): + manager = _manager() + manager.config.hf_config.streaming_speech_delay = 0 + manager.connector.config["extra"].update({"codec_chunk_frames": 4, "codec_startup_chunk_frames": [5]}) + request = _Request() + + for value in range(1, 5): + assert talker2code2wav_async_chunk(manager, _output(value), request) is None + first = talker2code2wav_async_chunk(manager, _output(5), request) + assert first is not None + torch.testing.assert_close(first.codes.audio, torch.tensor([[1, 101], [2, 102], [3, 103], [4, 104], [5, 105]])) + + +def test_async_codec_rejects_invalid_startup_chunk_ramp(): + manager = _manager() + manager.config.hf_config.streaming_speech_delay = 0 + manager.connector.config["extra"].update({"codec_chunk_frames": 4, "codec_startup_chunk_frames": [1, 0]}) + with pytest.raises(ValueError, match="codec_startup_chunk_frames"): + talker2code2wav_async_chunk(manager, _output(1), _Request()) + + +def test_stateful_codec_emits_only_new_time_major_rows(): + manager = _manager() + manager.config.hf_config.streaming_speech_delay = 0 + manager.connector.config["extra"].update({"codec_chunk_frames": 2}) + request = _Request() + + assert talker2code2wav_async_chunk(manager, _output(1), request) is None + first = talker2code2wav_async_chunk(manager, _output(2), request) + assert first.codes.audio.shape == (2, 2) + torch.testing.assert_close(first.codes.audio, torch.tensor([[1, 101], [2, 102]])) + assert first.meta.left_context_size == 0 + assert manager._emp_frame_buffer[request.external_req_id] == [] + + assert talker2code2wav_async_chunk(manager, _output(3), request) is None + second = talker2code2wav_async_chunk(manager, _output(4), request) + assert second.codes.audio.shape == (2, 2) + torch.testing.assert_close(second.codes.audio, torch.tensor([[3, 103], [4, 104]])) + assert manager._emp_frame_buffer_base[request.external_req_id] == 4 diff --git a/tests/collections/tts/easymagpie_vllm_omni/serving/test_streaming_protocol.py b/tests/collections/tts/easymagpie_vllm_omni/serving/test_streaming_protocol.py new file mode 100644 index 000000000000..ee0be618ea72 --- /dev/null +++ b/tests/collections/tts/easymagpie_vllm_omni/serving/test_streaming_protocol.py @@ -0,0 +1,50 @@ +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. 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 __future__ import annotations + +import pytest +from easymagpie_vllm_omni.easymagpie import _merge_streaming_text_chunk + + +def test_absolute_streaming_chunks_append_once_across_lookahead_repeats(): + tokens, appended = _merge_streaming_text_chunk([], [10], 0) + assert tokens == [10] + assert appended + + tokens, appended = _merge_streaming_text_chunk(tokens, [11, 12], 1) + assert tokens == [10, 11, 12] + assert appended + + # The async runner may expose this same segment again on later decode steps. + tokens, appended = _merge_streaming_text_chunk(tokens, [11, 12], 1) + assert tokens == [10, 11, 12] + assert not appended + + # A partially overlapping repeat is also safe and appends only the new tail. + tokens, appended = _merge_streaming_text_chunk(tokens, [12, 13], 2) + assert tokens == [10, 11, 12, 13] + assert appended + + +@pytest.mark.parametrize( + ("incoming", "start", "message"), + [ + ([11], None, "require text_token_start"), + ([11], 2, "Invalid text_token_start"), + ([99], 0, "Conflicting streaming text chunk"), + ], +) +def test_absolute_streaming_chunks_reject_missing_or_inconsistent_positions(incoming, start, message): + with pytest.raises(ValueError, match=message): + _merge_streaming_text_chunk([10], incoming, start) diff --git a/tests/functional_tests/L0_Unit_Tests_CPU_TTS.sh b/tests/functional_tests/L0_Unit_Tests_CPU_TTS.sh index 8f5544363145..d52815eb5ae0 100644 --- a/tests/functional_tests/L0_Unit_Tests_CPU_TTS.sh +++ b/tests/functional_tests/L0_Unit_Tests_CPU_TTS.sh @@ -11,4 +11,4 @@ # 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. -CUDA_VISIBLE_DEVICES="" NEMO_NUMBA_MINVER=0.53 coverage run -a --data-file=/workspace/.coverage --source=/workspace/ -m pytest tests/collections/tts -m "not pleasefixme" --cpu --with_downloads --relax_numba_compat +CUDA_VISIBLE_DEVICES="" NEMO_NUMBA_MINVER=0.53 coverage run -a --data-file=/workspace/.coverage --source=/workspace/ -m pytest tests/collections/tts -m "not pleasefixme" --ignore=tests/collections/tts/easymagpie_vllm_omni/serving --cpu --with_downloads --relax_numba_compat diff --git a/tests/functional_tests/L0_Unit_Tests_GPU_TTS.sh b/tests/functional_tests/L0_Unit_Tests_GPU_TTS.sh index 3351b747f3c6..139691fdea75 100644 --- a/tests/functional_tests/L0_Unit_Tests_GPU_TTS.sh +++ b/tests/functional_tests/L0_Unit_Tests_GPU_TTS.sh @@ -11,4 +11,4 @@ # 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. -TORCH_FORCE_NO_WEIGHTS_ONLY_LOAD=1 NEMO_NUMBA_MINVER=0.53 CUDA_VISIBLE_DEVICES=0 coverage run -a --data-file=/workspace/.coverage --source=/workspace/ -m pytest tests/collections/tts -m "not pleasefixme" --with_downloads +TORCH_FORCE_NO_WEIGHTS_ONLY_LOAD=1 NEMO_NUMBA_MINVER=0.53 CUDA_VISIBLE_DEVICES=0 coverage run -a --data-file=/workspace/.coverage --source=/workspace/ -m pytest tests/collections/tts -m "not pleasefixme" --ignore=tests/collections/tts/easymagpie_vllm_omni/serving --with_downloads diff --git a/tests/functional_tests/L0_Unit_Tests_GPU_TTS_EasyMagpie_vLLM.sh b/tests/functional_tests/L0_Unit_Tests_GPU_TTS_EasyMagpie_vLLM.sh new file mode 100755 index 000000000000..11757c98af2a --- /dev/null +++ b/tests/functional_tests/L0_Unit_Tests_GPU_TTS_EasyMagpie_vLLM.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash + +# Copyright (c) 2026, NVIDIA CORPORATION. +# +# 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. + +set -euo pipefail + +CONTRACT_DIR=/workspace/.ci_artifacts/easymagpie-codec-contract +test -f "$CONTRACT_DIR/config.json" +test -f "$CONTRACT_DIR/model.safetensors" +test -f "$CONTRACT_DIR/contract.safetensors" +export EASYMAGPIE_CODEC_CONTRACT="$CONTRACT_DIR" + +python3 -m pip install --no-build-isolation --no-deps -e tools/easymagpie_vllm_omni + +CUDA_VISIBLE_DEVICES=0 coverage run \ + -a \ + --data-file=/workspace/.coverage \ + --source=/workspace/tools/easymagpie_vllm_omni \ + -m pytest \ + --confcutdir=tests/collections/tts/easymagpie_vllm_omni/serving \ + tests/collections/tts/easymagpie_vllm_omni/serving diff --git a/tools/easymagpie_vllm_omni/Dockerfile.ci b/tools/easymagpie_vllm_omni/Dockerfile.ci new file mode 100644 index 000000000000..9fdc722dd210 --- /dev/null +++ b/tools/easymagpie_vllm_omni/Dockerfile.ci @@ -0,0 +1,28 @@ +# Copyright (c) 2026, NVIDIA CORPORATION. +# +# 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 vllm/vllm-openai:v0.24.0 + +COPY tools/easymagpie_vllm_omni/requirements.txt /tmp/easymagpie-requirements.txt + +RUN python3 -m pip install --no-cache-dir \ + -r /tmp/easymagpie-requirements.txt \ + coverage \ + pytest \ + pytest-asyncio \ + "setuptools>=64" + +WORKDIR /workspace + +ENTRYPOINT [] diff --git a/tools/easymagpie_vllm_omni/README.md b/tools/easymagpie_vllm_omni/README.md new file mode 100644 index 000000000000..34bbf79dd4bb --- /dev/null +++ b/tools/easymagpie_vllm_omni/README.md @@ -0,0 +1,108 @@ +## EasyMagpieTTS — vLLM-Omni two-stage inference + +Streaming TTS for **NemotronTTS** (Nemotron-H backbone + per-codebook local +transformer over a 25 fps spectral codec) via [vLLM-Omni](https://github.com/vllm-project/vllm-omni). + +EasyMagpieTTS decomposes into EasyMagpie LM and SpectralCodec-BWE-22kHz: + +| Stage | Role | +|-------|------| +| **0 — EasyMagpie LM** | `EasyMagpie_LM_Backbone` (Nemotron-H) + `EasyMagpie_LM_LT` → stacked acoustic codes | +| **1 — SpectralCodec-BWE-22kHz** | Stateful native vLLM codec → 22.05 kHz waveform | + +Model definition and pipeline registration live in +[`easymagpie_vllm_omni/`](easymagpie_vllm_omni/) and +[`vllm_plugin_easymagpie_omni/`](vllm_plugin_easymagpie_omni/). +Deployment knobs are in [`deploy/easymagpie.yaml`](deploy/easymagpie.yaml). + +### Convert a NeMo checkpoint + +This step turns the training-time `.nemo` checkpoints into a self-contained +vLLM-Omni model directory: it converts EasyMagpie LM and the causal codec to native +vLLM models, precomputes the text-embedding lookup, and saves the tokenizer and +optional speaker embedding. Run it in the **NeMo environment** from the repository root: + +```bash +python tools/easymagpie_vllm_omni/scripts/convert_to_vllm.py \ + --nemo_file /path/to/emptts.nemo \ + --codec_model_path /path/to/25fps_spectral_codec.nemo \ + --phoneme_tokenizer_path /path/to/bpe_ipa_tokenizer.json \ + --outdir tools/easymagpie_vllm_omni/converted_model \ + --context_audio /path/to/reference_voice.wav \ + --speaker_name eng +``` + +### Setup the serving environment + +Serving needs a GPU, matching **vLLM 0.24 / vLLM-Omni 0.24** versions, and this package. +It does not need NeMo after conversion: + +```bash +cd tools/easymagpie_vllm_omni +conda create -n easymagpie-vllm python=3.12 -y +conda activate easymagpie-vllm +pip install -r requirements.txt +pip install -e . +# optionally for notebook +pip install ipykernel +python -m ipykernel install --user \ + --name easymagpie-vllm \ + --display-name "Python (easymagpie-vllm)" +``` + +Mamba's selective-state-update kernel requires shape- and GPU-specific tuning, so an untuned cache can give +suboptimal performance. Reuse the same Triton/vLLM cache directories across launches so repeated runs accumulate +better kernels; for an explicit sweep, run `python scripts/tune_mamba_ssu.py --model converted_model` and restart. + +### Quick start — offline synthesis + +See [`scripts/offline_demo.ipynb`](scripts/offline_demo.ipynb) to check how `AsyncOmni` is initialized and used. + +### Serve over HTTP and WebSocket + +```bash +bash ./scripts/run_server.sh ./converted_model 8091 +``` + +This starts `vllm serve` with the EasyMagpie plugin on port 8091. Two serving +APIs are available: + +- `POST /v1/audio/speech` with a complete text input. +- `WS /v1/audio/speech/stream` with incremental text/token updates and + asynchronous PCM audio output. + +For delayed-stream checkpoints, the adapter folds the known text-led positions +into the causal prefill. The current `phoneme_delay=3`, `speech_delay=5` model +therefore prefills four target positions: text-only positions 0–2 and position +3 with the known phoneme BOS input. Whole-text HTTP requests satisfy this +automatically. For incremental WebSocket input, the first `input.text` or +`input.tokens` update must tokenize to at least `phoneme_delay + 1` tokens +(four for this checkpoint); a shorter first update returns an error. Later +updates may contain any supported chunk size. + +Query the HTTP endpoint from any OpenAI-compatible client: + +```bash +curl -X POST http://localhost:8091/v1/audio/speech \ + -H 'Content-Type: application/json' \ + -d '{"input":"This is a TTS service test.","voice":"eng","response_format":"wav","stream":true,"stream_format":"audio"}' \ + --output out.wav +``` + +See [`scripts/server_request.ipynb`](scripts/server_request.ipynb) for examples +of both serving APIs. + +### Benchmarks + +```bash +# Benchmark acoustic token prediction only (no codec). +python scripts/benchmark_model.py --model ./converted_model -n 128 -c 1 32 \ + [--streaming --tokens-per-chunk 5] + +# Benchmark the service's HTTP API. +python scripts/benchmark_server.py --text-file vctk_subset.txt -n 128 -c 1 32 + +# Benchmark the service's incremental synthesis via its WebSocket API. +python scripts/benchmark_incremental_server.py --model ./converted_model \ + --text-file vctk_subset.txt --tokens-per-chunk 5 -n 128 -c 1 32 +``` diff --git a/tools/easymagpie_vllm_omni/deploy/easymagpie.yaml b/tools/easymagpie_vllm_omni/deploy/easymagpie.yaml new file mode 100644 index 000000000000..31b31419af12 --- /dev/null +++ b/tools/easymagpie_vllm_omni/deploy/easymagpie.yaml @@ -0,0 +1,64 @@ +# Stage 0 produces stacked acoustic codes; stage 1 decodes them to audio. +# Explicit routing is required because EasyMagpie LM reports model_type "nemotron_h". +pipeline: easymagpie + +# Required for streaming code transfer between the two stages. +async_chunk: true + +trust_remote_code: true +dtype: float16 + +connectors: + connector_of_shared_memory: + name: SharedMemoryConnector + extra: + shm_threshold_bytes: 65536 + connector_get_sleep_s: 0.01 + connector_get_max_wait_first_chunk: 3000 + connector_get_max_wait: 300 + # Native vLLM state pages retain the decoder's exact causal history. + # Two startup chunks provide playback headroom before steady-state streaming. + codec_chunk_frames: 8 + codec_startup_chunk_frames: [6, 6] + +stages: + # Keep engine settings flat at the stage level; nested settings are not parsed. + - stage_id: 0 + max_num_seqs: 32 + gpu_memory_utilization: 0.5 + enforce_eager: false + async_scheduling: true + enable_prefix_caching: false + enable_chunked_prefill: true + max_num_batched_tokens: 4096 + max_model_len: 4096 + devices: "0" + output_connectors: + to_stage_1: connector_of_shared_memory + mamba_ssm_cache_dtype: float32 + skip_tokenizer_init: true + engine_extras: + worker_cls: easymagpie_vllm_omni.runner.EasyMagpieGPUARWorker + default_sampling_params: + temperature: 0.0 + detokenize: false + max_tokens: 2048 + + - stage_id: 1 + max_num_seqs: 32 + gpu_memory_utilization: 0.35 + # FP16 is faster, but introduces audible codec artifacts. + dtype: float32 + enforce_eager: true + async_scheduling: false + enable_prefix_caching: false + # Codec chunks are atomic: never split one request's state update across steps. + enable_chunked_prefill: false + max_num_batched_tokens: 512 + max_model_len: 512 + devices: "0" + input_connectors: + from_stage_0: connector_of_shared_memory + default_sampling_params: + temperature: 0.0 + max_tokens: 65536 diff --git a/tools/easymagpie_vllm_omni/deploy/easymagpie_lm.yaml b/tools/easymagpie_vllm_omni/deploy/easymagpie_lm.yaml new file mode 100644 index 000000000000..52f137212fe1 --- /dev/null +++ b/tools/easymagpie_vllm_omni/deploy/easymagpie_lm.yaml @@ -0,0 +1,34 @@ +# EasyMagpie LM deploy: acoustic-token prediction without SpectralCodec-BWE-22kHz. +# +# Use this topology to benchmark and develop EasyMagpie_LM (the autoregressive +# backbone + local transformer) in isolation from the two-stage codec path: +# pipeline: easymagpie_lm +# +# scripts/benchmark_model.py uses this deploy config by default. +pipeline: easymagpie_lm + +# Required for streaming-text input (whole-text mode ignores this). +async_chunk: true + +trust_remote_code: true +dtype: float16 + +stages: + - stage_id: 0 + max_num_seqs: 32 + gpu_memory_utilization: 0.5 + enforce_eager: false + async_scheduling: true + enable_prefix_caching: false + enable_chunked_prefill: true + max_num_batched_tokens: 4096 + max_model_len: 4096 + devices: "0" + mamba_ssm_cache_dtype: float32 + skip_tokenizer_init: true + engine_extras: + worker_cls: easymagpie_vllm_omni.runner.EasyMagpieGPUARWorker + default_sampling_params: + temperature: 0.0 + detokenize: false + max_tokens: 2048 diff --git a/tools/easymagpie_vllm_omni/easymagpie_vllm_omni/__init__.py b/tools/easymagpie_vllm_omni/easymagpie_vllm_omni/__init__.py new file mode 100644 index 000000000000..8f4648ac6ed4 --- /dev/null +++ b/tools/easymagpie_vllm_omni/easymagpie_vllm_omni/__init__.py @@ -0,0 +1,32 @@ +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. 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. +"""EasyMagpieTTS model and pipeline definitions for vLLM-Omni.""" + +from easymagpie_vllm_omni.config import EASYMAGPIE_SMALLMAMBA, EasyMagpieOmniArch + +__all__ = ["EASYMAGPIE_SMALLMAMBA", "EasyMagpieOmniArch"] + + +def __getattr__(name: str): + # Lazily expose pipeline configs without importing vllm_omni (a heavy + # dependency) at package import time. + if name == "EASYMAGPIE_PIPELINE": + from easymagpie_vllm_omni.pipeline import EASYMAGPIE_PIPELINE + + return EASYMAGPIE_PIPELINE + if name == "EASYMAGPIE_LM_PIPELINE": + from easymagpie_vllm_omni.pipeline import EASYMAGPIE_LM_PIPELINE + + return EASYMAGPIE_LM_PIPELINE + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/tools/easymagpie_vllm_omni/easymagpie_vllm_omni/audio_output.py b/tools/easymagpie_vllm_omni/easymagpie_vllm_omni/audio_output.py new file mode 100644 index 000000000000..ed54b6801f61 --- /dev/null +++ b/tools/easymagpie_vllm_omni/easymagpie_vllm_omni/audio_output.py @@ -0,0 +1,69 @@ +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. 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. +"""Helpers for pulling decoded audio from vLLM-Omni stage outputs.""" +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, Optional + + +def extract_audio_from_stage_output(stage_output) -> Optional[tuple[Any, int]]: + """Return ``(waveform, sample_rate)`` from a final-stage output.""" + import torch + + if isinstance(stage_output, (list, tuple)): + for item in reversed(stage_output): + extracted = extract_audio_from_stage_output(item) + if extracted is not None: + return extracted + return None + + mm = getattr(stage_output, "multimodal_output", None) + ro = getattr(stage_output, "request_output", stage_output) + if not isinstance(mm, Mapping): + outputs = getattr(ro, "outputs", None) + if not outputs: + return None + mm = getattr(outputs[0], "multimodal_output", None) + if not isinstance(mm, Mapping): + return None + + audio_data = mm.get("audio") + if audio_data is None: + audio_data = mm.get("model_outputs") + if audio_data is None: + return None + + if isinstance(audio_data, list): + chunks = [t for t in audio_data if isinstance(t, torch.Tensor) and t.numel() > 0] + if not chunks: + return None + try: + wav_t = torch.cat([t.reshape(-1) for t in chunks], dim=0) + except RuntimeError: + wav_t = chunks[-1].reshape(-1) + elif isinstance(audio_data, torch.Tensor): + wav_t = audio_data.reshape(-1) + else: + wav_t = torch.as_tensor(audio_data).reshape(-1) + + wav_t = wav_t.detach().float().cpu() + if wav_t.numel() == 0: + return None + + sr = mm.get("sr") + sr_val = sr[-1] if isinstance(sr, (list, tuple)) and sr else sr + if isinstance(sr_val, torch.Tensor): + sr_val = int(sr_val.reshape(-1)[0].item()) + return wav_t.numpy(), int(sr_val or 22050) diff --git a/tools/easymagpie_vllm_omni/easymagpie_vllm_omni/backbone_patches.py b/tools/easymagpie_vllm_omni/easymagpie_vllm_omni/backbone_patches.py new file mode 100644 index 000000000000..5432fbd89d8b --- /dev/null +++ b/tools/easymagpie_vllm_omni/easymagpie_vllm_omni/backbone_patches.py @@ -0,0 +1,106 @@ +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. 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. +"""Compatibility fixes for the EasyMagpie backbone on the pinned vLLM 0.24.0.""" +from __future__ import annotations + +import torch +import vllm.v1.attention.backends.mamba_attn as _mamba_attn +from vllm.logger import init_logger +from vllm.model_executor.layers.activation import ReLUSquaredActivation, get_act_fn + +logger = init_logger(__name__) + + +def patch_mamba_streaming_decode() -> None: + """Classify one-token stream extensions as decodes. + + Decode CUDA graphs require decode metadata and a refreshed Mamba state + index. Multi-token context inputs remain prefills. Streaming callers must + generate exactly one new token per step. + """ + orig = _mamba_attn.split_decodes_and_prefills + if getattr(orig, "_easymagpie_patched", False): + return + + def patched( + common_attn_metadata, + decode_threshold: int = 1, + require_uniform: bool = False, + treat_short_extends_as_decodes: bool = True, + ): + return orig( + common_attn_metadata, + decode_threshold=decode_threshold, + require_uniform=require_uniform, + treat_short_extends_as_decodes=True, + ) + + patched._easymagpie_patched = True + _mamba_attn.split_decodes_and_prefills = patched + logger.info("Mamba streaming-decode classification patch installed") + + +def patch_shared_expert_activation(backbone) -> int: + """Make shared experts honor ``mlp_hidden_act`` from the model config. + + vLLM 0.24's ``NemotronHMLP`` hard-codes ReLU² even though routed experts + read ``mlp_hidden_act``. NeMo uses the configured activation for both. + """ + activation_name = getattr(getattr(backbone, "config", None), "mlp_hidden_act", None) + if not isinstance(activation_name, str) or not activation_name: + raise ValueError("Nemotron-H config must provide a non-empty mlp_hidden_act") + + expected_type = type(get_act_fn(activation_name)) + patched = 0 + for layer in backbone.layers: + mixer = getattr(layer, "mixer", None) + if mixer is None or mixer.__class__.__name__ != "NemotronHMoE": + continue + se = getattr(mixer, "shared_experts", None) + if se is None: + continue + if isinstance(se.act_fn, expected_type): + continue + if not isinstance(se.act_fn, ReLUSquaredActivation): + raise RuntimeError( + "vLLM Nemotron-H shared-expert activation implementation changed; " + "review the compatibility patch before replacing it" + ) + se.act_fn = get_act_fn(activation_name) + patched += 1 + logger.info("%s shared-expert activation fix installed on %d layers", activation_name, patched) + return patched + + +def patch_moe_routed_scale(backbone) -> int: + """Apply the routed scaling factor omitted from Nemotron-H FP16 outputs.""" + patched = 0 + for layer in backbone.layers: + mixer = getattr(layer, "mixer", None) + if mixer is None or mixer.__class__.__name__ != "NemotronHMoE": + continue + scale = float(getattr(mixer, "routed_scaling_factor", 1.0)) + if scale == 1.0: + continue + + def _scale_output(_mod, _inp, out, _scale=scale): + # FusedMoE only defers the scale in FP16; leave other dtypes alone. + if isinstance(out, torch.Tensor) and out.dtype == torch.float16: + return out * _scale + return out + + mixer.register_forward_hook(_scale_output) + patched += 1 + logger.info("FP16 MoE routed-scale fix installed on %d layers", patched) + return patched diff --git a/tools/easymagpie_vllm_omni/easymagpie_vllm_omni/codec/__init__.py b/tools/easymagpie_vllm_omni/easymagpie_vllm_omni/codec/__init__.py new file mode 100644 index 000000000000..c38f844f74e7 --- /dev/null +++ b/tools/easymagpie_vllm_omni/easymagpie_vllm_omni/codec/__init__.py @@ -0,0 +1,18 @@ +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. 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. +"""Native vLLM implementation of the EasyMagpie spectral codec decoder.""" + +from easymagpie_vllm_omni.codec.config import EasyMagpieCodecConfig + +__all__ = ["EasyMagpieCodecConfig"] diff --git a/tools/easymagpie_vllm_omni/easymagpie_vllm_omni/codec/config.py b/tools/easymagpie_vllm_omni/easymagpie_vllm_omni/codec/config.py new file mode 100644 index 000000000000..4a554927688b --- /dev/null +++ b/tools/easymagpie_vllm_omni/easymagpie_vllm_omni/codec/config.py @@ -0,0 +1,150 @@ +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. 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. +"""Transformers configuration for the native EasyMagpie codec.""" + +from __future__ import annotations + +from math import prod + +from transformers import PretrainedConfig + + +class EasyMagpieCodecConfig(PretrainedConfig): + """Configuration of the 25-fps causal spectral codec decoder. + + The model consumes one packed row per EasyMagpie acoustic frame. Each row + contains ``num_codebooks * frame_stacking_factor`` scalar FSQ indices. + """ + + model_type = "easymagpie_codec" + + def __init__( + self, + *, + input_dim: int = 40, + input_filters: int = 768, + hidden_filters: int = 1536, + num_hidden_layers: int = 6, + pre_upsample_rates: list[int] | None = None, + pre_upsample_filters: list[int] | None = None, + resblock_upsample_rates: list[int] | None = None, + resblock_upsample_filters: list[int] | None = None, + kernel_size: int = 3, + resblock_kernel_size: int = 7, + activation: str = "half_snake", + num_codebooks: int = 8, + codebook_size: int = 1024, + num_levels_per_group: list[int] | None = None, + frame_stacking_factor: int = 2, + output_sample_rate: int = 22050, + **kwargs, + ) -> None: + kwargs.setdefault("architectures", ["EasyMagpieCodecForConditionalGeneration"]) + kwargs.setdefault("torch_dtype", "float32") + super().__init__(**kwargs) + self.input_dim = int(input_dim) + self.input_filters = int(input_filters) + self.hidden_filters = int(hidden_filters) + self.num_hidden_layers = int(num_hidden_layers) + self.pre_upsample_rates = list(pre_upsample_rates or [2]) + self.pre_upsample_filters = list(pre_upsample_filters or [768]) + self.resblock_upsample_rates = list(resblock_upsample_rates or [9, 7, 7]) + self.resblock_upsample_filters = list(resblock_upsample_filters or [384, 128, 32]) + self.kernel_size = int(kernel_size) + self.resblock_kernel_size = int(resblock_kernel_size) + self.activation = activation + self.num_codebooks = int(num_codebooks) + self.codebook_size = int(codebook_size) + self.num_levels_per_group = list(num_levels_per_group or [4, 4, 4, 4, 4]) + self.frame_stacking_factor = int(frame_stacking_factor) + self.output_sample_rate = int(output_sample_rate) + # Minimal language-model-shaped fields used by generic vLLM input allocation. + self.vocab_size = max(self.codebook_size + 1, 2) + self.hidden_size = 1 + self.validate() + + def validate(self) -> None: + """Reject codec architectures the native decoder does not implement.""" + positive_fields = ( + "input_dim", + "input_filters", + "hidden_filters", + "kernel_size", + "resblock_kernel_size", + "num_codebooks", + "codebook_size", + "frame_stacking_factor", + "output_sample_rate", + ) + for name in positive_fields: + if getattr(self, name) <= 0: + raise ValueError(f"{name} must be positive, got {getattr(self, name)}") + if self.num_hidden_layers < 0: + raise ValueError(f"num_hidden_layers cannot be negative, got {self.num_hidden_layers}") + if len(self.pre_upsample_rates) != len(self.pre_upsample_filters): + raise ValueError("pre_upsample_rates and pre_upsample_filters must have the same length") + if len(self.resblock_upsample_rates) != len(self.resblock_upsample_filters): + raise ValueError("resblock_upsample_rates and resblock_upsample_filters must have the same length") + for name in ( + "pre_upsample_rates", + "pre_upsample_filters", + "resblock_upsample_rates", + "resblock_upsample_filters", + "num_levels_per_group", + ): + if any(value <= 0 for value in getattr(self, name)): + raise ValueError(f"all {name} values must be positive") + if self.input_dim != self.num_codebooks * len(self.num_levels_per_group): + raise ValueError( + "input_dim must equal num_codebooks * len(num_levels_per_group), got " + f"{self.input_dim} and {self.num_codebooks} * {len(self.num_levels_per_group)}" + ) + if any(level <= 1 for level in self.num_levels_per_group): + raise ValueError("all num_levels_per_group values must be greater than 1") + if prod(self.num_levels_per_group) != self.codebook_size: + raise ValueError( + "codebook_size must equal the product of num_levels_per_group, got " + f"{self.codebook_size} and {prod(self.num_levels_per_group)}" + ) + if self.activation != "half_snake": + raise ValueError( + "activation must be 'half_snake' because the native codec currently hard-codes HalfSnake; " + f"implement the matching activation in packed.py to support '{self.activation}'." + ) + + channels = self.input_filters + for name, filters in [("pre_upsample_filters", value) for value in self.pre_upsample_filters] + [ + ("resblock_upsample_filters", value) for value in self.resblock_upsample_filters + ]: + if channels % filters != 0: + raise ValueError( + f"decoder input channels {channels} must be divisible by {name} stage filters {filters} " + "because the native causal ConvTranspose1d uses groups=out_channels" + ) + channels = filters + + @property + def num_stacked_codebooks(self) -> int: + return self.num_codebooks * self.frame_stacking_factor + + @property + def samples_per_codec_frame(self) -> int: + factor = 1 + for rate in self.pre_upsample_rates + self.resblock_upsample_rates: + factor *= rate + return factor + + @property + def samples_per_frame(self) -> int: + return self.frame_stacking_factor * self.samples_per_codec_frame diff --git a/tools/easymagpie_vllm_omni/easymagpie_vllm_omni/codec/kernels.py b/tools/easymagpie_vllm_omni/easymagpie_vllm_omni/codec/kernels.py new file mode 100644 index 000000000000..22d7bfb60b64 --- /dev/null +++ b/tools/easymagpie_vllm_omni/easymagpie_vllm_omni/codec/kernels.py @@ -0,0 +1,581 @@ +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. 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. +"""Initial packed CUDA kernels for the stateful codec layers. + +These kernels establish the packed-sequence semantics and remove the Python +per-request loop. They are intentionally conservative first versions; tuning +tile sizes and fusing HalfSnake are separate performance work. +""" + +from __future__ import annotations + +import torch +from vllm.triton_utils import tl, triton + + +@triton.jit +def _packed_half_snake_kernel( + input_ptr, + alpha_ptr, + output_ptr, + numel, + channels: tl.constexpr, + snake_channels: tl.constexpr, + BLOCK: tl.constexpr, +): + offsets = tl.program_id(0) * BLOCK + tl.arange(0, BLOCK) + mask = offsets < numel + channel = offsets % channels + values = tl.load(input_ptr + offsets, mask=mask).to(tl.float32) + alpha = tl.load(alpha_ptr + channel, mask=mask & (channel < snake_channels), other=1.0).to(tl.float32) + sine = tl.sin(alpha * values) + periodic = values + sine * sine / (alpha + 1.0e-9) + leaky = tl.where(values >= 0.0, values, values * 0.01) + tl.store(output_ptr + offsets, tl.where(channel < snake_channels, periodic, leaky), mask=mask) + + +def packed_half_snake(inputs: torch.Tensor, alpha: torch.Tensor) -> torch.Tensor: + """Fused HalfSnake and leaky-ReLU over packed ``[tokens, channels]`` input.""" + inputs = inputs.contiguous() + channels = inputs.shape[1] + snake_channels = alpha.numel() + outputs = torch.empty_like(inputs) + block = 256 + _packed_half_snake_kernel[(triton.cdiv(inputs.numel(), block),)]( + inputs, + alpha, + outputs, + inputs.numel(), + channels, + snake_channels, + BLOCK=block, + ) + return outputs + + +@triton.jit +def _gather_packed_state_inputs_kernel( + input_ptr, + state_ptr, + cache_indices_ptr, + has_initial_ptr, + output_ptr, + stride_state_page: tl.constexpr, + channels: tl.constexpr, + history: tl.constexpr, + sequence_rows: tl.constexpr, + IS_DECODE: tl.constexpr, + numel, + BLOCK: tl.constexpr, +): + offsets = tl.program_id(0) * BLOCK + tl.arange(0, BLOCK) + mask = offsets < numel + channel = offsets % channels + packed_row = offsets // channels + joined_rows = history + sequence_rows + sequence = packed_row // joined_rows + row = packed_row % joined_rows + page = tl.load(cache_indices_ptr + sequence, mask=mask) + if IS_DECODE: + has_initial = 1 + else: + has_initial = tl.load(has_initial_ptr + sequence, mask=mask) + previous = tl.load( + state_ptr + page * stride_state_page + row * channels + channel, + mask=mask & (row < history) & (has_initial != 0), + other=0.0, + ) + input_row = sequence * sequence_rows + row - history + current = tl.load( + input_ptr + input_row * channels + channel, + mask=mask & (row >= history), + other=0.0, + ) + tl.store(output_ptr + offsets, tl.where(row < history, previous, current), mask=mask) + + +def gather_packed_state_inputs( + inputs: torch.Tensor, + state: torch.Tensor, + cache_indices: torch.Tensor, + has_initial: torch.Tensor, + *, + history: int, + is_decode: bool, +) -> torch.Tensor: + """Gather cached history and append uniform packed inputs in one kernel.""" + inputs = inputs.contiguous() + batch_size = cache_indices.numel() + sequence_rows = inputs.shape[0] // batch_size + channels = inputs.shape[1] + outputs = torch.empty( + (batch_size, history + sequence_rows, channels), + dtype=inputs.dtype, + device=inputs.device, + ) + block = 256 + _gather_packed_state_inputs_kernel[(triton.cdiv(outputs.numel(), block),)]( + inputs, + state, + cache_indices, + has_initial, + outputs, + state.stride(0), + channels, + history, + sequence_rows, + IS_DECODE=is_decode, + numel=outputs.numel(), + BLOCK=block, + ) + return outputs + + +@triton.jit +def _packed_causal_conv1d_kernel( + x_ptr, + weight_ptr, + bias_ptr, + state_ptr, + query_start_loc_ptr, + cache_indices_ptr, + has_initial_ptr, + output_ptr, + stride_state_page: tl.constexpr, + input_channels: tl.constexpr, + output_channels: tl.constexpr, + kernel_size: tl.constexpr, + time_factor: tl.constexpr, + IS_DECODE: tl.constexpr, + HAS_BIAS: tl.constexpr, + BLOCK_T: tl.constexpr, + BLOCK_I: tl.constexpr, + BLOCK_O: tl.constexpr, +): + seq_idx = tl.program_id(0) + token_offsets = tl.program_id(1) * BLOCK_T + tl.arange(0, BLOCK_T) + output_offsets = tl.program_id(2) * BLOCK_O + tl.arange(0, BLOCK_O) + input_offsets = tl.arange(0, BLOCK_I) + + if IS_DECODE: + base_start = seq_idx + base_end = seq_idx + 1 + has_initial = 1 + else: + base_start = tl.load(query_start_loc_ptr + seq_idx) + base_end = tl.load(query_start_loc_ptr + seq_idx + 1) + has_initial = tl.load(has_initial_ptr + seq_idx) + sequence_length = (base_end - base_start) * time_factor + sequence_start = base_start * time_factor + page = tl.load(cache_indices_ptr + seq_idx) + history = kernel_size - 1 + + accumulator = tl.zeros((BLOCK_T, BLOCK_O), dtype=tl.float32) + for kernel_offset in range(kernel_size): + source_offsets = token_offsets - (history - kernel_offset) + current_mask = source_offsets >= 0 + for input_block in range(tl.cdiv(input_channels, BLOCK_I)): + channels = input_block * BLOCK_I + input_offsets + x_offsets = (sequence_start + source_offsets[:, None]) * input_channels + channels[None, :] + current = tl.load( + x_ptr + x_offsets, + mask=(token_offsets[:, None] < sequence_length) + & current_mask[:, None] + & (channels[None, :] < input_channels), + other=0.0, + ) + state_row = history + source_offsets + state_offsets = page * stride_state_page + state_row[:, None] * input_channels + channels[None, :] + previous = tl.load( + state_ptr + state_offsets, + mask=(token_offsets[:, None] < sequence_length) + & (~current_mask[:, None]) + & (channels[None, :] < input_channels) + & (has_initial != 0), + other=0.0, + ) + values = tl.where(current_mask[:, None], current, previous) + weight_offsets = ( + output_offsets[None, :] * input_channels * kernel_size + + channels[:, None] * kernel_size + + kernel_offset + ) + weights = tl.load( + weight_ptr + weight_offsets, + mask=(channels[:, None] < input_channels) & (output_offsets[None, :] < output_channels), + other=0.0, + ) + accumulator += tl.dot(values, weights, input_precision="ieee") + + if HAS_BIAS: + bias = tl.load(bias_ptr + output_offsets, mask=output_offsets < output_channels, other=0.0) + accumulator += bias[None, :] + output_indices = (sequence_start + token_offsets[:, None]) * output_channels + output_offsets[None, :] + tl.store( + output_ptr + output_indices, + accumulator, + mask=(token_offsets[:, None] < sequence_length) & (output_offsets[None, :] < output_channels), + ) + + +@triton.jit +def _update_packed_state_kernel( + x_ptr, + state_ptr, + query_start_loc_ptr, + cache_indices_ptr, + has_initial_ptr, + stride_state_page: tl.constexpr, + channels: tl.constexpr, + history: tl.constexpr, + time_factor: tl.constexpr, + IS_DECODE: tl.constexpr, + BLOCK_C: tl.constexpr, + BLOCK_H: tl.constexpr, +): + seq_idx = tl.program_id(0) + channel_offsets = tl.program_id(1) * BLOCK_C + tl.arange(0, BLOCK_C) + history_offsets = tl.arange(0, BLOCK_H) + if IS_DECODE: + base_start = seq_idx + base_end = seq_idx + 1 + has_initial = 1 + else: + base_start = tl.load(query_start_loc_ptr + seq_idx) + base_end = tl.load(query_start_loc_ptr + seq_idx + 1) + has_initial = tl.load(has_initial_ptr + seq_idx) + sequence_start = base_start * time_factor + sequence_length = (base_end - base_start) * time_factor + page = tl.load(cache_indices_ptr + seq_idx) + + source_offsets = sequence_length - history + history_offsets + current_offsets = (sequence_start + source_offsets[:, None]) * channels + channel_offsets[None, :] + current = tl.load( + x_ptr + current_offsets, + mask=(history_offsets[:, None] < history) + & (source_offsets[:, None] >= 0) + & (channel_offsets[None, :] < channels), + other=0.0, + ) + old_rows = history + source_offsets + old_offsets = page * stride_state_page + old_rows[:, None] * channels + channel_offsets[None, :] + previous = tl.load( + state_ptr + old_offsets, + mask=(history_offsets[:, None] < history) + & (source_offsets[:, None] < 0) + & (channel_offsets[None, :] < channels) + & (has_initial != 0), + other=0.0, + ) + values = tl.where(source_offsets[:, None] >= 0, current, previous) + output_offsets = page * stride_state_page + history_offsets[:, None] * channels + channel_offsets[None, :] + tl.store( + state_ptr + output_offsets, + values, + mask=(history_offsets[:, None] < history) & (channel_offsets[None, :] < channels), + ) + + +@triton.jit +def _packed_causal_deconv1d_kernel( + x_ptr, + weight_ptr, + bias_ptr, + state_ptr, + query_start_loc_ptr, + cache_indices_ptr, + has_initial_ptr, + output_ptr, + stride_state_page: tl.constexpr, + input_channels: tl.constexpr, + output_channels: tl.constexpr, + stride: tl.constexpr, + input_time_factor: tl.constexpr, + input_channels_per_group: tl.constexpr, + output_channels_per_group: tl.constexpr, + IS_DECODE: tl.constexpr, + HAS_BIAS: tl.constexpr, + BLOCK_T: tl.constexpr, + BLOCK_O: tl.constexpr, +): + seq_idx = tl.program_id(0) + output_token_offsets = tl.program_id(1) * BLOCK_T + tl.arange(0, BLOCK_T) + output_offsets = tl.program_id(2) * BLOCK_O + tl.arange(0, BLOCK_O) + if IS_DECODE: + base_start = seq_idx + base_end = seq_idx + 1 + has_initial = 1 + else: + base_start = tl.load(query_start_loc_ptr + seq_idx) + base_end = tl.load(query_start_loc_ptr + seq_idx + 1) + has_initial = tl.load(has_initial_ptr + seq_idx) + input_start = base_start * input_time_factor + input_length = (base_end - base_start) * input_time_factor + output_length = input_length * stride + page = tl.load(cache_indices_ptr + seq_idx) + + input_token = output_token_offsets // stride + phase = output_token_offsets % stride + group = output_offsets // output_channels_per_group + output_in_group = output_offsets % output_channels_per_group + input_group_start = group * input_channels_per_group + accumulator = tl.zeros((BLOCK_T, BLOCK_O), dtype=tl.float32) + + for input_in_group in range(input_channels_per_group): + input_channel = input_group_start + input_in_group + current_offsets = (input_start + input_token[:, None]) * input_channels + input_channel[None, :] + current = tl.load( + x_ptr + current_offsets, + mask=(output_token_offsets[:, None] < output_length) + & (output_offsets[None, :] < output_channels) + & (input_channel[None, :] < input_channels), + other=0.0, + ) + previous_token = input_token - 1 + previous_offsets = (input_start + previous_token[:, None]) * input_channels + input_channel[None, :] + previous_current = tl.load( + x_ptr + previous_offsets, + mask=(output_token_offsets[:, None] < output_length) + & (previous_token[:, None] >= 0) + & (output_offsets[None, :] < output_channels) + & (input_channel[None, :] < input_channels), + other=0.0, + ) + state_offsets = page * stride_state_page + input_channel + previous_state = tl.load( + state_ptr + state_offsets, + mask=(output_offsets < output_channels) & (input_channel < input_channels) & (has_initial != 0), + other=0.0, + ) + previous = tl.where(previous_token[:, None] >= 0, previous_current, previous_state[None, :]) + + current_weight_offsets = ( + input_channel[None, :] * output_channels_per_group * (2 * stride) + + output_in_group[None, :] * (2 * stride) + + phase[:, None] + ) + previous_weight_offsets = current_weight_offsets + stride + current_weights = tl.load( + weight_ptr + current_weight_offsets, + mask=(output_token_offsets[:, None] < output_length) & (output_offsets[None, :] < output_channels), + other=0.0, + ) + previous_weights = tl.load( + weight_ptr + previous_weight_offsets, + mask=(output_token_offsets[:, None] < output_length) & (output_offsets[None, :] < output_channels), + other=0.0, + ) + accumulator += current * current_weights + previous * previous_weights + + if HAS_BIAS: + bias = tl.load(bias_ptr + output_offsets, mask=output_offsets < output_channels, other=0.0) + accumulator += bias[None, :] + output_start = base_start * input_time_factor * stride + output_indices = (output_start + output_token_offsets[:, None]) * output_channels + output_offsets[None, :] + tl.store( + output_ptr + output_indices, + accumulator, + mask=(output_token_offsets[:, None] < output_length) & (output_offsets[None, :] < output_channels), + ) + + +def _max_sequence_length(query_start_loc: torch.Tensor, time_factor: int) -> int: + # The Mamba metadata builder already has this value on the CPU; this + # correctness kernel recomputes it until the custom metadata subclass lands. + return int(torch.diff(query_start_loc).max().item()) * time_factor + + +def update_packed_state( + inputs: torch.Tensor, + state: torch.Tensor, + query_start_loc: torch.Tensor, + cache_indices: torch.Tensor, + has_initial: torch.Tensor, + *, + channels: int, + history: int, + time_factor: int, + is_decode: bool, +) -> None: + """Update fixed-history state pages from packed layer inputs.""" + num_sequences = cache_indices.numel() if is_decode else query_start_loc.numel() - 1 + metadata_placeholder = cache_indices + query_start_loc_ptr = metadata_placeholder if is_decode else query_start_loc + has_initial_ptr = metadata_placeholder if is_decode else has_initial + _update_packed_state_kernel[(num_sequences, triton.cdiv(channels, 64))]( + inputs, + state, + query_start_loc_ptr, + cache_indices, + has_initial_ptr, + state.stride(0), + channels, + history, + time_factor, + IS_DECODE=is_decode, + BLOCK_C=64, + BLOCK_H=triton.next_power_of_2(history), + ) + + +def packed_causal_conv1d( + inputs: torch.Tensor, + weight: torch.Tensor, + bias: torch.Tensor | None, + state: torch.Tensor, + query_start_loc: torch.Tensor, + cache_indices: torch.Tensor, + has_initial: torch.Tensor, + *, + time_factor: int, + max_query_len: int | None = None, + is_decode: bool = False, +) -> torch.Tensor: + """Packed dense causal Conv1d and in-place fixed-history update.""" + if not inputs.is_cuda: + raise ValueError("packed_causal_conv1d is a CUDA kernel") + inputs = inputs.contiguous() + weight = weight.contiguous() + output_channels, input_channels, kernel_size = weight.shape + outputs = torch.empty((inputs.shape[0], output_channels), dtype=inputs.dtype, device=inputs.device) + num_sequences = cache_indices.numel() if is_decode else query_start_loc.numel() - 1 + if is_decode: + max_length = time_factor + elif max_query_len is not None: + max_length = max_query_len * time_factor + else: + max_length = _max_sequence_length(query_start_loc, time_factor) + if is_decode and inputs.shape[0] != num_sequences * time_factor: + raise ValueError(f"decode input has {inputs.shape[0]} rows; expected {num_sequences} * {time_factor}") + metadata_placeholder = cache_indices + query_start_loc_ptr = metadata_placeholder if is_decode else query_start_loc + has_initial_ptr = metadata_placeholder if is_decode else has_initial + block_t, block_i, block_o = 16, 32, 32 + grid = ( + num_sequences, + triton.cdiv(max_length, block_t), + triton.cdiv(output_channels, block_o), + ) + _packed_causal_conv1d_kernel[grid]( + inputs, + weight, + bias, + state, + query_start_loc_ptr, + cache_indices, + has_initial_ptr, + outputs, + state.stride(0), + input_channels, + output_channels, + kernel_size, + time_factor, + IS_DECODE=is_decode, + HAS_BIAS=bias is not None, + BLOCK_T=block_t, + BLOCK_I=block_i, + BLOCK_O=block_o, + ) + update_packed_state( + inputs, + state, + query_start_loc, + cache_indices, + has_initial, + channels=input_channels, + history=kernel_size - 1, + time_factor=time_factor, + is_decode=is_decode, + ) + return outputs + + +def packed_causal_conv_transpose1d( + inputs: torch.Tensor, + weight: torch.Tensor, + bias: torch.Tensor | None, + state: torch.Tensor, + query_start_loc: torch.Tensor, + cache_indices: torch.Tensor, + has_initial: torch.Tensor, + *, + stride: int, + time_factor: int, + output_channels: int, + max_query_len: int | None = None, + is_decode: bool = False, +) -> torch.Tensor: + """Packed causal grouped ConvTranspose1d and one-frame state update.""" + inputs = inputs.contiguous() + weight = weight.contiguous() + input_channels = inputs.shape[1] + groups = output_channels + input_channels_per_group = input_channels // groups + output_channels_per_group = output_channels // groups + outputs = torch.empty( + (inputs.shape[0] * stride, output_channels), + dtype=inputs.dtype, + device=inputs.device, + ) + num_sequences = cache_indices.numel() if is_decode else query_start_loc.numel() - 1 + if is_decode: + max_length = time_factor * stride + elif max_query_len is not None: + max_length = max_query_len * time_factor * stride + else: + max_length = _max_sequence_length(query_start_loc, time_factor) * stride + if is_decode and inputs.shape[0] != num_sequences * time_factor: + raise ValueError(f"decode input has {inputs.shape[0]} rows; expected {num_sequences} * {time_factor}") + metadata_placeholder = cache_indices + query_start_loc_ptr = metadata_placeholder if is_decode else query_start_loc + has_initial_ptr = metadata_placeholder if is_decode else has_initial + block_t, block_o = 32, 32 + grid = ( + num_sequences, + triton.cdiv(max_length, block_t), + triton.cdiv(output_channels, block_o), + ) + _packed_causal_deconv1d_kernel[grid]( + inputs, + weight, + bias, + state, + query_start_loc_ptr, + cache_indices, + has_initial_ptr, + outputs, + state.stride(0), + input_channels, + output_channels, + stride, + time_factor, + input_channels_per_group, + output_channels_per_group, + IS_DECODE=is_decode, + HAS_BIAS=bias is not None, + BLOCK_T=block_t, + BLOCK_O=block_o, + ) + update_packed_state( + inputs, + state, + query_start_loc, + cache_indices, + has_initial, + channels=input_channels, + history=1, + time_factor=time_factor, + is_decode=is_decode, + ) + return outputs diff --git a/tools/easymagpie_vllm_omni/easymagpie_vllm_omni/codec/model.py b/tools/easymagpie_vllm_omni/easymagpie_vllm_omni/codec/model.py new file mode 100644 index 000000000000..1849c2817090 --- /dev/null +++ b/tools/easymagpie_vllm_omni/easymagpie_vllm_omni/codec/model.py @@ -0,0 +1,193 @@ +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. 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. +"""vLLM/vLLM-Omni model adapter for the stateful packed codec.""" + +from __future__ import annotations + +from collections.abc import Iterable +from typing import Any + +import torch +import torch.nn as nn +from easymagpie_vllm_omni.codec.config import EasyMagpieCodecConfig +from easymagpie_vllm_omni.codec.packed import PackedEasyMagpieCodec +from vllm.config import VllmConfig +from vllm.model_executor.layers.mamba.mamba_utils import MambaStateCopyFuncCalculator +from vllm.model_executor.models.utils import AutoWeightsLoader +from vllm_omni.model_executor.models.output_templates import OmniOutput + + +class EasyMagpieCodecForConditionalGeneration(nn.Module): + """One-placeholder-per-frame vLLM model producing packed waveform chunks. + + Real code matrices arrive through ``runtime_additional_information``. The + scheduled ``input_ids`` are only placeholders, but their per-request counts + must equal the number of EasyMagpie model frames so vLLM metadata is at the + base time resolution used by every cache layer. + """ + + input_modalities = "audio" + has_inner_state = True + is_attention_free = True + + @classmethod + def get_mamba_state_shape_from_config(cls, vllm_config: VllmConfig) -> tuple[tuple[int]]: + from easymagpie_vllm_omni.codec.packed import CODEC_STATE_ELEMENTS + + return ((CODEC_STATE_ELEMENTS,),) + + @classmethod + def get_mamba_state_dtype_from_config(cls, vllm_config: VllmConfig) -> tuple[torch.dtype]: + return (vllm_config.model_config.dtype,) + + @classmethod + def get_mamba_state_copy_func(cls): + return MambaStateCopyFuncCalculator.linear_attention_state_copy_func() + + def __init__(self, *, vllm_config: VllmConfig, prefix: str = "") -> None: + super().__init__() + config = vllm_config.model_config.hf_config + if not isinstance(config, EasyMagpieCodecConfig): + # AutoConfig registration is process-global and some inspection + # paths may deserialize a generic PretrainedConfig first. + config = EasyMagpieCodecConfig(**config.to_dict()) + self.config = config + self.vllm_config = vllm_config + self.codec = PackedEasyMagpieCodec( + config, + dtype=vllm_config.model_config.dtype, + prefix=prefix, + ) + self.have_multimodal_outputs = True + self.has_preprocess = False + self.has_postprocess = False + self.requires_raw_input_tokens = True + + def embed_input_ids(self, input_ids: torch.Tensor, **_: Any) -> torch.Tensor: + return torch.zeros((input_ids.shape[0], 1), dtype=torch.float32, device=input_ids.device) + + def compute_logits(self, hidden_states: Any, sampling_metadata: Any = None) -> None: + return None + + def _payload_codes( + self, + runtime_infos: list[dict[str, Any]], + device: torch.device, + request_token_spans: list[tuple[int, int]] | None = None, + ) -> tuple[torch.Tensor, list[int]]: + q = self.config.num_stacked_codebooks + packed: list[torch.Tensor] = [] + frame_counts: list[int] = [] + if request_token_spans is not None and len(request_token_spans) != len(runtime_infos): + raise ValueError(f"got {len(request_token_spans)} request spans for {len(runtime_infos)} codec payloads") + for index, info in enumerate(runtime_infos): + scheduled_frames = None + if request_token_spans is not None: + start, end = request_token_spans[index] + scheduled_frames = end - start + if scheduled_frames == 0: + continue + codes = info.get("codes", {}) if isinstance(info, dict) else {} + audio = codes.get("audio") if isinstance(codes, dict) else None + tensor = torch.as_tensor(audio, dtype=torch.long, device=device) + if tensor.ndim == 2: + if tensor.shape[1] != q: + raise ValueError(f"expected codec payload [frames, {q}], got {tuple(tensor.shape)}") + rows = tensor.contiguous() + elif tensor.ndim == 1: + flat = tensor.reshape(-1) + if flat.numel() % q: + raise ValueError(f"codec payload length {flat.numel()} is not divisible by {q}") + frames = flat.numel() // q + rows = flat.view(q, frames).transpose(0, 1).contiguous() + else: + raise ValueError(f"expected a 1-D or 2-D codec payload, got {tuple(tensor.shape)}") + if scheduled_frames is not None and rows.shape[0] != scheduled_frames: + raise ValueError( + f"scheduled {scheduled_frames} placeholders for request {index}, " + f"but its codec payload has {rows.shape[0]} frames" + ) + packed.append(rows) + frame_counts.append(int(rows.shape[0])) + if not packed: + return torch.empty((0, q), dtype=torch.long, device=device), [] + return torch.cat(packed, dim=0), frame_counts + + @torch.no_grad() + def forward( + self, + input_ids: torch.Tensor | None = None, + positions: torch.Tensor | None = None, + intermediate_tensors: Any = None, + inputs_embeds: torch.Tensor | None = None, + runtime_additional_information: list[dict[str, Any]] | None = None, + codec_codes: torch.Tensor | None = None, + request_token_spans: list[tuple[int, int]] | None = None, + **_: Any, + ) -> OmniOutput: + del positions, intermediate_tensors, inputs_embeds + if input_ids is None: + input_ids = torch.empty((0,), dtype=torch.long, device=self.vllm_config.device_config.device) + + if codec_codes is not None: + codes = codec_codes.to(device=input_ids.device, dtype=torch.long) + frame_counts = [int(codes.shape[0])] + elif runtime_additional_information: + codes, frame_counts = self._payload_codes( + runtime_additional_information, + input_ids.device, + request_token_spans, + ) + else: + # Profile runs do not carry connector payloads. One scheduled + # placeholder still represents one model frame. + frames = int(input_ids.numel()) + codes = torch.zeros( + (frames, self.config.num_stacked_codebooks), + dtype=torch.long, + device=input_ids.device, + ) + frame_counts = [frames] + + if codes.shape[0] != input_ids.numel(): + raise ValueError( + "EasyMagpie native codec requires one scheduled placeholder per model frame: " + f"got {input_ids.numel()} placeholders and {codes.shape[0]} code frames" + ) + packed_audio = self.codec(codes) + outputs: list[torch.Tensor] = [] + offset = 0 + for frames in frame_counts: + samples = frames * self.config.samples_per_frame + outputs.append(packed_audio[offset : offset + samples].float()) + offset += samples + sample_rate = torch.tensor(self.config.output_sample_rate, dtype=torch.int32) + return OmniOutput( + text_hidden_states=None, + multimodal_outputs={ + "model_outputs": outputs, + "sr": [sample_rate for _ in outputs], + }, + ) + + def make_omni_output(self, model_outputs: OmniOutput | tuple, **_: Any) -> OmniOutput: + if isinstance(model_outputs, OmniOutput): + return model_outputs + if isinstance(model_outputs, tuple) and len(model_outputs) == len(OmniOutput._fields): + return OmniOutput(*model_outputs) + raise TypeError(f"unexpected EasyMagpie codec output: {type(model_outputs)}") + + def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]: + mapped = ((f"codec.{name}" if name.startswith("audio_decoder.") else name, tensor) for name, tensor in weights) + return AutoWeightsLoader(self, skip_prefixes=["codec.dequantizer."]).load_weights(mapped) diff --git a/tools/easymagpie_vllm_omni/easymagpie_vllm_omni/codec/packed.py b/tools/easymagpie_vllm_omni/easymagpie_vllm_omni/codec/packed.py new file mode 100644 index 000000000000..84dc673c38bc --- /dev/null +++ b/tools/easymagpie_vllm_omni/easymagpie_vllm_omni/codec/packed.py @@ -0,0 +1,699 @@ +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. 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. +"""Packed decoder layers backed by vLLM-managed fixed-size state pages. + +The CPU fallback intentionally loops over sequence boundaries in eager PyTorch. +CUDA execution uses packed batched Triton and cuDNN kernels without changing +the model definition, weight names, or scheduler integration. +""" +from __future__ import annotations + +from collections.abc import Iterable +from dataclasses import dataclass, replace +from typing import Any + +import torch +import torch.nn as nn +import torch.nn.functional as F +from easymagpie_vllm_omni.codec.config import EasyMagpieCodecConfig +from easymagpie_vllm_omni.codec.packing import unstack_acoustic_codes +from vllm.config import VllmConfig, get_current_vllm_config +from vllm.forward_context import get_forward_context +from vllm.model_executor.layers.attention_layer_base import AttentionLayerBase +from vllm.v1.attention.backend import AttentionBackend, CommonAttentionMetadata +from vllm.v1.attention.backends.mamba1_attn import ( + Mamba1AttentionBackend, + Mamba1AttentionMetadata, + Mamba1AttentionMetadataBuilder, +) +from vllm.v1.attention.backends.registry import MambaAttentionBackendEnum +from vllm.v1.kv_cache_interface import KVCacheSpec, MambaSpec + +# Largest real history is 6 * 768 = 4608 values (a k=7 skip conv). +# Every codec state layer advertises the same allocation so vLLM can place all +# layers in uniform Mamba-style cache groups. +CODEC_STATE_ELEMENTS = 4608 + + +@dataclass +class CodecStateMetadata(Mamba1AttentionMetadata): + codec_uniform: bool = False + codec_max_query_len: int | None = None + + +class CodecStateMetadataBuilder(Mamba1AttentionMetadataBuilder): + """Annotate vLLM's CPU-known uniform shape for the codec fast path.""" + + metadata_cls = CodecStateMetadata + + def build( + self, + common_prefix_len: int, + common_attn_metadata: CommonAttentionMetadata, + fast_build: bool = False, + **kwargs: Any, + ) -> CodecStateMetadata: + metadata = super().build(common_prefix_len, common_attn_metadata, fast_build=fast_build, **kwargs) + uniform = metadata.num_decodes == 0 or metadata.num_prefills == 0 + max_query_len = None + if metadata.num_prefills: + starts = common_attn_metadata.query_start_loc_cpu[-metadata.num_prefills - 1 :] + lengths = torch.diff(starts) + uniform = uniform and bool(torch.all(lengths == lengths[0]).item()) + max_query_len = int(lengths.max().item()) + return replace(metadata, codec_uniform=uniform, codec_max_query_len=max_query_len) + + def build_for_cudagraph_capture( + self, + common_attn_metadata: CommonAttentionMetadata, + ) -> CodecStateMetadata: + lengths = torch.diff(common_attn_metadata.query_start_loc_cpu) + if lengths.numel() and not torch.all(lengths == lengths[0]).item(): + raise ValueError("EasyMagpie codec CUDA graphs require a uniform chunk size") + return self.build(0, common_attn_metadata) + + +class CodecStateBackend(Mamba1AttentionBackend): + """Mamba metadata/allocation semantics, extended to the codec's fp32 state.""" + + supported_dtypes = [torch.float16, torch.bfloat16, torch.float32] + supported_kv_cache_dtypes = ["auto", "float16", "bfloat16", "float32"] + + @staticmethod + def get_name() -> str: + return "EASYMAGPIE_CODEC_STATE" + + @staticmethod + def get_builder_cls() -> type[CodecStateMetadataBuilder]: + return CodecStateMetadataBuilder + + +class PackedFiniteScalarDequantizer(nn.Module): + def __init__(self, config: EasyMagpieCodecConfig) -> None: + super().__init__() + levels = torch.tensor(config.num_levels_per_group, dtype=torch.int64) + bases = torch.cumprod( + torch.tensor([1, *config.num_levels_per_group[:-1]], dtype=torch.int64), + dim=0, + ) + self.num_groups = config.num_codebooks + self.register_buffer("levels", levels, persistent=False) + self.register_buffer("bases", bases, persistent=False) + + def forward(self, indices: torch.Tensor) -> torch.Tensor: + nonnegative = torch.div(indices.unsqueeze(-1), self.bases, rounding_mode="floor") % self.levels + scale = torch.div(self.levels, 2, rounding_mode="floor") + return ((nonnegative - scale) / scale).flatten(start_dim=1) + + +class PackedHalfSnake(nn.Module): + def __init__(self, channels: int) -> None: + super().__init__() + self.snake_channels = channels // 2 + # Preserve the NeMo checkpoint shape. + self.alpha = nn.Parameter(torch.ones(1, self.snake_channels, 1)) + + def forward(self, inputs: torch.Tensor) -> torch.Tensor: + if inputs.is_cuda: + from easymagpie_vllm_omni.codec.kernels import packed_half_snake + + return packed_half_snake(inputs, self.alpha) + snake_in = inputs[:, : self.snake_channels] + alpha = self.alpha.reshape(1, -1) + snake_out = snake_in + torch.sin(alpha * snake_in).square() / (alpha + 1e-9) + return torch.cat((snake_out, F.leaky_relu(inputs[:, self.snake_channels :])), dim=-1) + + +class CodecStateLayer(nn.Module, AttentionLayerBase): + """Base class for a cache-owning packed codec layer.""" + + def __init__(self, *, time_factor: int, dtype: torch.dtype, prefix: str) -> None: + super().__init__() + self.time_factor = int(time_factor) + self.dtype = dtype + self.kv_cache = [torch.tensor([])] + + compilation = get_current_vllm_config().compilation_config + # vLLM's cache binder requires one unique integer in every state-layer + # name. Architectural paths contain zero or repeated local indices. + layer_index = sum(isinstance(layer, CodecStateLayer) for layer in compilation.static_forward_context.values()) + prefix = f"easymagpie_codec_state.{layer_index}" + self.prefix = prefix + if prefix in compilation.static_forward_context: + raise ValueError(f"duplicate layer name: {prefix}") + compilation.static_forward_context[prefix] = self + + def get_attn_backend(self) -> type[AttentionBackend]: + return CodecStateBackend + + def get_kv_cache_spec(self, vllm_config: VllmConfig) -> KVCacheSpec: + block_size = vllm_config.cache_config.mamba_block_size + if block_size is None: + raise ValueError("EasyMagpie codec requires a resolved mamba_block_size") + return MambaSpec( + block_size=block_size, + shapes=((CODEC_STATE_ELEMENTS,),), + dtypes=(self.dtype,), + page_size_padded=vllm_config.cache_config.mamba_page_size_padded, + mamba_type=MambaAttentionBackendEnum.MAMBA1, + mamba_cache_mode=vllm_config.cache_config.mamba_cache_mode, + num_speculative_blocks=0, + ) + + def _metadata(self) -> Mamba1AttentionMetadata | None: + raw = get_forward_context().attn_metadata + if raw is None: + return None + if not isinstance(raw, dict): + raise TypeError(f"expected per-layer attention metadata, got {type(raw)}") + metadata = raw[self.prefix] + if not isinstance(metadata, Mamba1AttentionMetadata): + raise TypeError(f"expected Mamba1AttentionMetadata, got {type(metadata)}") + if metadata.num_decode_tokens != metadata.num_decodes: + raise NotImplementedError( + "the codec supports one frame per decode request; speculative multi-frame decode is not supported" + ) + return metadata + + @staticmethod + def _decode_state_indices(metadata: Mamba1AttentionMetadata) -> torch.Tensor: + state_indices = metadata.state_indices_tensor_d + if state_indices is None: + raise RuntimeError("incomplete codec decode metadata") + if state_indices.dim() == 2: + state_indices = state_indices[:, 0] + if state_indices.dim() != 1 or state_indices.numel() != metadata.num_decodes: + raise RuntimeError(f"invalid codec decode state indices: {tuple(state_indices.shape)}") + return state_indices.contiguous() + + @staticmethod + def _prefill_max_query_len(metadata: Mamba1AttentionMetadata) -> int: + explicit = getattr(metadata, "codec_max_query_len", None) + if explicit is not None: + return int(explicit) + + nums_dict = metadata.nums_dict + if nums_dict and 8 in nums_dict: + # vLLM builds this dictionary from query_start_loc_p_cpu specifically + # to avoid D2H synchronization in causal-convolution launch grids. + nums = nums_dict[8].get("nums") + if isinstance(nums, torch.Tensor) and nums.numel(): + return int(nums.max().item()) * 8 + + # Safe graph-capturable upper bound for metadata produced outside the + # vLLM builder. It can overlaunch for a ragged batch, but sequence masks + # preserve correctness. Benchmarks/tests set codec_max_query_len. + return metadata.num_prefill_tokens + + def _iter_sequences( + self, + inputs: torch.Tensor, + metadata: Mamba1AttentionMetadata, + ) -> Iterable[tuple[torch.Tensor, torch.Tensor, bool]]: + cache = self.kv_cache[0] + offset = 0 + if metadata.num_decodes: + state_indices_d = self._decode_state_indices(metadata) + rows = self.time_factor + for seq_idx in range(metadata.num_decodes): + page = int(state_indices_d[seq_idx].item()) + if page < 0: + raise RuntimeError("codec decode request has no state page") + yield inputs[offset : offset + rows], cache[page], True + offset += rows + + if metadata.num_prefills: + query_start_loc = metadata.query_start_loc_p + state_indices = metadata.state_indices_tensor_p + has_initial = metadata.has_initial_states_p + if query_start_loc is None or state_indices is None or has_initial is None: + raise RuntimeError("incomplete codec prefill metadata") + for seq_idx in range(metadata.num_prefills): + start = offset + int(query_start_loc[seq_idx].item()) * self.time_factor + end = offset + int(query_start_loc[seq_idx + 1].item()) * self.time_factor + page = int(state_indices[seq_idx].item()) + if page < 0: + raise RuntimeError("codec prefill request has no state page") + yield inputs[start:end], cache[page], bool(has_initial[seq_idx].item()) + + +class PackedCausalConv1d(CodecStateLayer): + def __init__( + self, + in_channels: int, + out_channels: int, + kernel_size: int, + *, + activate: bool, + time_factor: int, + dtype: torch.dtype, + prefix: str, + ) -> None: + super().__init__(time_factor=time_factor, dtype=dtype, prefix=prefix) + self.conv = nn.Conv1d(in_channels, out_channels, kernel_size) + self.in_channels = in_channels + self.history = kernel_size - 1 + self.activation = PackedHalfSnake(out_channels) if activate else nn.Identity() + if self.history * self.in_channels > CODEC_STATE_ELEMENTS: + raise ValueError("codec convolution history exceeds the uniform state page") + + def _one(self, inputs: torch.Tensor, page: torch.Tensor, has_initial: bool) -> torch.Tensor: + state = page[: self.history * self.in_channels].view(self.history, self.in_channels) + if not has_initial: + state.zero_() + joined = torch.cat((state, inputs), dim=0) + outputs = self.conv(joined.transpose(0, 1).unsqueeze(0)).squeeze(0).transpose(0, 1) + state.copy_(joined[-self.history :]) + return self.activation(outputs) + + def _uniform_cuda( + self, + inputs: torch.Tensor, + metadata: Mamba1AttentionMetadata, + *, + is_decode: bool, + ) -> torch.Tensor: + """Run a uniform packed batch through one batched cuDNN convolution.""" + from easymagpie_vllm_omni.codec.kernels import gather_packed_state_inputs, update_packed_state + + inputs = inputs.contiguous() + if is_decode: + state_indices = self._decode_state_indices(metadata) + query_start_loc = state_indices + has_initial = state_indices + else: + state_indices = metadata.state_indices_tensor_p + query_start_loc = metadata.query_start_loc_p + has_initial = metadata.has_initial_states_p + if state_indices is None or query_start_loc is None or has_initial is None: + raise RuntimeError("incomplete codec prefill metadata") + + joined = gather_packed_state_inputs( + inputs, + self.kv_cache[0], + state_indices, + has_initial, + history=self.history, + is_decode=is_decode, + ) + outputs = self.conv(joined.transpose(1, 2)).transpose(1, 2).contiguous() + + update_packed_state( + inputs, + self.kv_cache[0], + query_start_loc, + state_indices, + has_initial, + channels=self.in_channels, + history=self.history, + time_factor=self.time_factor, + is_decode=is_decode, + ) + return outputs.reshape(-1, self.conv.out_channels) + + def forward(self, inputs: torch.Tensor) -> torch.Tensor: + metadata = self._metadata() + if metadata is None: + channels_first = inputs.transpose(0, 1).unsqueeze(0) + outputs = self.conv(F.pad(channels_first, (self.history, 0))) + return self.activation(outputs.squeeze(0).transpose(0, 1)) + expected_rows = (metadata.num_decode_tokens + metadata.num_prefill_tokens) * self.time_factor + if inputs.shape[0] != expected_rows: + raise RuntimeError(f"codec metadata describes {expected_rows} rows, got {inputs.shape[0]}") + if inputs.is_cuda: + from easymagpie_vllm_omni.codec.kernels import packed_causal_conv1d + + if getattr(metadata, "codec_uniform", False): + if metadata.num_decodes and metadata.num_prefills: + raise NotImplementedError("uniform codec batches cannot mix prefill and decode") + outputs = self._uniform_cuda(inputs, metadata, is_decode=bool(metadata.num_decodes)) + return self.activation(outputs) + + parts = [] + decode_rows = metadata.num_decode_tokens * self.time_factor + if metadata.num_decodes: + state_indices_d = self._decode_state_indices(metadata) + parts.append( + packed_causal_conv1d( + inputs[:decode_rows], + self.conv.weight, + self.conv.bias, + self.kv_cache[0], + state_indices_d, + state_indices_d, + state_indices_d, + time_factor=self.time_factor, + is_decode=True, + ) + ) + if metadata.num_prefills: + if ( + metadata.query_start_loc_p is None + or metadata.state_indices_tensor_p is None + or metadata.has_initial_states_p is None + ): + raise RuntimeError("incomplete codec prefill metadata") + parts.append( + packed_causal_conv1d( + inputs[decode_rows:], + self.conv.weight, + self.conv.bias, + self.kv_cache[0], + metadata.query_start_loc_p, + metadata.state_indices_tensor_p, + metadata.has_initial_states_p, + time_factor=self.time_factor, + max_query_len=self._prefill_max_query_len(metadata), + ) + ) + if len(parts) == 1: + outputs = parts[0] + else: + outputs = torch.cat(parts, dim=0) if parts else inputs.new_empty((0, self.conv.out_channels)) + return self.activation(outputs) + outputs = [ + self._one(sequence, page, has_initial) + for sequence, page, has_initial in self._iter_sequences(inputs, metadata) + ] + return torch.cat(outputs, dim=0) if outputs else inputs.new_empty((0, self.conv.out_channels)) + + +class PackedCausalConvTranspose1d(CodecStateLayer): + def __init__( + self, + in_channels: int, + out_channels: int, + stride: int, + *, + time_factor: int, + dtype: torch.dtype, + prefix: str, + ) -> None: + super().__init__(time_factor=time_factor, dtype=dtype, prefix=prefix) + self.in_channels = in_channels + self.stride = stride + self.conv = nn.ConvTranspose1d( + in_channels, + out_channels, + kernel_size=2 * stride, + stride=stride, + groups=out_channels, + ) + self.activation = PackedHalfSnake(out_channels) + + def _one(self, inputs: torch.Tensor, page: torch.Tensor, has_initial: bool) -> torch.Tensor: + state = page[: self.in_channels].view(1, self.in_channels) + if not has_initial: + state.zero_() + joined = torch.cat((state, inputs), dim=0) + outputs = self.conv(joined.transpose(0, 1).unsqueeze(0)).squeeze(0) + outputs = outputs[:, self.stride : -self.stride].transpose(0, 1) + state.copy_(joined[-1:]) + return self.activation(outputs) + + def _uniform_cuda( + self, + inputs: torch.Tensor, + metadata: Mamba1AttentionMetadata, + *, + is_decode: bool, + ) -> torch.Tensor: + """Run a uniform packed batch through one batched cuDNN deconvolution.""" + from easymagpie_vllm_omni.codec.kernels import gather_packed_state_inputs, update_packed_state + + inputs = inputs.contiguous() + if is_decode: + state_indices = self._decode_state_indices(metadata) + query_start_loc = state_indices + has_initial = state_indices + else: + state_indices = metadata.state_indices_tensor_p + query_start_loc = metadata.query_start_loc_p + has_initial = metadata.has_initial_states_p + if state_indices is None or query_start_loc is None or has_initial is None: + raise RuntimeError("incomplete codec prefill metadata") + + joined = gather_packed_state_inputs( + inputs, + self.kv_cache[0], + state_indices, + has_initial, + history=1, + is_decode=is_decode, + ) + outputs = self.conv(joined.transpose(1, 2))[:, :, self.stride : -self.stride] + outputs = outputs.transpose(1, 2).contiguous() + + update_packed_state( + inputs, + self.kv_cache[0], + query_start_loc, + state_indices, + has_initial, + channels=self.in_channels, + history=1, + time_factor=self.time_factor, + is_decode=is_decode, + ) + return outputs.reshape(-1, self.conv.out_channels) + + def forward(self, inputs: torch.Tensor) -> torch.Tensor: + metadata = self._metadata() + if metadata is None: + outputs = self.conv(inputs.transpose(0, 1).unsqueeze(0)).squeeze(0) + return self.activation(outputs[:, : -self.stride].transpose(0, 1)) + expected_rows = (metadata.num_decode_tokens + metadata.num_prefill_tokens) * self.time_factor + if inputs.shape[0] != expected_rows: + raise RuntimeError(f"codec metadata describes {expected_rows} rows, got {inputs.shape[0]}") + if inputs.is_cuda: + from easymagpie_vllm_omni.codec.kernels import packed_causal_conv_transpose1d + + if getattr(metadata, "codec_uniform", False): + if metadata.num_decodes and metadata.num_prefills: + raise NotImplementedError("uniform codec batches cannot mix prefill and decode") + outputs = self._uniform_cuda(inputs, metadata, is_decode=bool(metadata.num_decodes)) + return self.activation(outputs) + + parts = [] + decode_rows = metadata.num_decode_tokens * self.time_factor + if metadata.num_decodes: + state_indices_d = self._decode_state_indices(metadata) + parts.append( + packed_causal_conv_transpose1d( + inputs[:decode_rows], + self.conv.weight, + self.conv.bias, + self.kv_cache[0], + state_indices_d, + state_indices_d, + state_indices_d, + stride=self.stride, + time_factor=self.time_factor, + output_channels=self.conv.out_channels, + is_decode=True, + ) + ) + if metadata.num_prefills: + if ( + metadata.query_start_loc_p is None + or metadata.state_indices_tensor_p is None + or metadata.has_initial_states_p is None + ): + raise RuntimeError("incomplete codec prefill metadata") + parts.append( + packed_causal_conv_transpose1d( + inputs[decode_rows:], + self.conv.weight, + self.conv.bias, + self.kv_cache[0], + metadata.query_start_loc_p, + metadata.state_indices_tensor_p, + metadata.has_initial_states_p, + stride=self.stride, + time_factor=self.time_factor, + output_channels=self.conv.out_channels, + max_query_len=self._prefill_max_query_len(metadata), + ) + ) + if len(parts) == 1: + outputs = parts[0] + else: + outputs = torch.cat(parts, dim=0) if parts else inputs.new_empty((0, self.conv.out_channels)) + return self.activation(outputs) + outputs = [ + self._one(sequence, page, has_initial) + for sequence, page, has_initial in self._iter_sequences(inputs, metadata) + ] + return torch.cat(outputs, dim=0) if outputs else inputs.new_empty((0, self.conv.out_channels)) + + +class PackedResidualBlock(nn.Module): + def __init__( + self, + channels: int, + filters: int, + kernel_size: int, + *, + time_factor: int, + dtype: torch.dtype, + prefix: str, + ) -> None: + super().__init__() + self.input_conv = PackedCausalConv1d( + channels, + filters, + kernel_size, + activate=True, + time_factor=time_factor, + dtype=dtype, + prefix=f"{prefix}.input_conv", + ) + self.skip_conv = PackedCausalConv1d( + filters, + channels, + kernel_size, + activate=False, + time_factor=time_factor, + dtype=dtype, + prefix=f"{prefix}.skip_conv", + ) + self.output_activation = PackedHalfSnake(channels) + + def forward(self, inputs: torch.Tensor) -> torch.Tensor: + return self.output_activation(inputs + self.skip_conv(self.input_conv(inputs))) + + +class PackedResNetDecoder(nn.Module): + def __init__(self, config: EasyMagpieCodecConfig, *, dtype: torch.dtype, prefix: str) -> None: + super().__init__() + factor = config.frame_stacking_factor + self.pre_conv = PackedCausalConv1d( + config.input_dim, + config.input_filters, + config.kernel_size, + activate=False, + time_factor=factor, + dtype=dtype, + prefix=f"{prefix}.pre_conv", + ) + + channels = config.input_filters + self.pre_resblocks = nn.ModuleList() + self.pre_up_sample_layers = nn.ModuleList() + for index, (rate, filters) in enumerate(zip(config.pre_upsample_rates, config.pre_upsample_filters)): + self.pre_resblocks.append( + PackedResidualBlock( + channels, + 2 * channels, + config.kernel_size, + time_factor=factor, + dtype=dtype, + prefix=f"{prefix}.pre_resblocks.{index}", + ) + ) + self.pre_up_sample_layers.append( + PackedCausalConvTranspose1d( + channels, + filters, + rate, + time_factor=factor, + dtype=dtype, + prefix=f"{prefix}.pre_up_sample_layers.{index}", + ) + ) + factor *= rate + channels = filters + + self.conv_layers = nn.ModuleList( + PackedResidualBlock( + channels, + config.hidden_filters, + config.kernel_size, + time_factor=factor, + dtype=dtype, + prefix=f"{prefix}.conv_layers.{index}", + ) + for index in range(config.num_hidden_layers) + ) + + self.resblock_up_sample_layers = nn.ModuleList() + self.resblocks = nn.ModuleList() + for index, (rate, filters) in enumerate(zip(config.resblock_upsample_rates, config.resblock_upsample_filters)): + self.resblock_up_sample_layers.append( + PackedCausalConvTranspose1d( + channels, + filters, + rate, + time_factor=factor, + dtype=dtype, + prefix=f"{prefix}.resblock_up_sample_layers.{index}", + ) + ) + factor *= rate + self.resblocks.append( + PackedResidualBlock( + filters, + 2 * filters, + config.resblock_kernel_size, + time_factor=factor, + dtype=dtype, + prefix=f"{prefix}.resblocks.{index}", + ) + ) + channels = filters + + self.post_conv = PackedCausalConv1d( + channels, + 1, + config.resblock_kernel_size, + activate=False, + time_factor=factor, + dtype=dtype, + prefix=f"{prefix}.post_conv", + ) + + def forward(self, inputs: torch.Tensor) -> torch.Tensor: + hidden = self.pre_conv(inputs) + for block, upsample in zip(self.pre_resblocks, self.pre_up_sample_layers): + hidden = upsample(block(hidden)) + for block in self.conv_layers: + hidden = block(hidden) + for upsample, block in zip(self.resblock_up_sample_layers, self.resblocks): + hidden = block(upsample(hidden)) + return self.post_conv(hidden).squeeze(-1).clamp(-1.0, 1.0) + + +class PackedEasyMagpieCodec(nn.Module): + def __init__(self, config: EasyMagpieCodecConfig, *, dtype: torch.dtype, prefix: str = "") -> None: + super().__init__() + self.config = config + self.dtype = dtype + self.dequantizer = PackedFiniteScalarDequantizer(config) + decoder_prefix = f"{prefix}.audio_decoder" if prefix else "audio_decoder" + self.audio_decoder = PackedResNetDecoder(config, dtype=dtype, prefix=decoder_prefix) + + def forward(self, codes: torch.Tensor) -> torch.Tensor: + if codes.dim() != 2 or codes.shape[-1] != self.config.num_stacked_codebooks: + raise ValueError( + f"expected packed [BT, {self.config.num_stacked_codebooks}] codes, got {tuple(codes.shape)}" + ) + # [BT,C*S] -> [BT*S,C]. Sequence boundaries remain contiguous, and + # every downstream layer scales metadata by the same S. + unstacked = unstack_acoustic_codes( + codes, + num_codebooks=self.config.num_codebooks, + frame_stacking_factor=self.config.frame_stacking_factor, + ) + latent = self.dequantizer(unstacked.clamp(0, self.config.codebook_size - 1)).to(self.dtype) + return self.audio_decoder(latent) diff --git a/tools/easymagpie_vllm_omni/easymagpie_vllm_omni/codec/packing.py b/tools/easymagpie_vllm_omni/easymagpie_vllm_omni/codec/packing.py new file mode 100644 index 000000000000..bcdca3889f9b --- /dev/null +++ b/tools/easymagpie_vllm_omni/easymagpie_vllm_omni/codec/packing.py @@ -0,0 +1,44 @@ +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. 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. +"""EasyMagpie acoustic-code packing helpers.""" + +from __future__ import annotations + +import torch + + +def unstack_acoustic_codes( + codes: torch.Tensor, + *, + num_codebooks: int, + frame_stacking_factor: int, +) -> torch.Tensor: + """Convert predictor rows ``[..., T, C*S]`` to codec rows ``[..., T*S, C]``. + + EasyMagpie stacks adjacent codec frames inside each codebook. For ``S=2`` + a predictor row is ordered as ``c0_t0, c0_t1, c1_t0, c1_t1, ...``. This + function restores time-major 8-codebook frames without moving data before + the final packed reshape. + """ + expected = num_codebooks * frame_stacking_factor + if codes.dim() < 2 or codes.shape[-1] != expected: + raise ValueError(f"expected [..., T, {expected}] stacked codes, got {tuple(codes.shape)}") + + leading_shape = codes.shape[:-2] + frames = codes.shape[-2] + return ( + codes.unflatten(-1, (num_codebooks, frame_stacking_factor)) + .transpose(-2, -1) + .reshape(*leading_shape, frames * frame_stacking_factor, num_codebooks) + ) diff --git a/tools/easymagpie_vllm_omni/easymagpie_vllm_omni/codec/weight_conversion.py b/tools/easymagpie_vllm_omni/easymagpie_vllm_omni/codec/weight_conversion.py new file mode 100644 index 000000000000..1e512a159855 --- /dev/null +++ b/tools/easymagpie_vllm_omni/easymagpie_vllm_omni/codec/weight_conversion.py @@ -0,0 +1,52 @@ +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. 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. +"""Weight conversion helpers shared by the CLI and parity tests.""" + +from __future__ import annotations + +from collections.abc import Mapping + +import torch + + +def fold_weight_norm(g: torch.Tensor, v: torch.Tensor) -> torch.Tensor: + """Materialize PyTorch weight norm with its default output-channel dim.""" + norm_dims = tuple(range(1, v.dim())) + return v * (g / torch.linalg.vector_norm(v, dim=norm_dims, keepdim=True)) + + +def convert_decoder_state_dict(state: Mapping[str, torch.Tensor]) -> dict[str, torch.Tensor]: + """Extract the decoder, fold weight norm, and rename NeMo activations.""" + prefix = "audio_decoder." + decoder = {name: tensor for name, tensor in state.items() if name.startswith(prefix)} + converted: dict[str, torch.Tensor] = {} + + suffix_v = ".parametrizations.weight.original1" + suffix_g = ".parametrizations.weight.original0" + for name, value in decoder.items(): + if name.endswith(suffix_g): + continue + if name.endswith(suffix_v): + base = name[: -len(suffix_v)] + g_name = base + suffix_g + if g_name not in decoder: + raise KeyError(f"missing weight-norm magnitude {g_name}") + converted[base + ".weight"] = fold_weight_norm(decoder[g_name], value).contiguous() + continue + + renamed = name.replace(".activation.activation.snake_act.alpha", ".activation.alpha") + renamed = renamed.replace(".output_activation.activation.snake_act.alpha", ".output_activation.alpha") + converted[renamed] = value.contiguous() + + return converted diff --git a/tools/easymagpie_vllm_omni/easymagpie_vllm_omni/config.py b/tools/easymagpie_vllm_omni/easymagpie_vllm_omni/config.py new file mode 100644 index 000000000000..bfaec1b07531 --- /dev/null +++ b/tools/easymagpie_vllm_omni/easymagpie_vllm_omni/config.py @@ -0,0 +1,287 @@ +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. 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. +"""EasyMagpieTTS architecture configuration for vLLM-Omni.""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +# Each audio codebook appends BOS, EOS, context, mask, and reserved tokens. +NUM_SPECIAL_AUDIO_TOKENS: int = 8 + +# Offsets within the trailing special-token block. +SPECIAL_AUDIO_BOS: int = 0 +SPECIAL_AUDIO_EOS: int = 1 +SPECIAL_AUDIO_CONTEXT_BOS: int = 2 +SPECIAL_AUDIO_CONTEXT_EOS: int = 3 +SPECIAL_AUDIO_MASK: int = 4 + + +@dataclass +class EasyMagpieOmniArch: + """Static architecture description for an EasyMagpieTTS checkpoint.""" + + hidden_dim: int = 1536 + embedding_dim: int = 1536 + audio_embedding_dim: int = 1536 + + num_audio_codebooks: int = 8 + codebook_size: int = 1024 + frame_stacking_factor: int = 2 + + phoneme_stacking_factor: int = 1 + phoneme_vocab_size: int = 2051 + + # Text EOS is normally the second-to-last text-vocabulary row. Multiturn + # checkpoints append an interruption token after CFG_UNK, so converters pin + # the actual ID explicitly instead of deriving it from the final table size. + text_eos_id: int | None = None + use_multiturn_dataset: bool = False + + # The text/phoneme/audio streams are temporally offset: at decode step ``k`` + # the text channel consumes ``text_tokens[k]``, the phoneme channel starts at + # ``k == streaming_phonemes_delay`` (seeded with phoneme BOS), and the audio + # channel starts at ``k == streaming_speech_delay`` (seeded with audio BOS). + # Both default to 0 (lock-step), which reproduces a non-delayed / "full" mode. + streaming_phonemes_delay: int = 0 + streaming_speech_delay: int = 0 + + # Phoneme special-token ids (into the per-stack ``phoneme_embeddings`` table) + # and the confidence→UNK replacement threshold. ``None`` falls back to the + # IPABPETokenizer convention (bos/eos/unk = vocab-3/-2/-1). + phoneme_bos_id: int | None = None + phoneme_eos_id: int | None = None + phoneme_unk_id: int | None = None + phoneme_confidence_unk_threshold: float = 0.0 + + # Number of task embeddings; zero disables task conditioning. + num_task_embeddings: int = 0 + + local_transformer_n_layers: int = 3 + local_transformer_n_heads: int = 12 + local_transformer_hidden_dim: int = 1536 + + # Optional checkpoint-specific special-token ids. + forced_audio_bos_id: int | None = None + forced_audio_eos_id: int | None = None + forced_mask_token_id: int | None = None + + extra: dict[str, Any] = field(default_factory=dict) + + def validate(self, *, text_vocab_size: int | None = None) -> None: + """Reject architecture variants the current vLLM implementation cannot serve.""" + + positive_fields = ( + "hidden_dim", + "embedding_dim", + "audio_embedding_dim", + "num_audio_codebooks", + "codebook_size", + "frame_stacking_factor", + "local_transformer_n_layers", + "local_transformer_n_heads", + "local_transformer_hidden_dim", + ) + for name in positive_fields: + if getattr(self, name) <= 0: + raise ValueError(f"{name} must be positive, got {getattr(self, name)}") + + if self.hidden_dim != self.embedding_dim: + raise ValueError( + "hidden_dim must equal embedding_dim because the vLLM backbone currently consumes text/audio " + "embeddings without an input projection. Add the corresponding projection to support unequal widths; " + f"got hidden_dim={self.hidden_dim}, embedding_dim={self.embedding_dim}." + ) + if self.local_transformer_hidden_dim % self.local_transformer_n_heads != 0: + raise ValueError( + "local_transformer_hidden_dim must be divisible by local_transformer_n_heads for the current " + "attention implementation; extend EasyMagpieCodePredictor to support other head layouts. Got " + f"{self.local_transformer_hidden_dim} and {self.local_transformer_n_heads}." + ) + + phonemes_enabled = self.phoneme_vocab_size > 0 and self.phoneme_stacking_factor > 0 + if phonemes_enabled != (self.phoneme_vocab_size > 0 or self.phoneme_stacking_factor > 0): + raise ValueError( + "phoneme_vocab_size and phoneme_stacking_factor must be both enabled or both disabled; " + f"got {self.phoneme_vocab_size} and {self.phoneme_stacking_factor}." + ) + if self.phoneme_vocab_size < 0 or self.phoneme_stacking_factor < 0: + raise ValueError("phoneme_vocab_size and phoneme_stacking_factor cannot be negative") + if phonemes_enabled: + if self.phoneme_vocab_size < 3: + raise ValueError("phoneme_vocab_size must include at least BOS, EOS, and UNK tokens") + phoneme_ids = { + "phoneme_bos_id": self.resolved_phoneme_bos_id, + "phoneme_eos_id": self.resolved_phoneme_eos_id, + "phoneme_unk_id": self.resolved_phoneme_unk_id, + } + for name, token_id in phoneme_ids.items(): + if not 0 <= token_id < self.phoneme_vocab_size: + raise ValueError(f"{name}={token_id} must be in [0, {self.phoneme_vocab_size})") + if len(set(phoneme_ids.values())) != len(phoneme_ids): + raise ValueError("phoneme BOS, EOS, and UNK token ids must be distinct") + if not 0.0 <= self.phoneme_confidence_unk_threshold <= 1.0: + raise ValueError("phoneme_confidence_unk_threshold must be in [0, 1]") + + if self.streaming_phonemes_delay < 0 or self.streaming_speech_delay < 0: + raise ValueError("streaming delays cannot be negative") + if (self.streaming_phonemes_delay or self.streaming_speech_delay) and ( + self.streaming_speech_delay <= self.streaming_phonemes_delay + ): + raise ValueError( + "streaming_speech_delay must be greater than streaming_phonemes_delay for delayed streaming; " + "extend the prefill/decode scheduling before using other delay layouts. Got " + f"{self.streaming_speech_delay} and {self.streaming_phonemes_delay}." + ) + + audio_special_ids = { + "forced_audio_bos_id" if self.forced_audio_bos_id is not None else "audio_bos_id": self.audio_bos_id, + "forced_audio_eos_id" if self.forced_audio_eos_id is not None else "audio_eos_id": self.audio_eos_id, + "forced_mask_token_id" if self.forced_mask_token_id is not None else "mask_token_id": self.mask_token_id, + } + special_start = self.codebook_size + special_end = self.num_all_tokens_per_codebook + for name, token_id in audio_special_ids.items(): + if not special_start <= token_id < special_end: + raise ValueError( + f"{name}={token_id} must be in the special-token range [{special_start}, {special_end})" + ) + if len(set(audio_special_ids.values())) != len(audio_special_ids): + raise ValueError("audio BOS, EOS, and MASK token ids must be distinct") + + if self.num_task_embeddings < 0: + raise ValueError("num_task_embeddings cannot be negative") + if text_vocab_size is not None: + if text_vocab_size <= 0: + raise ValueError(f"text_vocab_size must be positive, got {text_vocab_size}") + text_eos_id = self.resolved_text_eos_id(text_vocab_size) + if not 0 <= text_eos_id < text_vocab_size: + raise ValueError(f"text_eos_id={text_eos_id} must be in [0, {text_vocab_size})") + + @property + def num_stacked_codebooks(self) -> int: + """Number of independent codebooks the model autoregresses over (``C * S``).""" + return self.num_audio_codebooks * self.frame_stacking_factor + + @property + def text_prefill_num(self) -> int: + """Text-led decode positions that can be folded into causal prefill. + + Positions before ``streaming_phonemes_delay`` have no phoneme input. + The next position is also deterministic because it receives phoneme + BOS, so it can be prefetched as long as speech starts later. + """ + if self.phoneme_vocab_size <= 0 or self.phoneme_stacking_factor <= 0: + return 0 + if self.streaming_speech_delay <= 0: + return 0 + if self.streaming_speech_delay <= self.streaming_phonemes_delay: + raise ValueError( + "streaming_speech_delay must be greater than streaming_phonemes_delay for text-led prefill" + ) + return self.streaming_phonemes_delay + 1 + + @property + def num_all_tokens_per_codebook(self) -> int: + """Per-codebook vocabulary size including the trailing special tokens.""" + return self.codebook_size + NUM_SPECIAL_AUDIO_TOKENS + + @property + def audio_bos_id(self) -> int: + """Embedding-table id of the audio BOS token.""" + if self.forced_audio_bos_id is not None: + return self.forced_audio_bos_id + return self.codebook_size + SPECIAL_AUDIO_BOS + + @property + def audio_eos_id(self) -> int: + """Embedding-table id of the audio EOS token.""" + if self.forced_audio_eos_id is not None: + return self.forced_audio_eos_id + return self.codebook_size + SPECIAL_AUDIO_EOS + + @property + def mask_token_id(self) -> int: + """Embedding-table id of the MaskGit MASK token.""" + if self.forced_mask_token_id is not None: + return self.forced_mask_token_id + return self.codebook_size + SPECIAL_AUDIO_MASK + + @property + def resolved_phoneme_bos_id(self) -> int: + """Phoneme BOS id, falling back to the IPABPETokenizer convention (vocab-3).""" + return self.phoneme_bos_id if self.phoneme_bos_id is not None else self.phoneme_vocab_size - 3 + + @property + def resolved_phoneme_eos_id(self) -> int: + """Phoneme EOS id, falling back to the IPABPETokenizer convention (vocab-2).""" + return self.phoneme_eos_id if self.phoneme_eos_id is not None else self.phoneme_vocab_size - 2 + + @property + def resolved_phoneme_unk_id(self) -> int: + """Phoneme UNK id, falling back to the IPABPETokenizer convention (vocab-1).""" + return self.phoneme_unk_id if self.phoneme_unk_id is not None else self.phoneme_vocab_size - 1 + + def resolved_text_eos_id(self, text_vocab_size: int) -> int: + """Text EOS id, preserving the legacy second-to-last-row convention.""" + return self.text_eos_id if self.text_eos_id is not None else text_vocab_size - 2 + + @classmethod + def from_hf_config(cls, hf_config: Any) -> "EasyMagpieOmniArch": + """Build an arch description from a vLLM ``hf_config``. + + Attributes present on ``hf_config`` override the defaults; unknown + attributes are ignored. + """ + defaults = cls() + kwargs: dict[str, Any] = {} + for f in ( + "hidden_dim", + "embedding_dim", + "audio_embedding_dim", + "num_audio_codebooks", + "codebook_size", + "frame_stacking_factor", + "phoneme_stacking_factor", + "phoneme_vocab_size", + "text_eos_id", + "use_multiturn_dataset", + "streaming_phonemes_delay", + "streaming_speech_delay", + "phoneme_bos_id", + "phoneme_eos_id", + "phoneme_unk_id", + "phoneme_confidence_unk_threshold", + "num_task_embeddings", + "local_transformer_n_layers", + "local_transformer_n_heads", + "local_transformer_hidden_dim", + "forced_audio_bos_id", + "forced_audio_eos_id", + "forced_mask_token_id", + ): + if hasattr(hf_config, f): + kwargs[f] = getattr(hf_config, f) + # ``hidden_size`` is the canonical HF name for the backbone width. + if "hidden_dim" not in kwargs and hasattr(hf_config, "hidden_size"): + kwargs["hidden_dim"] = hf_config.hidden_size + kwargs.setdefault("embedding_dim", hf_config.hidden_size) + merged = {**defaults.__dict__, **kwargs} + merged.pop("extra", None) + arch = cls(**merged) + arch.validate(text_vocab_size=getattr(hf_config, "text_vocab_size", None)) + return arch + + +EASYMAGPIE_SMALLMAMBA = EasyMagpieOmniArch() diff --git a/tools/easymagpie_vllm_omni/easymagpie_vllm_omni/easymagpie.py b/tools/easymagpie_vllm_omni/easymagpie_vllm_omni/easymagpie.py new file mode 100644 index 000000000000..5380837124b8 --- /dev/null +++ b/tools/easymagpie_vllm_omni/easymagpie_vllm_omni/easymagpie.py @@ -0,0 +1,1216 @@ +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. 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. +"""Inference-only EasyMagpieTTS model for vLLM-Omni. + +The Nemotron-H backbone consumes additive text, phoneme, and previous-audio +embeddings. A local transformer predicts the stacked audio codebooks for each +frame. Request metadata supplies the target text, speaker id or embedding, +optional context text and task mode, and audio sampling parameters. + +``prompt_token_ids`` must have the same length as the assembled speaker +conditioning plus any causal target-text rows moved into prefill. Streaming +text requests provide one or more ``text_token`` ids per decode chunk and +terminate the stream with ``text_eos_id``. +""" +from __future__ import annotations + +import bisect +from collections.abc import Callable, Iterable +from typing import Any, Optional + +import torch +from easymagpie_vllm_omni.backbone_patches import ( + patch_mamba_streaming_decode, + patch_moe_routed_scale, + patch_shared_expert_activation, +) +from easymagpie_vllm_omni.config import EasyMagpieOmniArch +from easymagpie_vllm_omni.local_transformer import EasyMagpieCodePredictor +from torch import nn +from vllm.compilation.backends import set_model_tag +from vllm.config import CUDAGraphMode, VllmConfig +from vllm.forward_context import BatchDescriptor, get_forward_context +from vllm.logger import init_logger +from vllm.model_executor.models.interfaces import HasInnerState, IsHybrid, SupportsMambaPrefixCaching +from vllm.model_executor.models.nemotron_h import NemotronHForCausalLM, NemotronHModel +from vllm.model_executor.models.utils import maybe_prefix +from vllm.sequence import IntermediateTensors +from vllm_omni.model_executor.models.output_templates import OmniOutput + +logger = init_logger(__name__) + +# Placeholder token id stuffed into the per-step ``input_ids`` returned by +# ``preprocess`` — the model never consumes ``input_ids`` (decode behaviour is +# driven by the per-token buffers), and ``compute_logits`` returns +# argmax-at-0 dummy logits, so this only needs to be a valid id. +_DUMMY_TOKEN_ID = 0 + + +def _merge_streaming_text_chunk( + text_tokens: list[int], incoming: list[int], text_token_start: Any +) -> tuple[list[int], bool]: + """Merge one absolute-position text chunk into a request's token buffer. + + vLLM async scheduling can expose the next segment's metadata one decode + step early. Absolute positions make that lookahead harmless: an already + merged chunk is a no-op, while the next contiguous chunk is appended once. + Gaps and conflicting overlaps indicate a malformed streaming request and + are rejected instead of silently dropping text conditioning. + """ + if not incoming: + return text_tokens, False + if text_token_start is None: + raise ValueError("Streaming text_token updates require text_token_start") + + start = int(text_token_start) + if start < 0 or start > len(text_tokens): + raise ValueError(f"Invalid text_token_start={start} for accumulated text length {len(text_tokens)}") + + chunk = [int(token) for token in incoming] + overlap = min(len(chunk), len(text_tokens) - start) + if text_tokens[start : start + overlap] != chunk[:overlap]: + raise ValueError(f"Conflicting streaming text chunk at absolute position {start}") + if overlap == len(chunk): + return text_tokens, False + + return text_tokens + chunk[overlap:], True + + +# Context text used when the request omits ``context_text`` +_DEFAULT_CONTEXT_TEXT = "[EN]" + + +# This class is not wrapped in ``@support_torch_compile``: the Nemotron-H +# backbone and :class:`EasyMagpieCodePredictor` each manage their own +# ``torch.compile`` / CUDA-graph capture internally, so the outer ``forward`` +# runs eagerly and dispatches into the two self-compiled subgraphs. +class EasyMagpieTTSForConditionalGeneration( + nn.Module, + HasInnerState, + IsHybrid, + SupportsMambaPrefixCaching, +): + """EasyMagpie LM stage for vLLM-Omni. + + See the module docstring for the per-step flow and the per-request I/O + contract. The class exposes the omni hooks (``has_preprocess`` / + ``has_postprocess`` / ``have_multimodal_outputs``) consumed by the + ``OmniGPUModelRunner``. + """ + + # Hybrid-Mamba bookkeeping (delegated to vLLM's NemotronH causal-LM). vLLM + # expects these as class attributes. + get_mamba_state_dtype_from_config = NemotronHForCausalLM.get_mamba_state_dtype_from_config + get_mamba_state_shape_from_config = NemotronHForCausalLM.get_mamba_state_shape_from_config + get_mamba_state_copy_func = NemotronHForCausalLM.get_mamba_state_copy_func + + # Omni runner hooks. + has_preprocess: bool = True + has_postprocess: bool = True + have_multimodal_outputs: bool = True + + # Stage 1 (Code2Wav) consumes only the sampled codes (multimodal outputs), + # never the backbone hidden states. Opt out of attaching ``hidden`` to the + # inter-stage pooler payload so the runner skips the per-step D2H copy + + # transport of hidden states (the default is True and would pass them). + omni_pooler_payload_include_hidden: bool = False + + # Keep small per-step tensors GPU-resident across steps (no D2H/H2D). + gpu_resident_buffer_keys: set[str] = { + "last_audio_codes", + "last_phoneme_token", + "last_hidden", + } + + def __init__(self, *, vllm_config: VllmConfig, prefix: str = "") -> None: + super().__init__() + hf_config = vllm_config.model_config.hf_config + self.hf_config = hf_config + self.vllm_config = vllm_config + self.arch = EasyMagpieOmniArch.from_hf_config(hf_config) + self.model_path = vllm_config.model_config.model + + arch = self.arch + self.hidden_dim = arch.hidden_dim + self.embedding_dim = arch.embedding_dim + self.num_codebooks = arch.num_stacked_codebooks + + # How to surface sampled acoustic codes from ``make_omni_output``, driven + # by the stage's pipeline-config ``engine_output_type``: + # + # * "audio" (single-stage EasyMagpie LM that is also the *final*, client-facing + # stage): emit under the ``model_outputs`` key. vLLM-Omni's output + # processor remaps ``model_outputs`` -> the drainable ``audio`` modality + # key, so DELTA streaming DRAINS the codes every step (the client gets + # per-step deltas) instead of re-accumulating and re-sending the whole + # cumulative code tensor on every step. + # * otherwise (two-stage EasyMagpie LM; ``engine_output_type="latent"``): emit + # the inter-stage keys (``audio_codes`` + nested ``codes.audio``) that + # the Code2Wav connector / async-chunk streamer consume. + engine_output_type = getattr(vllm_config.model_config, "engine_output_type", None) + self._single_stage_audio = str(engine_output_type or "").lower() == "audio" + + # ── Backbone (reused vLLM Nemotron-H LM; fed via inputs_embeds) ── + self.backbone = NemotronHModel( + vllm_config=vllm_config, + prefix=maybe_prefix(prefix, "backbone"), + ) + # vLLM 0.24's NemotronHMLP hard-codes ReLU² in shared_experts, + # ignoring the checkpoint's mlp_hidden_act. Restore the configured + # activation (no-op when the backbone has no MoE layers). + patch_shared_expert_activation(self.backbone) + # vLLM's FusedMoE defers routed_scaling_factor to the decoder layer in + # FP16, but NemotronH's decoder layer never compensates, so the MoE + # output is under-scaled by routed_scaling_factor. Restore it (no-op in + # fp32/bf16 and when there are no MoE layers). + patch_moe_routed_scale(self.backbone) + # The streaming-input path keeps extending the prompt, so vLLM's Mamba2 + # metadata builder would classify every single-token decode step as a + # prefill — breaking the FULL decode cudagraph (stale + # state_indices_tensor_d). Force single-token extends to classify as + # decodes so FULL/FULL_DECODE_ONLY cudagraphs read the right Mamba slot. + patch_mamba_streaming_decode() + + # ── Local transformer (its own compile group / CUDA graph) ────── + with set_model_tag("local_transformer"): + self.code_predictor = EasyMagpieCodePredictor( + vllm_config=vllm_config, + prefix=maybe_prefix(prefix, "code_predictor"), + ) + + # ── Text + phoneme embedding heads ────────────────────────────── + # Precomputed per-subword text embedding (one row per subword id), baked + # at conversion time and fed additively on every decode step. + text_vocab_size = int(getattr(hf_config, "text_vocab_size", getattr(hf_config, "vocab_size", 0))) + self.text_embedding = nn.Embedding(text_vocab_size, self.embedding_dim) + + # Text-stream EOS id. Legacy checkpoints place it at vocab_size - 2; + # multiturn checkpoints append an interruption token after the existing + # specials, so their converter pins the unchanged EOS id explicitly. + self.text_eos_id = arch.resolved_text_eos_id(text_vocab_size) + + # Task ("service token") embedding — a single learned per-mode row + # prepended to the prefill context for multi-mode checkpoints. Built only + # when the checkpoint carries one; otherwise ``None``. + self.num_task_embeddings = int(arch.num_task_embeddings) + if self.num_task_embeddings > 0: + self.task_embedding = nn.Embedding(self.num_task_embeddings, self.embedding_dim) + else: + self.task_embedding = None + + # Context-text tokenizer, loaded lazily from the model directory. It + # turns the per-request ``context_text`` string (e.g. ``"[EN]"``) into the + # subword ids that the baked ``text_embedding`` table consumes — so the + # caller passes plain text, never pre-tokenized ids. + self._text_tokenizer: Any = None + + # ── Streaming delays (text leads phoneme by ``phonemes_delay`` and audio + # by ``speech_delay`` decode steps; 0/0 == lock-step). ── + self.phonemes_delay = int(getattr(arch, "streaming_phonemes_delay", 0) or 0) + self.speech_delay = int(getattr(arch, "streaming_speech_delay", 0) or 0) + + # Phoneme channel (optional — only built when the checkpoint has one). + self.has_phoneme = arch.phoneme_vocab_size > 0 and arch.phoneme_stacking_factor > 0 + if self.has_phoneme: + self.phoneme_embeddings = nn.ModuleList( + [ + nn.Embedding(arch.phoneme_vocab_size, self.embedding_dim) + for _ in range(arch.phoneme_stacking_factor) + ] + ) + self.phoneme_final_proj = nn.Linear( + self.hidden_dim, arch.phoneme_vocab_size * arch.phoneme_stacking_factor + ) + # Phoneme special-token ids + confidence→UNK replacement threshold. + self.phoneme_bos_id = int(arch.resolved_phoneme_bos_id) + self.phoneme_eos_id = int(arch.resolved_phoneme_eos_id) + self.phoneme_unk_id = int(arch.resolved_phoneme_unk_id) + self.phoneme_confidence_unk_threshold = float(arch.phoneme_confidence_unk_threshold) + + # ── Persistent, address-stable scratch buffers ───────────────── + max_num_tokens = vllm_config.scheduler_config.max_num_batched_tokens + dtype = vllm_config.model_config.dtype + # Combined per-token input embedding fed into the backbone. + self._combined_embeddings = torch.zeros(max_num_tokens, self.embedding_dim, dtype=dtype) + # Per-token decode inputs assembled by ``preprocess``. + self._dec_text_tokens = torch.zeros(max_num_tokens, dtype=torch.long) + self._dec_text_mask = torch.zeros(max_num_tokens, dtype=torch.long) + self._dec_audio_codes = torch.zeros(max_num_tokens, self.num_codebooks, dtype=torch.long) + self._dec_audio_valid = torch.zeros(max_num_tokens, dtype=torch.long) + if self.has_phoneme: + self._dec_phoneme_tokens = torch.zeros(max_num_tokens, arch.phoneme_stacking_factor, dtype=torch.long) + self._dec_phoneme_valid = torch.zeros(max_num_tokens, dtype=torch.long) + + self._out_codes = torch.zeros(max_num_tokens, self.num_codebooks, dtype=torch.long) + + # ── Audio-EOS → engine stop ───────────────────────────────────── + # The model signals end-of-speech inside the audio codebooks. + # To make vLLM terminate the request at the EOS frame, + # we flags decode positions with ``audio_eos_id`` emit designated ``stop_token_id`` + # in ``compute_logits``. + # Callers must pass ``SamplingParams(stop_token_ids=[stop_id])`` with + # ``stop_id = audio_eos_stop_token_id(hf_config)``. + self.audio_eos_id = int(arch.audio_eos_id) + self._stop_token_id = self.audio_eos_stop_token_id(hf_config) + # flags frames in which ``_out_codes`` contain ``audio_eos_id`` + self._token_stop = torch.zeros(max_num_tokens, dtype=torch.bool) + # slice of ``token_stop`` based on ``logit_idx`` that can be used in + # ``compute_logits`` + self._sample_stop = torch.zeros(max_num_tokens, dtype=torch.bool) + + # ── Assembled prefill context embeddings (the only context cache) ── + # ``preprocess`` runs on the host, once per request, serially on the + # runner's critical path, so per-request speaker-tensor transfer + the + # tokenize/embed/cat dominate TTFT under concurrency. Cache the *whole* + # assembled context ``[task | speaker | context_text]`` per + # ``(task_mode_id, speaker_id, context_text, device)`` (see + # :meth:`_build_prefill_embeds`): for a known speaker it is identical on + # every request, so the cache subsumes a separate speaker-embedding table + # — the speaker ``.pt`` is read from disk only on the (first) cache miss + # for that combo (see :meth:`_load_known_speaker_embedding`), then never + # again. Custom raw-tensor voices are one-off and skip the cache. + self._prefill_cache: dict[tuple, torch.Tensor] = {} + + # ------------------------------------------------------------------ + # Embedding helpers + # ------------------------------------------------------------------ + + @staticmethod + def audio_eos_stop_token_id(hf_config: Any) -> int: + """Backbone token id this model emits when audio EOS is reached. + + Audio end-of-speech lives in the codebooks, not the backbone token + stream, so the dummy backbone vocab is repurposed as a 2-way stop + signal: index ``0`` == "continue", the last index == "stop". Callers + must pass ``SamplingParams(stop_token_ids=[this])`` + """ + return max(1, int(getattr(hf_config, "vocab_size", 2)) - 1) + + def get_input_embeddings(self, input_ids: torch.Tensor) -> torch.Tensor: + """Compatibility shim — unused at runtime (everything goes via inputs_embeds).""" + return self.text_embedding(input_ids) + + def embed_input_ids(self, input_ids: torch.Tensor) -> torch.Tensor: + return self.get_input_embeddings(input_ids) + + def _embed_phoneme(self, phoneme_tokens: torch.Tensor) -> torch.Tensor: + """Average the per-stack phoneme embeddings (``[num_tokens, S] -> [num_tokens, dim]``).""" + acc = self.phoneme_embeddings[0](phoneme_tokens[:, 0]) + for s in range(1, len(self.phoneme_embeddings)): + acc = acc + self.phoneme_embeddings[s](phoneme_tokens[:, s]) + return acc / len(self.phoneme_embeddings) + + # ------------------------------------------------------------------ + # Decode-token dispatch (which positions need the local transformer) + # ------------------------------------------------------------------ + + @staticmethod + def _select_query_layout(attn_metadata): + """Return ``(max_query_len, query_start_loc)`` from heterogeneous metadata. + + The Nemotron-H backbone is hybrid, so ``attn_metadata`` is a per-layer + dict mixing two metadata types: + + * **attention** layers expose the batch-level ``max_query_len`` and + ``query_start_loc``; + * **Mamba2** layers carry ``Mamba2AttentionMetadata``, which has *no* + ``max_query_len`` and splits the query layout into ``query_start_loc_p`` + / ``query_start_loc_d`` instead. + + Both are built from the same batch query layout, so we prefer any + attention-layer metadata. As a fallback for a (hypothetical) attention-free + backbone, we infer a decode-only batch from the Mamba2 ``num_prefills`` + counter. Returns ``(None, None)`` when the layout can't be determined. + """ + metas = list(attn_metadata.values()) if isinstance(attn_metadata, dict) else [attn_metadata] + + # Preferred: an attention layer exposes the unified query layout. + for m in metas: + mql = getattr(m, "max_query_len", None) + qsl = getattr(m, "query_start_loc", None) + if mql is not None and qsl is not None: + return int(mql), qsl + + # Fallback: Mamba2-only backbone. We can at least detect a decode-only + # batch (every request contributes a single token) from the counters. + for m in metas: + if hasattr(m, "num_prefills") and hasattr(m, "num_decodes"): + if int(getattr(m, "num_prefills", 0)) == 0: + return 1, None # decode-only -> caller runs the LT everywhere + break + return None, None + + def _get_query_dispatch(self): + """Return decode rows and the final row of each prefill query. + + * ``(None, 0, None)`` → run the local transformer on every token (a warm-up + run with no ``attn_metadata``, or a decode-only batch where + ``max_query_len == 1``), so the captured CUDA graph covers every + ``cudagraph_capture_sizes`` value. + * ``(indices, num_requests, prefill_last_indices)`` → run the local + transformer only on listed decode rows, and the phoneme projection on + each final prefill row. ``indices`` is CUDA-graph padded; + ``num_requests`` is its unpadded count. + """ + ctx = get_forward_context() + attn_metadata = ctx.attn_metadata + if attn_metadata is None: + return None, 0, None + + max_query_len, start_loc = self._select_query_layout(attn_metadata) + + # Decode-only batch (or layout unavailable) -> run the LT on every token. + if max_query_len is None or max_query_len == 1 or start_loc is None: + return None, 0, None + + tokens_per_req = start_loc[1:] - start_loc[:-1] + is_decode = tokens_per_req == 1 + decode_token_indices = start_loc[:-1][is_decode] + prefill_last_indices = start_loc[1:][~is_decode] - 1 + + num_requests = decode_token_indices.shape[0] + padded_num_requests = num_requests + if self.vllm_config.compilation_config.cudagraph_mode != CUDAGraphMode.NONE: + sizes = self.vllm_config.compilation_config.cudagraph_capture_sizes + idx = bisect.bisect_left(sizes, num_requests) + if idx < len(sizes): + padded_num_requests = sizes[idx] + if padded_num_requests != num_requests: + decode_token_indices = torch.nn.functional.pad( + decode_token_indices, (0, padded_num_requests - num_requests) + ) + return decode_token_indices, num_requests, prefill_last_indices + + # ------------------------------------------------------------------ + # forward + # ------------------------------------------------------------------ + + def forward( + self, + input_ids: torch.Tensor, + positions: torch.Tensor, + intermediate_tensors: Optional[IntermediateTensors] = None, + inputs_embeds: Optional[torch.Tensor] = None, + **kwargs: Any, + ) -> torch.Tensor: + """Assemble the per-token embedding, run the backbone, then the codes. + + ``inputs_embeds`` carries the prefill embedding span produced by + :meth:`preprocess` (zeros at decode positions). For decode positions we + assemble ``text_emb + phoneme_emb + audio_emb`` in-place from the + per-token buffers, run the backbone, then sample the codebooks with the + local transformer (skipping prefill positions). + """ + num_tokens = input_ids.shape[0] + combined = self._combined_embeddings[:num_tokens] + if inputs_embeds is not None: + combined.copy_(inputs_embeds) + else: + combined.zero_() + + # Reset per-token stop flags for this step (so prefill / warm-up rows stay + # "continue"); decode positions get set below by :meth:`_flag_audio_eos`. + self._token_stop[:num_tokens].zero_() + logits_index = kwargs.get("logits_index") + + decode_idx, num_req, prefill_last_idx = self._get_query_dispatch() + + if decode_idx is not None: + # Acoustic prediction is skipped on prefill rows. Clear their shared + # scratch slots so a prior request cannot leak a fake codec frame. + self._out_codes[:num_tokens].zero_() + if decode_idx is None: + # Warm-up or decode-only batches exercise the full decode path. + self._assemble_decode_embeddings(combined, slice(0, num_tokens)) + elif num_req > 0: + valid = decode_idx[:num_req] + self._assemble_decode_embeddings(combined, valid) + + hidden_states = self.backbone( + input_ids=input_ids, + positions=positions, + intermediate_tensors=intermediate_tensors, + inputs_embeds=combined, + ) + + # Sample codes (local transformer) only where needed. + if decode_idx is None: + codes = self.code_predictor.generate_codes(hidden_states) + self._out_codes[:num_tokens].copy_(codes) + self._flag_audio_eos(codes, slice(0, num_tokens)) + if self.has_phoneme: + self._predict_phonemes(hidden_states, slice(0, num_tokens)) + elif num_req > 0: + ctx = get_forward_context() + orig_bd = ctx.batch_descriptor + ctx.batch_descriptor = BatchDescriptor(num_tokens=decode_idx.shape[0]) + codes = self.code_predictor.generate_codes(hidden_states[decode_idx]) + ctx.batch_descriptor = orig_bd + valid = decode_idx[:num_req] + self._out_codes[valid] = codes[:num_req] + self._flag_audio_eos(codes[:num_req], valid) + if self.has_phoneme: + self._predict_phonemes(hidden_states, valid) + + # The final prefetched text position predicts the phoneme consumed by the + # first decode step. It does not need an acoustic-code prediction. + if self.has_phoneme and prefill_last_idx is not None and prefill_last_idx.numel() > 0: + self._predict_phonemes(hidden_states, prefill_last_idx) + + # Re-index _token_stop into _sample_stop. + # this only happens for mixed/prefill, since for capture logits_index is None, + # so during decode-only the branch for logits_index is None will be executed. + if logits_index is not None: + self._sample_stop[: logits_index.shape[0]] = self._token_stop[logits_index] + else: + self._sample_stop[:num_tokens].copy_(self._token_stop[:num_tokens]) + + return hidden_states + + def _flag_audio_eos(self, codes: torch.Tensor, idx) -> None: + """Flag decode positions whose newly sampled frame ends speech. + Checks codes for eos and assigns token_stop[idx] + + Note: this uses the *sampled* codes. NeMo also checks armax(logits) == eos_idx, + i.e. checks if EOS is emited without sampling. Skip for now. + """ + eos = (codes == self.audio_eos_id).any(dim=1) & (self._dec_audio_valid[idx] == 1) + self._token_stop[idx] = eos + + def _assemble_decode_embeddings(self, combined: torch.Tensor, idx) -> None: + """Add ``text + phoneme + audio`` embeddings into ``combined`` at ``idx``.""" + # Audio: previous-frame codes (gated by validity). + audio_codes = self._dec_audio_codes[idx] + audio_emb = self.code_predictor.embed_audio_frame(audio_codes) + audio_emb = audio_emb * self._dec_audio_valid[idx].unsqueeze(-1).to(audio_emb.dtype) + combined[idx] += audio_emb + + # Text: current subword token (gated by validity). + text_emb = self.text_embedding(self._dec_text_tokens[idx]) + text_emb = text_emb * self._dec_text_mask[idx].unsqueeze(-1).to(text_emb.dtype) + combined[idx] += text_emb + + # Phoneme: previous predicted phoneme (gated by validity). + if self.has_phoneme: + phon_emb = self._embed_phoneme(self._dec_phoneme_tokens[idx]) + phon_emb = phon_emb * self._dec_phoneme_valid[idx].unsqueeze(-1).to(phon_emb.dtype) + combined[idx] += phon_emb + + @torch.no_grad() + def _predict_phonemes(self, hidden_states: torch.Tensor, idx) -> None: + """Argmax the phoneme head (with confidence→UNK replacement) and stash it. + + When any stacked channel falls below + ``phoneme_confidence_unk_threshold``, + the whole step is replaced with the UNK id to curb error propagation. + + This is done here — not in ``preprocess``/``postprocess`` — because this + is the only place the phoneme logits exist (preprocess has no logits, and + postprocess only sees the argmax id). It uses only elementwise ops + + ``torch.where`` (no ``.item()`` / host sync), so it stays CUDA-graph safe. + """ + # Run in the model dtype (don't force fp32): ``phoneme_final_proj`` weights + # follow ``model_config.dtype`` (e.g. bf16), and argmax is dtype-insensitive, + # so an fp32 upcast here would mismatch the weight dtype in ``F.linear``. + logits = self.phoneme_final_proj(hidden_states[idx]) + s = self.arch.phoneme_stacking_factor + logits = logits.view(-1, s, self.arch.phoneme_vocab_size) + preds = logits.argmax(dim=-1).long() # (n, S) + + if self.phoneme_confidence_unk_threshold > 0.0: + max_probs = torch.softmax(logits.float(), dim=-1).amax(dim=-1) # (n, S) + underconfident = (max_probs < self.phoneme_confidence_unk_threshold).any(dim=1, keepdim=True) + eos_step = (preds == self.phoneme_eos_id).any(dim=1, keepdim=True) + replace = underconfident & (~eos_step) + preds = torch.where(replace, torch.full_like(preds, self.phoneme_unk_id), preds) + + self._dec_phoneme_tokens[idx] = preds + self._dec_phoneme_valid[idx] = 1 + + # ------------------------------------------------------------------ + # compute_logits — dummy (real output is the codes tensor) + # ------------------------------------------------------------------ + + def compute_logits(self, hidden_states, sampling_metadata: Any = None) -> Optional[torch.Tensor]: + f"""Dummy backbone logits, repurposed as a 2-way continue/stop signal. + ``_sample_stop`` indicates which frames contain EOS. We set logits, + based on that: logits[sample_stop == True, stop_token_id] = 30 or -30 otherwise. + SamplingParams should set stop_token_id as EOS token though. + """ + if isinstance(hidden_states, OmniOutput): + hidden_states = hidden_states.text_hidden_states + if hidden_states is None: + return None + batch_size = hidden_states.shape[0] + logits = hidden_states.new_zeros(batch_size, int(self.hf_config.vocab_size)) + if self._stop_token_id < logits.shape[1]: + stop_rows = self._sample_stop[:batch_size] + logits[:, self._stop_token_id] = torch.where( + stop_rows, + logits.new_full((), 30.0), + logits.new_full((), -30.0), + ) + return logits + + # ------------------------------------------------------------------ + # multimodal output plumbing + # ------------------------------------------------------------------ + + def make_omni_output(self, model_outputs, **_: Any) -> OmniOutput: + """Surface the sampled codes (``BT x num_codebooks``). + + The codes are exposed under **two** keys so the same model serves both + deployment shapes: + + * ``audio_codes`` — the flat single-stage key read by :meth:`postprocess`. + * ``codes.audio`` — the nested :class:`~vllm_omni.data_entry_keys.OmniPayload` + layout consumed by the in-engine two-stage pipeline (Code2Wav). The + AR runner's ``flatten_payload`` turns this into the ``codes.audio`` + dotted key, which CONCATenates across decode steps into the full + acoustic sequence for the Stage-1 producer / async-chunk streamer + (see :mod:`easymagpie_vllm_omni.stage_processors`). + """ + if isinstance(model_outputs, OmniOutput): + return model_outputs + hidden = model_outputs + num_tokens = int(hidden.shape[0]) + audio_codes = self._out_codes[:num_tokens].clone() + if self._single_stage_audio: + # Drainable client key (see ``self._single_stage_audio`` in __init__): + # ``model_outputs`` is remapped to the ``audio`` modality and drained + # per step, so the client streams true deltas rather than a growing + # cumulative payload. ``postprocess`` also reads this key. + return OmniOutput( + text_hidden_states=hidden, + multimodal_outputs={"model_outputs": audio_codes}, + ) + return OmniOutput( + text_hidden_states=hidden, + multimodal_outputs={"audio_codes": audio_codes, "codes": {"audio": audio_codes}}, + ) + + # ------------------------------------------------------------------ + # preprocess / postprocess + # ------------------------------------------------------------------ + + def preprocess( + self, + input_ids: torch.Tensor, + input_embeds: Optional[torch.Tensor], + *, + start: int = 0, + end: int = 0, + **info_dict: Any, + ) -> tuple[torch.Tensor, torch.Tensor, dict[str, Any]]: + """Build per-request ``(input_ids, inputs_embeds)`` for this step. + + Prefill (``span_len > 1``): assemble the full context embedding + (``[task_embedding | speaker_embedding | context_text_embedded]`` from + the per-request inputs; see :meth:`_build_prefill_embeds`), slice this + chunk out of it, and return it; + ``input_ids`` are placeholders. Decode (``span_len == 1``): write the per-token decode + inputs (previous codes, current text token, previous phoneme) into the + model buffers at ``start`` and return a zero embedding that + :meth:`forward` accumulates into. + """ + nested = info_dict.get("additional_information") + if isinstance(nested, dict): + merged = {k: v for k, v in info_dict.items() if k != "additional_information"} + for k, v in nested.items(): + merged.setdefault(k, v) + info_dict = merged + + device = input_ids.device + span_len = int(input_ids.shape[0]) + if span_len <= 0: + base = input_embeds if input_embeds is not None else self.embed_input_ids(input_ids) + return input_ids, base, {} + + if span_len > 1: + return self._preprocess_prefill(input_ids, span_len, device, info_dict) + + start = self._batch_slot_offset(input_ids, start) + return self._preprocess_decode(input_ids, start, device, info_dict) + + @staticmethod + def _batch_slot_offset(input_ids_view: torch.Tensor, fallback: int) -> int: + """Recover a request's batch-row offset from its 1-D ``input_ids`` view. + The runner passes ``input_ids = input_ids_buffer[s:e]`` + """ + if input_ids_view.dim() == 1 and input_ids_view.is_contiguous(): + return int(input_ids_view.storage_offset()) + return int(fallback) + + def _preprocess_prefill( + self, + input_ids: torch.Tensor, + span_len: int, + device: torch.device, + info_dict: dict[str, Any], + ) -> tuple[torch.Tensor, torch.Tensor, dict[str, Any]]: + # Forward the audio (local-transformer) sampling params from the request. + # vLLM's ``SamplingParams.temperature`` drives only the dummy backbone + # token sampler, so the real audio temperature/top-k are passed via + # ``additional_information`` and applied to the code predictor here (once, + # at prefill — they are scalars that persist across decode steps). + self._maybe_set_lt_sampling_params(info_dict) + + prefill_embeds = self._build_prefill_embeds(device, info_dict) + + offset = int(info_dict.get("prefill_offset", 0) or 0) + total = int(prefill_embeds.shape[0]) + take = prefill_embeds[offset : offset + span_len] + # The prefill chunk must lie fully within the assembled context. Padding + # short chunks with zeros / a repeated last row is invalid: the backbone + # was never trained on padded context frames, so silently doing so would + # corrupt conditioning rather than fail loudly. This holds iff the caller + # sized ``prompt_token_ids`` to the complete assembled prefill. + assert int(take.shape[0]) == span_len, ( + f"EasyMagpieTTS prefill chunk [{offset}:{offset + span_len}] is not fully covered by the " + f"assembled context embedding (length {total}). The caller must pass " + f"prompt_token_ids of length [task?] + speaker_embedding.shape[0] + " + f"len(tokenize(context_text)) + text_prefill_num; " + f"zero-padding the backbone context is invalid (the model was not trained on it)." + ) + + info_update = { + "prefill_offset": offset + span_len, + "decode_offset": int(info_dict.get("text_prefill_num", 0) or 0), + } + # Tokenize the caller's ``text`` in-model and stash the subword ids in the + # per-request info dict (alongside the offsets) so each decode step + # consumes one id from it without the caller ever running the tokenizer + # (see :meth:`_preprocess_decode`). When the caller passes ``text`` whole + # at prefill we bake the ``text_tokens`` list here; an already-present + # ``text_tokens`` list is left untouched. When *neither* ``text`` nor + # ``text_tokens`` is provided the request runs in **streaming-text mode**: + # no list is baked, and :meth:`_preprocess_decode` instead reads one + # subword id per step from the streamed ``additional_information.text_token``. + if not info_dict.get("text_tokens"): + text = info_dict.get("text") + if text: + info_update["text_tokens"] = self._encode_text_stream(text) + else: + # Absolute-position streaming: seed the buffer with the prefill + # segment's own ``text_token`` chunk here instead of relying on it + # being absorbed at the first decode step. When that chunk carries a + # single id (max_tokens==1) vLLM's one-step segment lookahead swaps + # the visible ``text_token`` for the *next* segment's payload before + # ``decode_offset==0`` runs, so the prefill id would otherwise be + # dropped and every later chunk misaligned (start=1 vs len=0). + incoming = info_dict.get("text_token") or [] + if incoming: + info_update["text_tokens"], _ = _merge_streaming_text_chunk( + [], incoming, info_dict.get("text_token_start") + ) + input_ids_out = torch.full_like(input_ids, _DUMMY_TOKEN_ID) + return input_ids_out, take, info_update + + def _build_prefill_embeds( + self, + device: torch.device, + info_dict: dict[str, Any], + ) -> torch.Tensor: + """Assemble the full ``(T_ctx, embedding_dim)`` prefill context embedding:: + + [task_embedding | speaker_embedding | context_text_embedded | target_text_prefill] + + from the per-request inputs: + + * speaker context audio — either ``speaker_id`` (a known speaker whose + embedding is precomputed model state, see + :meth:`_resolve_speaker_embedding`) or, for custom / one-off voices, a + 2-D ``(T_audio, embedding_dim)`` ``speaker_embedding`` tensor. + * ``context_text`` — a plain string (e.g. ``"[EN]"``); tokenized in-model + and embedded through the baked per-subword ``text_embedding`` table. + * ``task_mode_id`` — selects the per-mode task ("service token") + embedding row; prepended only when the checkpoint has a task table. + + Returns the full conditioning plus request-specific causal text prefix; + per-chunk slicing is done by :meth:`_preprocess_prefill`. + + For a known ``speaker_id`` the result is a pure function of + ``(task_mode_id, speaker_id, context_text)`` and is cached in + ``self._prefill_cache``, so the tokenize + embed + cat below run once per + distinct combo instead of on every request's prefill. The returned tensor + is only ever read (sliced) downstream, never mutated, so sharing the + cached instance is safe. + """ + speaker_id = info_dict.get("speaker_id") + context_text = info_dict.get("context_text") or _DEFAULT_CONTEXT_TEXT + if self.task_embedding is not None: + task_mode_id = int(info_dict.get("task_mode_id", 0) or 0) + task_mode_id = max(0, min(task_mode_id, self.num_task_embeddings - 1)) + else: + task_mode_id = 0 + + # Custom raw-tensor voices (no speaker_id) are one-off, so skip the cache. + cache_key = (task_mode_id, speaker_id, context_text, str(device)) if speaker_id else None + if cache_key is not None: + cached = self._prefill_cache.get(cache_key) + if cached is not None: + target_prefill = self._build_text_prefill_embeds(device, self._combined_embeddings.dtype, info_dict) + return cached if target_prefill is None else torch.cat((cached, target_prefill), dim=0) + + dtype = self._combined_embeddings.dtype + parts: list[torch.Tensor] = [] + + # Task / "service token" embedding (prepended), when present. + if self.task_embedding is not None: + task_row = self.task_embedding(torch.tensor([task_mode_id], device=device, dtype=torch.long)) + parts.append(task_row.to(dtype)) + + # Speaker-encoded context audio (known-speaker state or custom tensor). + parts.append(self._resolve_speaker_embedding(device, info_dict)) + + # Context text: tokenized in-model and embedded through the baked table. + ctx_ids = self._encode_context_text(context_text, device) + if ctx_ids.numel() > 0: + parts.append(self.text_embedding(ctx_ids).to(dtype)) + + embeds = torch.cat(parts, dim=0) + if cache_key is not None: + self._prefill_cache[cache_key] = embeds + target_prefill = self._build_text_prefill_embeds(device, dtype, info_dict) + return embeds if target_prefill is None else torch.cat((embeds, target_prefill), dim=0) + + def _build_text_prefill_embeds( + self, + device: torch.device, + dtype: torch.dtype, + info_dict: dict[str, Any], + ) -> Optional[torch.Tensor]: + """Build the causal text-led rows moved from decode into prefill.""" + text_prefill_num = int(info_dict.get("text_prefill_num", 0) or 0) + if text_prefill_num == 0: + return None + assert text_prefill_num == self.arch.text_prefill_num, ( + f"EasyMagpieTTS expected text_prefill_num={self.arch.text_prefill_num}, " f"got {text_prefill_num}" + ) + + prefix_ids = list(info_dict.get("prefill_text_tokens") or []) + assert len(prefix_ids) <= text_prefill_num, ( + f"EasyMagpieTTS got {len(prefix_ids)} prefill text tokens for " f"text_prefill_num={text_prefill_num}" + ) + rows = torch.zeros((text_prefill_num, self.embedding_dim), device=device, dtype=dtype) + if prefix_ids: + ids = torch.tensor(prefix_ids, device=device, dtype=torch.long) + rows[: len(prefix_ids)] = self.text_embedding(ids).to(dtype) + + # At position phonemes_delay the phoneme input is known: it is BOS. The + # phoneme projected from this row is fed back at the first decode step. + if self.has_phoneme: + bos_row = self.phonemes_delay + assert bos_row < text_prefill_num + bos = torch.full( + (1, self.arch.phoneme_stacking_factor), + self.phoneme_bos_id, + device=device, + dtype=torch.long, + ) + rows[bos_row : bos_row + 1] += self._embed_phoneme(bos).to(dtype) + return rows + + def _resolve_speaker_embedding(self, device: torch.device, info_dict: dict[str, Any]) -> torch.Tensor: + """Return the speaker context-audio embedding on ``device`` in model dtype. + + For a known ``speaker_id`` the embedding is read from disk by + :meth:`_load_known_speaker_embedding`; this only ever runs on a + prefill-cache miss (see :meth:`_build_prefill_embeds`), i.e. once per + ``(speaker_id, context_text, task)`` combo, so there is no separate + speaker-embedding table — the assembled prefill cache subsumes it. Falls + back to a raw ``speaker_embedding`` tensor (custom / one-off voice), + copied H2D here. Exactly one of the two must be supplied. + """ + dtype = self._combined_embeddings.dtype + speaker_id = info_dict.get("speaker_id") + if speaker_id: + return self._load_known_speaker_embedding(speaker_id, device, dtype) + + speaker_embedding = info_dict.get("speaker_embedding") + assert isinstance(speaker_embedding, torch.Tensor) and speaker_embedding.ndim == 2, ( + "EasyMagpieTTS preprocess expects additional_information.speaker_id (a known speaker) or " + "speaker_embedding as a 2-D (T_audio, embedding_dim) tensor (the speaker-encoded context " + f"audio); got speaker_embedding={type(speaker_embedding).__name__}" + + (f" with ndim={speaker_embedding.ndim}" if isinstance(speaker_embedding, torch.Tensor) else "") + ) + return speaker_embedding.to(device=device, dtype=dtype) + + def _load_known_speaker_embedding(self, speaker_id: str, device: torch.device, dtype: torch.dtype) -> torch.Tensor: + """Read one known speaker's embedding from ``/speaker_embeddings/.pt``. + + The file holds either a bare ``(T_audio, embedding_dim)`` tensor or a dict + with a ``speaker_encoding`` key (the converter/caller layout); it is moved + to ``device`` in model dtype. Called only on a prefill-cache miss, so each + known speaker is read at most once per ``(context_text, task)`` combo and + the result is then baked into ``self._prefill_cache``. Read from disk (not + via :meth:`load_weights`) so known speakers work even under + ``--load-format dummy``, which skips weight loading. + """ + import glob + import os + + spk_dir = os.path.join(self.model_path, "speaker_embeddings") + path = os.path.join(spk_dir, f"{speaker_id}.pt") + if not os.path.exists(path): + known = sorted(os.path.splitext(os.path.basename(p))[0] for p in glob.glob(os.path.join(spk_dir, "*.pt"))) + raise AssertionError( + f"EasyMagpieTTS preprocess got unknown speaker_id {speaker_id!r}; known speakers: {known}. " + "Register it under the checkpoint's speaker_embeddings/ dir, or pass a raw " + "speaker_embedding tensor for a custom voice." + ) + loaded = torch.load(path, map_location="cpu") + embedding = loaded["speaker_encoding"] if isinstance(loaded, dict) else loaded + assert isinstance(embedding, torch.Tensor) and embedding.ndim == 2, ( + f"EasyMagpieTTS: speaker embedding {path} must be a 2-D (T_audio, embedding_dim) tensor; " + f"got {type(embedding).__name__}" + + (f" with ndim={embedding.ndim}" if isinstance(embedding, torch.Tensor) else "") + ) + return embedding.to(device=device, dtype=dtype) + + def _maybe_set_lt_sampling_params(self, info_dict: dict[str, Any]) -> None: + """Apply per-request audio sampling params to the local transformer. + + Reads ``temperature`` / ``top_k`` (alias ``topk``) from the request's + ``additional_information`` and stores them on the code predictor. Absent + keys leave the existing defaults untouched. + """ + temperature = info_dict.get("temperature") + if temperature is not None: + self.code_predictor.temperature = float(temperature) + top_k = info_dict.get("top_k", info_dict.get("topk")) + if top_k is not None: + self.code_predictor.top_k = int(top_k) + + def _get_text_tokenizer(self): + """Lazily load the context-text tokenizer from the model directory. + + The converted checkpoint ships a HuggingFace ``AutoTokenizer`` (the + model's text-conditioning tokenizer) alongside its weights, so we load it + on first use from ``model_path``. + """ + if self._text_tokenizer is None: + from transformers import AutoTokenizer + + self._text_tokenizer = AutoTokenizer.from_pretrained(self.model_path, trust_remote_code=True) + return self._text_tokenizer + + def _encode_context_text(self, context_text: str, device: torch.device) -> torch.Tensor: + """Tokenize ``context_text`` to subword ids. + + The text-conditioning tokenizer sits at offset 0 in the model's + tokenizer aggregate, so its raw ids index the baked ``text_embedding`` + table directly. + """ + tok = self._get_text_tokenizer() + ids = tok.encode(context_text) + return torch.tensor(ids, device=device, dtype=torch.long) + + def _encode_text_stream(self, text: str) -> list[int]: + """Tokenize the target ``text`` into the streaming subword-id list. + + HF special tokens are disabled so the raw ids index the baked + ``text_embedding`` table directly, and the trailing text-EOS id closes + the stream. One id is consumed per decode step (see + :meth:`_preprocess_decode`); once exhausted the text channel is masked + off. + """ + tok = self._get_text_tokenizer() + ids = tok.encode(text, add_special_tokens=False) + return list(ids) + [self.text_eos_id] + + @staticmethod + def estimate_prompt_len( + speaker_embedding: torch.Tensor, + *, + tokenize: Callable[[str], Iterable[int]], + context_text: str = _DEFAULT_CONTEXT_TEXT, + has_task_embedding: bool = False, + ) -> int: + """Compute the speaker-conditioning prefill length for a custom voice. + + The engine assembles the prefill context as + ``[task_embedding? | speaker_embedding | context_text_embedded]``, so the + this base length plus ``text_prefill_num`` target rows. The caller must + pass that total as the placeholder length so it matches the assembled + embedding (otherwise vLLM pads / truncates and quality drops). The base + length is a pure function of + lengths, so it stays static — callable in the request-building process + without an engine instance. + + For a **known speaker** the caller holds only a ``speaker_id`` (not the + tensor); use :meth:`get_prompt_len`, which loads the embedding from the + checkpoint dir and calls this method. + + Args: + speaker_embedding: ``(T_audio, embedding_dim)`` speaker-encoded + context-audio embedding (only its length is used). + tokenize: callable turning ``context_text`` into its subword ids + (e.g. ``lambda t: tokenizer.encode(t)``) — must match the + tokenizer the engine loads from ``model_path``. + context_text: conditioning string (default ``"[EN]"``). + has_task_embedding: whether the checkpoint prepends a task / + "service token" embedding (``num_task_embeddings > 0``). + """ + t_audio = int(speaker_embedding.shape[0]) + ctx_len = len(list(tokenize(context_text or _DEFAULT_CONTEXT_TEXT))) + task_len = 1 if has_task_embedding else 0 + return task_len + t_audio + ctx_len + + @classmethod + def get_prompt_len(cls, speaker_id: str, model_path: str, *, tokenize: Callable[[str], Iterable[int]]) -> int: + """Known-speaker convenience wrapper around :meth:`estimate_prompt_len`. + + Resolves everything from the checkpoint dir so it cannot disagree with + what the engine actually uses: loads the speaker embedding from + ``speaker_embeddings/.pt`` (the same file the engine reads in + :meth:`_load_known_speaker_embedding`), reads ``has_task_embedding`` from + ``config.json`` (``num_task_embeddings``), + and conditions on the fixed :data:`_DEFAULT_CONTEXT_TEXT`. Lets a caller + holding only a ``speaker_id`` size ``prompt_token_ids`` without an engine + instance (``context_text`` / ``has_task_embedding`` are intentionally not + params — they must match the precomputed checkpoint, not be overridden). + """ + import json + import os + + path = os.path.join(model_path, "speaker_embeddings", f"{speaker_id}.pt") + if not os.path.exists(path): + raise FileNotFoundError(f"EasyMagpieTTS: no speaker embedding {path} for speaker_id {speaker_id!r}") + loaded = torch.load(path, map_location="cpu") + speaker_embedding = loaded["speaker_encoding"] if isinstance(loaded, dict) else loaded + + with open(os.path.join(model_path, "config.json")) as f: + num_task_embeddings = int(json.load(f).get("num_task_embeddings", 0)) + + return cls.estimate_prompt_len( + speaker_embedding, + tokenize=tokenize, + context_text=_DEFAULT_CONTEXT_TEXT, + has_task_embedding=num_task_embeddings > 0, + ) + + def _preprocess_decode( + self, + input_ids: torch.Tensor, + start: int, + device: torch.device, + info_dict: dict[str, Any], + ) -> tuple[torch.Tensor, torch.Tensor, dict[str, Any]]: + decode_offset = int(info_dict.get("decode_offset", 0) or 0) + info_update: dict[str, Any] = {"decode_offset": decode_offset + 1} + + # ── Text channel ── (delay 0: one subword per step from step 0). The text + # stream leads the phoneme/audio streams by their respective delays. The + # model always consumes exactly one buffered subword id per decode step, + # indexed by ``decode_offset`` from a persistent ``text_tokens`` list. That + # list is populated by one of two mutually exclusive input modes: + # + # * **Whole-text (non-streaming)** — the caller passed ``text`` whole at + # prefill; it was tokenized in-model and stashed as the ``text_tokens`` + # list (see :meth:`_preprocess_prefill`). No per-step ``text_token`` + # arrives, so the buffer never grows here. + # * **Streamed** — the caller did *not* pass ``text`` at prefill and instead + # pushes subword ids during decode via ``additional_information`` under + # ``text_token`` (always a ``list[int]``). Each chunk may carry a single id + # (``[id]`` with ``max_tokens == 1``, one frame per chunk) or several ids at + # once (``max_tokens == N``, so the engine free-runs N frames off one chunk — + # fewer round-trips). Those ids are appended to ``text_tokens`` and consumed + # one per step. + # + # In both modes, once the buffer is exhausted the channel is masked off + # (adds nothing) rather than repeating the last token, so the caller can keep + # pumping decode steps (passing an empty ``text_token`` list) while the audio + # tail finishes. + # + # A streamed chunk's ``text_token`` payload stays identical across every + # decode step of its segment. ``text_token_start`` is its absolute position + # in the accumulated buffer, which makes repeated metadata and async + # one-segment lookahead safe. + text_tokens = info_dict.get("text_tokens") or [] + incoming = info_dict.get("text_token") or [] + text_token_start = info_dict.get("text_token_start") + if incoming: + text_tokens, appended = _merge_streaming_text_chunk(text_tokens, incoming, text_token_start) + if appended: + info_update["text_tokens"] = text_tokens + if decode_offset < len(text_tokens): + self._dec_text_tokens[start] = int(text_tokens[decode_offset]) + self._dec_text_mask[start] = 1 + else: + self._dec_text_mask[start] = 0 + + # ── Phoneme channel ── opens at decode step == ``phonemes_delay`` (seeded + # with phoneme BOS), then feeds back the previous step's prediction, and + # closes one step after the model emits the phoneme EOS (sticky flag). + if self.has_phoneme: + phoneme_ended = bool(info_dict.get("phoneme_ended", False)) + feed_eos = False + if phoneme_ended or decode_offset < self.phonemes_delay: + self._dec_phoneme_valid[start] = 0 + elif decode_offset == self.phonemes_delay: + self._dec_phoneme_tokens[start].fill_(self.phoneme_bos_id) + self._dec_phoneme_valid[start] = 1 + else: + last_phon = info_dict.get("last_phoneme_token") + if isinstance(last_phon, torch.Tensor) and last_phon.numel() > 0: + p = last_phon.to(device=device, dtype=torch.long).reshape(-1)[: self.arch.phoneme_stacking_factor] + self._dec_phoneme_tokens[start, : p.shape[0]].copy_(p) + self._dec_phoneme_valid[start] = 1 + feed_eos = bool((p == self.phoneme_eos_id).any()) + else: + self._dec_phoneme_valid[start] = 0 + if phoneme_ended or feed_eos: + info_update["phoneme_ended"] = True + + # ── Audio channel ── opens at decode step == ``speech_delay`` (seeded with + # audio BOS), then feeds back the previous frame's codes. For the leading + # ``speech_delay`` steps the channel is masked off (only text/phoneme + # condition the backbone); the local transformer still runs for CUDA-graph + # stability but its codes for those frames are discarded by the caller and + # never fed back here. + if decode_offset < self.speech_delay: + self._dec_audio_valid[start] = 0 + elif decode_offset == self.speech_delay: + self._dec_audio_codes[start].fill_(self.arch.audio_bos_id) + self._dec_audio_valid[start] = 1 + else: + last_codes = info_dict.get("last_audio_codes") + if isinstance(last_codes, torch.Tensor) and last_codes.numel() > 0: + c = last_codes.to(device=device, dtype=torch.long).reshape(-1)[: self.num_codebooks] + self._dec_audio_codes[start, : c.shape[0]].copy_(c) + self._dec_audio_valid[start] = 1 + else: + # Fallback (should not happen once audio has started): seed BOS. + self._dec_audio_codes[start].fill_(self.arch.audio_bos_id) + self._dec_audio_valid[start] = 1 + + inputs_embeds_out = torch.zeros((1, self.embedding_dim), device=device, dtype=self._combined_embeddings.dtype) + return input_ids, inputs_embeds_out, info_update + + def postprocess(self, hidden_states: torch.Tensor, multimodal_outputs: Optional[dict[str, Any]] = None, **_: Any): + """Stash the last frame's codes (and phoneme) for the next decode step.""" + if hidden_states.numel() == 0: + return {} + stride0 = hidden_states.stride(0) or 1 + req_start = hidden_states.storage_offset() // stride0 + last = req_start + hidden_states.shape[0] - 1 + + out: dict[str, Any] = {} + mm = multimodal_outputs or {} + # The codes key depends on the emission mode (see make_omni_output): + # single-stage uses "model_outputs", two-stage uses "audio_codes" / + # nested "codes.audio". Read whichever is present. + audio_codes = mm.get("audio_codes") + if audio_codes is None: + audio_codes = mm.get("model_outputs") + if audio_codes is None: + codes = mm.get("codes") + if isinstance(codes, dict): + audio_codes = codes.get("audio") + elif "codes.audio" in mm: + audio_codes = mm.get("codes.audio") + if isinstance(audio_codes, torch.Tensor) and audio_codes.numel() > 0: + out["last_audio_codes"] = audio_codes[last : last + 1].detach() + if self.has_phoneme: + out["last_phoneme_token"] = self._dec_phoneme_tokens[last : last + 1].detach().clone() + return out + + # ------------------------------------------------------------------ + # weight loading + # ------------------------------------------------------------------ + + # Checkpoint prefixes (EasyMagpieTTS state dict) → in-model paths. + # ``decoder.*`` is fed to the vLLM backbone loader separately (it understands + # HF Nemotron-H naming + Mamba/MoE packing). The TTS submodules are copied + # manually. + _TTS_PREFIX_MAP = { + "local_transformer.": "code_predictor.local_transformer.", + "local_transformer_in_projection.": "code_predictor.local_transformer_in_projection.", + "local_transformer_audio_out_projection.": "code_predictor.local_transformer_audio_out_projection.", + "local_transformer_out_projections.": "code_predictor.local_transformer_out_projections.", + "audio_embeddings.": "code_predictor.audio_embeddings.", + "audio_in_projection.": "code_predictor.audio_in_projection.", + "phoneme_embeddings.": "phoneme_embeddings.", + "phoneme_final_proj.": "phoneme_final_proj.", + "text_embedding.": "text_embedding.", + "task_embedding.": "task_embedding.", + } + + def _remap_tts_key(self, name: str) -> Optional[str]: + """Map a raw checkpoint key to its in-model parameter path (or ``None``).""" + for src, dst in self._TTS_PREFIX_MAP.items(): + if name.startswith(src): + return dst + name[len(src) :] + return None + + def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]: + """Load backbone (Nemotron-H) + TTS submodule weights from a converted checkpoint. + + The converted checkpoint carries the backbone under ``decoder.*`` (HF + Nemotron-H names) and the TTS submodules at top level + (``audio_embeddings.*``, ``local_transformer.*``, ``phoneme_*``, + ``text_embedding.*``, projection heads). Backbone weights are routed to + :meth:`NemotronHModel.load_weights` (which handles HF naming + Mamba/MoE + packing); TTS weights are copied directly by name. + """ + own_params = dict(self.named_parameters()) + loaded: set[str] = set() + backbone_weights: list[tuple[str, torch.Tensor]] = [] + + for name, tensor in weights: + if name.startswith("decoder."): + backbone_weights.append((name[len("decoder.") :], tensor)) + continue + mapped = self._remap_tts_key(name) + if mapped is None: + # Unrelated checkpoint section (codec, speaker encoder, CAS, etc.). + continue + if mapped.startswith("task_embedding.") and self.task_embedding is None: + # Single-mode model: checkpoint may still ship an (unused) table. + continue + target = own_params.get(mapped) + if target is None: + logger.warning("EasyMagpieTTS: no parameter for checkpoint key %s -> %s", name, mapped) + continue + # The local-transformer FFN ships as kernel-1 ``Conv1d`` weights + # (``[out, in, 1]``) but now lives as ``nn.Linear`` (``[out, in]``). + # Squeeze the trailing singleton conv dim so the dense layer loads 1:1. + if tensor.ndim == target.ndim + 1 and tensor.shape[-1] == 1: + tensor = tensor.squeeze(-1) + if target.shape != tensor.shape: + raise RuntimeError( + f"EasyMagpieTTS weight shape mismatch at {mapped!r}: " + f"ckpt {tuple(tensor.shape)} vs model {tuple(target.shape)}" + ) + with torch.no_grad(): + target.data.copy_(tensor.to(target.dtype)) + loaded.add(mapped) + + # ``NemotronHModel.load_weights`` (the inner model) does *not* apply the + # HF->vLLM renaming that lives on the ``NemotronHForCausalLM`` wrapper, so + # raw HF names such as ``embeddings.weight`` / ``...mixer.A_log`` would not + # match the inner param names (``embed_tokens.weight`` / ``...mixer.A``). + # Apply that mapper here so the converted checkpoint can keep stock HF + # Nemotron-H names. The wrapper's ``backbone -> model`` prefix rule is a + # no-op here because we already stripped the ``decoder.`` prefix. + backbone_weights = list(NemotronHForCausalLM.hf_to_vllm_mapper.apply(backbone_weights)) + backbone_loaded = self.backbone.load_weights(backbone_weights) + loaded |= {f"backbone.{n}" for n in backbone_loaded} + + # Derived runtime state. + self.code_predictor.init_forbidden_mask() + + logger.info("Loaded %d weights for EasyMagpieTTSForConditionalGeneration", len(loaded)) + return loaded diff --git a/tools/easymagpie_vllm_omni/easymagpie_vllm_omni/local_transformer.py b/tools/easymagpie_vllm_omni/easymagpie_vllm_omni/local_transformer.py new file mode 100644 index 000000000000..34f8bcfe7f61 --- /dev/null +++ b/tools/easymagpie_vllm_omni/easymagpie_vllm_omni/local_transformer.py @@ -0,0 +1,354 @@ +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. 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. +"""Autoregressive intra-frame codebook predictor for EasyMagpieTTS.""" +from __future__ import annotations + +import torch +from easymagpie_vllm_omni.config import EasyMagpieOmniArch +from torch import nn +from vllm.compilation.decorators import support_torch_compile +from vllm.config import VllmConfig + +# Default top-k width for audio-codebook sampling. Because ``torch.topk``'s ``k`` +# shapes tensors inside the captured graph, this becomes a capture-time constant. +_DEFAULT_TOP_K = 80 + +# A positive floor avoids data-dependent branches in the captured graph. +_MIN_SAMPLING_TEMPERATURE = 1e-4 + + +class EasyMagpieLTSelfAttention(nn.Module): + """Bias-free causal self-attention without a KV cache.""" + + def __init__(self, d_model: int, n_heads: int) -> None: + super().__init__() + assert d_model % n_heads == 0, "d_model must be divisible by n_heads" + self.n_heads = n_heads + self.d_head = d_model // n_heads + self.scale = self.d_head**-0.5 + self.qkv_net = nn.Linear(d_model, 3 * n_heads * self.d_head, bias=False) + self.o_net = nn.Linear(n_heads * self.d_head, d_model, bias=False) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + b, t, _ = x.shape + qkv = self.qkv_net(x).reshape(b, t, 3, self.n_heads, self.d_head) + q, k, v = qkv.unbind(dim=2) # each [b, t, nh, dh] + # [b, nh, t, dh] + q = q.transpose(1, 2) + k = k.transpose(1, 2) + v = v.transpose(1, 2) + attn = torch.nn.functional.scaled_dot_product_attention(q, k, v, is_causal=True, scale=self.scale) + attn = attn.transpose(1, 2).contiguous().view(b, t, -1) + return self.o_net(attn) + + +class EasyMagpieLTFeedForward(nn.Module): + """Positionwise feed-forward network. + + ``conv`` parameter names preserve checkpoint compatibility, while the + kernel-1 operations use equivalent linear projections on ``[B, T, C]``. + """ + + def __init__(self, d_model: int, d_ffn: int) -> None: + super().__init__() + self.proj = _LinearWrapper(d_model, d_ffn) + self.o_net = _LinearWrapper(d_ffn, d_model) + self.act = nn.GELU(approximate="tanh") + + def forward(self, x: torch.Tensor) -> torch.Tensor: + return self.o_net(self.act(self.proj(x))) + + +class _LinearWrapper(nn.Module): + """Holds a bias-free ``nn.Linear`` under attribute name ``conv``. + + The attribute is named ``conv`` purely so the parameter path matches the + training checkpoint's kernel-1 ``Conv1d`` (``...proj.conv.weight``); the math + is a plain dense projection on the channel dim. + """ + + def __init__(self, in_ch: int, out_ch: int) -> None: + super().__init__() + self.conv = nn.Linear(in_ch, out_ch, bias=False) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + return self.conv(x) + + +class EasyMagpieLTLayer(nn.Module): + """One pre-norm transformer layer (self-attn + FFN), bias-free LayerNorms. + + Residual structure: ``x = x + attn(norm_self(x))`` then + ``x = x + ff(norm_pos_ff(x))``. + """ + + def __init__(self, d_model: int, d_ffn: int, n_heads: int) -> None: + super().__init__() + self.norm_self = nn.LayerNorm(d_model, bias=False) + self.self_attention = EasyMagpieLTSelfAttention(d_model, n_heads) + self.norm_pos_ff = nn.LayerNorm(d_model, bias=False) + self.pos_ff = EasyMagpieLTFeedForward(d_model, d_ffn) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + x = x + self.self_attention(self.norm_self(x)) + x = x + self.pos_ff(self.norm_pos_ff(x)) + return x + + +class EasyMagpieLocalTransformer(nn.Module): + """Causal transformer stack with learnable positional embeddings. + + Plain (uncompiled) module: it is invoked from inside + :class:`EasyMagpieCodeLoop`'s compiled forward, so it gets *inlined* into + that single captured graph rather than being compiled / replayed on its own. + Holds learnable ``position_embeddings``, the stacked ``layers.{i}.*`` and a + no-op ``norm_out`` (names match the training checkpoint). + """ + + def __init__(self, *, vllm_config: VllmConfig, prefix: str = "") -> None: + super().__init__() + arch = EasyMagpieOmniArch.from_hf_config(vllm_config.model_config.hf_config) + d_model = arch.local_transformer_hidden_dim + n_heads = arch.local_transformer_n_heads + n_layers = arch.local_transformer_n_layers + d_ffn = d_model * 4 + # +2 of head-room over ``num_stacked_codebooks`` for the positional table. + max_len = arch.num_stacked_codebooks + 2 + + self.position_embeddings = nn.Embedding(max_len, d_model) + self.register_buffer("_positions", torch.arange(max_len), persistent=False) + self.layers = nn.ModuleList([EasyMagpieLTLayer(d_model, d_ffn, n_heads) for _ in range(n_layers)]) + self.norm_out = nn.Identity() + + def forward(self, inputs_embeds: torch.Tensor) -> torch.Tensor: + seq_len = inputs_embeds.shape[1] + pos_emb = self.position_embeddings(self._positions[:seq_len]) + x = inputs_embeds + pos_emb.unsqueeze(0) + for layer in self.layers: + x = layer(x) + return self.norm_out(x) + + +# NOTE: ``dynamic_arg_dims`` is passed explicitly rather than relying on vLLM's +# annotation-based inference. This file uses ``from __future__ import +# annotations`` (PEP 563), so ``forward``'s annotations are stored as strings +# (``"torch.Tensor"``) and vLLM's ``v.annotation in [torch.Tensor, ...]`` check +# would never match, raising "No dynamic dimensions found...". Both ``dec_hidden`` +# and ``gumbel_noise`` are ``[num_tokens, ...]`` -> dim 0 (num_tokens) is dynamic. +@support_torch_compile(dynamic_arg_dims={"dec_hidden": 0, "gumbel_noise": 0}) +class EasyMagpieCodeLoop(nn.Module): + """Compiled per-frame codebook loop. + + Gumbel noise is supplied at runtime for fresh samples. Temperature is + dynamic, while ``top_k`` is fixed when the graph is captured. + """ + + def __init__(self, *, vllm_config: VllmConfig, prefix: str = "") -> None: + super().__init__() + arch = EasyMagpieOmniArch.from_hf_config(vllm_config.model_config.hf_config) + self.num_codebooks = arch.num_stacked_codebooks + self.lt_hidden = arch.local_transformer_hidden_dim + self.top_k = min(_DEFAULT_TOP_K, arch.num_all_tokens_per_codebook) + # Set by :meth:`bind_predictor`; held in a tuple so nn.Module does not + # register the parent as a submodule (which would duplicate params). + self._predictor_ref: tuple = () + + def bind_predictor(self, predictor: "EasyMagpieCodePredictor") -> None: + self._predictor_ref = (predictor,) + self.top_k = predictor._sample_top_k + + def forward( + self, + dec_hidden: torch.Tensor, + gumbel_noise: torch.Tensor, + temperature: torch.Tensor, + ) -> torch.Tensor: + """Sample all ``num_codebooks`` codes for every frame in one graph. + + Args: + dec_hidden: ``[num_tokens, embedding_dim]`` backbone hidden state. + gumbel_noise: ``[num_tokens, num_codebooks, top_k]`` pre-drawn + Gumbel noise (``-log(-log(u))``), one slice per codebook. + temperature: ``[1]`` sampling temperature (already clamped > 0). + + Returns: + ``[num_tokens, num_codebooks]`` int64 sampled codes. + """ + cp = self._predictor_ref[0] + num_tokens = dec_hidden.shape[0] + n = self.num_codebooks + + buf = dec_hidden.new_zeros(num_tokens, n, self.lt_hidden) + buf[:, 0, :] = cp.local_transformer_in_projection(dec_hidden) + + forbidden = cp.forbidden_mask + codes: list[torch.Tensor] = [] + for k in range(n): + hidden = cp.local_transformer(buf) + row = cp.local_transformer_audio_out_projection(hidden[:, k, :]) + logits = cp.local_transformer_out_projections[k](row) + logits = logits.masked_fill(forbidden, float("-inf")) / temperature + vals, idxs = torch.topk(logits, self.top_k, dim=-1) + picked = (vals + gumbel_noise[:, k, :]).argmax(dim=-1, keepdim=True) + code_k = idxs.gather(-1, picked).squeeze(-1) + codes.append(code_k) + if k + 1 < n: + emb = cp.audio_in_projection(cp.audio_embeddings[k](code_k)) + buf[:, k + 1, :] = cp.local_transformer_in_projection(emb) + return torch.stack(codes, dim=1) + + +class EasyMagpieCodePredictor(nn.Module): + """Predict all stacked audio codebooks from each backbone hidden state.""" + + def __init__(self, *, vllm_config: VllmConfig, prefix: str = "") -> None: + super().__init__() + arch = EasyMagpieOmniArch.from_hf_config(vllm_config.model_config.hf_config) + self.arch = arch + self.num_codebooks = arch.num_stacked_codebooks + self.num_tokens_per_codebook = arch.num_all_tokens_per_codebook + self.audio_embedding_dim = arch.audio_embedding_dim + self.embedding_dim = arch.embedding_dim + lt_hidden = arch.local_transformer_hidden_dim + + # Per-codebook audio token embeddings (shared with the outer model's + # decode-step input-embedding assembly). + self.audio_embeddings = nn.ModuleList( + [nn.Embedding(self.num_tokens_per_codebook, self.audio_embedding_dim) for _ in range(self.num_codebooks)] + ) + # audio_embedding_dim -> embedding_dim (Identity when equal). + if self.audio_embedding_dim != self.embedding_dim: + self.audio_in_projection = nn.Linear(self.audio_embedding_dim, self.embedding_dim) + else: + self.audio_in_projection = nn.Identity() + + # embedding_dim (== backbone hidden) -> local-transformer hidden. + if lt_hidden != self.embedding_dim: + self.local_transformer_in_projection = nn.Linear(self.embedding_dim, lt_hidden) + else: + self.local_transformer_in_projection = nn.Identity() + + self.local_transformer = EasyMagpieLocalTransformer( + vllm_config=vllm_config, prefix=f"{prefix}.local_transformer" + ) + + # local-transformer hidden -> audio_embedding_dim (Identity when equal). + if self.audio_embedding_dim != lt_hidden: + self.local_transformer_audio_out_projection = nn.Linear(lt_hidden, self.audio_embedding_dim) + else: + self.local_transformer_audio_out_projection = nn.Identity() + + # Per-codebook output heads. + self.local_transformer_out_projections = nn.ModuleList( + [nn.Linear(self.audio_embedding_dim, self.num_tokens_per_codebook) for _ in range(self.num_codebooks)] + ) + + # Forbidden-token mask (reserved/special tokens, EOS kept reachable). + # Populated by :meth:`init_forbidden_mask` once arch ids are known. + self.register_buffer( + "forbidden_mask", + torch.zeros(self.num_tokens_per_codebook, dtype=torch.bool), + persistent=False, + ) + + # Sampling knobs (overridable from the outer model / request). ``top_k`` + # is captured into the compiled loop graph (see ``EasyMagpieCodeLoop``), + # so per-request ``top_k`` changes are not honored once captured; + # per-request ``temperature`` is, since it is fed as a runtime tensor. + self.temperature: float = 0.7 + self.top_k: int = _DEFAULT_TOP_K + self.lt_hidden = lt_hidden + self._sample_top_k = min(self.top_k, self.num_tokens_per_codebook) + + # Compiled single-graph autoregressive loop (owns no params; reaches the + # projection heads / embeddings / mask on ``self`` via a bound reference). + self._code_loop = EasyMagpieCodeLoop(vllm_config=vllm_config, prefix=f"{prefix}.code_loop") + self._code_loop.bind_predictor(self) + + # ── Persistent address-stable scratch buffers ────────────────── + # (created on the CUDA default device that vLLM sets during model init). + max_num_tokens = vllm_config.scheduler_config.max_num_batched_tokens + dtype = vllm_config.model_config.dtype + # Stable-address input for the captured loop graph. + self._dec_hidden_buf = torch.zeros(max_num_tokens, self.embedding_dim, dtype=dtype) + # Gumbel noise drawn eagerly each frame and injected into the graph; fp32 + # so the small ``-log(-log(u))`` values don't underflow in fp16. + self._gumbel_buf = torch.zeros(max_num_tokens, self.num_codebooks, self._sample_top_k, dtype=torch.float32) + self._temperature_buf = torch.zeros(1, dtype=torch.float32) + + @torch.no_grad() + def init_forbidden_mask(self) -> None: + """Forbid all trailing special tokens except audio EOS. + + Everything in the special-token block above ``codebook_size`` is blocked + at sampling time, except ``audio_eos`` which must remain reachable to + terminate. + """ + mask = torch.zeros(self.num_tokens_per_codebook, dtype=torch.bool, device=self.forbidden_mask.device) + mask[self.arch.codebook_size :] = True + eos = self.arch.audio_eos_id + if 0 <= eos < self.num_tokens_per_codebook: + mask[eos] = False + self.forbidden_mask.copy_(mask) + + def embed_codebook(self, codebook_idx: int, codes: torch.Tensor) -> torch.Tensor: + """Embed a single codebook's tokens (``[num_tokens] -> [num_tokens, audio_dim]``).""" + return self.audio_embeddings[codebook_idx](codes) + + def embed_audio_frame(self, codes: torch.Tensor) -> torch.Tensor: + """Embed a full frame of stacked codes into the backbone embedding space. + + Averages the per-codebook embeddings then applies ``audio_in_projection``. + Used by the outer model to build the decode input embedding from the + previous frame's codes. + + Args: + codes: ``[num_tokens, num_codebooks]`` int64 codes. + + Returns: + ``[num_tokens, embedding_dim]`` float embedding. + """ + acc = self.audio_embeddings[0](codes[:, 0]) + for c in range(1, self.num_codebooks): + acc = acc + self.audio_embeddings[c](codes[:, c]) + acc = acc / self.num_codebooks + return self.audio_in_projection(acc) + + @torch.no_grad() + def generate_codes(self, dec_hidden: torch.Tensor) -> torch.Tensor: + """Autoregressively sample all ``C * S`` codebooks for each frame. + + Draws this frame's Gumbel noise eagerly into a stable buffer (fresh + randomness per frame, outside the captured graph) and stages the inputs + at fixed addresses, then runs the whole loop as a single captured graph + via :class:`EasyMagpieCodeLoop`. + + Args: + dec_hidden: ``[num_tokens, hidden]`` backbone hidden state (one row + per frame being decoded). + + Returns: + ``[num_tokens, num_codebooks]`` int64 sampled codes. + """ + num_tokens = dec_hidden.shape[0] + in_buf = self._dec_hidden_buf[:num_tokens] + in_buf.copy_(dec_hidden) + + # ``-log(-log(u))`` Gumbel noise, computed in place in fp32. + noise = self._gumbel_buf[:num_tokens] + noise.uniform_(1e-20, 1.0 - 1e-20) + noise.log_().neg_().log_().neg_() + + self._temperature_buf.fill_(max(float(self.temperature), _MIN_SAMPLING_TEMPERATURE)) + return self._code_loop(in_buf, noise, self._temperature_buf) diff --git a/tools/easymagpie_vllm_omni/easymagpie_vllm_omni/pipeline.py b/tools/easymagpie_vllm_omni/easymagpie_vllm_omni/pipeline.py new file mode 100644 index 000000000000..811e06f21bdf --- /dev/null +++ b/tools/easymagpie_vllm_omni/easymagpie_vllm_omni/pipeline.py @@ -0,0 +1,122 @@ +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. 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. +"""EasyMagpieTTS pipeline topologies for vLLM-Omni. + +EasyMagpie LM reports the generic ``model_type="nemotron_h"``, so routing uses +its HF architecture or an explicit ``pipeline: …`` deployment setting. + +* :data:`EASYMAGPIE_PIPELINE` — two-stage text → acoustic codes → waveform. +* :data:`EASYMAGPIE_LM_PIPELINE` — single-stage acoustic-token + prediction only (no in-engine Code2Wav). Use this to benchmark and develop + EasyMagpie LM in isolation; select it with ``pipeline: easymagpie_lm``. +""" +from vllm_omni.config.stage_config import PipelineConfig, StageExecutionType, StagePipelineConfig + +_PROC = "easymagpie_vllm_omni.stage_processors" + +# EasyMagpie LM repurposes a 2-wide dummy backbone vocab as a continue/stop signal; +# the last index is the audio-EOS stop token (see +# ``EasyMagpieTTSForConditionalGeneration.audio_eos_stop_token_id``). +_AUDIO_EOS_STOP_TOKEN_ID = 1 + +EASYMAGPIE_PIPELINE = PipelineConfig( + model_type="easymagpie", + model_arch="EasyMagpieTTSForConditionalGeneration", + hf_architectures=( + "EasyMagpieTTSForConditionalGeneration", + "EasyMagpieTTS", + ), + stages=( + StagePipelineConfig( + stage_id=0, + model_stage="easymagpie", + execution_type=StageExecutionType.LLM_AR, + input_sources=(), + owns_tokenizer=True, + # Surface stage 0 as a (latent) final output so incremental WebSocket + # requests can pace their next text chunk against the acoustic frames + # already produced. This is safe for the plain /v1/audio/speech path: + # in two-stage (latent) mode stage-0 codes are emitted under the + # ``audio_codes``/``codes`` keys (see EasyMagpieTTS.make_omni_output), + # which the HTTP handler's ``_extract_audio_output`` does not treat as + # audio (it only keys on ``audio``/``model_outputs``), so those deltas + # are skipped and only the stage-1 waveform is returned. + final_output=True, + final_output_type="latent", + engine_output_type="latent", + # Resumable/segment-stop scheduling for paced streaming; a no-op for + # non-resumable single-shot HTTP requests. + scheduler_cls="easymagpie_vllm_omni.scheduler.EasyMagpieARAsyncScheduler", + async_chunk_process_next_stage_input_func=f"{_PROC}.talker2code2wav_async_chunk", + custom_process_next_stage_input_func=f"{_PROC}.talker2code2wav_full_payload", + sampling_constraints={ + "detokenize": False, + "stop_token_ids": [_AUDIO_EOS_STOP_TOKEN_ID], + }, + ), + StagePipelineConfig( + stage_id=1, + model_stage="easymagpie_codec", + execution_type=StageExecutionType.LLM_GENERATION, + input_sources=(0,), + final_output=True, + final_output_type="audio", + engine_output_type="audio", + model_arch="EasyMagpieCodecForConditionalGeneration", + model_subdir="codec_native", + scheduler_cls="easymagpie_vllm_omni.scheduler.EasyMagpieCodecScheduler", + # Sync mode uses one placeholder per frame; the connector carries codes. + sync_process_input_func=f"{_PROC}.talker2code2wav_token_only", + sampling_constraints={"detokenize": True}, + ), + ), +) + +EASYMAGPIE_LM_PIPELINE = PipelineConfig( + model_type="easymagpie_lm", + model_arch="EasyMagpieTTSForConditionalGeneration", + hf_architectures=( + "EasyMagpieTTSForConditionalGeneration", + "EasyMagpieTTS", + ), + stages=( + StagePipelineConfig( + stage_id=0, + model_stage="easymagpie", + execution_type=StageExecutionType.LLM_AR, + input_sources=(), + owns_tokenizer=True, + final_output=True, + # A single stage that is *also* the final stage needs + # ``engine_output_type="audio"`` for two reasons: + # 1. vLLM-Omni's AR runner force-includes single-stage requests in + # the client pooler payload only for ``engine_output_type == + # "audio"`` (see GPUARModelRunner._resolve_pooler_payload_req_ids). + # With "latent" there is no downstream stage to consume the codes + # and no client payload is built, so the codes get filtered out. + # 2. It drives the output modality to "audio", so the model's + # ``model_outputs`` codes key (see EasyMagpieTTS.make_omni_output, + # which keys off engine_output_type) is remapped to the DRAINABLE + # ``audio`` modality — the client then streams per-step code + # deltas instead of a growing cumulative payload every step. + final_output_type="audio", + engine_output_type="audio", + scheduler_cls="easymagpie_vllm_omni.scheduler.EasyMagpieARAsyncScheduler", + sampling_constraints={ + "detokenize": False, + "stop_token_ids": [_AUDIO_EOS_STOP_TOKEN_ID], + }, + ), + ), +) diff --git a/tools/easymagpie_vllm_omni/easymagpie_vllm_omni/runner.py b/tools/easymagpie_vllm_omni/easymagpie_vllm_omni/runner.py new file mode 100644 index 000000000000..ee3882ea3af0 --- /dev/null +++ b/tools/easymagpie_vllm_omni/easymagpie_vllm_omni/runner.py @@ -0,0 +1,94 @@ +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. 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. +"""vLLM-Omni 0.24 streaming-input compatibility classes. + +vLLM-Omni 0.24 no longer merges a resumed request's +``additional_information`` into ``model_intermediate_buffer``. Consequently, +per-chunk EasyMagpie ``text_token`` payloads reach the scheduler but not the +model runner. The custom runner restores the merge performed by 0.21 while +preserving model-generated state such as ``decode_offset`` and ``text_tokens``. +""" +from __future__ import annotations + +from typing import Any + +import torch +from vllm_omni.engine.serialization import deserialize_additional_information +from vllm_omni.worker import gpu_ar_worker +from vllm_omni.worker.gpu_ar_model_runner import GPUARModelRunner +from vllm_omni.worker.gpu_ar_worker import GPUARWorker + + +def merge_streaming_additional_information( + cached: dict[str, Any], + incoming: dict[str, Any], + accumulated_keys: set[tuple[str, str]] | None = None, +) -> dict[str, Any]: + """Merge one streaming chunk without dropping persistent model state.""" + accumulated_keys = accumulated_keys or set() + merged = dict(cached) + + for key, value in incoming.items(): + if not isinstance(value, dict): + merged[key] = value + continue + + old_value = merged.get(key) + merged_sub = dict(old_value) if isinstance(old_value, dict) else {} + for subkey, subvalue in value.items(): + if (key, subkey) in accumulated_keys and isinstance(subvalue, torch.Tensor): + new_tensor = subvalue.detach().to("cpu").contiguous() + old_tensor = merged_sub.get(subkey) + merged_sub[subkey] = new_tensor if old_tensor is None else torch.cat((old_tensor, new_tensor), dim=0) + else: + merged_sub[subkey] = subvalue + merged[key] = merged_sub + + meta = dict(merged.get("meta", {})) + meta["num_processed_tokens"] = 0 + meta["resumable"] = True + merged["meta"] = meta + return merged + + +class EasyMagpieGPUARModelRunner(GPUARModelRunner): + """GPU AR runner that restores streaming chunk metadata propagation.""" + + def _update_streaming_request(self, req_id, new_req_data): + payload = getattr(new_req_data, "additional_information", None) + incoming = deserialize_additional_information(payload) + if isinstance(incoming, dict) and incoming: + model = getattr(self, "model", None) + accumulated_keys = getattr(model, "streaming_accumulated_keys", set()) + cached = self.model_intermediate_buffer.get(req_id, {}) + merged = merge_streaming_additional_information(cached, incoming, accumulated_keys) + self.model_intermediate_buffer[req_id] = merged + setattr(self.requests[req_id], "additional_information_cpu", merged) + + return super()._update_streaming_request(req_id, new_req_data) + + +class EasyMagpieGPUARWorker(GPUARWorker): + """GPU AR worker that constructs :class:`EasyMagpieGPUARModelRunner`.""" + + def init_device(self): + # GPUARWorker hardcodes its module-level GPUARModelRunner symbol rather + # than exposing a runner-class hook. Swap it only while the base method + # constructs this worker's runner; each worker lives in its own process. + original_runner_cls = gpu_ar_worker.GPUARModelRunner + gpu_ar_worker.GPUARModelRunner = EasyMagpieGPUARModelRunner + try: + return super().init_device() + finally: + gpu_ar_worker.GPUARModelRunner = original_runner_cls diff --git a/tools/easymagpie_vllm_omni/easymagpie_vllm_omni/scheduler.py b/tools/easymagpie_vllm_omni/easymagpie_vllm_omni/scheduler.py new file mode 100644 index 000000000000..b39c9085ae6b --- /dev/null +++ b/tools/easymagpie_vllm_omni/easymagpie_vllm_omni/scheduler.py @@ -0,0 +1,264 @@ +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. 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. +"""Streaming scheduler that propagates EasyMagpie request metadata. + +Configure it on a single-stage deployment with:: + + "scheduler_cls": "easymagpie_vllm_omni.scheduler.EasyMagpieARAsyncScheduler" +""" +from __future__ import annotations + +import threading +from types import MethodType + +import torch +from vllm.v1.request import Request, RequestStatus, StreamingUpdate +from vllm_omni.core.sched.omni_ar_scheduler import OmniARAsyncScheduler +from vllm_omni.core.sched.omni_generation_scheduler import OmniGenerationScheduler +from vllm_omni.distributed.omni_connectors.transfer_adapter.chunk_transfer_adapter import OmniChunkTransferAdapter + + +class EasyMagpieARAsyncScheduler(OmniARAsyncScheduler): + """Forward each chunk's token limit and additional information. + + This class also works around a bug in vLLM-Omni's async segment-stop + handling that deadlocks paced streaming sessions. On a resumable segment + stop, ``OmniARScheduler.update_from_output`` does:: + + request.async_tokens_to_discard = 1 # hardcoded + request.num_output_placeholders = 0 + + i.e. it assumes exactly one async token is in flight and, unlike omni's own + *resume* path, it never rolls ``num_computed_tokens`` back for the tokens it + is about to discard. Combined with vLLM 0.24's async accounting (a discarded + token returns early from ``AsyncScheduler._update_request_with_output`` + without decrementing ``num_output_placeholders``) this leaves the re-admitted + session in an unschedulable state: + + * a leaked placeholder (``placeholders>0`` with ``num_computed==num_tokens``) + permanently trips the scheduler's async skip-optimisation, or + * ``num_computed_tokens == num_tokens`` with ``placeholders==0`` yields + ``num_new_tokens==0``. + + Either way the request is never scheduled again and paced clients hang. + + The fix mirrors omni's resume path: snapshot the *true* number of in-flight + async tokens at the moment of the stop, then after ``update_from_output`` set + ``async_tokens_to_discard`` to that count (0 when nothing is in flight, so no + spurious discard) and roll ``num_computed_tokens`` back by the same amount. + + TODO(upstream): fix ``OmniARScheduler.update_from_output`` directly so the + segment-stop branch uses ``async_tokens_to_discard = num_output_placeholders`` + and ``num_computed_tokens -= num_output_placeholders`` (matching the resume + branch), then drop this override. + """ + + def _update_request_with_output(self, request: Request, new_token_ids): + new_token_ids, stopped = super()._update_request_with_output(request, new_token_ids) + if stopped: + # After super() has decremented the placeholder for the stopping + # token, ``num_output_placeholders`` is the number of *other* async + # tokens still in flight for this request — the value omni's stop + # handler should have used but overwrites with a hardcoded 1. Record + # it so update_from_output can restore the correct accounting. Only + # tracked while inside update_from_output, so there is no per-step + # cost beyond the (rare) segment stops themselves. + pending = getattr(self, "_emp_stopped_this_step", None) + if pending is not None: + pending.append((request, request.num_output_placeholders)) + return new_token_ids, stopped + + def update_from_output(self, scheduler_output, model_runner_output): + self._emp_stopped_this_step = [] + try: + outputs = super().update_from_output(scheduler_output, model_runner_output) + for request, snap in self._emp_stopped_this_step: + # Only correct resumable stops where omni actually armed a discard. + if getattr(request, "async_tokens_to_discard", 0) > 0: + request.async_tokens_to_discard = snap + if snap > 0: + request.num_computed_tokens -= snap + finally: + self._emp_stopped_this_step = None + return outputs + + def _handle_stopped_request(self, request: Request) -> bool: + # The input engine queues ``None`` after the final StreamingInput but + # leaves the existing session's ``resumable`` flag set. Clear it before + # the base handler consumes the sentinel so the chunk-transfer adapter + # emits a true terminal payload and releases request-persistent codec + # state. An empty queue still means "waiting for more websocket input". + streaming_queue = getattr(request, "streaming_queue", None) + if getattr(request, "resumable", False) and streaming_queue and streaming_queue[0] is None: + request.resumable = False + return super()._handle_stopped_request(request) + + def _update_request_as_session(self, session: Request, update: StreamingUpdate) -> None: + outstanding_async_tokens = getattr(session, "num_output_placeholders", 0) + super()._update_request_as_session(session, update) + + # Upstream hardcodes one discard on resume even when multiple async + # outputs are outstanding. Its rollback is otherwise correct, so retain + # it and replace only the discard count with the captured real value. + if outstanding_async_tokens > 0 and getattr(session, "async_tokens_to_discard", 0) > 0: + session.async_tokens_to_discard = outstanding_async_tokens + + new_max_tokens = getattr(update, "max_tokens", None) + if new_max_tokens is not None: + session.max_tokens = new_max_tokens + + if self.vllm_config.model_config.stage_id == 0: + new_info = getattr(update, "additional_information", None) + if new_info is not None: + session.additional_information = new_info + + # Defensive guard: if a resumed session has every token already computed + # (``num_computed_tokens >= num_tokens``), the upstream scheduler computes + # ``num_new_tokens == 0`` and trips ``assert num_new_tokens > 0``. Roll + # back one token so there is always something to recompute and sample from + # — the same "recompute the last token" corrective vLLM applies on a full + # prompt cache hit (see Scheduler._update_waiting_for_remote_kv). + if session.num_computed_tokens >= session.num_tokens: + session.num_computed_tokens = session.num_tokens - 1 + + +def _codec_payload_frames(info, num_quantizers: int) -> int: + """Return the number of time-major acoustic rows in a connector payload.""" + codes = info.get("codes", {}) if isinstance(info, dict) else {} + audio = codes.get("audio") if isinstance(codes, dict) else None + if not isinstance(audio, torch.Tensor) or audio.numel() == 0: + return 0 + if audio.ndim == 2: + return int(audio.shape[0]) + if audio.ndim == 1 and audio.numel() % num_quantizers == 0: + return int(audio.numel() // num_quantizers) + raise ValueError(f"invalid native codec payload shape: {tuple(audio.shape)}") + + +def _poll_native_codec_chunk_unlocked(adapter: OmniChunkTransferAdapter, request: Request) -> bool: + """Receive a chunk without resetting the vLLM state-cache position.""" + old_num_computed_tokens = request.num_computed_tokens + # Async-chunk prewarm may install one unscheduled placeholder before the + # first real payload. Only tokens with materialized state are retained. + old_prompt = list(request.prompt_token_ids or [])[:old_num_computed_tokens] + old_all_token_ids = list(request._all_token_ids)[:old_num_computed_tokens] + + received = OmniChunkTransferAdapter._poll_single_request(adapter, request) + if not received: + request.prompt_token_ids = old_prompt + request._all_token_ids[:] = old_all_token_ids + request.num_prompt_tokens = len(old_prompt) + request.num_computed_tokens = old_num_computed_tokens + request.update_block_hashes() + return False + + frames = _codec_payload_frames(request.additional_information, adapter._easymagpie_num_quantizers) + placeholders = [0] * frames + request.prompt_token_ids = old_prompt + placeholders + request._all_token_ids[:] = old_all_token_ids + placeholders + request.num_prompt_tokens = len(request.prompt_token_ids) + request.num_computed_tokens = old_num_computed_tokens + request.update_block_hashes() + return True + + +def _poll_native_codec_chunk(adapter: OmniChunkTransferAdapter, request: Request) -> bool: + """Publish connector readiness only after the request payload is coherent.""" + with adapter._easymagpie_chunk_lock: + return _poll_native_codec_chunk_unlocked(adapter, request) + + +class EasyMagpieCodecScheduler(OmniGenerationScheduler): + """Keep each Stage-1 stream on one append-only native vLLM request.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + adapter = self.chunk_transfer_adapter + if adapter is None: + raise ValueError("the native EasyMagpie codec requires async_chunk") + config = self.vllm_config.model_config.hf_config + num_quantizers = int(getattr(config, "num_stacked_codebooks", 0)) + if num_quantizers <= 0: + raise ValueError("native EasyMagpie codec config has no stacked codebooks") + adapter._easymagpie_chunk_lock = threading.Lock() + adapter._easymagpie_num_quantizers = num_quantizers + adapter._poll_single_request = MethodType(_poll_native_codec_chunk, adapter) + + def _update_request_as_session(self, session: Request, update: StreamingUpdate) -> None: + """Resume connector polling without resetting the stateful codec. + + Every incremental text update prewarms downstream stages again. vLLM + turns the duplicate Stage-1 request into a streaming update, but the + generation scheduler's default handler replaces its prompt and resets + ``num_computed_tokens``. For the native codec the update carries no new + codec input; it only signals that another upstream segment is coming. + Keep the append-only prompt position and vLLM-managed codec state intact. + """ + prompt_token_ids = session.prompt_token_ids + all_token_ids = list(session._all_token_ids) + num_prompt_tokens = session.num_prompt_tokens + num_computed_tokens = session.num_computed_tokens + additional_information = session.additional_information + + super()._update_request_as_session(session, update) + + session.prompt_token_ids = prompt_token_ids + session._all_token_ids[:] = all_token_ids + session.num_prompt_tokens = num_prompt_tokens + session.num_computed_tokens = num_computed_tokens + session.additional_information = additional_information + session.update_block_hashes() + + def _handle_stopped_request(self, request: Request) -> bool: + finished = super()._handle_stopped_request(request) + stopped_sessions = getattr(self, "_easymagpie_stopped_sessions", None) + if not finished and stopped_sessions is not None: + stopped_sessions.append(request) + return finished + + def _resume_codec_after_segment(self, session: Request) -> None: + """Keep a resumable codec request on the worker's cached-request path.""" + waiting_for_input = session.status == RequestStatus.WAITING_FOR_STREAMING_REQ + if session in self.waiting: + self.waiting.remove_requests((session,)) + if session in self.skipped_waiting: + self.skipped_waiting.remove_requests((session,)) + if waiting_for_input: + self.num_waiting_for_streaming_input -= 1 + + session.status = RequestStatus.RUNNING + if session not in self.running: + self.running.append(session) + self.chunk_transfer_adapter.segment_finished_requests.discard(session.request_id) + + def update_from_output(self, scheduler_output, model_runner_output): + # A segment finish must reach the output processor, but Stage 1 must not + # be re-admitted through the generation scheduler's ``scheduled_new`` + # path afterward. That path recreates the worker batch row, losing the + # codec's recurrent cache even when ``num_computed_tokens`` is retained. + # Move resumable segment stops back to ``running`` after the base method + # has emitted the finish and removed them. Their next codec frames are + # then scheduled as cached tokens against the same state pages. + self._easymagpie_stopped_sessions = [] + try: + outputs = super().update_from_output(scheduler_output, model_runner_output) + for session in self._easymagpie_stopped_sessions: + self._resume_codec_after_segment(session) + finally: + self._easymagpie_stopped_sessions = None + return outputs + + def schedule(self, *args, **kwargs): + with self.chunk_transfer_adapter._easymagpie_chunk_lock: + return super().schedule(*args, **kwargs) diff --git a/tools/easymagpie_vllm_omni/easymagpie_vllm_omni/serving_adapter.py b/tools/easymagpie_vllm_omni/easymagpie_vllm_omni/serving_adapter.py new file mode 100644 index 000000000000..11e9a228665e --- /dev/null +++ b/tools/easymagpie_vllm_omni/easymagpie_vllm_omni/serving_adapter.py @@ -0,0 +1,263 @@ +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. 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. +"""``/v1/audio/speech`` support for EasyMagpieTTS on vLLM-Omni 0.24+.""" +from __future__ import annotations + +import json +import logging +from dataclasses import dataclass +from pathlib import Path +from types import SimpleNamespace +from typing import TYPE_CHECKING, Any, Callable + +from easymagpie_vllm_omni.config import EasyMagpieOmniArch + +logger = logging.getLogger(__name__) + +MODEL_TYPE = "easymagpie" +_TALKER_STAGE = "easymagpie" +_TALKER_ARCH = "EasyMagpieTTSForConditionalGeneration" +_SERVING_MODULE = "vllm_omni.entrypoints.openai.serving_speech" + +_DEFAULT_SPEAKER = "eng" +_DEFAULT_CONTEXT_TEXT = "[EN]" +_DEFAULT_TEMPERATURE = 0.7 +_DEFAULT_TOP_K = 80 + +# Legacy checkpoints use the last-but-one text-vocab row for EOS. Converted +# multiturn checkpoints pin the actual ID explicitly in ``config.json``. +_TEXT_EOS_OFFSET_FROM_VOCAB = 2 + +if TYPE_CHECKING: + from vllm_omni.entrypoints.openai.protocol.audio import OpenAICreateSpeechRequest + + +@dataclass(frozen=True) +class EasyMagpieStreamingSpec: + """Model metadata needed by the incremental WebSocket input stream.""" + + prefill_prompt: dict[str, Any] + tokenizer: Any + text_eos_id: int + sample_rate: int + text_prefill_num: int + + +def _build_adapter_cls() -> type: + """Define the adapter class lazily (needs vllm_omni imported first).""" + from vllm_omni.entrypoints.openai.tts_adapters.base import ARTTSAdapter, PreparedRequest + + class EasyMagpieTTSAdapter(ARTTSAdapter): + """Build speaker-conditioned prompts for ``/v1/audio/speech`` requests.""" + + name = MODEL_TYPE + stage_keys = frozenset({_TALKER_STAGE}) + + def __init__(self, ctx: Any) -> None: + super().__init__(ctx) + self._tokenizer: Any = None + self._model_path_cache: str | None = None + self._prompt_len_cache: dict[str, int] = {} + self._model_config_cache: dict[str, Any] | None = None + self._arch_cache: EasyMagpieOmniArch | None = None + + def _model_path(self) -> str: + if self._model_path_cache is not None: + return self._model_path_cache + engine_client = getattr(self.ctx, "engine_client", None) + model_config = getattr(engine_client, "model_config", None) + path = getattr(model_config, "model", None) + if not path: + for stage in getattr(engine_client, "stage_configs", None) or []: + stage_path = getattr(getattr(stage, "engine_args", None), "model", None) + if stage_path: + path = stage_path + break + if not path: + raise RuntimeError("EasyMagpie serving adapter could not resolve the model path.") + self._model_path_cache = path + return path + + def _tokenize(self) -> Callable[[str], Any]: + if self._tokenizer is None: + from transformers import AutoTokenizer + + self._tokenizer = AutoTokenizer.from_pretrained(self._model_path(), trust_remote_code=True) + return lambda text: self._tokenizer.encode(text) + + def _model_tokenizer(self): + self._tokenize() + return self._tokenizer + + def _prompt_len(self, speaker_id: str) -> int: + cached = self._prompt_len_cache.get(speaker_id) + if cached is not None: + return cached + from easymagpie_vllm_omni.easymagpie import EasyMagpieTTSForConditionalGeneration + + plen = int( + EasyMagpieTTSForConditionalGeneration.get_prompt_len( + speaker_id, self._model_path(), tokenize=self._tokenize() + ) + ) + self._prompt_len_cache[speaker_id] = plen + return plen + + def _model_config(self) -> dict[str, Any]: + if self._model_config_cache is None: + path = Path(self._model_path()) / "config.json" + self._model_config_cache = json.loads(path.read_text()) + return self._model_config_cache + + def _arch(self) -> EasyMagpieOmniArch: + if self._arch_cache is None: + self._arch_cache = EasyMagpieOmniArch.from_hf_config(SimpleNamespace(**self._model_config())) + return self._arch_cache + + def _text_stream_metadata(self) -> tuple[int, int]: + config = self._model_config() + text_vocab_size = int(config.get("text_vocab_size", config.get("vocab_size", 0))) + if text_vocab_size <= _TEXT_EOS_OFFSET_FROM_VOCAB: + raise ValueError("EasyMagpie config must define text_vocab_size") + configured_text_eos_id = config.get("text_eos_id") + text_eos_id = ( + text_vocab_size - _TEXT_EOS_OFFSET_FROM_VOCAB + if configured_text_eos_id is None + else int(configured_text_eos_id) + ) + return text_eos_id, self._arch().text_prefill_num + + def validate(self, request: OpenAICreateSpeechRequest) -> str | None: + if not request.input or not request.input.strip(): + return "Input text cannot be empty" + extra = request.extra_params + if extra is not None and not isinstance(extra, dict): + return "extra_params must be a JSON object/dict" + return None + + async def build( + self, + request: OpenAICreateSpeechRequest, + sampling_params_list: list, + has_inline_ref_audio: bool, + ) -> "PreparedRequest": + del sampling_params_list, has_inline_ref_audio # EasyMagpie needs neither. + speaker_id = (request.voice or _DEFAULT_SPEAKER).strip() + extra = request.extra_params or {} + text_eos_id, text_prefill_num = self._text_stream_metadata() + text_tokens = list(self._model_tokenizer().encode(request.input, add_special_tokens=False)) + text_tokens.append(text_eos_id) + + prompt = { + "prompt_token_ids": [0] * (self._prompt_len(speaker_id) + text_prefill_num), + "additional_information": { + "context_text": extra.get("context_text", _DEFAULT_CONTEXT_TEXT), + "text_tokens": text_tokens, + "prefill_text_tokens": text_tokens[:text_prefill_num], + "text_prefill_num": text_prefill_num, + "temperature": float(extra.get("temperature", _DEFAULT_TEMPERATURE)), + "top_k": int(extra.get("top_k", _DEFAULT_TOP_K)), + "speaker_id": speaker_id, + }, + } + return PreparedRequest(prompt=prompt, tts_params={}, model_type=MODEL_TYPE) + + def build_streaming_spec(self, request: OpenAICreateSpeechRequest) -> EasyMagpieStreamingSpec: + """Build the speaker prefill and tokenizer metadata without complete text.""" + speaker_id = (request.voice or _DEFAULT_SPEAKER).strip() + model_path = Path(self._model_path()) + text_eos_id, text_prefill_num = self._text_stream_metadata() + + sample_rate = 22050 + codec_config_path = model_path / "codec_native" / "config.json" + if codec_config_path.exists(): + codec_config = json.loads(codec_config_path.read_text()) + sample_rate = int(codec_config.get("output_sample_rate", sample_rate)) + + return EasyMagpieStreamingSpec( + prefill_prompt={ + "prompt_token_ids": [0] * (self._prompt_len(speaker_id) + text_prefill_num), + "additional_information": { + "context_text": _DEFAULT_CONTEXT_TEXT, + "temperature": _DEFAULT_TEMPERATURE, + "top_k": _DEFAULT_TOP_K, + "speaker_id": speaker_id, + "text_prefill_num": text_prefill_num, + }, + }, + tokenizer=self._model_tokenizer(), + text_eos_id=text_eos_id, + sample_rate=sample_rate, + text_prefill_num=text_prefill_num, + ) + + return EasyMagpieTTSAdapter + + +def _register_adapter() -> None: + from vllm_omni.entrypoints.openai import tts_adapters + + if MODEL_TYPE in tts_adapters.TTS_ADAPTER_REGISTRY: + return + tts_adapters.register_tts_adapter(_build_adapter_cls()) + + +def _patch_detection() -> None: + from vllm_omni.entrypoints.openai import serving_speech as ss + + ss._TTS_MODEL_STAGES.add(_TALKER_STAGE) + + detect = ss.OmniOpenAIServingSpeech._detect_tts_model_type + if getattr(detect, "_easymagpie_patched", False): + return + _orig_detect = detect + + def _detect_tts_model_type(self): + stage = getattr(self, "_tts_stage", None) + if stage is not None: + engine_args = getattr(stage, "engine_args", None) + model_stage = getattr(engine_args, "model_stage", None) + model_arch = getattr(engine_args, "model_arch", None) + if model_stage == _TALKER_STAGE or model_arch == _TALKER_ARCH: + return MODEL_TYPE + return _orig_detect(self) + + _detect_tts_model_type._easymagpie_patched = True + ss.OmniOpenAIServingSpeech._detect_tts_model_type = _detect_tts_model_type + + +def _patch_streaming_handler() -> None: + """Select the EasyMagpie-aware handler when API state is initialized.""" + from easymagpie_vllm_omni.serving_stream import EasyMagpieStreamingSpeechHandler + from vllm_omni.entrypoints.openai import api_server + + api_server.OmniStreamingSpeechHandler = EasyMagpieStreamingSpeechHandler + + +def apply_serving_patches(force: bool = False) -> None: + """Install speech support in the API-server process.""" + import sys + + if not force and _SERVING_MODULE not in sys.modules: + return + try: + _patch_detection() + _register_adapter() + _patch_streaming_handler() + logger.info( + "EasyMagpie: /v1/audio/speech and /v1/audio/speech/stream serving registered (model_type=%r).", + MODEL_TYPE, + ) + except Exception: # never let a serving-layer change break model/pipeline loading + logger.exception("EasyMagpie: failed to install /v1/audio/speech serving support.") diff --git a/tools/easymagpie_vllm_omni/easymagpie_vllm_omni/serving_stream.py b/tools/easymagpie_vllm_omni/easymagpie_vllm_omni/serving_stream.py new file mode 100644 index 000000000000..95ed971ecb11 --- /dev/null +++ b/tools/easymagpie_vllm_omni/easymagpie_vllm_omni/serving_stream.py @@ -0,0 +1,388 @@ +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. 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. +"""Incremental text-input WebSocket serving for EasyMagpieTTS.""" +from __future__ import annotations + +import asyncio +import copy +import json +from contextlib import aclosing +from typing import Any, cast + +from fastapi import WebSocket, WebSocketDisconnect +from vllm.engine.protocol import StreamingInput +from vllm.logger import init_logger +from vllm.utils import random_uuid +from vllm_omni.entrypoints.openai.protocol.audio import OpenAICreateSpeechRequest +from vllm_omni.entrypoints.openai.serving_speech_stream import OmniStreamingSpeechHandler +from vllm_omni.entrypoints.utils import coerce_param_message_types + +logger = init_logger(__name__) + +_MODEL_TYPE = "easymagpie" +# Segment completion includes time spent queued behind other requests. Keep +# this comfortably above the multi-second queueing observed at concurrency 32, +# while still bounding a genuinely stalled engine request. +_DEFAULT_PACE_TIMEOUT_S = 30.0 +_MAX_TOKEN_CHUNK_SIZE = 256 +_MAX_INPUT_MESSAGE_SIZE = 128 * 1024 +_QUEUE_DEPTH = 8 +_DONE = object() + + +def _sampling_params_with_max_tokens(params: Any, max_tokens: int) -> Any: + cloned = copy.deepcopy(params) + cloned.max_tokens = max(1, int(max_tokens)) + return cloned + + +class EasyMagpieInputStream: + """Turn queued text-token chunks into one resumable engine input stream.""" + + def __init__( + self, + *, + prefill_prompt: dict[str, Any], + sampling_params: Any, + text_eos_id: int, + max_new_tokens: int, + pace_timeout_s: float = _DEFAULT_PACE_TIMEOUT_S, + text_prefill_num: int = 0, + queue_depth: int = _QUEUE_DEPTH, + coalesce_queued_tokens: bool = True, + ) -> None: + self.prefill_prompt = prefill_prompt + self.sampling_params = sampling_params + self.text_eos_id = int(text_eos_id) + self.max_new_tokens = max(1, int(max_new_tokens)) + self.text_prefill_num = max(0, int(text_prefill_num)) + self.pace_timeout_s = max(0.0, float(pace_timeout_s)) + self.coalesce_queued_tokens = coalesce_queued_tokens + self._input_queue: asyncio.Queue[list[int] | object] = asyncio.Queue(maxsize=max(1, queue_depth)) + self._segment_completions: asyncio.Queue[None] = asyncio.Queue() + self._finished = False + self._received_first_update = False + self.observed_output_frames = 0 + + @property + def finished(self) -> bool: + return self._finished + + async def put_tokens(self, token_ids: list[int]) -> None: + if self._finished: + raise RuntimeError("Cannot append tokens after input.done") + if not token_ids: + return + normalized = [int(token_id) for token_id in token_ids] + if not self._received_first_update and len(normalized) < self.text_prefill_num: + raise ValueError(f"first input update must contain at least {self.text_prefill_num} text tokens") + await self._input_queue.put(normalized) + self._received_first_update = True + + async def finish(self) -> None: + if not self._finished: + self._finished = True + await self._input_queue.put(_DONE) + + def observe_output(self, output: Any) -> None: + """Track generated frames and release the next text update at segment completion.""" + if getattr(output, "stage_id", None) != 0: + return + outputs = getattr(output, "outputs", None) or [] + if not outputs: + return + token_ids = getattr(outputs[0], "token_ids", None) or [] + finish_reason = getattr(outputs[0], "finish_reason", None) + self.observed_output_frames += len(token_ids) + if finish_reason is not None: + self._segment_completions.put_nowait(None) + + async def _wait_for_segment_completion(self) -> None: + if self.pace_timeout_s <= 0: + return + try: + await asyncio.wait_for(self._segment_completions.get(), timeout=self.pace_timeout_s) + except asyncio.TimeoutError as error: + raise TimeoutError("Timed out waiting for stage-0 segment completion") from error + + def _accumulate_queued_tokens(self, token_ids: list[int]) -> tuple[list[int], bool]: + """Coalesce text received while stage 0 was producing prior frames.""" + if not self.coalesce_queued_tokens: + return token_ids, False + done = False + while not self._input_queue.empty(): + item = self._input_queue.get_nowait() + if item is _DONE: + done = True + break + token_ids.extend(cast(list[int], item)) + return token_ids, done + + async def inputs(self): + """Yield token-bearing prefill, updates, EOS, then the acoustic tail.""" + first_item = await self._input_queue.get() + if first_item is _DONE: + return + # Cumulative index of the next text id in the model's buffer. Each segment + # is tagged with the absolute ``text_token_start`` of its first id so the + # model absorbs it exactly once regardless of async segment lookahead (see + # EasyMagpieTTSForConditionalGeneration._preprocess_decode). + text_token_start = 0 + + first_token_ids = cast(list[int], first_item) + first_prompt = copy.deepcopy(self.prefill_prompt) + first_info = first_prompt.setdefault("additional_information", {}) + first_info["text_token"] = first_token_ids + first_info["text_token_start"] = text_token_start + if self.text_prefill_num: + first_info["text_prefill_num"] = self.text_prefill_num + first_info["prefill_text_tokens"] = first_token_ids[: self.text_prefill_num] + first_required_frames = 1 + len(first_token_ids) - self.text_prefill_num + else: + first_required_frames = len(first_token_ids) + yield StreamingInput( + prompt=first_prompt, + sampling_params=_sampling_params_with_max_tokens(self.sampling_params, first_required_frames), + ) + text_token_start += len(first_token_ids) + + input_done = False + while True: + item = await self._input_queue.get() + if item is _DONE: + break + token_ids = cast(list[int], item) + await self._wait_for_segment_completion() + token_ids, input_done = self._accumulate_queued_tokens(token_ids) + if input_done: + token_ids.append(self.text_eos_id) + yield StreamingInput( + prompt={ + "prompt_token_ids": [0], + "additional_information": {"text_token": token_ids, "text_token_start": text_token_start}, + }, + sampling_params=_sampling_params_with_max_tokens(self.sampling_params, len(token_ids)), + ) + text_token_start += len(token_ids) + if input_done: + break + + await self._wait_for_segment_completion() + if not input_done: + yield StreamingInput( + prompt={ + "prompt_token_ids": [0], + "additional_information": { + "text_token": [self.text_eos_id], + "text_token_start": text_token_start, + }, + }, + sampling_params=_sampling_params_with_max_tokens(self.sampling_params, 1), + ) + text_token_start += 1 + await self._wait_for_segment_completion() + + tail_max_tokens = self.max_new_tokens - self.observed_output_frames + yield StreamingInput( + prompt={ + "prompt_token_ids": [0], + "additional_information": {"text_token": []}, + }, + sampling_params=_sampling_params_with_max_tokens( + self.sampling_params, + tail_max_tokens, + ), + ) + + +class EasyMagpieStreamingSpeechHandler(OmniStreamingSpeechHandler): + """Use resumable EasyMagpie requests while preserving the generic handler.""" + + async def handle_session(self, websocket: WebSocket) -> None: + if getattr(self._speech_service, "_tts_model_type", None) != _MODEL_TYPE: + await super().handle_session(websocket) + return + + await websocket.accept() + request_id: str | None = None + audio_task: asyncio.Task[int] | None = None + input_stream: EasyMagpieInputStream | None = None + completed = False + try: + config = await self._receive_config(websocket) + if config is None: + return + if not config.stream_audio or config.response_format != "pcm": + await self._send_error( + websocket, + "Incremental EasyMagpie input requires stream_audio=true and response_format='pcm'.", + ) + return + if config.word_timestamps: + await self._send_error( + websocket, "word_timestamps is not supported with incremental EasyMagpie input." + ) + return + if config.model and hasattr(self._speech_service, "_check_model"): + error = await self._speech_service._check_model( + OpenAICreateSpeechRequest(input="ping", model=config.model) + ) + if error is not None: + await self._send_error(websocket, str(error)) + return + + adapter = self._speech_service._get_tts_adapter() + if adapter is None or not hasattr(adapter, "build_streaming_spec"): + await self._send_error(websocket, "EasyMagpie incremental serving adapter is unavailable.") + return + + request = OpenAICreateSpeechRequest( + input="", + model=config.model, + voice=config.voice, + response_format="pcm", + max_new_tokens=config.max_new_tokens, + stream=True, + ) + spec = adapter.build_streaming_spec(request) + sampling_params_list = list(self._speech_service.engine_client.default_sampling_params_list) + sampling_params_list = coerce_param_message_types(sampling_params_list, is_streaming=True) + stage0_params = sampling_params_list[0] + max_new_tokens = config.max_new_tokens or getattr(stage0_params, "max_tokens", 2048) + input_stream = EasyMagpieInputStream( + prefill_prompt=spec.prefill_prompt, + sampling_params=stage0_params, + text_eos_id=spec.text_eos_id, + max_new_tokens=max_new_tokens, + text_prefill_num=getattr(spec, "text_prefill_num", 0), + ) + request_id = f"speech-stream-{random_uuid()}" + generator = self._speech_service.engine_client.generate( + prompt=input_stream.inputs(), + request_id=request_id, + sampling_params_list=sampling_params_list, + # Stage 0 is marked final_output only to expose pacing deltas; + # completion must still be governed solely by stage-1 audio. + output_modalities=["audio"], + ) + + async def observed_generator(): + async for output in generator: + input_stream.observe_output(output) + yield output + + await websocket.send_json( + { + "type": "audio.start", + "sentence_index": 0, + "sentence_text": "", + "format": "pcm", + "sample_rate": spec.sample_rate, + } + ) + + async def send_audio() -> int: + total_bytes = 0 + async with aclosing( + self._speech_service._generate_pcm_chunks(observed_generator(), request_id) + ) as chunks: + async for chunk in chunks: + total_bytes += len(chunk) + await websocket.send_bytes(chunk) + return total_bytes + + audio_task = asyncio.create_task(send_audio()) + text_parts: list[str] = [] + input_token_count = 0 + while True: + raw = await asyncio.wait_for(websocket.receive_text(), timeout=self._idle_timeout) + if audio_task.done(): + audio_task.result() + if len(raw) > _MAX_INPUT_MESSAGE_SIZE: + await self._send_error(websocket, "Input message too large") + continue + + try: + msg = json.loads(raw) + except json.JSONDecodeError: + await self._send_error(websocket, "Invalid JSON message") + continue + if not isinstance(msg, dict): + await self._send_error(websocket, "WebSocket messages must be JSON objects") + continue + + msg_type = msg.get("type") + if msg_type == "input.tokens": + tokens = msg.get("tokens") + if ( + not isinstance(tokens, list) + or not tokens + or len(tokens) > _MAX_TOKEN_CHUNK_SIZE + or any(not isinstance(token, int) or token < 0 for token in tokens) + ): + await self._send_error( + websocket, + f"input.tokens requires 1-{_MAX_TOKEN_CHUNK_SIZE} non-negative integer token IDs.", + ) + continue + await input_stream.put_tokens(tokens) + input_token_count += len(tokens) + elif msg_type == "input.text": + text = msg.get("text") + if not isinstance(text, str): + await self._send_error(websocket, "input.text requires a string value") + continue + tokens = spec.tokenizer.encode(text, add_special_tokens=False) + if tokens: + await input_stream.put_tokens(tokens) + input_token_count += len(tokens) + text_parts.append(text) + elif msg_type == "input.done": + if input_token_count == 0: + await self._send_error(websocket, "No text or token input was provided.") + return + await input_stream.finish() + break + else: + await self._send_error(websocket, f"Unknown message type: {msg_type}") + + total_bytes = await audio_task + await websocket.send_json( + { + "type": "audio.done", + "sentence_index": 0, + "sentence_text": "".join(text_parts), + "total_bytes": total_bytes, + "talker_frames": input_stream.observed_output_frames, + "text_tokens": input_token_count, + "error": False, + } + ) + await websocket.send_json({"type": "session.done", "total_sentences": 1}) + completed = True + except (WebSocketDisconnect, asyncio.TimeoutError): + # Client disconnects and input timeouts are expected terminal conditions. + pass + except Exception as error: + logger.exception("Incremental EasyMagpie generation failed for %s", request_id) + await self._send_error(websocket, f"Incremental EasyMagpie generation failed: {error}") + finally: + if audio_task is not None and not audio_task.done(): + audio_task.cancel() + await asyncio.gather(audio_task, return_exceptions=True) + if request_id is not None and not completed: + try: + await self._speech_service.engine_client.abort(request_id) + except Exception: + logger.debug("Failed to abort incremental request %s", request_id, exc_info=True) diff --git a/tools/easymagpie_vllm_omni/easymagpie_vllm_omni/stage_processors.py b/tools/easymagpie_vllm_omni/easymagpie_vllm_omni/stage_processors.py new file mode 100644 index 000000000000..37e853a252c9 --- /dev/null +++ b/tools/easymagpie_vllm_omni/easymagpie_vllm_omni/stage_processors.py @@ -0,0 +1,444 @@ +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. 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. +"""Transfer stacked acoustic codes from the talker to the native codec. + +Stage 0 emits ``[frames, codebooks]`` codes. The stateful Stage 1 consumes one +placeholder and one code row per newly generated acoustic frame. +""" +from __future__ import annotations + +from collections import defaultdict +from collections.abc import Mapping +from typing import Any + +import torch +from vllm.logger import init_logger +from vllm_omni.data_entry_keys import CodesStruct, MetaStruct, OmniPayload, OmniPayloadStruct +from vllm_omni.engine.serialization import deserialize_additional_information + +logger = init_logger(__name__) + + +# Base codebook size, excluding control tokens. +_CODEBOOK_SIZE = 1024 + + +def _empty_finished_payload() -> dict[str, Any]: + """Release Stage 1 when no usable codec frames were produced.""" + return { + "codes": {"audio": torch.zeros(0, dtype=torch.long)}, + "meta": {"finished": torch.tensor(True, dtype=torch.bool)}, + } + + +def _filter_audio_codes(audio_codes: torch.Tensor) -> torch.Tensor: + """Drop all-zero (padding/warm-up), negative, and special-token frames. + + Special audio tokens (bos/eos/mask) live at ``codebook_size + offset`` — any + frame containing one is an out-of-band control frame, not audio, so it is + removed here (the decoder additionally clamps as a safety net). + """ + if not isinstance(audio_codes, torch.Tensor) or audio_codes.numel() == 0 or audio_codes.ndim != 2: + return audio_codes + valid_mask = ( + (audio_codes >= 0).all(dim=1) & audio_codes.any(dim=1) & (audio_codes.max(dim=1).values < _CODEBOOK_SIZE) + ) + return audio_codes[valid_mask] + + +def _flatten_codebook_major(audio_codes: torch.Tensor) -> torch.Tensor: + """``[F, Q]`` -> codebook-major flat ``[Q*F]`` (long, cpu, contiguous).""" + return audio_codes.transpose(0, 1).to(device="cpu", dtype=torch.long).reshape(-1).contiguous() + + +def talker2code2wav( + source_outputs: list[Any], + prompt: Any = None, + _requires_multimodal_data: bool = False, +) -> list[Any]: + """Non-async orchestrator path: collect all talker codes, decode at once.""" + from vllm_omni.inputs.data import OmniTokensPrompt + + code2wav_inputs: list[OmniTokensPrompt] = [] + for talker_output in source_outputs: + if not talker_output.finished: + continue + output = talker_output.outputs[0] + mm = output.multimodal_output if isinstance(output.multimodal_output, dict) else {} + audio = _extract_audio_codes(mm) + if audio is None: + code2wav_inputs.append(_empty_prompt()) + continue + audio = _filter_audio_codes(audio.to(torch.long)) + token_ids = getattr(output, "cumulative_token_ids", []) or [] + seq_len = max(len(token_ids) - 1, 0) + if seq_len > 0 and audio.ndim == 2 and int(audio.shape[0]) > seq_len: + audio = audio[-seq_len:] + if audio.numel() == 0: + code2wav_inputs.append(_empty_prompt()) + continue + codec_codes = _flatten_codebook_major(audio).tolist() + code2wav_inputs.append( + OmniTokensPrompt( + prompt_token_ids=codec_codes, + multi_modal_data=None, + mm_processor_kwargs=None, + additional_information=None, + ) + ) + return code2wav_inputs + + +def talker2code2wav_token_only( + source_outputs: list, + prompt=None, + _requires_multimodal_data: bool = False, +) -> list: + """Sync-side one-placeholder-per-frame Stage-1 input. + + The real codec rows ship via the worker connector payload built by + :func:`talker2code2wav_full_payload`. + """ + from vllm_omni.inputs.data import OmniTokensPrompt + + code2wav_inputs: list = [] + for talker_output in source_outputs: + if not talker_output.finished: + continue + output = talker_output.outputs[0] + mm = output.multimodal_output if isinstance(getattr(output, "multimodal_output", None), dict) else {} + audio = _extract_audio_codes(mm) + token_ids = getattr(output, "cumulative_token_ids", []) or [] + seq_len = max(len(token_ids) - 1, 0) + + if isinstance(audio, torch.Tensor) and audio.numel() > 0: + audio = _filter_audio_codes(audio.to(torch.long)) + if seq_len > 0 and audio.ndim == 2 and int(audio.shape[0]) > seq_len: + audio = audio[-seq_len:] + num_frames = int(audio.shape[0]) if audio.ndim == 2 else 0 + else: + num_frames = 0 + + code2wav_inputs.append( + OmniTokensPrompt( + prompt_token_ids=[0] * num_frames, + additional_information=None, + multi_modal_data=None, + mm_processor_kwargs=None, + ) + ) + return code2wav_inputs + + +def talker2code2wav_full_payload(transfer_manager, multimodal_output, request, is_finished: bool = False): + """Send accumulated codec frames through the worker connector. + + ``is_finished`` is part of the transfer-adapter callback contract. + """ + pooling_output = multimodal_output + del transfer_manager, is_finished + rid = getattr(request, "request_id", "?") + if not isinstance(pooling_output, dict): + logger.warning( + "easymagpie.talker2code2wav_full_payload: pooling_output is %s (not dict) for req=%s", + type(pooling_output).__name__, + rid, + ) + return _empty_finished_payload() + + audio = pooling_output.get("codes.audio") + if audio is None: + codes_nested = pooling_output.get("codes") + if isinstance(codes_nested, dict): + audio = codes_nested.get("audio") + if not isinstance(audio, torch.Tensor) or audio.numel() == 0: + logger.warning( + "easymagpie.talker2code2wav_full_payload: missing/empty codes.audio (keys=%s) for req=%s", + list(pooling_output.keys()), + rid, + ) + return _empty_finished_payload() + + raw_frames = int(audio.shape[0]) if audio.ndim == 2 else 0 + audio = _filter_audio_codes(audio.to(torch.long)) + kept_frames = int(audio.shape[0]) if audio.ndim == 2 else 0 + logger.info( + "easymagpie.talker2code2wav_full_payload: req=%s accumulated %d frames " + "(%d after filtering control/padding).", + rid, + raw_frames, + kept_frames, + ) + if audio.numel() == 0: + return _empty_finished_payload() + + output_token_ids = list(getattr(request, "output_token_ids", None) or []) + seq_len = max(len(output_token_ids) - 1, 0) + if seq_len > 0 and audio.ndim == 2 and int(audio.shape[0]) > seq_len: + audio = audio[-seq_len:] + + return { + "codes": {"audio": audio.to(device="cpu", dtype=torch.long).contiguous()}, + "meta": {"finished": torch.tensor(True, dtype=torch.bool)}, + } + + +def _extract_last_frame(multimodal_output: OmniPayload | dict[str, Any]) -> torch.Tensor | None: + audio_codes = _extract_audio_codes(multimodal_output) + if not isinstance(audio_codes, torch.Tensor) or audio_codes.numel() == 0: + return None + if audio_codes.ndim == 2: + frame = audio_codes[-1] + if frame.numel() == 0 or not bool(frame.any().item()): + return None + if int(frame.max().item()) >= _CODEBOOK_SIZE: + # Control frame (audio eos/mask) — not audio. + return None + return frame.to(torch.long).reshape(-1) + if audio_codes.ndim == 1: + return audio_codes.to(torch.long).reshape(-1) + raise ValueError(f"Invalid audio_codes shape for EasyMagpie async_chunk: {tuple(audio_codes.shape)}") + + +def _resolve_speech_delay(transfer_manager: Any) -> int: + """Return and cache the number of non-audio frames before speech starts.""" + cached = getattr(transfer_manager, "_easymagpie_speech_delay", None) + if cached is not None: + return cached + + # Transfer managers expose model configuration through either interface. + model_config = getattr(transfer_manager, "config", None) + if getattr(model_config, "hf_config", None) is None: + getter = getattr(transfer_manager, "_get_model_config", None) + if callable(getter): + try: + model_config = getter() + except Exception: + # Version-specific getters may fail before initialization; use the existing config fallback. + pass + + hf_config = getattr(model_config, "hf_config", None) + try: + delay = int(getattr(hf_config, "streaming_speech_delay", 0) or 0) + except Exception: + delay = 0 + transfer_manager._easymagpie_speech_delay = delay + logger.info("easymagpie: resolved streaming_speech_delay=%d (leading warm-up frames dropped)", delay) + return delay + + +def _persistent_state(transfer_manager: Any, attr: str) -> dict: + """Lazily create a request-keyed ``int`` dict on the transfer manager. + + These survive the scheduler's per-segment reset of ``output_token_ids`` / + the codec buffer, so warm-up and emission accounting stay continuous across + the segment stops that a streaming (chunk-by-chunk) request goes through. + """ + state = getattr(transfer_manager, attr, None) + if state is None: + state = defaultdict(int) + setattr(transfer_manager, attr, state) + return state + + +def _persistent_list_state(transfer_manager: Any, attr: str) -> dict: + """Lazily create a request-keyed ``list`` dict on the transfer manager.""" + state = getattr(transfer_manager, attr, None) + if state is None: + state = defaultdict(list) + setattr(transfer_manager, attr, state) + return state + + +def _is_true_request_finish(request: Any) -> bool: + """True only at the real end of the utterance, not at a segment stop. + + Mirrors the transfer adapter's own ``request.is_finished() and not + request.resumable`` rule: a resumable streaming request reports + ``is_finished()`` at every segment boundary while still expecting more + input, so it must not be treated as the terminal finish. + """ + return bool(getattr(request, "is_finished", lambda: False)()) and not bool(getattr(request, "resumable", False)) + + +def talker2code2wav_async_chunk( + transfer_manager: Any, + multimodal_output: OmniPayload | dict[str, Any] | None, + request: Any, + is_finished: bool = False, +) -> OmniPayloadStruct | None: + """Emit newly generated time-major codec rows to the stateful native codec. + + ``multimodal_output`` must retain this name because the transfer adapter + passes it by keyword. + """ + request_id = request.external_req_id + finished = bool(is_finished or request.is_finished()) + + if isinstance(multimodal_output, Mapping): + frame = _extract_last_frame(multimodal_output) + # EOS and other control rows intentionally become ``None`` here: they + # stop Stage 0 and flush pending audio, but never enter the codec stream. + delay_state = _persistent_state(transfer_manager, "_emp_request_speech_delay") + if request_id not in delay_state: + base_speech_delay = _resolve_speech_delay(transfer_manager) + info = deserialize_additional_information(getattr(request, "additional_information", None)) + text_prefill_num = int(info.get("text_prefill_num", 0) or 0) + if not 0 <= text_prefill_num <= base_speech_delay: + raise ValueError( + f"Invalid EasyMagpie text_prefill_num={text_prefill_num} for speech delay {base_speech_delay}" + ) + delay_state[request_id] = base_speech_delay - text_prefill_num + speech_delay = delay_state[request_id] + + # Count actual predicted code frames across segment stops. The prefill + # callback has no frame and must not consume one of the remaining warm-up + # positions. + seen_state = _persistent_state(transfer_manager, "_emp_seen_frames") + _ = seen_state[request_id] + is_warmup = False + if frame is not None: + seen_state[request_id] += 1 + frame_index = seen_state[request_id] + is_warmup = speech_delay > 0 and frame_index <= speech_delay + # Accumulate real frames into a request-persistent buffer. The framework's + # per-segment buffer can reset; this one keeps the acoustic stream + # continuous regardless of how the text was chunked. + frame_buffer = _persistent_list_state(transfer_manager, "_emp_frame_buffer") + if frame is not None and not is_warmup: + # ``multimodal_output`` is already a CPU snapshot. Keep it as a + # tensor so codec chunks can be assembled with a single stack + # instead of a Python list round-trip. + frame_row = frame.detach().to(device="cpu", dtype=torch.long).reshape(-1).contiguous() + frame_buffer[request_id].append(frame_row) + # Keep the framework buffer populated too (some connector bookkeeping + # counts active requests by its non-empty per-request lists). + transfer_manager.code_prompt_token_ids[request_id].append(frame_row) + elif not finished: + return None + + connector = getattr(transfer_manager, "connector", None) + raw_cfg = getattr(connector, "config", {}) or {} + cfg = raw_cfg.get("extra", raw_cfg) if isinstance(raw_cfg, dict) else {} + chunk_size = int(cfg.get("codec_chunk_frames", 25)) + raw_startup_chunks = cfg.get("codec_startup_chunk_frames", []) + if not isinstance(raw_startup_chunks, (list, tuple)): + raise ValueError( + "Invalid EasyMagpie codec chunk config: codec_startup_chunk_frames " + f"must be a list, got {type(raw_startup_chunks).__name__}" + ) + startup_chunk_sizes = [int(value) for value in raw_startup_chunks] + if chunk_size <= 0 or any(value <= 0 for value in startup_chunk_sizes): + raise ValueError( + f"Invalid EasyMagpie codec chunk config: codec_chunk_frames={chunk_size}, " + f"codec_startup_chunk_frames={startup_chunk_sizes}" + ) + # Track one absolute emission high-water mark across resumable text + # segments so repeated segment-finish notifications cannot duplicate frames. + frame_buffer = _persistent_list_state(transfer_manager, "_emp_frame_buffer") + buffer = frame_buffer[request_id] + base_state = _persistent_state(transfer_manager, "_emp_frame_buffer_base") + base_index = base_state[request_id] + length = base_index + len(buffer) + + emitted_state = _persistent_state(transfer_manager, "_emp_emitted_frames") + emitted = emitted_state[request_id] + emitted_chunks_state = _persistent_state(transfer_manager, "_emp_emitted_chunks") + emitted_chunks = emitted_chunks_state[request_id] + + true_finished = _is_true_request_finish(request) + + def _cleanup() -> None: + emitted_state.pop(request_id, None) + emitted_chunks_state.pop(request_id, None) + _persistent_state(transfer_manager, "_emp_seen_frames").pop(request_id, None) + _persistent_state(transfer_manager, "_emp_request_speech_delay").pop(request_id, None) + base_state.pop(request_id, None) + frame_buffer.pop(request_id, None) + + if length <= 0: + if finished: + if true_finished: + _cleanup() + return OmniPayloadStruct( + codes=CodesStruct(audio=torch.empty(0, dtype=torch.long)), + meta=MetaStruct(finished=torch.tensor(True, dtype=torch.bool)), + ) + return None + + pending = length - emitted + if pending <= 0: + # Nothing new to emit. Never re-emit already-sent frames; the adapter + # still forwards segment/request finish markers when we return None. + if true_finished: + _cleanup() + return None + + # Startup targets ramp independently from the steady codec chunk size. + target = startup_chunk_sizes[emitted_chunks] if emitted_chunks < len(startup_chunk_sizes) else chunk_size + # A resumable segment stop is not an audio flush: retain a partial body + # across text-input segments so steady codec chunks keep one logical size. + # Only the terminal request finish may emit a short final tail. + if not true_finished and pending < target: + return None + context_length = pending if true_finished else min(pending, target) + + new_end = emitted + context_length + relative_start = emitted - base_index + relative_end = new_end - base_index + code_predictor_codes = torch.stack(buffer[relative_start:relative_end], dim=0).contiguous() + + emitted_state[request_id] = new_end + emitted_chunks_state[request_id] += 1 + drop = new_end - base_index + if drop > 0: + del buffer[:drop] + base_state[request_id] = new_end + if true_finished: + _cleanup() + + return OmniPayloadStruct( + codes=CodesStruct(audio=code_predictor_codes), + meta=MetaStruct( + left_context_size=0, + finished=torch.tensor(finished, dtype=torch.bool), + ), + ) + + +def _extract_audio_codes(mm: Mapping | dict[str, Any] | None) -> torch.Tensor | None: + """Read the talker acoustic codes, preferring nested ``codes.audio`` then + the single-stage ``audio_codes`` key.""" + if not isinstance(mm, Mapping): + return None + codes = mm.get("codes") + if isinstance(codes, Mapping): + audio = codes.get("audio") + if isinstance(audio, torch.Tensor): + return audio + audio = mm.get("audio_codes") + if isinstance(audio, torch.Tensor): + return audio + return None + + +def _empty_prompt(): + from vllm_omni.inputs.data import OmniTokensPrompt + + return OmniTokensPrompt( + prompt_token_ids=[0], + multi_modal_data=None, + mm_processor_kwargs=None, + additional_information=None, + ) diff --git a/tools/easymagpie_vllm_omni/pyproject.toml b/tools/easymagpie_vllm_omni/pyproject.toml new file mode 100644 index 000000000000..c6d4d8942c93 --- /dev/null +++ b/tools/easymagpie_vllm_omni/pyproject.toml @@ -0,0 +1,20 @@ +[build-system] +requires = ["setuptools>=64"] +build-backend = "setuptools.build_meta" + +[project] +name = "easymagpie-vllm-omni" +version = "0.1.0" +description = "vLLM-Omni model definition for EasyMagpieTTS (Nemotron-H hybrid-Mamba backbone + AR local transformer)" +requires-python = ">=3.10" +dependencies = [ + # The heavy runtime deps (vllm, vllm-omni, torch) are provided by the + # target vllm_omni_env. Treat this as "install into an already-bootstrapped + # vllm_omni_env"; do not install into NeMo's nemo_virtual_environment. +] + +[project.entry-points."vllm.general_plugins"] +easymagpie_omni = "vllm_plugin_easymagpie_omni:register" + +[tool.setuptools.packages.find] +include = ["easymagpie_vllm_omni*", "vllm_plugin_easymagpie_omni*"] diff --git a/tools/easymagpie_vllm_omni/requirements.txt b/tools/easymagpie_vllm_omni/requirements.txt new file mode 100644 index 000000000000..38e3130f8983 --- /dev/null +++ b/tools/easymagpie_vllm_omni/requirements.txt @@ -0,0 +1,10 @@ +# vLLM and vLLM-Omni must use the same major/minor version. +vllm==0.24.0 +vllm-omni==0.24.0 + +# Runtime dependencies used by EasyMagpie or the speech-serving endpoint. +transformers>=5.5.3 +safetensors>=0.8.0 +soundfile>=0.13.1 +numpy>=1.26 +PyYAML>=6.0 diff --git a/tools/easymagpie_vllm_omni/scripts/benchmark_incremental_server.py b/tools/easymagpie_vllm_omni/scripts/benchmark_incremental_server.py new file mode 100644 index 000000000000..aa3294750067 --- /dev/null +++ b/tools/easymagpie_vllm_omni/scripts/benchmark_incremental_server.py @@ -0,0 +1,377 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. 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. +"""Benchmark token-chunked EasyMagpieTTS through /v1/audio/speech/stream. + +The input text is tokenized once in the parent process. Each worker sends exact +EasyMagpie token IDs in configurable chunk sizes while concurrently receiving +binary PCM frames from the WebSocket. + +Examples: + python scripts/benchmark_incremental_server.py \ + --model ./converted_model --text-file vctk_subset.txt -n 100 -c 1 8 + python scripts/benchmark_incremental_server.py \ + --model ./converted_model --text-file vctk_subset.txt -n 100 \ + -c 1 8 --tokens-per-chunk 1 3 5 10 + python scripts/benchmark_incremental_server.py \ + --model ./converted_model --text-file vctk_subset.txt -n 50 \ + --tokens-per-chunk 5 --send-delay-ms 20 +""" +from __future__ import annotations + +import argparse +import asyncio +import json +import random +import time +from concurrent.futures import ProcessPoolExecutor +from dataclasses import dataclass +from pathlib import Path +from urllib.parse import urlsplit, urlunsplit + +import benchmark_server as base +import numpy as np +import websockets +from transformers import AutoTokenizer + + +@dataclass +class IncrementalRequestResult(base.RequestResult): + num_text_tokens: int = 0 + num_input_chunks: int = 0 + input_send_s: float = 0.0 + + +def _websocket_url(server_url: str) -> str: + parts = urlsplit(server_url) + if parts.scheme not in {"http", "https", "ws", "wss"}: + raise ValueError(f"Unsupported server URL scheme: {parts.scheme!r}") + scheme = {"http": "ws", "https": "wss"}.get(parts.scheme, parts.scheme) + path = f"{parts.path.rstrip('/')}/v1/audio/speech/stream" + return urlunsplit((scheme, parts.netloc, path, "", "")) + + +async def _do_request_async(task: dict) -> IncrementalRequestResult: + uttid = task["uttid"] + token_ids = task["token_ids"] + tokens_per_chunk = int(task["tokens_per_chunk"]) + token_chunks = [ + token_ids[index : index + tokens_per_chunk] for index in range(0, len(token_ids), tokens_per_chunk) + ] + t0 = time.perf_counter() + t_first: float | None = None + input_send_s = 0.0 + num_samples = 0 + sr = int(task["sample_rate"]) + trailing_byte = b"" + chunk_arrivals: list[float] = [] + chunk_durations: list[float] = [] + output_parts: list[np.ndarray] = [] + + try: + async with asyncio.timeout(float(task["timeout"])): + async with websockets.connect( + _websocket_url(task["url"]), + max_size=64 * 1024 * 1024, + close_timeout=5, + ) as websocket: + await websocket.send( + json.dumps( + { + "type": "session.config", + "voice": task["speaker_id"] or "eng", + "stream_audio": True, + "response_format": "pcm", + "max_new_tokens": task["max_new_tokens"], + } + ) + ) + + async def send_tokens() -> None: + nonlocal input_send_s + for chunk in token_chunks: + await websocket.send(json.dumps({"type": "input.tokens", "tokens": chunk})) + if task["send_delay_s"] > 0: + await asyncio.sleep(task["send_delay_s"]) + await websocket.send(json.dumps({"type": "input.done"})) + input_send_s = time.perf_counter() - t0 + + sender = asyncio.create_task(send_tokens()) + try: + while True: + message = await websocket.recv() + now = time.perf_counter() + if isinstance(message, bytes): + data = trailing_byte + message + even_bytes = len(data) - (len(data) % 2) + trailing_byte = data[even_bytes:] + if even_bytes == 0: + continue + if t_first is None: + t_first = now + pcm = np.frombuffer(data[:even_bytes], dtype=" IncrementalRequestResult: + """Run one WebSocket request inside a benchmark worker process.""" + return asyncio.run(_do_request_async(task)) + + +def _make_tasks( + items: list[tuple[str, str, list[int]]], + n: int, + *, + url: str, + speaker_id: str | None, + max_new_tokens: int, + sample_rate: int, + timeout: float, + output_dir: str | None, + tokens_per_chunk: int, + send_delay_s: float, +) -> list[dict]: + selected_items = random.sample(items, n) if n <= len(items) else random.choices(items, k=n) + tasks = [] + for uttid, _, token_ids in selected_items: + tasks.append( + { + "url": url, + "uttid": uttid, + "token_ids": token_ids, + "speaker_id": speaker_id, + "max_new_tokens": max_new_tokens, + "sample_rate": sample_rate, + "timeout": timeout, + "output_dir": output_dir, + "tokens_per_chunk": tokens_per_chunk, + "send_delay_s": send_delay_s, + } + ) + return tasks + + +def _run_level(tasks: list[dict], concurrency: int) -> tuple[list[IncrementalRequestResult], float]: + wall0 = time.perf_counter() + with ProcessPoolExecutor(max_workers=concurrency) as executor: + results = list(executor.map(_do_request, tasks)) + return results, time.perf_counter() - wall0 + + +def _summarize( + results: list[IncrementalRequestResult], + wall_s: float, + concurrency: int, + tokens_per_chunk: int, +) -> dict: + summary = base._summarize(results, wall_s, concurrency) + ok = [result for result in results if result.error is None] + input_send_ms = sorted(result.input_send_s * 1000.0 for result in ok) + post_input_ms = sorted((result.elapsed_s - result.input_send_s) * 1000.0 for result in ok) + total_tokens = sum(result.num_text_tokens for result in ok) + total_input_chunks = sum(result.num_input_chunks for result in ok) + summary.update( + { + "tokens_per_chunk": tokens_per_chunk, + "total_text_tokens": total_tokens, + "total_input_chunks": total_input_chunks, + "text_tokens_per_s": total_tokens / wall_s if wall_s else 0.0, + "input_chunks_per_s": total_input_chunks / wall_s if wall_s else 0.0, + "input_send_mean_ms": base._mean(input_send_ms), + "input_send_p95_ms": base._percentile(input_send_ms, 0.95), + "post_input_mean_ms": base._mean(post_input_ms), + "post_input_p95_ms": base._percentile(post_input_ms, 0.95), + } + ) + return summary + + +def _print_detailed(summary: dict) -> None: + print( + f"[tokens/chunk={summary['tokens_per_chunk']}, concurrency={summary['concurrency']}] " + f"{summary['ok']} ok / {summary['failed']} failed" + ) + print( + f" req/s {summary['tput']:.2f} | audio RTF {summary['rtf']:.2f}x " + f"(audio {summary['audio_s']:.0f}s / wall {summary['wall_s']:.2f}s)" + ) + print(f" ttfa mean {summary['ttfa_mean_ms']:.1f}ms p95 {summary['ttfa_p95_ms']:.1f}ms") + print(f" lat mean {summary['lat_mean_s']:.2f}s p95 {summary['lat_p95_s']:.2f}s") + print( + f" input sent mean {summary['input_send_mean_ms']:.1f}ms " + f"p95 {summary['input_send_p95_ms']:.1f}ms | " + f"after input.done mean {summary['post_input_mean_ms']:.1f}ms" + ) + print( + f" input rate {summary['text_tokens_per_s']:.1f} token/s | " + f"{summary['input_chunks_per_s']:.1f} chunks/s" + ) + print( + f" audio ITL mean {summary['itl_mean_ms']:.1f}ms p95 {summary['itl_p95_ms']:.1f}ms | " + f"underruns {summary['total_underruns']}/{summary['total_chunks']} " + f"({summary['underrun_pct']:.2f}%)" + ) + + +def _print_summary(summary: dict) -> None: + print( + f"tokens/chunk={summary['tokens_per_chunk']}, concurrency={summary['concurrency']}: " + f"req/s {summary['tput']:.2f}, ttfa {summary['ttfa_mean_ms']:.1f}ms, " + f"lat {summary['lat_mean_s']:.2f}s, rtf {summary['rtf']:.2f}x, " + f"input {summary['text_tokens_per_s']:.1f} token/s, " + f"{summary['ok']} ok / {summary['failed']} failed" + ) + + +def parse_args(): + parser = argparse.ArgumentParser( + description="Benchmark incremental EasyMagpieTTS WebSocket serving", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=__doc__, + ) + parser.add_argument("--model", required=True, help="Converted EasyMagpie model directory (for its tokenizer)") + parser.add_argument("--text-file", required=True, help="Path to file with '\\t' per line") + parser.add_argument("-n", "--num-requests", type=int, required=True, help="Requests per benchmark level") + parser.add_argument("-c", "--concurrency", type=int, nargs="+", default=[4], help="Concurrency levels") + parser.add_argument( + "--tokens-per-chunk", + type=int, + nargs="+", + default=[5], + help="Token chunk sizes to benchmark (default: %(default)s)", + ) + parser.add_argument("--send-delay-ms", type=float, default=0.0, help="Delay after every input chunk") + parser.add_argument("--url", default="http://localhost:8091", help="Server base URL (default: %(default)s)") + parser.add_argument("--speaker-id", default=None, help="Speaker id (default: server default)") + parser.add_argument("--max-new-tokens", type=int, default=1024) + parser.add_argument("--sample-rate", type=int, default=22050, help="Fallback raw PCM sample rate") + parser.add_argument("--timeout", type=float, default=300.0, help="Per-request timeout in seconds") + parser.add_argument("--no-warmup", action="store_true", help="Skip one warmup request per worker") + parser.add_argument("--output-dir", default=None, help="If set, save each generated waveform") + return parser.parse_args() + + +def main() -> None: + args = parse_args() + if args.num_requests < 1: + raise ValueError("--num-requests must be positive") + if any(value < 1 for value in args.concurrency): + raise ValueError("--concurrency values must be positive") + if any(value < 1 for value in args.tokens_per_chunk): + raise ValueError("--tokens-per-chunk values must be positive") + if args.send_delay_ms < 0: + raise ValueError("--send-delay-ms cannot be negative") + + text_items = base._load_items(args.text_file) + if not text_items: + raise ValueError(f"No usable lines found in {args.text_file}") + tokenizer = AutoTokenizer.from_pretrained(args.model, trust_remote_code=True) + items = [(uttid, text, tokenizer.encode(text, add_special_tokens=False)) for uttid, text in text_items] + + output_dir: str | None = None + if args.output_dir is not None: + Path(args.output_dir).mkdir(parents=True, exist_ok=True) + output_dir = args.output_dir + + print( + f"Loaded {len(items)} utterances; {args.num_requests} req/level; " + f"concurrency {args.concurrency}; tokens/chunk {args.tokens_per_chunk}; " + f"send delay {args.send_delay_ms:.1f}ms; url {args.url}" + ) + summaries = [] + for tokens_per_chunk in args.tokens_per_chunk: + for concurrency in args.concurrency: + common = { + "url": args.url, + "speaker_id": args.speaker_id, + "max_new_tokens": args.max_new_tokens, + "sample_rate": args.sample_rate, + "timeout": args.timeout, + "output_dir": output_dir, + "tokens_per_chunk": tokens_per_chunk, + "send_delay_s": args.send_delay_ms / 1000.0, + } + if not args.no_warmup: + warmup_tasks = _make_tasks(items, concurrency, **{**common, "output_dir": None}) + _run_level(warmup_tasks, concurrency) + + tasks = _make_tasks(items, args.num_requests, **common) + results, wall_s = _run_level(tasks, concurrency) + summary = _summarize(results, wall_s, concurrency, tokens_per_chunk) + summaries.append(summary) + _print_detailed(summary) + + print("\n=== Summary ===") + for summary in summaries: + _print_summary(summary) + + +if __name__ == "__main__": + main() diff --git a/tools/easymagpie_vllm_omni/scripts/benchmark_model.py b/tools/easymagpie_vllm_omni/scripts/benchmark_model.py new file mode 100644 index 000000000000..303b6256bd3b --- /dev/null +++ b/tools/easymagpie_vllm_omni/scripts/benchmark_model.py @@ -0,0 +1,931 @@ +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. 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. +"""Benchmark EasyMagpie LM via a single-stage AsyncOmni engine. + +Measures acoustic-token prediction only (no in-engine Code2Wav). Uses the +``easymagpie_lm`` pipeline so LM throughput can be tracked separately from +the two-stage codec path. + +Two input modes, selectable with ``--streaming``: + +* whole-text (default) — the full target text is handed to the engine up front. +* streaming-text — subword ids are pushed as the model decodes, ``--tokens-per-chunk`` + ids at a time (prefill chunk, then one ``StreamingInput`` per chunk carrying a + ``list[int]`` of ids with ``max_tokens == len(chunk)`` so the engine free-runs + that many frames off one message, then a free-running acoustic tail). + +Both run on the same engine config. Reports throughput, TTFT, ITL (mean + p95), +EOS hit rate and overall RTF (estimated from codec frame rate, not decoded audio). + +Usage: + python benchmark_model.py --model ./converted_model_multiturn --num-requests 50 + python benchmark_model.py --model ./converted_model_multiturn -n 50 --streaming + python benchmark_model.py --model ./converted_model_multiturn -n 50 --streaming --tokens-per-chunk 3 + python benchmark_model.py --model ./converted_model_multiturn -n 50 -c 1 4 8 +""" + +import os + +os.environ.setdefault("VLLM_WORKER_MULTIPROC_METHOD", "spawn") + +import argparse +import asyncio +import copy +import json +import logging +import tempfile +import time +import uuid +from collections.abc import Mapping +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Optional + +import numpy as np +import yaml + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") +logger = logging.getLogger(__name__) + +_SCRIPT_DIR = Path(__file__).resolve().parent +DEFAULT_DEPLOY_CONFIG = _SCRIPT_DIR.parent / "deploy" / "easymagpie_lm.yaml" + +# ── Hardcoded run settings ───────────────────────────────────────────────── +SPEAKER = "eng" +CONTEXT_TEXT = "[EN]" +LT_TEMPERATURE = 0.7 # audio (local-transformer) sampling temperature +LT_TOPK = 80 # audio sampling top-k +CODEC_FRAME_RATE = 25.0 # Hz, used to convert decoded frames -> audio seconds (RTF) +GPU_MEMORY_UTILIZATION = 0.5 +DISTRIBUTED_EXECUTOR_BACKEND = "uni" +ENFORCE_EAGER = False +DTYPE = "float16" +STAGE_INIT_TIMEOUT = 300 +# vLLM CUDA-graph capture strategy; None == vLLM default (FULL_AND_PIECEWISE). +CUDAGRAPH_MODE: Optional[str] = None + +DEFAULT_PROMPTS = [ + "Hello, welcome to the voice synthesis benchmark test.", + "She said she would be here by noon, but nobody showed up.", + "The quick brown fox jumps over the lazy dog near the riverbank.", + "I can't believe how beautiful the sunset looks from up here on the mountain.", + "Please remember to bring your identification documents to the appointment tomorrow morning.", + "Have you ever wondered what it would be like to travel through time and visit ancient civilizations?", + "The restaurant on the corner serves the best pasta I have ever tasted in my entire life.", + "After the meeting, we should discuss the quarterly results and plan for the next phase.", + "Learning a new language takes patience, practice, and a genuine curiosity about other cultures.", + "The train leaves at half past seven, so we need to arrive at the station before then.", +] + + +# --------------------------------------------------------------------------- +# Deploy config +# --------------------------------------------------------------------------- + + +def _build_deploy_config( + deploy_config: str, + max_num_seqs: int, + max_model_len: int, + max_num_batched_tokens: int, + max_new_tokens: int, + profile: bool, + torch_profiler_dir: str, + load_format: Optional[str], +) -> dict: + """Load the EasyMagpie LM deploy YAML and apply benchmark runtime overrides.""" + config_path = Path(deploy_config) + cfg = yaml.safe_load(config_path.read_text()) + if cfg.get("pipeline") != "easymagpie_lm": + raise ValueError(f"{config_path} must set pipeline: easymagpie_lm") + stages = cfg.get("stages", []) + if len(stages) != 1: + raise ValueError(f"{config_path} must define exactly one EasyMagpie LM stage") + + cfg = copy.deepcopy(cfg) + stage: dict[str, Any] = cfg["stages"][0] + stage.update( + { + "max_num_seqs": max_num_seqs, + "gpu_memory_utilization": GPU_MEMORY_UTILIZATION, + "enforce_eager": ENFORCE_EAGER, + "max_num_batched_tokens": max_num_batched_tokens, + "max_model_len": max_model_len, + } + ) + sampling = stage.setdefault("default_sampling_params", {}) + sampling.update({"max_tokens": max_new_tokens, "ignore_eos": True}) + if load_format is not None: + stage["load_format"] = load_format + if CUDAGRAPH_MODE is not None and not ENFORCE_EAGER: + stage["compilation_config"] = {"cudagraph_mode": CUDAGRAPH_MODE} + if profile: + stage["profiler_config"] = { + "profiler": "torch", + "torch_profiler_dir": os.path.abspath(torch_profiler_dir), + "torch_profiler_with_stack": True, + "torch_profiler_record_shapes": True, + } + + cfg["dtype"] = DTYPE + cfg["distributed_executor_backend"] = DISTRIBUTED_EXECUTOR_BACKEND + return cfg + + +def _write_temp_deploy_config(cfg: dict) -> str: + tmp = tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", prefix="easymagpie_bench_", delete=False) + yaml.dump(cfg, tmp, default_flow_style=False, sort_keys=False) + tmp.close() + return tmp.name + + +# --------------------------------------------------------------------------- +# Model metadata +# --------------------------------------------------------------------------- + + +@dataclass +class ModelMeta: + tokenizer: Any + speaker_embedding: Any # torch.Tensor (T_audio, embedding_dim); None in speaker_id mode + speaker_id: Optional[str] # known-speaker id (None => pass raw speaker_embedding) + prompt_len: int + audio_eos_id: int + speech_delay: int + frame_stacking_factor: int + text_prefill_num: int + stop_token_id: int # backbone token emitted at the audio-EOS frame + text_eos_id: int # appended to streamed subword ids + + +def _load_model_meta( + model_dir: str, + lim_prefill: Optional[int] = None, + speaker_id: str = SPEAKER, + use_spkr_emb: bool = False, +) -> ModelMeta: + import torch + from easymagpie_vllm_omni.config import EasyMagpieOmniArch + from easymagpie_vllm_omni.easymagpie import EasyMagpieTTSForConditionalGeneration + from transformers import AutoTokenizer + + model_path = Path(model_dir) + config = json.loads((model_path / "config.json").read_text()) + arch = EasyMagpieOmniArch.from_hf_config(type("Cfg", (), config)) + + use_id = not (use_spkr_emb or lim_prefill is not None) + tokenizer = AutoTokenizer.from_pretrained(model_dir, trust_remote_code=True) + + if use_id: + speaker_embedding = None + prompt_len = EasyMagpieTTSForConditionalGeneration.get_prompt_len( + speaker_id, + model_dir, + tokenize=lambda t: tokenizer.encode(t), + ) + else: + emb_path = model_path / "speaker_embeddings" / f"{speaker_id}.pt" + if not emb_path.exists(): + raise FileNotFoundError(f"Speaker embedding not found: {emb_path}") + loaded = torch.load(emb_path, map_location="cpu") + speaker_embedding = (loaded["speaker_encoding"] if isinstance(loaded, dict) else loaded).to(torch.float32) + if lim_prefill is not None: + orig_frames = int(speaker_embedding.shape[0]) + speaker_embedding = speaker_embedding[: max(1, int(lim_prefill))].contiguous() + logger.info("Limiting speaker-embedding prefill: %d -> %d frames", orig_frames, speaker_embedding.shape[0]) + prompt_len = EasyMagpieTTSForConditionalGeneration.estimate_prompt_len( + speaker_embedding, + tokenize=lambda t: tokenizer.encode(t), + context_text=CONTEXT_TEXT, + has_task_embedding=arch.num_task_embeddings > 0, + ) + + text_prefill_num = arch.text_prefill_num + prompt_len += text_prefill_num + + return ModelMeta( + tokenizer=tokenizer, + speaker_embedding=speaker_embedding, + speaker_id=speaker_id if use_id else None, + prompt_len=int(prompt_len), + audio_eos_id=int(arch.audio_eos_id), + speech_delay=int(getattr(arch, "streaming_speech_delay", 0) or 0), + frame_stacking_factor=int(arch.frame_stacking_factor), + text_prefill_num=text_prefill_num, + stop_token_id=EasyMagpieTTSForConditionalGeneration.audio_eos_stop_token_id(type("Cfg", (), config)), + text_eos_id=int(arch.resolved_text_eos_id(int(config.get("text_vocab_size", config.get("vocab_size", 0))))), + ) + + +def build_prompt(text: str, meta: ModelMeta) -> dict: + text_prefill_num = getattr(meta, "text_prefill_num", 0) + text_tokens = list(meta.tokenizer.encode(text, add_special_tokens=False)) + [meta.text_eos_id] + info: dict = { + "context_text": CONTEXT_TEXT, + "text_tokens": text_tokens, + "prefill_text_tokens": text_tokens[:text_prefill_num], + "text_prefill_num": text_prefill_num, + "temperature": LT_TEMPERATURE, + "top_k": LT_TOPK, + } + info.update(_speaker_info(meta)) + return {"prompt_token_ids": [0] * meta.prompt_len, "additional_information": info} + + +def _speaker_info(meta: ModelMeta) -> dict: + if meta.speaker_id is not None: + return {"speaker_id": meta.speaker_id} + return {"speaker_embedding": meta.speaker_embedding} + + +# --------------------------------------------------------------------------- +# Result dataclasses +# --------------------------------------------------------------------------- + + +@dataclass +class RequestResult: + success: bool = False + audio_s: float = 0.0 + generated_tokens: int = 0 + audio_frames: int = 0 + eos_reached: bool = False + finish_reason: Optional[str] = None + ttft_s: float = 0.0 + ttfa_s: float = 0.0 + inter_token_latencies: list = field(default_factory=list) + error: str = "" + request_index: int = -1 + text: str = "" + text_tokens: int = 0 + audio_codes: Any = field(default=None, repr=False) + + +# --------------------------------------------------------------------------- +# Inference +# --------------------------------------------------------------------------- + + +def _extract_request_output(stage_output): + return getattr(stage_output, "request_output", stage_output) + + +def _extract_step_audio_codes(stage_output): + """Find this step's audio-code payload as one ``[T, Q]`` delta tensor. + + The single-stage EasyMagpie LM emits its codes under the ``model_outputs`` key, + which vLLM-Omni remaps to the drainable ``audio`` modality; in DELTA mode + that key is drained every step, so each per-step snapshot carries only the + codes produced this step (a delta), NOT the cumulative sequence. Callers + accumulate these deltas in a list and concatenate once to reconstruct the + full ``[prompt_len + generated, Q]`` tensor — no per-step growth on the wire. + + The ``codes.audio`` / ``audio_codes`` keys are read only as a fallback for + non-drainable configs. + """ + import torch + + def reduce_payload(payload): + if isinstance(payload, list): + parts = [part for part in payload if isinstance(part, torch.Tensor) and part.numel() > 0] + return torch.cat(parts, dim=0) if parts else None + if isinstance(payload, torch.Tensor) and payload.numel() > 0: + return payload + return None + + def inspect_output(output): + if isinstance(output, (list, tuple)): + for item in reversed(output): + codes = inspect_output(item) + if codes is not None: + return codes + return None + + multimodal_output = getattr(output, "multimodal_output", None) + if isinstance(multimodal_output, Mapping): + # Drainable single-stage key (remapped from "model_outputs"), then + # the non-drainable inter-stage keys as a fallback. + payload = multimodal_output.get("audio") + if payload is None: + payload = multimodal_output.get("model_outputs") + if payload is None: + payload = multimodal_output.get("audio_codes") + if payload is None: + nested_codes = multimodal_output.get("codes") + if isinstance(nested_codes, Mapping): + payload = nested_codes.get("audio") + codes = reduce_payload(payload) + if codes is not None: + return codes + + request_output = getattr(output, "request_output", None) + if request_output is not None and request_output is not output: + codes = inspect_output(request_output) + if codes is not None: + return codes + for completion in getattr(output, "outputs", None) or []: + codes = inspect_output(completion) + if codes is not None: + return codes + return None + + codes = inspect_output(stage_output) + if codes is not None and codes.ndim == 2: + return codes + return None + + +class StepMeter: + """Cheap per-request measurement: TTFT, ITL, generated-frame count.""" + + def __init__(self, meta: ModelMeta, capture_audio_codes: bool = False): + self.meta = meta + self.capture_audio_codes = capture_audio_codes + self.result = RequestResult() + self.steps = 0 + self._t_start = time.perf_counter() + self._t_last = None + self._prev_tokens = 0 + self._finish_reason = None + remaining_delay = max(0, meta.speech_delay - getattr(meta, "text_prefill_num", 0)) + self._audio_overhead = 1 + remaining_delay + + # Per-step code deltas, concatenated once in finalize() (never per step, + # so measurement is not polluted by O(n^2) accumulation/serialization). + self._code_chunks: list = [] + + def observe(self, stage_output) -> None: + now = time.perf_counter() + ro = _extract_request_output(stage_output) + self.steps += 1 + + out0 = ro.outputs[0] if getattr(ro, "outputs", None) else None + if out0 is None: + return + fr = getattr(out0, "finish_reason", None) + if fr is not None: + self._finish_reason = fr + if self.capture_audio_codes: + audio_codes = _extract_step_audio_codes(stage_output) + if audio_codes is not None and audio_codes.numel() > 0: + self._code_chunks.append(audio_codes.detach().cpu()) + cum = getattr(out0, "cumulative_token_ids", None) + cur = len(cum) if cum is not None else self._prev_tokens + len(getattr(out0, "token_ids", []) or []) + if cur <= self._prev_tokens: + return + + if self.result.ttfa_s == 0.0 and cur > self._audio_overhead: + self.result.ttfa_s = now - self._t_start + if self._t_last is None: + self.result.ttft_s = now - self._t_start + else: + self.result.inter_token_latencies.append(now - self._t_last) + self._t_last = now + self._prev_tokens = cur + + def finalize(self) -> RequestResult: + e2e_s = time.perf_counter() - self._t_start + self.result.success = True + if self.result.ttft_s == 0.0 and self.steps > 0: + self.result.ttft_s = e2e_s + self.result.eos_reached = self._finish_reason == "stop" + self.result.finish_reason = self._finish_reason + self.result.generated_tokens = self._prev_tokens + audio_frames = max(0, self._prev_tokens - self._audio_overhead) + self.result.audio_frames = audio_frames + self.result.audio_s = audio_frames * self.meta.frame_stacking_factor / CODEC_FRAME_RATE + if self.capture_audio_codes and self._code_chunks: + import torch + + self.result.audio_codes = torch.cat(self._code_chunks, dim=0) + return self.result + + def mark_error(self, exc) -> RequestResult: + self.result.error = str(exc) + return self.result + + +async def run_one_request( + omni, + inputs, + sampling_params, + request_id: str, + meta: ModelMeta, + capture_audio_codes: bool = False, + output_observer=None, + max_steps: Optional[int] = None, +) -> RequestResult: + meter = StepMeter(meta, capture_audio_codes=capture_audio_codes) + gen = None + try: + gen = omni.generate(inputs, sampling_params_list=[sampling_params], request_id=request_id) + async for stage_output in gen: + meter.observe(stage_output) + if output_observer is not None: + output_observer(stage_output) + if max_steps is not None and meter.steps >= max_steps: + break + meter.finalize() + except Exception as exc: + meter.mark_error(exc) + logger.error("Request %s failed: %s", request_id, exc) + finally: + if gen is not None: + try: + await gen.aclose() + except Exception: + # Cleanup failure must not replace the request result already recorded above. + pass + return meter.result + + +def build_streaming_request(text: str, meta: ModelMeta, stream_params, max_new_tokens: int, tokens_per_chunk: int = 1): + from easymagpie_vllm_omni.serving_stream import EasyMagpieInputStream + + text_prefill_num = getattr(meta, "text_prefill_num", 0) + prefill_info = { + "context_text": CONTEXT_TEXT, + "temperature": LT_TEMPERATURE, + "top_k": LT_TOPK, + "text_prefill_num": text_prefill_num, + } + prefill_info.update(_speaker_info(meta)) + text_ids = list(meta.tokenizer.encode(text, add_special_tokens=False)) + n = max(1, int(tokens_per_chunk)) + if len(text_ids) < text_prefill_num: + raise ValueError(f"streaming benchmark text must contain at least {text_prefill_num} tokens") + first_chunk_size = max(n, text_prefill_num) + chunks = [text_ids[:first_chunk_size]] + chunks.extend(text_ids[i : i + n] for i in range(first_chunk_size, len(text_ids), n)) + stream = EasyMagpieInputStream( + prefill_prompt={"prompt_token_ids": [0] * meta.prompt_len, "additional_information": prefill_info}, + sampling_params=stream_params, + text_eos_id=meta.text_eos_id, + max_new_tokens=max_new_tokens, + text_prefill_num=text_prefill_num, + queue_depth=len(chunks) + 1, + coalesce_queued_tokens=False, + ) + + async def inputs(): + for chunk in chunks: + await stream.put_tokens(chunk) + await stream.finish() + async for engine_input in stream.inputs(): + yield engine_input + + return inputs(), stream.observe_output + + +# --------------------------------------------------------------------------- +# Worker / concurrency +# --------------------------------------------------------------------------- + + +async def worker( + worker_id: int, + omni, + texts: list, + text_token_counts: list, + meta: ModelMeta, + sampling_params, + stream_params, + streaming: bool, + capture_audio_codes: bool, + max_new_tokens: int, + tokens_per_chunk: int, + results: list, + counter: dict, + lock: asyncio.Lock, +): + while True: + async with lock: + if counter["remaining"] <= 0: + break + counter["remaining"] -= 1 + idx = counter["issued"] + counter["issued"] += 1 + + text = texts[idx % len(texts)] + request_id = f"bench-easymp-w{worker_id}-{uuid.uuid4().hex[:8]}" + + if streaming: + inputs, observe_output = build_streaming_request( + text, meta, stream_params, max_new_tokens, tokens_per_chunk + ) + result = await run_one_request( + omni, + inputs, + stream_params, + request_id, + meta, + capture_audio_codes=capture_audio_codes, + output_observer=observe_output, + max_steps=4 * max_new_tokens + 16, + ) + else: + result = await run_one_request( + omni, + build_prompt(text, meta), + sampling_params, + request_id, + meta, + capture_audio_codes=capture_audio_codes, + ) + result.request_index = idx + result.text = text + result.text_tokens = text_token_counts[idx % len(text_token_counts)] + + async with lock: + results.append(result) + done = len(results) + if done % 10 == 0 or done == counter["total"]: + logger.info(" progress: %d / %d", done, counter["total"]) + + +# --------------------------------------------------------------------------- +# Metrics +# --------------------------------------------------------------------------- + + +def compute_and_print_metrics( + results: list, duration: float, concurrency: int, print_request_stats: bool = False +) -> dict: + ok = [r for r in results if r.success] + failed = [r for r in results if not r.success] + + ttfts = [r.ttft_s * 1000 for r in ok] + ttfas = [r.ttfa_s * 1000 for r in ok if r.ttfa_s > 0.0] + itls = [t * 1000 for r in ok for t in r.inter_token_latencies] + total_audio_s = sum(r.audio_s for r in ok) + eos_hits = sum(1 for r in ok if r.eos_reached) + produced_frames = [r.audio_frames for r in ok] + + summary = { + "concurrency": concurrency, + "completed": len(ok), + "failed": len(failed), + "eos_hits": eos_hits, + "duration_s": duration, + "req_per_s": len(ok) / duration if duration > 0 else 0.0, + "ttft_mean_ms": float(np.mean(ttfts)) if ttfts else 0.0, + "ttft_p95_ms": float(np.percentile(ttfts, 95)) if ttfts else 0.0, + "ttfa_mean_ms": float(np.mean(ttfas)) if ttfas else 0.0, + "ttfa_p95_ms": float(np.percentile(ttfas, 95)) if ttfas else 0.0, + "itl_mean_ms": float(np.mean(itls)) if itls else 0.0, + "itl_p95_ms": float(np.percentile(itls, 95)) if itls else 0.0, + "rtf": total_audio_s / duration if duration > 0 else 0.0, + "frames_per_utterance": float(np.mean(produced_frames)) if produced_frames else 0.0, + } + + W = 48 + print(f"\n{'=' * W}") + print(f"{f'Benchmark (concurrency={concurrency})':^{W}}") + print(f"{'=' * W}") + if not ok: + print("ERROR: no requests completed successfully.") + if failed: + print(f" e.g. {failed[0].error[:200]}") + return summary + print(f"{'Requests (ok / failed):':<28}{summary['completed']} / {summary['failed']}") + print(f"{'Reached audio EOS:':<28}{eos_hits} / {summary['completed']}") + print(f"{'Duration (s):':<28}{duration:.2f}") + print(f"{'Throughput (req/s):':<28}{summary['req_per_s']:.2f}") + print(f"{'TTFT mean / p95 (ms):':<28}{summary['ttft_mean_ms']:.2f} / {summary['ttft_p95_ms']:.2f}") + print(f"{'ITL mean / p95 (ms):':<28}{summary['itl_mean_ms']:.2f} / {summary['itl_p95_ms']:.2f}") + print(f"{'TTFA mean / p95 (ms):':<28}{summary['ttfa_mean_ms']:.2f} / {summary['ttfa_p95_ms']:.2f}") + print(f"{'Produced frames / utterance:':<28}{summary['frames_per_utterance']:.1f}") + print(f"{'RTF (audio_s / wall):':<28}{summary['rtf']:.2f}x") + print(f"{'=' * W}\n") + if print_request_stats: + print("Per-request generation (one generated token = one stacked acoustic model frame):") + for r in sorted(ok, key=lambda item: item.request_index): + text_preview = r.text.replace("\n", " ") + if len(text_preview) > 72: + text_preview = text_preview[:69] + "..." + print( + f" [{r.request_index:04d}] text_tokens={r.text_tokens:3d} " + f"generated={r.generated_tokens:4d} audio_frames={r.audio_frames:4d} " + f"est_audio={r.audio_s:6.2f}s finish={r.finish_reason or 'unknown':>7} {text_preview}" + ) + print() + return summary + + +def save_audio_codes(results: list, output_dir: str, meta: ModelMeta, concurrency: int) -> None: + """Save raw and codec-ready acoustic codes without including disk I/O in benchmark time.""" + import torch + + level_dir = Path(output_dir) / f"concurrency_{concurrency}" + level_dir.mkdir(parents=True, exist_ok=True) + saved = 0 + for result in results: + raw_codes = result.audio_codes + if not isinstance(raw_codes, torch.Tensor) or raw_codes.ndim != 2: + logger.warning("No cumulative audio codes captured for request %d", result.request_index) + continue + + raw_codes = raw_codes.detach().to(device="cpu", dtype=torch.long).contiguous() + remaining_delay = max(0, meta.speech_delay - getattr(meta, "text_prefill_num", 0)) + start = min(raw_codes.shape[0], meta.prompt_len + remaining_delay) + end = raw_codes.shape[0] - (1 if result.eos_reached and raw_codes.shape[0] > start else 0) + acoustic_codes = raw_codes[start:end].contiguous() + path = level_dir / f"request_{result.request_index:04d}.pt" + torch.save( + { + "text": result.text, + "text_tokens": result.text_tokens, + "finish_reason": result.finish_reason, + "prompt_len": meta.prompt_len, + "speech_delay": meta.speech_delay, + "frame_stacking_factor": meta.frame_stacking_factor, + "codec_frame_rate": CODEC_FRAME_RATE, + "raw_codes": raw_codes, + "audio_codes": acoustic_codes, + }, + path, + ) + saved += 1 + logger.info("Saved audio codes for %d requests to %s", saved, level_dir) + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + + +async def _run_workers( + omni, texts, text_token_counts, meta, sampling_params, stream_params, args, n_requests, concurrency +): + results: list = [] + counter = {"remaining": n_requests, "issued": 0, "total": n_requests} + lock = asyncio.Lock() + tasks = [ + asyncio.create_task( + worker( + i, + omni, + texts, + text_token_counts, + meta, + sampling_params, + stream_params, + args.streaming, + bool(args.audio_codes_dir), + args.max_new_tokens, + args.tokens_per_chunk, + results, + counter, + lock, + ) + ) + for i in range(concurrency) + ] + await asyncio.gather(*tasks) + return results + + +async def main(args): + import vllm_plugin_easymagpie_omni + + vllm_plugin_easymagpie_omni.register() + + from vllm import SamplingParams + from vllm.sampling_params import RequestOutputKind + from vllm_omni import AsyncOmni + + if args.text_file: + path = Path(args.text_file) + if not path.exists(): + print(f"ERROR: text file not found: {path}") + return + texts = [] + for line in path.read_text().splitlines(): + line = line.strip() + if line: + texts.append(line.split("\t", 1)[1].strip() if "\t" in line else line) + texts = [t for t in texts if t] + else: + texts = DEFAULT_PROMPTS + if not texts: + print("ERROR: no texts available.") + return + logger.info("Loaded %d texts", len(texts)) + + meta = _load_model_meta( + args.model, lim_prefill=args.lim_prefill, speaker_id=args.speaker_id, use_spkr_emb=args.use_spkr_emb + ) + logger.info( + "Speaker mode: %s", + f"known speaker_id={meta.speaker_id!r}" if meta.speaker_id else "raw speaker_embedding tensor per request", + ) + logger.info( + "prompt_len=%d audio_eos_id=%d speech_delay=%d text_prefill=%d frame_stacking=%d", + meta.prompt_len, + meta.audio_eos_id, + meta.speech_delay, + meta.text_prefill_num, + meta.frame_stacking_factor, + ) + if meta.prompt_len + args.max_new_tokens > args.max_model_len: + logger.warning("prompt_len + max_new_tokens exceeds max_model_len (%d)", args.max_model_len) + if args.streaming: + logger.info("Mode: streaming-text tokens_per_chunk=%d", max(1, args.tokens_per_chunk)) + else: + logger.info("Mode: whole-text") + text_token_counts = [len(meta.tokenizer.encode(text, add_special_tokens=False)) for text in texts] + + deploy_cfg = _build_deploy_config( + deploy_config=args.deploy_config, + max_num_seqs=max(args.concurrency), + max_model_len=args.max_model_len, + max_num_batched_tokens=args.max_num_batched_tokens, + max_new_tokens=args.max_new_tokens, + profile=args.profile, + torch_profiler_dir=args.torch_profiler_dir, + load_format=args.load_format, + ) + tmp_config_path = _write_temp_deploy_config(deploy_cfg) + + is_dummy = args.load_format == "dummy" + stop_token_ids = [] if is_dummy else [meta.stop_token_id] + if is_dummy: + logger.info("Dummy weights: dropping stop token; every request runs exactly %d steps", args.max_new_tokens) + + sampling_params = SamplingParams( + temperature=0.0, + max_tokens=args.max_new_tokens, + detokenize=False, + ignore_eos=True, + stop_token_ids=stop_token_ids, + output_kind=RequestOutputKind.DELTA, + ) + stream_params = SamplingParams( + temperature=0.0, + max_tokens=1, + detokenize=False, + ignore_eos=True, + stop_token_ids=stop_token_ids, + output_kind=RequestOutputKind.DELTA, + ) + + try: + logger.info("Creating AsyncOmni engine for %s (pipeline=easymagpie_lm) ...", args.model) + omni = AsyncOmni( + model=args.model, + deploy_config=tmp_config_path, + log_stats=False, + stage_init_timeout=STAGE_INIT_TIMEOUT, + ) + logger.info("Engine ready.") + + summaries = [] + for concurrency in args.concurrency: + logger.info("=== concurrency=%d requests=%d ===", concurrency, args.num_requests) + + warmup_count = 0 if args.no_warmup else args.num_warmups * concurrency + if warmup_count > 0: + logger.info("Warming up with %d requests...", warmup_count) + await _run_workers( + omni, + texts, + text_token_counts, + meta, + sampling_params, + stream_params, + args, + warmup_count, + concurrency, + ) + + if args.profile: + await omni.start_profile(stages=[0]) + start = time.perf_counter() + try: + results = await _run_workers( + omni, + texts, + text_token_counts, + meta, + sampling_params, + stream_params, + args, + args.num_requests, + concurrency, + ) + finally: + if args.profile: + await omni.stop_profile(stages=[0]) + duration = time.perf_counter() - start + + summaries.append(compute_and_print_metrics(results, duration, concurrency, args.print_request_stats)) + + if args.audio_codes_dir: + save_audio_codes(results, args.audio_codes_dir, meta, concurrency) + + print(f"\n{'=' * 56}") + print(f"{'Summary':^56}") + print(f"{'=' * 56}") + for s in summaries: + print( + f"concurrency={s['concurrency']}: " + f"req/s {s['req_per_s']:.2f}, " + f"ttft {s['ttft_mean_ms']:.1f}ms, " + f"itl {s['itl_mean_ms']:.1f}ms, " + f"rtf {s['rtf']:.2f}x" + ) + print(f"{'=' * 56}\n") + + omni.shutdown() + finally: + os.unlink(tmp_config_path) + logger.info("Done.") + + +def parse_args(): + parser = argparse.ArgumentParser( + description="Benchmark EasyMagpie LM (acoustic tokens only) via AsyncOmni", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=__doc__, + ) + parser.add_argument( + "--model", type=str, default="./converted_model_multiturn", help="Converted EasyMagpie model dir" + ) + parser.add_argument( + "--deploy-config", + type=str, + default=str(DEFAULT_DEPLOY_CONFIG), + help="Base EasyMagpie LM deploy YAML; benchmark runtime values override capacity settings", + ) + parser.add_argument("--text-file", type=str, default=None, help="One utterance per line (optionally tab-sep)") + parser.add_argument( + "--print-request-stats", + action="store_true", + help="Print text-token count, generated frames, estimated audio duration, and finish reason per request", + ) + parser.add_argument( + "--audio-codes-dir", + type=str, + default=None, + help="Diagnostic: save raw and codec-ready acoustic-code tensors for every request as .pt files", + ) + parser.add_argument("--streaming", action="store_true", help="Benchmark the token-streamed input path") + parser.add_argument( + "--tokens-per-chunk", + type=int, + default=1, + help="Streaming only: number of subword ids to feed per StreamingInput chunk " + "(the engine free-runs that many frames off one message). Default: %(default)s.", + ) + parser.add_argument("-c", "--concurrency", type=int, nargs="+", default=[1], help="Concurrency levels to test") + parser.add_argument("-n", "--num-requests", type=int, default=50, help="Requests per concurrency level") + parser.add_argument("--num-warmups", type=int, default=3, help="Warmup rounds (total = concurrency * this)") + parser.add_argument("--no-warmup", action="store_true", help="Skip warmup") + parser.add_argument("--max-new-tokens", type=int, default=2048, help="Max decode frames per request") + parser.add_argument( + "--lim-prefill", + type=int, + default=None, + help="Cap the speaker-embedding prefill to the first N frames (default: no limit). " + "Use e.g. --lim-prefill 1 to mimic a single-token custom-voice prefill.", + ) + parser.add_argument( + "--speaker-id", + type=str, + default=SPEAKER, + help="Known speaker id (string) passed in the prompt; the model holds its embedding as " + "precomputed state (default: %(default)s).", + ) + parser.add_argument( + "--use-spkr-emb", + action="store_true", + help="Ship the raw speaker_embedding tensor per request instead of the known speaker_id " + "(exercises the custom-voice path).", + ) + parser.add_argument("--max-model-len", type=int, default=4096) + parser.add_argument("--max-num-batched-tokens", type=int, default=4096) + parser.add_argument( + "--load-format", + type=str, + default=None, + choices=["auto", "dummy", "safetensors", "pt"], + help="Weight loading strategy ('dummy' = random weights, skip checkpoint)", + ) + parser.add_argument("--profile", action="store_true", help="Enable torch profiler (with stack + shapes)") + parser.add_argument("--torch-profiler-dir", type=str, default="./profiler_traces", help="Profiler trace dir") + return parser.parse_args() + + +if __name__ == "__main__": + asyncio.run(main(parse_args())) diff --git a/tools/easymagpie_vllm_omni/scripts/benchmark_server.py b/tools/easymagpie_vllm_omni/scripts/benchmark_server.py new file mode 100644 index 000000000000..837a03928fd3 --- /dev/null +++ b/tools/easymagpie_vllm_omni/scripts/benchmark_server.py @@ -0,0 +1,396 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. 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. +"""Benchmark EasyMagpieTTS served by ``scripts/run_server.sh``. + +Sends requests from ``-c`` parallel processes against POST /v1/audio/speech and +reports throughput, time-to-first-audio (TTFA), and streaming playback metrics. + +Usage: + python benchmark_server.py --text-file vctk_subset.txt -n 100 -c 8 + python benchmark_server.py --text-file lines.txt -n 50 -c 1 4 8 --url http://localhost:8091 +""" +from __future__ import annotations + +import argparse +import random +import time +import wave +from concurrent.futures import ProcessPoolExecutor +from dataclasses import dataclass, field +from pathlib import Path + +import numpy as np +import requests + + +@dataclass +class RequestResult: + uttid: str + num_samples: int = 0 + sr: int = 22050 + elapsed_s: float = 0.0 + ttfa_s: float = 0.0 + error: str | None = None + chunk_arrivals: list[float] = field(default_factory=list) + chunk_durations: list[float] = field(default_factory=list) + + +def _save_wav(path: Path, audio: np.ndarray, sample_rate: int) -> None: + audio = np.clip(audio, -1.0, 1.0) + pcm = (audio * 32767.0).astype(np.int16) + with wave.open(str(path), "wb") as wf: + wf.setnchannels(1) + wf.setsampwidth(2) + wf.setframerate(sample_rate) + wf.writeframes(pcm.tobytes()) + + +def _do_request(task: dict) -> RequestResult: + """One streaming TTS request; runs inside a worker process.""" + url = task["url"] + uttid = task["uttid"] + payload = { + "input": task["text"], + "voice": task["speaker_id"] or "eng", + "response_format": "pcm", + "stream": True, + "stream_format": "audio", + "max_new_tokens": task["max_new_tokens"], + } + + t0 = time.perf_counter() + t_first: float | None = None + chunks: list[np.ndarray] = [] + chunk_arrivals: list[float] = [] + chunk_durations: list[float] = [] + num_samples = 0 + sr = int(task["sample_rate"]) + trailing_byte = b"" + try: + with requests.post(f"{url}/v1/audio/speech", json=payload, stream=True, timeout=task["timeout"]) as resp: + resp.raise_for_status() + for chunk in resp.iter_content(chunk_size=None): + if not chunk: + continue + now = time.perf_counter() + data = trailing_byte + chunk + even_bytes = len(data) - (len(data) % 2) + trailing_byte = data[even_bytes:] + if even_bytes == 0: + continue + if t_first is None: + t_first = now + pcm = np.frombuffer(data[:even_bytes], dtype=" 0: + _save_wav(Path(task["output_dir"]) / f"{uttid}.wav", np.concatenate(chunks), sr) + return RequestResult( + uttid=uttid, + num_samples=num_samples, + sr=sr, + elapsed_s=elapsed, + ttfa_s=ttfa, + chunk_arrivals=chunk_arrivals, + chunk_durations=chunk_durations, + ) + + +def _load_items(text_file: str) -> list[tuple[str, str]]: + items: list[tuple[str, str]] = [] + with open(text_file) as f: + for line in f: + line = line.rstrip("\n") + if not line.strip(): + continue + parts = line.split("\t", 1) + if len(parts) != 2: + raise ValueError(f"Expected '\\t' per line, got: {line!r}") + uttid, text = parts[0].strip(), parts[1].strip() + if not uttid or not text: + raise ValueError(f"Empty uttid or text in line: {line!r}") + items.append((uttid, text)) + return items + + +def _make_tasks(items, n, url, speaker_id, max_new_tokens, sample_rate, timeout, output_dir) -> list[dict]: + selected_items = random.sample(items, n) if n <= len(items) else random.choices(items, k=n) + tasks = [] + for uttid, text in selected_items: + tasks.append( + { + "url": url, + "uttid": uttid, + "text": text, + "speaker_id": speaker_id, + "max_new_tokens": max_new_tokens, + "sample_rate": sample_rate, + "timeout": timeout, + "output_dir": output_dir, + } + ) + return tasks + + +def _run_level(tasks: list[dict], concurrency: int, output_dir: str | None) -> tuple[list[RequestResult], float]: + for t in tasks: + t["output_dir"] = output_dir + wall0 = time.perf_counter() + with ProcessPoolExecutor(max_workers=concurrency) as ex: + results = list(ex.map(_do_request, tasks)) + return results, time.perf_counter() - wall0 + + +def _percentile(sorted_vals: list[float], pct: float) -> float: + if not sorted_vals: + return 0.0 + idx = min(len(sorted_vals) - 1, int(len(sorted_vals) * pct)) + return sorted_vals[idx] + + +def _mean(vals: list[float]) -> float: + return (sum(vals) / len(vals)) if vals else 0.0 + + +def _playback_metrics(arrivals: list[float], durations: list[float]) -> dict: + n = len(arrivals) + if n == 0: + return { + "chunks": 0, + "underruns": 0, + "deadline_misses": 0, + "gaps": [], + "chunk_rtfs": [], + "headrooms": [], + "transitions": [], + } + + underruns = 0 + deadline_misses = 0 + gaps: list[float] = [] + chunk_rtfs: list[float] = [] + headrooms: list[float] = [] + transitions: list[tuple[float, float, bool, bool]] = [] + playback_end = arrivals[0] + durations[0] + for i in range(1, n): + gap = arrivals[i] - arrivals[i - 1] + gaps.append(gap) + previous_audio = durations[i - 1] + headrooms.append(previous_audio - gap) + if gap > 0: + # The next chunk must arrive while the *previous* chunk is playing. + # Using durations[i] hid startup underruns when the first logical + # codec chunk was one frame and the second was a larger steady hop. + chunk_rtfs.append(previous_audio / gap) + missed_deadline = gap > previous_audio + if missed_deadline: + deadline_misses += 1 + cumulative_underrun = arrivals[i] > playback_end + if cumulative_underrun: + underruns += 1 + playback_end = arrivals[i] + transitions.append((previous_audio, gap, missed_deadline, cumulative_underrun)) + playback_end += durations[i] + return { + "chunks": n, + "underruns": underruns, + "deadline_misses": deadline_misses, + "gaps": gaps, + "chunk_rtfs": chunk_rtfs, + "headrooms": headrooms, + "transitions": transitions, + } + + +def _summarize(results: list[RequestResult], wall_s: float, concurrency: int) -> dict: + ok = [r for r in results if r.error is None] + failed = [r for r in results if r.error is not None] + sr = ok[0].sr if ok else 22050 + audio_s = sum(r.num_samples for r in ok) / sr if sr else 0.0 + ttfa_ms = sorted(r.ttfa_s * 1000.0 for r in ok) + lat_s = sorted(r.elapsed_s for r in ok) + + itl_ms: list[float] = [] + chunk_rtfs: list[float] = [] + headrooms_ms: list[float] = [] + total_chunks = 0 + total_underruns = 0 + total_deadline_misses = 0 + reqs_with_underrun = 0 + transition_buckets: dict[int, dict] = {} + for r in ok: + pm = _playback_metrics(r.chunk_arrivals, r.chunk_durations) + total_chunks += pm["chunks"] + total_underruns += pm["underruns"] + total_deadline_misses += pm["deadline_misses"] + if pm["underruns"] > 0: + reqs_with_underrun += 1 + itl_ms.extend(g * 1000.0 for g in pm["gaps"]) + chunk_rtfs.extend(pm["chunk_rtfs"]) + headrooms_ms.extend(value * 1000.0 for value in pm["headrooms"]) + for previous_audio, gap, missed_deadline, cumulative_underrun in pm["transitions"]: + duration_ms = int(round(previous_audio * 1000.0)) + bucket = transition_buckets.setdefault( + duration_ms, {"count": 0, "deadline_misses": 0, "underruns": 0, "gaps_ms": []} + ) + bucket["count"] += 1 + bucket["deadline_misses"] += int(missed_deadline) + bucket["underruns"] += int(cumulative_underrun) + bucket["gaps_ms"].append(gap * 1000.0) + itl_ms.sort() + headrooms_ms.sort() + transition_summary = [] + for duration_ms, bucket in sorted(transition_buckets.items()): + gaps_ms = sorted(bucket.pop("gaps_ms")) + transition_summary.append( + { + "duration_ms": duration_ms, + **bucket, + "gap_p95_ms": _percentile(gaps_ms, 0.95), + "gap_max_ms": gaps_ms[-1], + } + ) + + return { + "concurrency": concurrency, + "ok": len(ok), + "failed": len(failed), + "wall_s": wall_s, + "audio_s": audio_s, + "tput": len(ok) / wall_s if wall_s > 0 else 0.0, + "rtf": audio_s / wall_s if wall_s > 0 else 0.0, + "ttfa_mean_ms": _mean(ttfa_ms), + "ttfa_p95_ms": _percentile(ttfa_ms, 0.95), + "lat_mean_s": _mean(lat_s), + "lat_p95_s": _percentile(lat_s, 0.95), + "itl_mean_ms": _mean(itl_ms), + "itl_p95_ms": _percentile(itl_ms, 0.95), + "total_chunks": total_chunks, + "total_underruns": total_underruns, + "total_deadline_misses": total_deadline_misses, + "reqs_with_underrun": reqs_with_underrun, + "underrun_pct": (100.0 * total_underruns / total_chunks) if total_chunks else 0.0, + "playback_rtf_mean": _mean(chunk_rtfs), + "playback_headroom_mean_ms": _mean(headrooms_ms), + "playback_headroom_p05_ms": _percentile(headrooms_ms, 0.05), + "playback_transitions": transition_summary, + } + + +def _print_detailed(s: dict) -> None: + print(f"[concurrency={s['concurrency']}] {s['ok']} ok / {s['failed']} failed") + print(f" req/s {s['tput']:.2f} | rtf {s['rtf']:.2f}x (audio {s['audio_s']:.0f}s / wall {s['wall_s']:.2f}s)") + print(f" ttfa mean {s['ttfa_mean_ms']:.1f}ms p95 {s['ttfa_p95_ms']:.1f}ms") + print(f" lat mean {s['lat_mean_s']:.2f}s p95 {s['lat_p95_s']:.2f}s") + print(f" itl mean {s['itl_mean_ms']:.1f}ms p95 {s['itl_p95_ms']:.1f}ms") + print( + f" playback underruns {s['total_underruns']}/{s['total_chunks']} chunks " + f"({s['underrun_pct']:.2f}%) in {s['reqs_with_underrun']}/{s['ok']} reqs | " + f"deadlines missed {s['total_deadline_misses']} | " + f"realtime factor mean {s['playback_rtf_mean']:.2f}x (previous chunk play / fetch) | " + f"headroom mean {s['playback_headroom_mean_ms']:.1f}ms p05 {s['playback_headroom_p05_ms']:.1f}ms" + ) + for row in s["playback_transitions"]: + print( + f" after {row['duration_ms']:4d}ms audio: {row['count']:4d} gaps, " + f"{row['deadline_misses']:3d} deadlines / {row['underruns']:3d} underruns, " + f"gap p95 {row['gap_p95_ms']:.1f}ms max {row['gap_max_ms']:.1f}ms" + ) + + +def _print_summary(s: dict) -> None: + print( + f"concurrency={s['concurrency']}: req/s {s['tput']:.2f}, " + f"ttfa {s['ttfa_mean_ms']:.1f}ms, itl {s['itl_mean_ms']:.1f}ms, " + f"rtf {s['rtf']:.2f}x, underrun {s['underrun_pct']:.1f}%, " + f"{s['ok']} ok / {s['failed']} failed" + ) + + +def main() -> None: + parser = argparse.ArgumentParser(description="Benchmark the EasyMagpieTTS HTTP server") + parser.add_argument("--text-file", required=True, help="Path to file with '\\t' per line") + parser.add_argument("-n", "--num-requests", type=int, required=True, help="Requests per concurrency level") + parser.add_argument("-c", "--concurrency", type=int, nargs="+", default=[4], help="Concurrency (process) levels") + parser.add_argument("--url", default="http://localhost:8091", help="Server base URL (default: %(default)s)") + parser.add_argument("--speaker-id", default=None, help="Speaker id (default: server default)") + parser.add_argument("--max-new-tokens", type=int, default=1024) + parser.add_argument("--sample-rate", type=int, default=22050, help="Raw PCM sample rate (default: %(default)s)") + parser.add_argument("--timeout", type=float, default=300, help="Per-request timeout, s (default: 300)") + parser.add_argument("--no-warmup", action="store_true", help="Skip warmup phase (concurrency requests)") + parser.add_argument("--output-dir", default=None, help="If set, write each waveform to /.wav") + args = parser.parse_args() + + items = _load_items(args.text_file) + if not items: + print(f"ERROR: no usable lines found in {args.text_file}") + return + + output_dir: str | None = None + if args.output_dir is not None: + Path(args.output_dir).mkdir(parents=True, exist_ok=True) + output_dir = args.output_dir + + print( + f"Loaded {len(items)} utterances; {args.num_requests} req/level; concurrency {args.concurrency}; url {args.url}" + ) + + summaries = [] + for concurrency in args.concurrency: + if not args.no_warmup: + warmup = _make_tasks( + items, + concurrency, + args.url, + args.speaker_id, + args.max_new_tokens, + args.sample_rate, + args.timeout, + None, + ) + _run_level(warmup, concurrency, None) + + tasks = _make_tasks( + items, + args.num_requests, + args.url, + args.speaker_id, + args.max_new_tokens, + args.sample_rate, + args.timeout, + output_dir, + ) + results, wall = _run_level(tasks, concurrency, output_dir) + summary = _summarize(results, wall, concurrency) + summaries.append(summary) + _print_detailed(summary) + + print("\n=== Summary ===") + for s in summaries: + _print_summary(s) + + +if __name__ == "__main__": + main() diff --git a/tools/easymagpie_vllm_omni/scripts/convert_codec.py b/tools/easymagpie_vllm_omni/scripts/convert_codec.py new file mode 100644 index 000000000000..f9bddf4936dd --- /dev/null +++ b/tools/easymagpie_vllm_omni/scripts/convert_codec.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. 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. +"""Convert an EasyMagpie spectral codec ``.nemo`` to a native vLLM bundle.""" + +from __future__ import annotations + +import argparse +import shutil +import tarfile +import tempfile +from pathlib import Path + +import torch +import yaml +from easymagpie_vllm_omni.codec.config import EasyMagpieCodecConfig +from easymagpie_vllm_omni.codec.weight_conversion import convert_decoder_state_dict +from safetensors.torch import save_file + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("codec", type=Path, help="Input 25-fps spectral codec .nemo") + parser.add_argument("output", type=Path, help="Output Hugging Face/vLLM model directory") + parser.add_argument("--num-codebooks", type=int, default=8, help="EasyMagpie-side FSQ group count") + parser.add_argument("--frame-stacking-factor", type=int, default=2) + parser.add_argument( + "--num-levels-per-group", + type=int, + nargs="+", + default=[4, 4, 4, 4, 4], + help="EasyMagpie-side FSQ levels; their product is the codebook size", + ) + return parser.parse_args() + + +def _read_nemo(codec_path: Path) -> tuple[dict, dict[str, torch.Tensor]]: + with tarfile.open(codec_path) as archive: + config_member = archive.getmember("./model_config.yaml") + weights_member = archive.getmember("./model_weights.ckpt") + config_file = archive.extractfile(config_member) + weights_file = archive.extractfile(weights_member) + if config_file is None or weights_file is None: + raise RuntimeError(f"invalid NeMo archive: {codec_path}") + config = yaml.safe_load(config_file) + with tempfile.NamedTemporaryFile(suffix=".ckpt") as temporary: + shutil.copyfileobj(weights_file, temporary) + temporary.flush() + state = torch.load(temporary.name, map_location="cpu", weights_only=True) + return config, state + + +def validate_decoder_config(decoder_config: dict) -> None: + """Validate NeMo decoder features implemented by the native vLLM codec.""" + expected_target = "nemo.collections.tts.modules.audio_codec_modules.ResNetDecoder" + if decoder_config.get("_target_") != expected_target: + raise ValueError( + f"expected {expected_target}, got {decoder_config.get('_target_')}; " + "add a matching native decoder before converting this codec" + ) + if decoder_config.get("is_causal") is not True: + raise ValueError( + "only the causal spectral codec decoder is supported; set is_causal=true or add state handling" + ) + activation = str(decoder_config.get("activation", "half_snake")) + if activation != "half_snake": + raise ValueError( + "the native codec currently requires activation='half_snake'; implement the matching activation in " + f"packed.py to support '{activation}'" + ) + + +def restore_speech_decoder(decoder_config: dict, state: dict[str, torch.Tensor]) -> torch.nn.Module: + """Instantiate and strictly restore the canonical Speech decoder.""" + from hydra.utils import instantiate + + prefix = "audio_decoder." + decoder_state = {name.removeprefix(prefix): tensor for name, tensor in state.items() if name.startswith(prefix)} + decoder = instantiate(decoder_config) + decoder.load_state_dict(decoder_state, strict=True) + return decoder.eval() + + +def main() -> None: + args = parse_args() + nemo_config, state = _read_nemo(args.codec) + decoder_config = nemo_config["audio_decoder"] + validate_decoder_config(decoder_config) + + levels = list(args.num_levels_per_group) + codebook_size = 1 + for level in levels: + codebook_size *= level + config = EasyMagpieCodecConfig( + input_dim=int(decoder_config["input_dim"]), + input_filters=int(decoder_config["input_filters"]), + hidden_filters=int(decoder_config["hidden_filters"]), + num_hidden_layers=int(decoder_config["n_hidden_layers"]), + pre_upsample_rates=list(decoder_config["pre_up_sample_rates"]), + pre_upsample_filters=list(decoder_config["pre_up_sample_filters"]), + resblock_upsample_rates=list(decoder_config["resblock_up_sample_rates"]), + resblock_upsample_filters=list(decoder_config["resblock_up_sample_filters"]), + kernel_size=int(decoder_config.get("kernel_size", 3)), + resblock_kernel_size=int(decoder_config.get("resblock_up_sample_kernel_size", 7)), + activation=str(decoder_config.get("activation", "half_snake")), + num_codebooks=args.num_codebooks, + codebook_size=codebook_size, + num_levels_per_group=levels, + frame_stacking_factor=args.frame_stacking_factor, + output_sample_rate=int(nemo_config.get("output_sample_rate", nemo_config["sample_rate"])), + ) + + decoder = restore_speech_decoder(decoder_config, state) + converted = convert_decoder_state_dict(state) + parameter_count = sum(parameter.numel() for parameter in converted.values()) + del decoder + + args.output.mkdir(parents=True, exist_ok=True) + save_file(converted, args.output / "model.safetensors") + config.save_pretrained(args.output) + print( + f"Converted {len(converted)} tensors ({parameter_count:,} parameters) to {args.output}; " + f"{config.num_stacked_codebooks} stacked codebooks -> {config.samples_per_frame} samples/frame" + ) + + +if __name__ == "__main__": + main() diff --git a/tools/easymagpie_vllm_omni/scripts/convert_to_vllm.py b/tools/easymagpie_vllm_omni/scripts/convert_to_vllm.py new file mode 100644 index 000000000000..11398bca6c07 --- /dev/null +++ b/tools/easymagpie_vllm_omni/scripts/convert_to_vllm.py @@ -0,0 +1,540 @@ +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. 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. +"""Convert an EasyMagpieTTS ``.nemo`` checkpoint to a vLLM-Omni model directory. + +The output directory is self-contained and ready to be passed as ``model=`` +to the ``easymagpie_vllm_omni`` vLLM-Omni model +(:class:`easymagpie_vllm_omni.easymagpie.EasyMagpieTTSForConditionalGeneration`). +It contains: + +* ``config.json`` — the flat HF-style config the vLLM model reads at + construction (the Nemotron-H backbone fields + the EasyMagpie scalars consumed + by :class:`easymagpie_vllm_omni.config.EasyMagpieOmniArch`). +* ``model.safetensors`` (+ ``model.safetensors.index.json``) — the converted + weights using the reference EasyMagpieTTS key layout expected by the vLLM + model's ``load_weights`` (``decoder.*`` backbone + top-level TTS submodules). +* the checkpoint's **text-conditioning tokenizer** saved via + ``AutoTokenizer.save_pretrained`` so the model can tokenize per-request + ``context_text`` in-engine. +* ``speaker_embeddings/.pt`` (optional) — pre-computed speaker-encoder + outputs for one or more reference audio files, used as the ``speaker_embedding`` + input at inference time. +* ``codec_native/`` (optional, on by default) — the causal audio codec converted + to a stateful vLLM model for the in-engine second stage. Disable codec conversion + with ``--no-bundle-codec`` when serving the EasyMagpie LM pipeline. + +Compared to running the reference model, the character-aware subword (CAS) +encoder is collapsed into a single pre-computed lookup table mapping +``subword_id -> embedding`` (the CAS encoder is fully deterministic per subword +id, so it is baked once at conversion time and never run inside the engine). The +``decoder``'s unused token-embedding table is replaced by a tiny dummy (the +backbone is always fed via ``inputs_embeds``). + +Example:: + + python tools/easymagpie_vllm_omni/scripts/convert_to_vllm.py \\ + --nemo_file /path/to/EMTTS_SmallMamba.nemo \\ + --codec_model_path /path/to/25fps_spectral_codec.nemo \\ + --outdir ./easymagpie_vllm_model \\ + --context_audio /path/to/reference_voice.wav --speaker_name eng +""" +from __future__ import annotations + +import argparse +import json +import logging +import os +import subprocess +import sys + +import torch +import tqdm +from omegaconf import OmegaConf +from safetensors.torch import save_file + + +# Top-level checkpoint key prefixes the vLLM model's ``load_weights`` consumes +# for the TTS submodules (everything else under these names maps 1:1 into the +# vLLM model). ``text_embedding.*`` is intentionally excluded here: it is +# replaced by the pre-computed per-subword lookup table. +_TTS_PREFIXES = ( + "audio_embeddings.", + "audio_in_projection.", + "local_transformer.", + "local_transformer_in_projection.", + "local_transformer_audio_out_projection.", + "local_transformer_out_projections.", + "phoneme_embeddings.", + "phoneme_final_proj.", + "task_embedding.", +) + +# The backbone token-embedding table is never consumed at runtime (the model +# runs off ``inputs_embeds``), so we ship a dummy table. It must still be >= 2: +# vLLM's profiling ``_dummy_sampler_run`` sets ``top_k = vocab_size - 1`` and then +# gathers at index ``vocab_size - top_k``, which is out of bounds for a width-1 +# logits tensor (device-side "scatter gather index out of bounds" assert). +_BACKBONE_VOCAB_SIZE = 2 + +# Nemotron-H backbone config fields forwarded into the flat vLLM ``config.json``. +# Names match the HF/vLLM Nemotron-H config (and the NeMo ``NemotronHConfig``). +_NEMOTRON_CONFIG_FIELDS = ( + "hidden_size", + "num_hidden_layers", + "num_attention_heads", + "num_key_value_heads", + "head_dim", + "attention_dropout", + "attention_bias", + "max_position_embeddings", + "mamba_num_heads", + "mamba_head_dim", + "ssm_state_size", + "conv_kernel", + "n_groups", + "chunk_size", + "mamba_hidden_act", + "use_conv_bias", + "use_bias", + "intermediate_size", + "mlp_hidden_act", + "mlp_bias", + "n_routed_experts", + "num_experts_per_tok", + "moe_intermediate_size", + "moe_shared_expert_intermediate_size", + "n_group", + "topk_group", + "routed_scaling_factor", + "norm_topk_prob", + "hybrid_override_pattern", + "layer_norm_epsilon", + "residual_in_fp32", +) + + +def parse_args(): + parser = argparse.ArgumentParser( + description="Convert an EasyMagpieTTS .nemo checkpoint to a vLLM-Omni model directory." + ) + parser.add_argument("--nemo_file", required=True, help="Path to the EasyMagpieTTS .nemo checkpoint.") + parser.add_argument("--codec_model_path", required=True, help="Path to the audio codec .nemo checkpoint.") + parser.add_argument("--outdir", required=True, help="Output directory for the vLLM model.") + parser.add_argument( + "--phoneme_tokenizer_path", + default=None, + help="Override the phoneme (IPA BPE) tokenizer path baked into the checkpoint.", + ) + parser.add_argument( + "--disable_cas_for_context_text", + action="store_true", + help="Set for legacy checkpoints trained without CAS embeddings on context text.", + ) + parser.add_argument( + "--text_tokenizer", + default=None, + help="HuggingFace tokenizer name/path to export. Defaults to the checkpoint's " + "text-conditioning AutoTokenizer (`pretrained_model`).", + ) + parser.add_argument( + "--context_audio", + default=None, + help="Optional reference wav for which to pre-compute a speaker embedding.", + ) + parser.add_argument( + "--speaker_name", + default="default", + help="Name for the saved speaker embedding (speaker_embeddings/.pt).", + ) + parser.add_argument( + "--bundle_codec", + action=argparse.BooleanOptionalAction, + default=True, + help="Bundle the codec into the model dir so the in-engine two-stage native codec " + "can decode without an external codec service. Use " + "--no-bundle-codec to serve EasyMagpie LM only.", + ) + parser.add_argument("--context_audio_duration", type=float, default=5.0) + parser.add_argument( + "--dtype", + default="float32", + choices=["bfloat16", "float16", "float32"], + help="Saved weight dtype / config torch_dtype. bf16 matches the reference inference setup.", + ) + parser.add_argument( + "--precompute_batch_size", + type=int, + default=1024, + help="Batch size for pre-computing per-subword text embeddings.", + ) + parser.add_argument("--device", default="cuda") + return parser.parse_args() + + +@torch.no_grad() +def precompute_text_embeddings(model, batch_size: int) -> torch.Tensor: + """Bake the per-subword text embedding into a single lookup table. + + Runs ``embed_text_tokens`` (decoder subword embedding + the deterministic + char-aware subword encoder) once per subword id so the vLLM model can replace + the whole text-embedding path with a single ``nn.Embedding`` lookup. + + Returns: + Tensor of shape ``[vocab_size, embedding_dim]`` (float32). + """ + device = next(model.parameters()).device + + # Vocabulary size of the subword id space (decoder text-embedding table when + # present; otherwise the CAS-only id range). Multiturn checkpoints append an + # interruption token after CFG_UNK, so cfg_unk_token_id is not always last. + if getattr(model, "text_embedding", None) is not None: + vocab_size = model.text_embedding.num_embeddings + else: + last_special_id = max( + int(model.cfg_unk_token_id), + int(getattr(model, "interruption_token_id", model.cfg_unk_token_id)), + ) + vocab_size = last_special_id + 1 + embedding_dim = int(model.cfg.embedding_dim) + + table = torch.zeros((vocab_size, embedding_dim), dtype=torch.float32, device=device) + logging.info(f"Pre-computing text embeddings for {vocab_size} subword ids on {device}") + for start in tqdm.tqdm(range(0, vocab_size, batch_size), desc="Pre-computing text embeddings"): + end = min(start + batch_size, vocab_size) + ids = torch.arange(start, end, dtype=torch.long, device=device).unsqueeze(0) # (1, n) + lens = torch.tensor([end - start], dtype=torch.long, device=device) + embeds = model.embed_text_tokens(ids, text_lens=lens, disable_cas_embedding=False) # (1, n, E) + table[start:end] = embeds.squeeze(0).to(torch.float32) + return table.cpu() + + +@torch.no_grad() +def extract_speaker_embedding(model, context_audio_path: str, context_audio_duration: float) -> torch.Tensor: + """Reproduce the audio branch of ``prepare_context_tensors`` for one wav. + + Mirrors ``easy_magpietts_extract_speaker_encoding.py``: encode the (trimmed) + reference audio to codec codes, add special tokens, frame-stack, embed the + per-codebook tokens, and (when enabled) run the speaker encoder. Returns the + ``(T_audio, embedding_dim)`` tensor consumed as the model's ``speaker_embedding``. + """ + from nemo.collections.tts.modules.magpietts_modules import add_special_tokens + + device = next(model.parameters()).device + + context_audio = model._load_audio_for_inference(context_audio_path, model.sample_rate) + context_audio = model._adjust_audio_to_duration_for_inference( + context_audio, + model.sample_rate, + context_audio_duration, + model.codec_model_samples_per_frame, + ) + context_audio = context_audio.to(device) + context_audio_lens = torch.tensor([context_audio.size(1)], dtype=torch.long, device=device) + context_audio_codes, context_audio_codes_lens = model._codec_helper.audio_to_codes( + context_audio, context_audio_lens + ) + + if model._codec_converter is not None: + context_audio_codes = model._codec_converter.convert_original_to_new( + audio_tokens=context_audio_codes, audio_lens=context_audio_codes_lens + ).long() + + context_audio_codes, context_audio_codes_lens = add_special_tokens( + codes=context_audio_codes, + codes_len=context_audio_codes_lens, + bos_id=model.context_audio_bos_id, + eos_id=model.context_audio_eos_id, + ) + context_audio_codes, context_audio_codes_lens = model.stack_codes( + context_audio_codes, + context_audio_codes_lens, + model.context_audio_bos_id, + model.context_audio_eos_id, + model.frame_stacking_factor, + model.num_audio_codebooks, + ) + + context_audio_embedded = model.embed_audio_tokens(context_audio_codes) # (B, T_audio, E) + if getattr(model, "use_speaker_encoder", False): + context_audio_embedded = model.encode_context_audio_embeddings( + context_audio_embedded=context_audio_embedded, + context_audio_lens=context_audio_codes_lens, + ) + else: + logging.warning( + "Checkpoint has use_speaker_encoder=False; saving raw per-codebook audio embeddings " + "(no speaker encoder applied)." + ) + + audio_len = int(context_audio_codes_lens[0].item()) + return context_audio_embedded[0, :audio_len].contiguous().float().detach().cpu() + + +def validate_model_config(model) -> None: + """Validate checkpoint features implemented by the vLLM serving model.""" + cfg = model.cfg + decoder_type = str(cfg.get("decoder_type", "huggingface")) + if decoder_type != "nemotron_h": + raise ValueError( + "The easymagpie_vllm_omni model only supports a Nemotron-H backbone " + f"(decoder_type='nemotron_h'); got '{decoder_type}'. Add a vLLM backbone adapter to support it." + ) + + hidden_dim = int(cfg.hidden_dim) + embedding_dim = int(cfg.embedding_dim) + if hidden_dim != embedding_dim: + raise ValueError( + "hidden_dim must equal embedding_dim because the current vLLM model has no projection between them; " + f"got hidden_dim={hidden_dim}, embedding_dim={embedding_dim}. Add the projection to support this checkpoint." + ) + backbone_hidden_dim = int(cfg.get("nemotron_h_config", {}).get("hidden_size", embedding_dim)) + if backbone_hidden_dim != embedding_dim: + raise ValueError( + "nemotron_h_config.hidden_size must equal embedding_dim because the backbone consumes inputs_embeds " + f"directly; got {backbone_hidden_dim} and {embedding_dim}. Add an input projection to support this layout." + ) + + local_transformer_type = str(cfg.get("local_transformer_type", "none")) + if local_transformer_type != "ar": + raise ValueError( + "The serving code currently requires local_transformer_type='ar'; extend EasyMagpieCodePredictor " + f"to support '{local_transformer_type}'." + ) + + default_mode = model.mode_name_to_mode.get(model.default_inference_mode) + if default_mode is None: + raise ValueError(f"default inference mode '{model.default_inference_mode}' is missing from mode_name_to_mode") + if default_mode.text_input_mode != "streaming": + raise ValueError( + "The serving code currently requires text_input_mode='streaming' for the default inference mode; " + f"got '{default_mode.text_input_mode}'. Implement full-text conditioning to support this checkpoint." + ) + + +def build_config(model, vocab_size: int, torch_dtype: str) -> dict: + """Build the flat vLLM ``config.json`` dict from the loaded NeMo model.""" + from nemo.collections.tts.modules.nemotron_h_decoder import NemotronHConfig + + validate_model_config(model) + cfg = model.cfg + + hidden_dim = int(cfg.hidden_dim) + embedding_dim = int(cfg.embedding_dim) + + # Resolve the backbone config exactly as NeMo does (fills head_dim, expands + # the hybrid pattern to num_hidden_layers, etc.). + nemotron_dict = dict(OmegaConf.to_container(cfg.nemotron_h_config, resolve=True)) + nemotron_dict.setdefault("hidden_size", embedding_dim) + nemotron_cfg = NemotronHConfig(**nemotron_dict) + + config: dict = {"architectures": ["EasyMagpieTTSForConditionalGeneration"], "model_type": "nemotron_h"} + for field in _NEMOTRON_CONFIG_FIELDS: + if hasattr(nemotron_cfg, field): + config[field] = getattr(nemotron_cfg, field) + config["tie_word_embeddings"] = False + config["torch_dtype"] = torch_dtype + # The backbone token-embedding table is never consumed (inputs_embeds path); + # the dummy logits width follows it. Must be >= 2 (see ``_BACKBONE_VOCAB_SIZE``). + # The text path is driven by ``text_vocab_size`` / the baked ``text_embedding`` + # table instead. + config["vocab_size"] = _BACKBONE_VOCAB_SIZE + + # ── EasyMagpie scalars (read by EasyMagpieOmniArch.from_hf_config) ── + config["text_vocab_size"] = vocab_size + config["text_eos_id"] = int(model.eos_id) + config["use_multiturn_dataset"] = bool(cfg.get("use_multiturn_dataset", False)) + if hasattr(model, "interruption_token_id"): + config["text_interruption_id"] = int(model.interruption_token_id) + config["embedding_dim"] = embedding_dim + config["audio_embedding_dim"] = int(cfg.get("audio_embedding_dim", hidden_dim)) + config["num_audio_codebooks"] = int(model.num_audio_codebooks) + config["codebook_size"] = int(model.codebook_size) + config["frame_stacking_factor"] = int(model.frame_stacking_factor) + + has_phoneme = getattr(model, "phoneme_tokenizer", None) is not None + config["phoneme_stacking_factor"] = int(getattr(model, "phoneme_stacking_factor", 0)) if has_phoneme else 0 + config["phoneme_vocab_size"] = int(getattr(model, "phoneme_vocab_size", 0)) if has_phoneme else 0 + if has_phoneme: + # Phoneme special-token ids + the confidence→UNK replacement threshold, + # consumed by the in-engine phoneme stream (BOS seeding, EOS-stop, UNK). + config["phoneme_bos_id"] = int(model.phoneme_tokenizer.bos_token_id) + config["phoneme_eos_id"] = int(model.phoneme_tokenizer.eos_token_id) + unk_id = getattr(model.phoneme_tokenizer, "unk_token_id", None) + if unk_id is not None: + config["phoneme_unk_id"] = int(unk_id) + config["phoneme_confidence_unk_threshold"] = float(getattr(model, "phoneme_confidence_unk_threshold", 0.0)) + + # ── Streaming delays from the default inference mode ── + # The reference offsets the text/phoneme/audio streams by these per-mode + # delays; the vLLM model reproduces them in its decode step. A 0/0 (or "full") + # mode runs the three streams in lock-step. + default_mode = model.mode_name_to_mode.get(model.default_inference_mode) + config["streaming_phonemes_delay"] = int(default_mode.streaming_phonemes_delay) + config["streaming_speech_delay"] = int(default_mode.streaming_speech_delay) + + config["num_task_embeddings"] = len(model.training_modes) if model.task_embedding is not None else 0 + + config["local_transformer_n_layers"] = int(cfg.get("local_transformer_n_layers", 2)) + config["local_transformer_n_heads"] = int(cfg.get("local_transformer_n_heads", 1)) + config["local_transformer_hidden_dim"] = int(cfg.get("local_transformer_hidden_dim", hidden_dim)) + + # Pin the exact special-token ids (covers legacy ``forced_*`` checkpoints). + config["forced_audio_bos_id"] = int(model.audio_bos_id) + config["forced_audio_eos_id"] = int(model.audio_eos_id) + config["forced_mask_token_id"] = int(model.mask_token_id) + + return config + + +def select_weights(state_dict: dict, hidden_dim: int, dtype: torch.dtype) -> dict: + """Select + rename checkpoint weights into the vLLM ``load_weights`` layout.""" + weights: dict = {} + + # Backbone: keep all ``decoder.*`` except the unused token-embedding table. + for key, value in state_dict.items(): + if not key.startswith("decoder."): + continue + if key == "decoder.embeddings.weight": + continue + if key.endswith(".causal_mask"): + continue + weights[key] = value.to(dtype) if value.is_floating_point() else value + + # Dummy backbone embeddings (size ``_BACKBONE_VOCAB_SIZE``) — never consumed + # at runtime; sized to match ``config.vocab_size``. + weights["decoder.embeddings.weight"] = torch.zeros(_BACKBONE_VOCAB_SIZE, hidden_dim, dtype=dtype) + + # TTS submodules copied 1:1. + for key, value in state_dict.items(): + if key.endswith(".causal_mask"): + continue + if any(key.startswith(prefix) for prefix in _TTS_PREFIXES): + weights[key] = value.to(dtype) if value.is_floating_point() else value + + return weights + + +def bundle_native_codec(codec_model_path: str, outdir: str) -> None: + """Convert the causal codec to the stateful vLLM model subdirectory.""" + script_dir = os.path.dirname(os.path.abspath(__file__)) + project_dir = os.path.abspath(os.path.join(script_dir, "..")) + converter = os.path.join(script_dir, "convert_codec.py") + output = os.path.join(outdir, "codec_native") + env = os.environ.copy() + existing_pythonpath = env.get("PYTHONPATH") + env["PYTHONPATH"] = project_dir + (os.pathsep + existing_pythonpath if existing_pythonpath else "") + logging.info("Converting native stateful codec %s -> %s", codec_model_path, output) + subprocess.run( + [ + sys.executable, + converter, + codec_model_path, + output, + ], + check=True, + env=env, + ) + + +def save_text_tokenizer(model, outdir: str, override: str | None) -> None: + """Export the checkpoint's text-conditioning tokenizer into ``outdir``.""" + from transformers import AutoTokenizer + + pretrained = override + if pretrained is None: + tok_name = model.text_conditioning_tokenizer_name + tok_cfg = model.cfg.text_tokenizers[tok_name] + if tok_cfg.get("_target_", None) != "AutoTokenizer" or tok_cfg.get("pretrained_model", None) is None: + raise ValueError( + "Could not infer the text-conditioning AutoTokenizer from the checkpoint config. " + "Pass --text_tokenizer explicitly." + ) + pretrained = tok_cfg.pretrained_model + + logging.info(f"Saving text tokenizer '{pretrained}' to {outdir}") + AutoTokenizer.from_pretrained(pretrained, trust_remote_code=True).save_pretrained(outdir) + + +def convert(args) -> None: + from nemo.collections.tts.modules.magpietts_inference.utils import ModelLoadConfig, load_easy_magpie_model + + os.makedirs(args.outdir, exist_ok=True) + dtype = {"bfloat16": torch.bfloat16, "float16": torch.float16, "float32": torch.float32}[args.dtype] + + model, ckpt_name = load_easy_magpie_model( + ModelLoadConfig( + nemo_file=args.nemo_file, + codecmodel_path=args.codec_model_path, + phoneme_tokenizer_path=args.phoneme_tokenizer_path, + disable_cas_for_context_text=args.disable_cas_for_context_text, + ), + device=args.device, + ) + logging.info(f"Loaded EasyMagpieTTS checkpoint: {ckpt_name}") + + hidden_dim = int(model.cfg.hidden_dim) + + # ── 1. Pre-compute the per-subword text embedding table ────────────── + text_table = precompute_text_embeddings(model, args.precompute_batch_size) + vocab_size = int(text_table.shape[0]) + + # ── 2. config.json ─────────────────────────────────────────────────── + config = build_config(model, vocab_size, args.dtype) + if args.bundle_codec: + bundle_native_codec(args.codec_model_path, args.outdir) + with open(os.path.join(args.outdir, "config.json"), "w") as f: + json.dump(config, f, indent=2) + logging.info("Saved config.json") + + # ── 3. weights ─────────────────────────────────────────────────────── + state_dict = model.state_dict() + weights = select_weights(state_dict, hidden_dim, dtype) + weights["text_embedding.weight"] = text_table.to(dtype) + + safetensors_path = os.path.join(args.outdir, "model.safetensors") + save_file(weights, safetensors_path, metadata={"format": "pt"}) + index = { + "metadata": {"total_size": sum(w.numel() * w.element_size() for w in weights.values())}, + "weight_map": {name: "model.safetensors" for name in weights}, + } + with open(os.path.join(args.outdir, "model.safetensors.index.json"), "w") as f: + json.dump(index, f, indent=2) + logging.info(f"Saved {len(weights)} weights to {safetensors_path}") + + # ── 4. text tokenizer ──────────────────────────────────────────────── + save_text_tokenizer(model, args.outdir, args.text_tokenizer) + + # ── 5. optional speaker embedding ──────────────────────────────────── + if args.context_audio is not None: + speaker_dir = os.path.join(args.outdir, "speaker_embeddings") + os.makedirs(speaker_dir, exist_ok=True) + speaker_encoding = extract_speaker_embedding(model, args.context_audio, args.context_audio_duration) + out_path = os.path.join(speaker_dir, f"{args.speaker_name}.pt") + torch.save( + { + "speaker_encoding": speaker_encoding, + "context_audio": args.context_audio, + "embedding_dim": int(speaker_encoding.size(-1)), + "num_frames": int(speaker_encoding.size(0)), + "checkpoint": ckpt_name, + }, + out_path, + ) + logging.info(f"Saved speaker embedding '{args.speaker_name}' {tuple(speaker_encoding.shape)} to {out_path}") + + logging.info(f"Done. vLLM model directory: {args.outdir}") + + +if __name__ == "__main__": + convert(parse_args()) diff --git a/tools/easymagpie_vllm_omni/scripts/offline_demo.ipynb b/tools/easymagpie_vllm_omni/scripts/offline_demo.ipynb new file mode 100644 index 000000000000..f54d172d3921 --- /dev/null +++ b/tools/easymagpie_vllm_omni/scripts/offline_demo.ipynb @@ -0,0 +1,154 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# EasyMagpieTTS — offline two-stage synthesis\n", + "\n", + "Runs EasyMagpie LM → SpectralCodec-BWE-22kHz in a single `AsyncOmni` engine (no external codec service)\n", + "and writes a `.wav` file.\n", + "\n", + "**Prerequisites:** vLLM / vLLM-Omni 0.24+, GPU, and this package installed:\n", + "`pip install -e tools/easymagpie_vllm_omni`\n", + "\n", + "Point `MODEL_DIR` at a converted model with bundled codec under `codec/`.\n", + "The sample `converted_model_multiturn/` in this directory works out of the box." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "os.environ.setdefault(\"VLLM_WORKER_MULTIPROC_METHOD\", \"spawn\")\n", + "\n", + "import json\n", + "from pathlib import Path\n", + "\n", + "import vllm_plugin_easymagpie_omni\n", + "vllm_plugin_easymagpie_omni.register()\n", + "\n", + "from vllm import SamplingParams\n", + "from vllm.sampling_params import RequestOutputKind\n", + "from vllm_omni import AsyncOmni\n", + "from transformers import AutoTokenizer\n", + "\n", + "from easymagpie_vllm_omni.audio_output import extract_audio_from_stage_output\n", + "from easymagpie_vllm_omni.config import EasyMagpieOmniArch\n", + "from easymagpie_vllm_omni.easymagpie import EasyMagpieTTSForConditionalGeneration\n", + "\n", + "ROOT = Path(\"..\").resolve()\n", + "MODEL_DIR = str(ROOT / \"converted_model\")\n", + "DEPLOY_CONFIG = str(ROOT / \"deploy\" / \"easymagpie.yaml\")\n", + "TEXT = \"Hello, welcome to the text-to-speech demo.\"\n", + "SPEAKER_ID = \"eng\"\n", + "OUT_WAV = \"out.wav\"\n", + "MAX_NEW_TOKENS = 1024" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "def load_meta(model_dir: str, speaker_id: str):\n", + " config = json.loads((Path(model_dir) / \"config.json\").read_text())\n", + " arch = EasyMagpieOmniArch.from_hf_config(type(\"Cfg\", (), config))\n", + " tokenizer = AutoTokenizer.from_pretrained(model_dir, trust_remote_code=True)\n", + " prompt_len = EasyMagpieTTSForConditionalGeneration.get_prompt_len(\n", + " speaker_id, model_dir, tokenize=lambda t: tokenizer.encode(t)\n", + " )\n", + " stop_token_id = EasyMagpieTTSForConditionalGeneration.audio_eos_stop_token_id(type(\"Cfg\", (), config))\n", + " return {\n", + " \"prompt_len\": int(prompt_len),\n", + " \"stop_token_id\": int(stop_token_id),\n", + " }\n", + "\n", + "\n", + "def build_prompt(text: str, prompt_len: int, speaker_id: str) -> dict:\n", + " return {\n", + " \"prompt_token_ids\": [0] * prompt_len,\n", + " \"additional_information\": {\n", + " \"context_text\": \"[EN]\",\n", + " \"text\": text,\n", + " \"temperature\": 0.7,\n", + " \"top_k\": 80,\n", + " \"speaker_id\": speaker_id,\n", + " },\n", + " }\n", + "\n", + "\n", + "async def synthesize_one(omni, text: str, meta: dict, speaker_id: str, max_new_tokens: int):\n", + " lm_sp = SamplingParams(\n", + " temperature=0.0,\n", + " max_tokens=max_new_tokens,\n", + " detokenize=False,\n", + " ignore_eos=False,\n", + " stop_token_ids=[meta[\"stop_token_id\"]],\n", + " output_kind=RequestOutputKind.DELTA,\n", + " )\n", + " code2wav_sp = SamplingParams(temperature=0.0, max_tokens=max_new_tokens, detokenize=True)\n", + " prompt = build_prompt(text, meta[\"prompt_len\"], speaker_id)\n", + " audio = None\n", + " gen = omni.generate(\n", + " prompt,\n", + " sampling_params_list=[lm_sp, code2wav_sp],\n", + " request_id=f\"easymp-{abs(hash(text)) & 0xFFFF:x}\",\n", + " )\n", + " async for stage_output in gen:\n", + " extracted = extract_audio_from_stage_output(stage_output)\n", + " if extracted is not None:\n", + " audio = extracted\n", + " return audio" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import asyncio\n", + "import soundfile as sf\n", + "from IPython.display import Audio, display\n", + "\n", + "meta = load_meta(MODEL_DIR, SPEAKER_ID)\n", + "print(f\"model={MODEL_DIR} prompt_len={meta['prompt_len']} stop={meta['stop_token_id']}\")\n", + "\n", + "omni = AsyncOmni(model=MODEL_DIR, deploy_config=DEPLOY_CONFIG, log_stats=False)\n", + "try:\n", + " wav, sr = await synthesize_one(omni, TEXT, meta, SPEAKER_ID, MAX_NEW_TOKENS)\n", + " sf.write(OUT_WAV, wav, sr)\n", + " print(f\"Wrote {OUT_WAV} ({len(wav)/sr:.2f}s @ {sr} Hz)\")\n", + " display(Audio(wav, rate=sr))\n", + "finally:\n", + " omni.shutdown()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "easymagpie-vllm", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.13" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tools/easymagpie_vllm_omni/scripts/run_server.sh b/tools/easymagpie_vllm_omni/scripts/run_server.sh new file mode 100755 index 000000000000..3cb1366bbd1f --- /dev/null +++ b/tools/easymagpie_vllm_omni/scripts/run_server.sh @@ -0,0 +1,21 @@ +#!/bin/bash +# Requires vLLM / vLLM-Omni 0.24+. +set -e + +MODEL="${1:?Usage: run_server.sh [port]}" +PORT="${2:-8091}" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DEPLOY_CONFIG="${EASYMAGPIE_DEPLOY_CONFIG:-${SCRIPT_DIR}/../deploy/easymagpie.yaml}" + +echo "Starting EasyMagpieTTS: model=${MODEL} deploy=${DEPLOY_CONFIG} port=${PORT}" + +VLLM_PLUGINS=easymagpie_omni vllm serve "$MODEL" \ + --deploy-config "$DEPLOY_CONFIG" \ + --host 0.0.0.0 \ + --port "$PORT" \ + --trust-remote-code \ + --disable-log-stats \ + --disable-uvicorn-access-log \ + --uvicorn-log-level warning \ + --omni diff --git a/tools/easymagpie_vllm_omni/scripts/server_request.ipynb b/tools/easymagpie_vllm_omni/scripts/server_request.ipynb new file mode 100644 index 000000000000..640d81ddf036 --- /dev/null +++ b/tools/easymagpie_vllm_omni/scripts/server_request.ipynb @@ -0,0 +1,249 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# EasyMagpieTTS — HTTP and incremental WebSocket requests\n", + "\n", + "This notebook demonstrates both serving APIs exposed by the same `vllm serve` process:\n", + "\n", + "1. `POST /v1/audio/speech` for a complete text input.\n", + "2. `WS /v1/audio/speech/stream` for incremental EasyMagpie token-ID chunks and asynchronous audio output.\n", + "\n", + "Start the server first from the EasyMagpie example directory:\n", + "\n", + "```bash\n", + "bash ./scripts/run_server.sh ./converted_model 8091\n", + "```\n", + "\n", + "Both examples request raw PCM (`response_format=\"pcm\"`): mono 16-bit little-endian at 22050 Hz." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9f5d2730", + "metadata": {}, + "outputs": [], + "source": [ + "import asyncio\n", + "import json\n", + "import time\n", + "from pathlib import Path\n", + "\n", + "import numpy as np\n", + "import requests\n", + "import websockets\n", + "from IPython.display import Audio, display\n", + "from transformers import AutoTokenizer\n", + "\n", + "SERVER_URL = \"http://localhost:8091\"\n", + "SPEECH_ENDPOINT = f\"{SERVER_URL}/v1/audio/speech\"\n", + "STREAM_ENDPOINT = \"ws://localhost:8091/v1/audio/speech/stream\"\n", + "MODEL_DIR = Path(\"../converted_model\")\n", + "if not MODEL_DIR.exists():\n", + " MODEL_DIR = Path(\"./converted_model\")\n", + "SAMPLE_RATE = 22050\n", + "\n", + "print(\"health:\", requests.get(f\"{SERVER_URL}/health\", timeout=5).status_code)\n", + "print(\"model:\", MODEL_DIR.resolve())" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "723f4918", + "metadata": {}, + "outputs": [], + "source": [ + "def synthesize_http(\n", + " text: str,\n", + " speaker_id: str = \"eng\",\n", + " max_new_tokens: int = 1024,\n", + " timeout: float = 300.0,\n", + ") -> tuple[np.ndarray, int, float]:\n", + " \"\"\"Stream raw PCM from POST /v1/audio/speech; return (audio, sr, ttfa_s).\"\"\"\n", + " payload = {\n", + " \"input\": text,\n", + " \"voice\": speaker_id,\n", + " \"response_format\": \"pcm\",\n", + " \"stream\": True,\n", + " \"stream_format\": \"audio\",\n", + " \"max_new_tokens\": max_new_tokens,\n", + " }\n", + " t0 = time.perf_counter()\n", + " t_first = None\n", + " chunks: list[np.ndarray] = []\n", + " trailing = b\"\"\n", + " with requests.post(SPEECH_ENDPOINT, json=payload, stream=True, timeout=timeout) as resp:\n", + " resp.raise_for_status()\n", + " for chunk in resp.iter_content(chunk_size=None):\n", + " if not chunk:\n", + " continue\n", + " data = trailing + chunk\n", + " even = len(data) - (len(data) % 2)\n", + " trailing = data[even:]\n", + " if even == 0:\n", + " continue\n", + " if t_first is None:\n", + " t_first = time.perf_counter()\n", + " pcm = np.frombuffer(data[:even], dtype=\" tuple[np.ndarray, int, float]:\n", + " \"\"\"Send token-ID chunks over WebSocket while receiving PCM asynchronously.\"\"\"\n", + " tokenizer = AutoTokenizer.from_pretrained(MODEL_DIR, trust_remote_code=True)\n", + " token_ids = tokenizer.encode(text, add_special_tokens=False)\n", + " token_chunks = [\n", + " token_ids[index : index + tokens_per_chunk]\n", + " for index in range(0, len(token_ids), tokens_per_chunk)\n", + " ]\n", + "\n", + " pcm_parts: list[bytes] = []\n", + " sample_rate = SAMPLE_RATE\n", + " t0 = time.perf_counter()\n", + " t_first = None\n", + "\n", + " async with websockets.connect(STREAM_ENDPOINT, max_size=64 * 1024 * 1024) as websocket:\n", + " await websocket.send(\n", + " json.dumps(\n", + " {\n", + " \"type\": \"session.config\",\n", + " \"voice\": speaker_id,\n", + " \"stream_audio\": True,\n", + " \"response_format\": \"pcm\",\n", + " \"max_new_tokens\": max_new_tokens,\n", + " }\n", + " )\n", + " )\n", + "\n", + " async def send_tokens() -> None:\n", + " for chunk in token_chunks:\n", + " await websocket.send(json.dumps({\"type\": \"input.tokens\", \"tokens\": chunk}))\n", + " await websocket.send(json.dumps({\"type\": \"input.done\"}))\n", + "\n", + " sender = asyncio.create_task(send_tokens())\n", + " try:\n", + " while True:\n", + " message = await websocket.recv()\n", + " if isinstance(message, bytes):\n", + " if t_first is None:\n", + " t_first = time.perf_counter()\n", + " pcm_parts.append(message)\n", + " continue\n", + "\n", + " event = json.loads(message)\n", + " event_type = event.get(\"type\")\n", + " if event_type == \"audio.start\":\n", + " sample_rate = int(event.get(\"sample_rate\", sample_rate))\n", + " elif event_type == \"audio.done\":\n", + " print(\n", + " \"server metrics:\",\n", + " f\"talker_frames={event.get('talker_frames')}\",\n", + " f\"text_tokens={event.get('text_tokens')}\",\n", + " f\"audio_bytes={event.get('total_bytes')}\",\n", + " )\n", + " elif event_type == \"error\":\n", + " raise RuntimeError(event[\"message\"])\n", + " elif event_type == \"session.done\":\n", + " break\n", + " finally:\n", + " await sender\n", + "\n", + " if not pcm_parts:\n", + " raise RuntimeError(\"empty audio response\")\n", + " pcm = b\"\".join(pcm_parts)\n", + " audio = np.frombuffer(pcm, dtype=\" ModelShape: + with (model_dir / "config.json").open() as config_file: + config = json.load(config_file) + + global_heads = int(config["mamba_num_heads"]) + global_groups = int(config.get("n_groups", 1)) + if global_heads % tensor_parallel_size or global_groups % tensor_parallel_size: + raise ValueError( + "mamba_num_heads and n_groups must be divisible by tensor parallel size; " + f"got heads={global_heads}, groups={global_groups}, tp={tensor_parallel_size}" + ) + return ModelShape( + nheads=global_heads // tensor_parallel_size, + headdim=int(config["mamba_head_dim"]), + dstate=int(config["ssm_state_size"]), + ngroups=global_groups // tensor_parallel_size, + ) + + +def _torch_dtype(name: str) -> torch.dtype: + try: + dtype = getattr(torch, name) + except AttributeError as exc: + raise ValueError(f"Unknown torch dtype: {name}") from exc + if not isinstance(dtype, torch.dtype): + raise ValueError(f"Not a torch dtype: {name}") + return dtype + + +def _inputs(batch: int, shape: ModelShape, model_dtype: torch.dtype, cache_dtype: torch.dtype) -> dict: + device = torch.device("cuda") + nheads, headdim, dstate, ngroups = shape + + # Match MambaMixer2's decode path, including stride-0 broadcasts for A, + # dt, D, and dt_bias and indexed reads/writes into the state cache. + state = torch.randn(batch, nheads, headdim, dstate, device=device, dtype=cache_dtype) + x = torch.randn(batch, nheads, headdim, device=device, dtype=model_dtype) + dt = torch.randn(batch, nheads, device=device, dtype=model_dtype)[..., None].expand(-1, -1, headdim) + a = -torch.rand(nheads, device=device, dtype=torch.float32) + A = a[:, None, None].expand(-1, headdim, dstate) + B = torch.randn(batch, ngroups, dstate, device=device, dtype=model_dtype) + C = torch.randn(batch, ngroups, dstate, device=device, dtype=model_dtype) + D = torch.randn(nheads, device=device, dtype=model_dtype)[:, None].expand(-1, headdim) + dt_bias = torch.randn(nheads, device=device, dtype=model_dtype)[:, None].expand(-1, headdim) + state_indices = torch.arange(batch, device=device, dtype=torch.int32) + return { + "state": state, + "x": x, + "dt": dt, + "A": A, + "B": B, + "C": C, + "D": D, + "dt_bias": dt_bias, + "dt_softplus": True, + "state_batch_indices": state_indices, + "dst_state_batch_indices": state_indices, + "out": torch.empty_like(x), + } + + +def _run(inputs: dict, config: tuple[int, int]) -> None: + with override_ssm_config(config): + selective_state_update(**inputs) + + +def _time_ms(inputs: dict, config: tuple[int, int], warmup: int, repeats: int, samples: int) -> float: + for _ in range(warmup): + _run(inputs, config) + torch.cuda.synchronize() + + timings = [] + for _ in range(samples): + start = torch.cuda.Event(enable_timing=True) + end = torch.cuda.Event(enable_timing=True) + start.record() + for _ in range(repeats): + _run(inputs, config) + end.record() + end.synchronize() + timings.append(start.elapsed_time(end) / repeats) + return statistics.median(timings) + + +def _validate(inputs: dict, config: tuple[int, int], reference: tuple[int, int] = (4, 4)) -> None: + reference_inputs = { + key: value.clone() if isinstance(value, torch.Tensor) else value for key, value in inputs.items() + } + candidate_inputs = { + key: value.clone() if isinstance(value, torch.Tensor) else value for key, value in inputs.items() + } + _run(reference_inputs, reference) + _run(candidate_inputs, config) + torch.testing.assert_close(candidate_inputs["out"], reference_inputs["out"], rtol=2e-3, atol=2e-3) + torch.testing.assert_close(candidate_inputs["state"], reference_inputs["state"], rtol=2e-3, atol=2e-3) + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--model", type=Path, default=Path("converted_model")) + parser.add_argument("--tensor-parallel-size", type=int, default=1) + parser.add_argument("--batch-sizes", type=int, nargs="+", default=list(range(1, 33))) + parser.add_argument("--model-dtype", default="float16") + parser.add_argument("--cache-dtype", default="float32") + parser.add_argument("--warmup", type=int, default=5) + parser.add_argument("--repeats", type=int, default=100) + parser.add_argument("--samples", type=int, default=5) + parser.add_argument( + "--output-dir", + type=Path, + default=None, + help="Defaults to VLLM_TUNED_CONFIG_FOLDER from setenv.sh.", + ) + return parser.parse_args() + + +def main() -> None: + args = _parse_args() + if not torch.cuda.is_available(): + raise RuntimeError("CUDA is required to tune the Mamba SSU kernel") + if args.tensor_parallel_size <= 0 or args.warmup < 0 or args.repeats <= 0 or args.samples <= 0: + raise ValueError("tensor parallel size and repeats must be positive; warmup must be non-negative") + if not args.batch_sizes or any(batch <= 0 for batch in args.batch_sizes): + raise ValueError("batch sizes must be positive") + + output_dir = args.output_dir + if output_dir is None: + configured = os.environ.get("VLLM_TUNED_CONFIG_FOLDER") + if not configured: + raise ValueError("Set VLLM_TUNED_CONFIG_FOLDER (source setenv.sh) or pass --output-dir") + output_dir = Path(configured) + + shape = _model_shape(args.model, args.tensor_parallel_size) + model_dtype = _torch_dtype(args.model_dtype) + cache_dtype = _torch_dtype(args.cache_dtype) + cache_dtype_name = str(cache_dtype).removeprefix("torch.") + candidates = [(block_size, num_warps) for block_size in (2, 4, 8, 16, 32, 64) for num_warps in (1, 2, 4)] + device_name = get_ssm_device_name() + print( + f"GPU={device_name} shape={shape} model_dtype={model_dtype} cache_dtype={cache_dtype}; " + f"tuning batches {min(args.batch_sizes)}..{max(args.batch_sizes)}" + ) + + results: dict[str, dict[str, int]] = {"triton_version": triton.__version__} + for batch in sorted(set(args.batch_sizes)): + inputs = _inputs(batch, shape, model_dtype, cache_dtype) + timings: list[tuple[float, tuple[int, int]]] = [] + for candidate in candidates: + try: + timings.append((_time_ms(inputs, candidate, args.warmup, args.repeats, args.samples), candidate)) + except Exception as exc: # A candidate may exceed a device resource limit. + print(f"batch={batch:2d} config={candidate}: skipped ({exc})") + if not timings: + raise RuntimeError(f"No valid SSU launch configuration for batch={batch}") + best_ms, best = min(timings) + _validate(inputs, best) + default_ms = next((elapsed for elapsed, config in timings if config == (4, 4)), float("nan")) + effective_batch = batch * shape.nheads + results[str(effective_batch)] = {"BLOCK_SIZE_M": best[0], "num_warps": best[1]} + print( + f"batch={batch:2d} effective_batch={effective_batch:4d}: " + f"BLOCK_SIZE_M={best[0]:2d} warps={best[1]} {best_ms * 1000:7.2f}us " + f"(default {default_ms * 1000:7.2f}us, {default_ms / best_ms:5.2f}x)" + ) + + output_dir.mkdir(parents=True, exist_ok=True) + filename = get_ssm_config_file_name(shape.headdim, shape.dstate, cache_dtype_name, device_name) + output_path = output_dir / filename + temporary_path = output_path.with_suffix(".json.tmp") + temporary_path.write_text(json.dumps(results, indent=4) + "\n") + temporary_path.replace(output_path) + print(f"Wrote {output_path}") + print("Restart vLLM-Omni so every stage-0 worker loads the tuned configuration.") + + +if __name__ == "__main__": + main() diff --git a/tools/easymagpie_vllm_omni/vllm_plugin_easymagpie_omni/__init__.py b/tools/easymagpie_vllm_omni/vllm_plugin_easymagpie_omni/__init__.py new file mode 100644 index 000000000000..193da2222064 --- /dev/null +++ b/tools/easymagpie_vllm_omni/vllm_plugin_easymagpie_omni/__init__.py @@ -0,0 +1,80 @@ +# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. 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. +"""Register EasyMagpieTTS models and pipeline with vLLM-Omni.""" + +_TARGET = "easymagpie_vllm_omni.easymagpie:EasyMagpieTTSForConditionalGeneration" +_ARCHS = ("EasyMagpieTTS", "EasyMagpieTTSForConditionalGeneration") +_CODEC_ARCH = "EasyMagpieCodecForConditionalGeneration" +_CODEC_TARGET = "easymagpie_vllm_omni.codec.model:EasyMagpieCodecForConditionalGeneration" + + +def register() -> None: + """Register model architectures in both vLLM registries.""" + from easymagpie_vllm_omni.codec.config import EasyMagpieCodecConfig + from transformers import AutoConfig + from vllm import ModelRegistry + from vllm.model_executor.models.config import MODELS_CONFIG_MAP, MambaModelConfig + + try: + AutoConfig.register(EasyMagpieCodecConfig.model_type, EasyMagpieCodecConfig) + except ValueError: + # Plugin reloads may encounter the same model type already registered. + pass + MODELS_CONFIG_MAP.setdefault(_CODEC_ARCH, MambaModelConfig) + + registries = [ModelRegistry] + omni_available = False + try: + from vllm_omni.model_executor.models import OmniModelRegistry + + registries.append(OmniModelRegistry) + omni_available = True + except Exception: + # vllm_omni not installed — stock vLLM registration is enough. + pass + + for registry in registries: + for arch in _ARCHS: + if arch not in registry.get_supported_archs(): + registry.register_model(arch, _TARGET) + if _CODEC_ARCH not in registry.get_supported_archs(): + registry.register_model(_CODEC_ARCH, _CODEC_TARGET) + + if omni_available: + _register_pipeline() + _register_serving_adapter() + + +def _register_serving_adapter() -> None: + """Install optional ``/v1/audio/speech`` support.""" + import logging + + try: + from easymagpie_vllm_omni.serving_adapter import apply_serving_patches + + apply_serving_patches() + except Exception: # pragma: no cover - serving support is best-effort + logging.getLogger(__name__).exception( + "EasyMagpie: /v1/audio/speech serving support could not be installed " + "(model + pipeline registration still succeeded)." + ) + + +def _register_pipeline() -> None: + """Register the EasyMagpieTTS and standalone EasyMagpie LM pipelines.""" + from easymagpie_vllm_omni.pipeline import EASYMAGPIE_LM_PIPELINE, EASYMAGPIE_PIPELINE + from vllm_omni.config.pipeline_registry import register_pipeline + + register_pipeline(EASYMAGPIE_PIPELINE) + register_pipeline(EASYMAGPIE_LM_PIPELINE)