[Model] Add MiniMax Music 3 text-to-music (#6186)

Signed-off-by: Yueqian Lin <linyueqian@outlook.com>
This commit is contained in:
Yueqian Lin authored and GitHub committed 2026-08-18 04:33:13 +00:00
1 parent 199ef7779c
commit b283215c28
43 files changed
+6876 -405

No files matched your search

+1
View File
@@ -78,6 +78,7 @@ th {
| `SoulXSingerPipeline` | SoulX-Singer (SVS) | `Soul-AILab/SoulX-Singer` | ✅︎ | | | | — |
| `SoulXSingerSVCPipeline` | SoulX-Singer-SVC | `Soul-AILab/SoulX-Singer` (`model-svc.pt`) | ✅︎ | | | | — |
| `AudioXPipeline` | AudioX | `zhangj1an/AudioX` | ✅︎ | ✅︎ | | | — |
| `MiniMaxMusic3ForConditionalGeneration` | MiniMax Music 3 (text-to-music) | `MiniMaxAI/MiniMax-Music3` | ✅︎ | | | | — |
| `Qwen3TTSForConditionalGeneration` | Qwen3-TTS-12Hz-1.7B-CustomVoice | `Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice` | ✅︎ | | | | [Published](https://recipes.vllm.ai/Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice) |
| `Qwen3TTSForConditionalGeneration` | Qwen3-TTS-12Hz-1.7B-VoiceDesign | `Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign` | ✅︎ | ✅︎ | ✅︎ | ✅︎ | — |
| `Qwen3TTSForConditionalGeneration` | Qwen3-TTS-12Hz-1.7B-Base | `Qwen/Qwen3-TTS-12Hz-1.7B-Base` | ✅︎ | ✅︎ | ✅︎ | ✅︎ | — |
+173
View File
@@ -0,0 +1,173 @@
# MiniMax Music 3
> Text-to-music: lyrics plus a style caption in, a 32 kHz stereo song out
## Summary
- Vendor: MiniMax
- Model: `MiniMaxAI/MiniMax-Music3`
- Task: Text-to-music generation (vocals and instrumental together)
- Mode: Online serving through the OpenAI-compatible `/v1/audio/speech` API
- Maintainer: @linyueqian
## When to use this recipe
Use it to serve `MiniMaxAI/MiniMax-Music3` for full-song generation up to six
minutes. This is not a TTS model with a musical voice: there is no reference
speaker, no `voice`, and no `temperature`. What you control is the lyrics, the
caption, the seed, and the length.
## References
- Model card: [MiniMaxAI/MiniMax-Music3](https://huggingface.co/MiniMaxAI/MiniMax-Music3)
- Demo samples: [MiniMax Music 3 demo](https://minimax-ai.github.io/music3-demo/)
- Deploy config: `vllm_omni/deploy/minimax_music3.yaml` (single GPU),
`vllm_omni/deploy/minimax_music3_2gpu.yaml` (split across two)
## Architecture
| Component | Spec |
|---|---|
| Backbone | Qwen3 decoder, 36 layers, hidden 4096, GQA 32/8, vocab 200k |
| Depth decoder | 4 layers, hidden 4096, 16 heads; 7 residual codebooks of 1024 |
| Frame | 8 codebooks; `c0` in the backbone vocabulary, `c1..c7` in the depth decoder |
| AR guidance | Classifier-free, scale 1.5, `c0` masked to the conditioned branch's top 50 |
| Acoustic stage | Flow-matching transformer (36 layers, dim 2048) plus a DAC-style decoder, 512x upsampling |
| Solver | Euler, 30 steps, acoustic guidance scale 1.7 |
| Window | 200 frames per acoustic chunk, 100-frame hop |
| Frame rate | 25 frames per second |
| Context length | 10,240 tokens |
| Output | 32 kHz stereo WAV |
Generation is two-stage. The backbone predicts one audio frame per decode step,
each frame eight RVQ codebooks deep. Every 200 frames are handed to the
flow-matching transformer, which solves a latent that the decoder turns into
waveform.
**Guidance doubles the rows.** Every request decodes twice: one row sees the
real prompt, the other sees the same prompt with the caption-and-lyrics span
replaced by `<|audio_cfg|>`. Both rows hold their own KV cache for the whole
song, so a request costs twice the KV its length suggests. Size the pool for
rows, not requests.
## Hardware Support
## GPU
### 1x H200 141GB
#### Environment
- OS: Linux
- Python: 3.12
- CUDA: 12.9
- vLLM version: 0.27.0
- vLLM-Omni version or commit: branch `feat/minimax-music3`
#### Command
```bash
vllm serve MiniMaxAI/MiniMax-Music3 \
--host 0.0.0.0 --port 8000 \
--trust-remote-code --omni
```
The default deploy config `vllm_omni/deploy/minimax_music3.yaml` is discovered
automatically from the checkpoint's `model_type=minimax_music3`. It colocates
both stages on device 0.
#### Verification
```bash
curl -X POST http://localhost:8000/v1/audio/speech \
-H "Content-Type: application/json" \
-d '{
"model": "MiniMaxAI/MiniMax-Music3",
"input": "[Verse]\nWalking down the empty street at midnight\nStreetlights flicker like a broken dream\n[Chorus]\nAnd I keep on walking\nTill the morning finds me",
"instructions": "A melancholic lo-fi hip-hop track at 85 BPM in F minor: mellow Rhodes piano riff, soft vinyl crackle, dusty boom-bap drums with a laid-back swing, warm upright bass. Intimate bedroom production, gentle tape saturation, no bright cymbals.",
"seed": 1,
"max_new_tokens": 750
}' \
--output song.wav
```
Expected: a 30 second 32 kHz stereo WAV. `max_new_tokens` counts audio frames
at 25 per second, so 750 frames is at most 30 seconds. It is a cap, not a
target: the model ends the song itself when it emits the audio-end token, and a
response shorter than the cap is the model finishing rather than a truncation.
#### Notes
- **Memory:** stage 0 takes `gpu_memory_utilization: 0.6` for the bf16 backbone
and its KV pool; stage 1 takes 0.2 for the transformer and decoder weights,
which sit outside any KV budget.
- **Key flags:** none required. Guidance, the solver step count and the window
geometry are fixed by the checkpoint and are not request parameters.
- **Known limitations:**
- The external API is non-streaming; `stream: true` is rejected.
- `max_model_len` is 10,240, so a maximum-length prompt (5,000 tokens) and a
maximum-length song (9,000 frames) cannot both fit. Long captions shorten
the maximum song. This is a property of the checkpoint.
- The acoustic stage runs in float32. bfloat16 is accepted but measurably
degrades the solver.
### 2x H200 141GB
Use `minimax_music3_2gpu.yaml` to put the acoustic stage on the second device:
```bash
vllm serve MiniMaxAI/MiniMax-Music3 \
--host 0.0.0.0 --port 8000 \
--trust-remote-code --omni \
--deploy-config minimax_music3_2gpu.yaml
```
Only the placement differs; both layouts run the acoustic stage in float32.
## Writing the request
Two fields carry the request, and both are required.
- `input` is the **lyrics**. Structure tags (`[Verse]`, `[Chorus]`, `[Bridge]`,
`[Outro]`) steer the arrangement and are part of the prompt contract.
**Put a tag on its own line.** Normalization keeps only the tags on a line
that starts with one and drops the rest of that line, so lyrics written next
to a tag are silently lost:
```text
"[Verse]\nWalking down the street" -> [start] [verse] Walking down the street
"[Verse] Walking down the street" -> [start] [verse]
```
- `instructions` is the **caption** describing genre, instrumentation, tempo,
mood and production. It is the strongest control available: vague captions
give generic arrangements. Long structured captions work well, since the
model was trained on descriptions covering global attributes, emotional
progression, vocal detail and arrangement.
A request is deterministic in its seed: the same lyrics, caption, seed and
length return the same audio. Changing only the seed gives another take of the
same song.
## Request parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
| `input` | string | required | Lyrics, non-empty. Tags on their own lines |
| `instructions` | string | required | Caption, non-empty |
| `seed` | int | `0` | Non-negative. Fixes the output for a given request |
| `max_new_tokens` | int | `9000` | Cap on audio frames, 25 per second. Maximum 9,000 |
| `response_format` | string | `"wav"` | Output container |
## Parameters the model rejects
The request is refused rather than silently ignored, so a mistake is visible
immediately.
| Parameter | Reason |
|---|---|
| `temperature`, `top_p`, `top_k`, `repetition_penalty` | Sampling is fixed: guidance at 1.5, then a seeded top-k 50 draw |
| `voice` | There is no speaker to select; the vocal comes from the caption |
| `ref_audio`, `ref_text`, `language`, `task_type` | No reference-audio conditioning and no language tag |
| `speed` | Only `1.0`. Tempo belongs in the caption, e.g. "at 92 BPM" |
| `stream: true` | The external API is non-streaming |
@@ -976,3 +976,68 @@ def test_voices_delete_invalid_requests(
"err_message": err_message,
}
)
# ─── POST /v1/audio/speech · MiniMax Music 3 ───
# Text-to-music on the speech endpoint. Most of the speech contract does not
# apply: there is no speaker, no reference audio and no temperature, and the
# lyrics alone are not a request. Each case below pins a rejection so a caller
# who assumes the usual TTS shape finds out immediately.
_MINIMAX_MUSIC3_SPEECH = [
pytest.param(
OmniServerParams(
model="MiniMaxAI/MiniMax-Music3",
stage_config_path=get_deploy_config_path("minimax_music3.yaml"),
server_args=_SPEECH_SERVER_ARGS,
),
id="minimax_music3",
marks=hardware_marks(res={"cuda": "H100"}),
),
]
_MINIMAX_MUSIC3_LYRICS = "[Verse]\nWalking down the empty street at midnight"
_MINIMAX_MUSIC3_CAPTION = "A melancholic lo-fi hip-hop track at 85 BPM in F minor."
# 25 frames per second, capped at six minutes of song.
_MINIMAX_MUSIC3_MAX_FRAMES = 9000
@pytest.mark.parametrize(
"overrides, err_message",
[
pytest.param({"instructions": None}, ("instructions",), id="caption_missing"),
pytest.param({"instructions": " "}, ("instructions",), id="caption_blank"),
pytest.param({"input": " "}, ("input",), id="lyrics_blank"),
pytest.param({"voice": "alloy"}, ("voice",), id="voice_rejected"),
pytest.param({"speed": 1.5}, ("speed",), id="speed_rejected"),
pytest.param(
{"max_new_tokens": _MINIMAX_MUSIC3_MAX_FRAMES + 1},
("max_new_tokens", str(_MINIMAX_MUSIC3_MAX_FRAMES)),
id="max_new_tokens_over_cap",
),
pytest.param({"max_new_tokens": 0}, ("max_new_tokens",), id="max_new_tokens_zero"),
],
)
@pytest.mark.parametrize("omni_server", _MINIMAX_MUSIC3_SPEECH, indirect=True)
def test_minimax_music3_speech_invalid_field_values(
omni_server: OmniServer,
openai_client: OpenAIClientHandler,
overrides: dict[str, object],
err_message: str | tuple[str, ...],
) -> None:
body: dict[str, Any] = {
"model": omni_server.model,
"input": _MINIMAX_MUSIC3_LYRICS,
"instructions": _MINIMAX_MUSIC3_CAPTION,
"seed": 1,
"max_new_tokens": 250,
"response_format": "wav",
}
for key, value in overrides.items():
if value is None:
body.pop(key, None)
else:
body[key] = value
openai_client.send_audio_speech_http_request(
{"json": body, "timeout": 120, "err_code": 400, "err_message": err_message}
)
@@ -0,0 +1,112 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""E2E online tests for MiniMax Music 3 text-to-music.
This is a music model on the speech endpoint, so the request shape differs
from every other TTS model here: ``input`` carries the lyrics, ``instructions``
carries the caption that decides genre and arrangement, and both are required.
Weekly rather than per-PR: the checkpoint is 27 GB across five components and a
single request occupies two decode rows, because classifier-free guidance is
mandatory for this model. Rejection cases live in
``tests/dfx/reliability/invalid_param_test/test_invalid_audio_speech.py``.
"""
import os
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
import pytest
from tests.helpers.mark import hardware_test
from tests.helpers.runtime import OmniServerParams
from tests.helpers.stage_config import get_deploy_config_path
MODEL = "MiniMaxAI/MiniMax-Music3"
DEFAULT_AUDIO_SPEECH_TIMEOUT_S = 600.0
# 25 frames per second, so 250 frames is ten seconds and 750 is thirty. The
# byte floors sit below the true payload (32 kHz stereo, 16-bit) to leave room
# for the model ending a song before the cap.
_FRAMES_10S = 250
_FRAMES_30S = 750
_MIN_BYTES_10S = 500_000
_MIN_BYTES_30S = 1_500_000
LYRICS = (
"[Verse]\nWalking down the empty street at midnight\n"
"Streetlights flicker like a broken dream\n"
"[Chorus]\nAnd I keep on walking\nTill the morning finds me"
)
CAPTION = (
"A melancholic lo-fi hip-hop track at 85 BPM in F minor: mellow Rhodes piano riff, "
"soft vinyl crackle, dusty boom-bap drums with a laid-back swing, warm upright bass. "
"Intimate bedroom production, gentle tape saturation, no bright cymbals."
)
tts_server_params = [
pytest.param(
OmniServerParams(
model=MODEL,
stage_config_path=get_deploy_config_path("minimax_music3.yaml"),
server_args=["--trust-remote-code", "--disable-log-stats"],
),
id="minimax_music3",
)
]
@pytest.mark.slow
@pytest.mark.tts
@hardware_test(res={"cuda": "H100"}, num_cards={"cuda": 1})
@pytest.mark.parametrize("omni_server", tts_server_params, indirect=True)
def test_text_to_music_001(omni_server, openai_client) -> None:
"""Baseline: lyrics plus caption in, one ten-second song out.
Deploy Setting: minimax_music3.yaml
Input Modal: text (lyrics) + text (caption)
Output Modal: audio, 32 kHz stereo
Input Setting: stream=False, seed pinned, 250 frames
"""
request_config = {
"model": omni_server.model,
"input": LYRICS,
"instructions": CAPTION,
"seed": 1,
"max_new_tokens": _FRAMES_10S,
"stream": False,
"response_format": "wav",
"timeout": DEFAULT_AUDIO_SPEECH_TIMEOUT_S,
"min_audio_bytes": _MIN_BYTES_10S,
}
openai_client.send_audio_speech_request(request_config)
@pytest.mark.slow
@pytest.mark.tts
@hardware_test(res={"cuda": "H100"}, num_cards={"cuda": 1})
@pytest.mark.parametrize("omni_server", tts_server_params, indirect=True)
def test_text_to_music_multi_window_002(omni_server, openai_client) -> None:
"""A song long enough to span several acoustic windows.
The acoustic stage decodes overlapping 200-frame windows on a 100-frame
hop and crops the overlap back off, so a request that fits in one window
never exercises the stitching. 750 frames covers seven of them.
Deploy Setting: minimax_music3.yaml
Input Setting: stream=False, seed pinned, 750 frames
"""
request_config = {
"model": omni_server.model,
"input": LYRICS,
"instructions": CAPTION,
"seed": 1,
"max_new_tokens": _FRAMES_30S,
"stream": False,
"response_format": "wav",
"timeout": DEFAULT_AUDIO_SPEECH_TIMEOUT_S,
"min_audio_bytes": _MIN_BYTES_30S,
}
openai_client.send_audio_speech_request(request_config)
@@ -371,3 +371,106 @@ def test_build_add_request_message_releases_preselected_replica_on_preprocess_er
)
assert stage_pool.get_bound_replica_id("req-error") is None
def test_cfg_companion_build_failure_admits_nothing(mocker: MockerFixture):
"""A guided request is all-or-nothing.
Admitting the parent before its companion is built leaves an orphan: a
model whose guidance is mandatory never completes the pair, so the request
holds scheduler and KV capacity for the scheduler's whole hold budget and
then produces no audio. The build has to raise before anything is enqueued.
"""
engine = object.__new__(AsyncOmniEngine)
params = SamplingParams(max_tokens=8)
engine.prompt_expand_func = mocker.Mock(side_effect=ValueError("cannot build the null twin"))
engine.supported_tasks = ("generate",)
engine.input_processor = mocker.Mock()
engine.request_queue = mocker.Mock()
with pytest.raises(ValueError, match="null twin"):
engine._build_cfg_companions(
parent_id="req",
original_prompt={"prompt": "positive"},
stage0_params=params,
sampling_params_list=[params],
)
engine.request_queue.sync_q.put.assert_not_called()
def test_cfg_companion_processing_failure_admits_nothing(mocker: MockerFixture):
"""The same holds when expansion succeeds but input processing does not."""
engine = object.__new__(AsyncOmniEngine)
params = SamplingParams(max_tokens=8)
engine.prompt_expand_func = lambda *_args: [
ExpandedPrompt(
prompt={"prompt": "negative"},
role="cfg_text",
request_id_suffix="__cfg_text",
)
]
engine.supported_tasks = ("generate",)
engine.input_processor = mocker.Mock()
engine.input_processor.process_inputs.side_effect = RuntimeError("tokenizer rejected the prompt")
engine.request_queue = mocker.Mock()
with pytest.raises(RuntimeError, match="tokenizer rejected"):
engine._build_cfg_companions(
parent_id="req",
original_prompt={"prompt": "positive"},
stage0_params=params,
sampling_params_list=[params],
)
engine.request_queue.sync_q.put.assert_not_called()
def test_cfg_companion_build_returns_messages_without_enqueueing(mocker: MockerFixture):
"""Construction and admission are separate steps."""
engine = object.__new__(AsyncOmniEngine)
params = SamplingParams(max_tokens=8)
engine.prompt_expand_func = lambda *_args: [
ExpandedPrompt(
prompt={"prompt": "negative"},
role="cfg_text",
request_id_suffix="__cfg_text",
)
]
engine.supported_tasks = ("generate",)
engine.input_processor = mocker.Mock()
engine.input_processor.process_inputs.return_value = _make_engine_core_request("req__cfg_text")
engine.request_queue = mocker.Mock()
companions = engine._build_cfg_companions(
parent_id="req",
original_prompt={"prompt": "positive"},
stage0_params=params,
sampling_params_list=[params],
)
assert len(companions) == 1
assert companions[0].companion_id == "req__cfg_text"
assert companions[0].parent_id == "req"
engine.request_queue.sync_q.put.assert_not_called()
def test_cfg_expansion_returning_nothing_is_not_an_error(mocker: MockerFixture):
"""An unguided request expands to no companions and admits normally."""
engine = object.__new__(AsyncOmniEngine)
params = SamplingParams(max_tokens=8)
engine.prompt_expand_func = lambda *_args: []
engine.supported_tasks = ("generate",)
engine.input_processor = mocker.Mock()
engine.request_queue = mocker.Mock()
assert (
engine._build_cfg_companions(
parent_id="req",
original_prompt={"prompt": "positive"},
stage0_params=params,
sampling_params_list=[params],
)
== []
)
engine.request_queue.sync_q.put.assert_not_called()
@@ -153,6 +153,7 @@ _PIPELINE_STAGES = [
"indextts2_talker",
"latent_generator",
"llm",
"minimax_music3_ar",
"ming_tts",
"moss_tts",
"moss_tts_codec",
@@ -0,0 +1,132 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Regression tests for MiniMax Music 3 batched payload partitioning.
The stage-0 payload is split per request by indexing list values: a list is
indexed by batch row, anything else is handed to every request unchanged. So
per-request metadata has to be carried as lists. Merging it into one dict made
every request in a batch receive the last request's seed, window offset and
terminal flag, which crosses seeds between songs, misplaces acoustic windows,
and lets one request's terminal flag end another request's stream early.
These drive ``make_omni_output`` directly with a hand-built batch, so no GPU
and no engine are needed.
"""
from __future__ import annotations
import pytest
import torch
from vllm_omni.model_executor.models.minimax_music3.talker import (
MiniMaxMusic3TalkerForConditionalGeneration as Talker,
)
from vllm_omni.utils.mm_outputs import to_payload_element
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
_WIDTH = 4
def _info(*, seed: int, start: int, frames: int, last: bool) -> dict:
"""One batch row's intermediate buffer, as ``postprocess`` leaves it."""
return {
"latent": torch.full((frames, _WIDTH), float(seed)),
"codes": {"audio": torch.full((frames, 8), seed)},
"meta": {
"audio_seed": seed,
"num_processed_tokens": start,
"codec_chunk_frames": frames,
"last_chunk": last,
},
}
def _payload_for(output, row: int) -> dict:
"""Partition the batch payload down to one request, as the runner does."""
flat = {}
for key, value in output.multimodal_outputs.items():
if isinstance(value, dict):
for qual, val in value.items():
flat[f"{key}.{qual}"] = val
else:
flat[key] = value
return {key: to_payload_element(value, row, 1, 1) for key, value in flat.items()}
class TestBatchedMetadataPartitioning:
"""Two requests in one batch must keep their own metadata."""
@staticmethod
def _two_request_batch():
infos = [
_info(seed=111, start=100, frames=3, last=False),
_info(seed=222, start=200, frames=5, last=True),
]
talker = Talker.__new__(Talker)
return talker.make_omni_output(torch.zeros(2, _WIDTH), model_intermediate_buffer=infos)
def test_seeds_are_not_crossed(self) -> None:
"""A song decoded with another request's seed is a different song."""
output = self._two_request_batch()
assert _payload_for(output, 0)["meta.audio_seed"] == 111
assert _payload_for(output, 1)["meta.audio_seed"] == 222
def test_window_offsets_stay_with_their_request(self) -> None:
"""The start frame decides where a window lands in the song."""
output = self._two_request_batch()
assert _payload_for(output, 0)["meta.num_processed_tokens"] == 100
assert _payload_for(output, 1)["meta.num_processed_tokens"] == 200
def test_terminal_flag_does_not_leak_across_requests(self) -> None:
"""One request finishing must not end another request's stream."""
output = self._two_request_batch()
assert _payload_for(output, 0)["meta.last_chunk"] is False
assert _payload_for(output, 1)["meta.last_chunk"] is True
def test_span_length_matches_its_own_latent(self) -> None:
output = self._two_request_batch()
for row, frames in ((0, 3), (1, 5)):
entry = _payload_for(output, row)
assert entry["meta.codec_chunk_frames"] == frames
assert entry["latent"].shape[0] == frames
def test_latent_and_codes_follow_the_same_row(self) -> None:
"""The tensors were already per-request; pin that they stay aligned."""
output = self._two_request_batch()
for row, seed in ((0, 111), (1, 222)):
entry = _payload_for(output, row)
assert entry["latent"][0, 0].item() == pytest.approx(float(seed))
assert int(entry["codes.audio"][0, 0]) == seed
class TestPartialBatches:
"""Only some rows carry a span on any given step."""
def test_row_without_a_span_does_not_take_another_row_metadata(self) -> None:
infos = [{}, _info(seed=777, start=300, frames=2, last=True)]
talker = Talker.__new__(Talker)
output = talker.make_omni_output(torch.zeros(2, _WIDTH), model_intermediate_buffer=infos)
idle = _payload_for(output, 0)
assert idle["latent"].numel() == 0
assert idle["meta.audio_seed"] is None
assert idle["meta.last_chunk"] is None
active = _payload_for(output, 1)
assert active["meta.audio_seed"] == 777
assert active["meta.last_chunk"] is True
def test_batch_with_no_spans_emits_no_payload(self) -> None:
talker = Talker.__new__(Talker)
output = talker.make_omni_output(torch.zeros(2, _WIDTH), model_intermediate_buffer=[{}, {}])
assert output.multimodal_outputs is None
def test_consumed_entries_are_cleared_from_the_buffer(self) -> None:
"""The runner never clears this buffer, so a span left behind would be
re-emitted on every later step."""
infos = [_info(seed=1, start=0, frames=2, last=False)]
talker = Talker.__new__(Talker)
talker.make_omni_output(torch.zeros(1, _WIDTH), model_intermediate_buffer=infos)
assert "latent" not in infos[0]
assert "meta" not in infos[0]
@@ -0,0 +1,214 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Unit tests for MiniMax Music 3 per-request AR frame bookkeeping.
The talker emits overlapping windows while dropping frames it can no longer
need, and flushes whatever is left as one span when the song ends. Getting the
eviction boundary wrong by one frame silently corrupts the conditioning handed
to the acoustic stage from the second window onward, which is audible but hard
to attribute, so the lifecycle is simulated here frame by frame.
"""
from __future__ import annotations
import pytest
import torch
from vllm_omni.model_executor.models.minimax_music3.chunking import chunk_windows
from vllm_omni.model_executor.models.minimax_music3.constants import (
AR_CHUNK_FRAMES,
AR_CHUNK_HOP_FRAMES,
NUM_CODEBOOKS,
)
from vllm_omni.model_executor.models.minimax_music3.frame_state import (
FrameBuffer,
RequestARState,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
# One scalar per frame instead of 32768 dims: the bookkeeping is what is under
# test, and a frame's value doubles as its identity.
_WIDTH = 4
def _frame(index: int) -> torch.Tensor:
return torch.full((1, _WIDTH), float(index))
def _identities(chunk: torch.Tensor) -> list[int]:
return [int(row[0].item()) for row in chunk]
class TestFrameBuffer:
def test_slice_returns_the_requested_frames(self) -> None:
buffer = FrameBuffer()
for index in range(10):
buffer.append(_frame(index))
assert _identities(buffer.slice(3, 7)) == [3, 4, 5, 6]
def test_slice_is_relative_to_the_live_base(self) -> None:
buffer = FrameBuffer()
for index in range(10):
buffer.append(_frame(index))
buffer.discard_before(4)
assert buffer.base_frame == 4
assert _identities(buffer.slice(4, 8)) == [4, 5, 6, 7]
def test_slicing_a_discarded_range_raises(self) -> None:
buffer = FrameBuffer()
for index in range(10):
buffer.append(_frame(index))
buffer.discard_before(5)
with pytest.raises(RuntimeError, match="unavailable"):
buffer.slice(3, 6)
def test_slicing_past_the_end_raises(self) -> None:
buffer = FrameBuffer()
for index in range(4):
buffer.append(_frame(index))
with pytest.raises(RuntimeError, match="unavailable"):
buffer.slice(2, 9)
def test_discard_is_clamped_at_both_ends(self) -> None:
buffer = FrameBuffer()
for index in range(5):
buffer.append(_frame(index))
buffer.discard_before(-3)
assert buffer.base_frame == 0
buffer.discard_before(99)
assert buffer.base_frame == 5
assert buffer.end_frame == 5
def test_peak_tracks_the_high_water_mark(self) -> None:
buffer = FrameBuffer()
for index in range(10):
buffer.append(_frame(index))
buffer.discard_before(8)
assert buffer.peak_frames == 10
class TestWindowLifecycle:
"""Drive the state exactly as the talker does, one frame per step."""
@staticmethod
def _run(total_frames: int) -> tuple[list[list[int]], int]:
"""Generate ``total_frames`` frames, returning the emitted spans.
Returns:
The frame identities of every emitted span, and the peak buffer
occupancy reached along the way.
"""
state = RequestARState(seed=1, max_frames=total_frames)
spans: list[list[int]] = []
for index in range(total_frames):
state.frames.append(_frame(index))
state.codes.append(torch.full((1, NUM_CODEBOOKS), index))
state.generated_frames += 1
window = state.ready_window()
if window is not None:
spans.append(_identities(state.take_window(window, is_final=False)))
# Terminal flush: everything not yet emitted, as one contiguous span.
start = state.emitted_windows * AR_CHUNK_HOP_FRAMES
end = state.frames.end_frame
if end > start:
spans.append(_identities(state.frames.slice(start, end)))
return spans, state.frames.peak_frames
def test_short_song_is_one_span(self) -> None:
spans, _ = self._run(50)
assert spans == [list(range(50))]
def test_every_frame_is_delivered_exactly_once_in_order(self) -> None:
"""Spans overlap by design, but their union must be the whole song.
Reconstruct by taking each span's non-overlapping remainder, which is
what the acoustic stage does after cropping.
"""
for total in (250, 437, 600, 901):
spans, _ = self._run(total)
rebuilt: list[int] = []
for span in spans:
rebuilt.extend(frame for frame in span if frame >= len(rebuilt))
assert rebuilt == list(range(total)), f"gap or duplicate at {total} frames"
def test_windows_advance_by_the_hop(self) -> None:
spans, _ = self._run(600)
for index, span in enumerate(spans[:-1]):
assert span[0] == index * AR_CHUNK_HOP_FRAMES
assert len(span) == AR_CHUNK_FRAMES
def test_mid_stream_windows_are_never_the_final_frame(self) -> None:
"""A window is held back until a further frame proves it is not last."""
total = 600
spans, _ = self._run(total)
for span in spans[:-1]:
assert span[-1] < total - 1
def test_terminal_span_covers_the_tail(self) -> None:
total = 437
spans, _ = self._run(total)
assert spans[-1][-1] == total - 1
def test_terminal_span_is_bounded(self) -> None:
"""The tail is at most one full window plus one hop."""
for total in (250, 301, 437, 600, 901, 1000):
spans, _ = self._run(total)
assert len(spans[-1]) <= AR_CHUNK_FRAMES + AR_CHUNK_HOP_FRAMES
def test_buffer_stays_bounded_over_a_long_song(self) -> None:
"""Peak occupancy must not grow with song length; that is the point
of evicting retired frames."""
_, peak_short = self._run(600)
_, peak_long = self._run(3000)
assert peak_long == peak_short
assert peak_long <= AR_CHUNK_FRAMES + AR_CHUNK_HOP_FRAMES
def test_emitted_window_count_matches_the_window_plan(self) -> None:
"""What the stream emitted must reconcile with chunk_windows()."""
for total in (250, 437, 600):
spans, _ = self._run(total)
planned = chunk_windows(total)
# Every window is accounted for: the pre-terminal spans are one
# window each, and the terminal span carries whatever remains.
assert len(spans) <= len(planned)
assert spans[0][0] == planned[0].start
class TestSamplingPositions:
def test_each_frame_reserves_one_slot_per_codebook(self) -> None:
"""Codebook streams must never collide, or codes repeat across frames."""
state = RequestARState(seed=1, max_frames=10)
positions = [state.advance_position() for _ in range(5)]
assert positions == [0, 8, 16, 24, 32]
def test_draw_slots_are_globally_unique(self) -> None:
state = RequestARState(seed=1, max_frames=64)
slots: set[int] = set()
for _ in range(64):
base = state.advance_position()
for codebook in range(NUM_CODEBOOKS):
slot = base + codebook
assert slot not in slots
slots.add(slot)
assert len(slots) == 64 * NUM_CODEBOOKS
class TestCodeRetention:
def test_codes_span_matches_the_frame_span(self) -> None:
state = RequestARState(seed=1, max_frames=10)
for index in range(10):
state.codes.append(torch.full((1, NUM_CODEBOOKS), index))
codes = state.take_codes(2, 6)
assert codes is not None
assert codes.shape == (4, NUM_CODEBOOKS)
assert [int(row[0]) for row in codes] == [2, 3, 4, 5]
def test_out_of_range_span_is_dropped_not_raised(self) -> None:
"""Codes are diagnostic; the audio path must not fail without them."""
state = RequestARState(seed=1, max_frames=10)
for index in range(4):
state.codes.append(torch.full((1, NUM_CODEBOOKS), index))
assert state.take_codes(2, 99) is None
assert state.take_codes(0, 0) is None
@@ -0,0 +1,203 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Unit tests for MiniMax Music 3 prompt assembly and window arithmetic.
The prompt transformations are not cosmetic: they decide token positions, and
token positions decide the audio. These tests pin the documented behaviours
that a well-meaning cleanup would otherwise "fix".
"""
from __future__ import annotations
import pytest
from vllm_omni.model_executor.models.minimax_music3.chunking import (
chunk_windows,
crop_sample_bounds,
overlap_mel_length,
)
from vllm_omni.model_executor.models.minimax_music3.constants import (
AR_CHUNK_FRAMES,
AR_CHUNK_HOP_FRAMES,
BLEND_MEL_FRAMES,
DAV_HOP_SAMPLES,
HOP_MEL_FRAMES,
)
from vllm_omni.model_executor.models.minimax_music3.prompt import (
SPECIAL_TOKEN_IDS,
build_cfg_null_token_ids,
build_prompt,
clean_caption,
normalize_lyrics,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
class TestNormalizeLyrics:
def test_prepends_start_tag(self) -> None:
assert normalize_lyrics("[Verse]\nhello").startswith("[start]\n")
def test_lowercases_structure_tags(self) -> None:
assert "[verse]" in normalize_lyrics("[Verse]\nhello")
assert "[Verse]" not in normalize_lyrics("[Verse]\nhello")
def test_text_after_a_leading_tag_is_dropped(self) -> None:
"""Documented contract: a tag must sit on its own line.
Lyrics written next to a tag are silently lost, which is why the
serving docs tell callers to put tags on their own line.
"""
out = normalize_lyrics("[Verse] Walking down the street")
assert "Walking" not in out
assert "[verse]" in out
def test_text_on_its_own_line_survives(self) -> None:
out = normalize_lyrics("[Verse]\nWalking down the street")
assert "Walking down the street" in out
def test_bracket_space_becomes_newline(self) -> None:
assert normalize_lyrics("a [chorus]") == "[start]\na\n[chorus]"
class TestCleanCaption:
def test_special_token_form_is_rewritten_not_passed_through(self) -> None:
"""A caption must not be able to inject a real special token."""
assert clean_caption("<|bpm 92|>") == "bpm is 92"
assert "<|" not in clean_caption("<|bpm 92|>")
def test_lone_tag_keeps_its_inner_text(self) -> None:
assert clean_caption("<|instrumental|>") == "instrumental"
def test_markdown_is_stripped(self) -> None:
assert clean_caption("## Heading") == "Heading"
assert clean_caption("**bold**") == "bold"
assert clean_caption("- item") == "item"
def test_blank_lines_collapse_at_two_not_three(self) -> None:
"""The source collapses runs of two or more, not three or more.
This shifts blank-line token positions and therefore the audio, so it
is pinned deliberately.
"""
assert clean_caption("a\n\nb") == "a\nb"
assert clean_caption("a\n\n\n\nb") == "a\nb"
class TestBuildPrompt:
def test_framing_tokens_are_present_and_ordered(self) -> None:
prompt = build_prompt("a bright pop song", "[Verse]\nhello")
assert prompt.startswith("<|im_start|><|caption_start|>")
assert prompt.endswith("<|im_end|><|audio_start|>")
assert prompt.index("<|caption_end|>") < prompt.index("<|lyrics_start|>")
class TestBuildCfgNullTokenIds:
@staticmethod
def _prompt_ids(body_len: int) -> list[int]:
return (
[SPECIAL_TOKEN_IDS["<|im_start|>"]]
+ list(range(1000, 1000 + body_len))
+ [SPECIAL_TOKEN_IDS["<|im_end|>"], SPECIAL_TOKEN_IDS["<|audio_start|>"]]
)
def test_length_is_preserved(self) -> None:
"""Equal length is what lets the two rows decode in lockstep."""
ids = self._prompt_ids(12)
assert len(build_cfg_null_token_ids(ids)) == len(ids)
def test_body_is_replaced_and_framing_survives(self) -> None:
ids = self._prompt_ids(12)
null = build_cfg_null_token_ids(ids)
assert null[0] == SPECIAL_TOKEN_IDS["<|im_start|>"]
assert null[-2] == SPECIAL_TOKEN_IDS["<|im_end|>"]
assert null[-1] == SPECIAL_TOKEN_IDS["<|audio_start|>"]
assert set(null[1:-2]) == {SPECIAL_TOKEN_IDS["<|audio_cfg|>"]}
def test_input_is_not_mutated(self) -> None:
ids = self._prompt_ids(6)
before = list(ids)
build_cfg_null_token_ids(ids)
assert ids == before
def test_minimal_prompt_with_empty_body(self) -> None:
ids = [
SPECIAL_TOKEN_IDS["<|im_start|>"],
SPECIAL_TOKEN_IDS["<|audio_cfg|>"],
SPECIAL_TOKEN_IDS["<|im_end|>"],
SPECIAL_TOKEN_IDS["<|audio_start|>"],
]
assert build_cfg_null_token_ids(ids) == ids
def test_too_short_is_rejected(self) -> None:
with pytest.raises(ValueError, match="too short"):
build_cfg_null_token_ids([1, 2, 3])
def test_wrong_framing_is_rejected(self) -> None:
with pytest.raises(ValueError, match="framing"):
build_cfg_null_token_ids([9, 9, 9, 9, 9])
class TestChunkWindows:
def test_no_frames_yields_nothing(self) -> None:
assert chunk_windows(0) == []
def test_short_song_is_one_final_window(self) -> None:
windows = chunk_windows(50)
assert len(windows) == 1
assert (windows[0].start, windows[0].end) == (0, 50)
assert windows[0].is_first and windows[0].is_last
def test_exactly_one_window(self) -> None:
windows = chunk_windows(AR_CHUNK_FRAMES)
assert len(windows) == 1
assert windows[0].is_last
def test_windows_advance_by_the_hop(self) -> None:
windows = chunk_windows(450)
starts = [w.start for w in windows]
assert starts == list(range(0, starts[-1] + 1, AR_CHUNK_HOP_FRAMES))
assert windows[-1].is_last
assert not any(w.is_last for w in windows[:-1])
def test_windows_cover_every_frame(self) -> None:
frames = 437
windows = chunk_windows(frames)
covered: set[int] = set()
for window in windows:
covered.update(range(window.start, window.end))
assert covered == set(range(frames))
def test_final_window_is_clamped_to_the_song(self) -> None:
windows = chunk_windows(437)
assert windows[-1].end == 437
def test_only_the_first_window_is_first(self) -> None:
windows = chunk_windows(600)
assert windows[0].is_first
assert not any(w.is_first for w in windows[1:])
class TestCropBounds:
def test_single_window_song_is_not_cropped(self) -> None:
window = chunk_windows(50)[0]
assert crop_sample_bounds(window) == (0, 0)
def test_first_window_keeps_its_head(self) -> None:
left, right = crop_sample_bounds(chunk_windows(600)[0])
assert left == 0
assert right == (HOP_MEL_FRAMES - BLEND_MEL_FRAMES) * DAV_HOP_SAMPLES
def test_last_window_keeps_its_tail(self) -> None:
left, right = crop_sample_bounds(chunk_windows(600)[-1])
assert left == BLEND_MEL_FRAMES * DAV_HOP_SAMPLES
assert right == 0
def test_middle_window_is_cropped_on_both_sides(self) -> None:
windows = chunk_windows(900)
middle = windows[len(windows) // 2]
left, right = crop_sample_bounds(middle)
assert left > 0 and right > 0
def test_overlap_is_half_a_window(self) -> None:
assert overlap_mel_length() == HOP_MEL_FRAMES // 2
@@ -0,0 +1,217 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Regression tests for MiniMax Music 3 row-to-request binding.
vLLM compacts and reorders the persistent batch whenever a request finishes or
is preempted, so a batch row number is only meaningful within the step that
produced it. Every failure pinned here was a real defect: state keyed by row
survived into a step where the row belonged to someone else, or state keyed by
one spelling of the request id was released under another and leaked.
These exercise the pure helpers and the binding arithmetic directly, so they
need no GPU and no engine.
"""
from __future__ import annotations
import pytest
from vllm_omni.model_executor.models.minimax_music3.constants import (
DEFAULT_MAX_AUDIO_FRAMES,
)
from vllm_omni.model_executor.models.minimax_music3.talker import (
_CFG_UNCOND_SUFFIX,
_first_int,
_pair_id_from_request,
_request_key,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
class TestRequestKey:
def test_engine_list_form_is_unwrapped(self) -> None:
"""The engine injects global_request_id as a single-element list.
Keying state on the list's ``str()`` would produce "['req-1']", which
never matches the bare id that ``on_requests_finished`` delivers, so
every finished song would leak its frame buffer.
"""
assert _request_key(["req-1"]) == "req-1"
def test_bare_string_is_unchanged(self) -> None:
assert _request_key("req-1") == "req-1"
def test_list_and_bare_forms_agree(self) -> None:
"""Allocation and release must land on the same key."""
assert _request_key(["req-1"]) == _request_key("req-1")
def test_empty_and_missing_are_stable(self) -> None:
assert _request_key(None) == ""
assert _request_key([]) == ""
def test_tuple_form(self) -> None:
assert _request_key(("req-9",)) == "req-9"
class TestPairIdDerivation:
def test_conditioned_request_is_its_own_pair(self) -> None:
assert _pair_id_from_request("req-1") == "req-1"
def test_companion_maps_to_its_partner(self) -> None:
assert _pair_id_from_request(f"req-1{_CFG_UNCOND_SUFFIX}") == "req-1"
def test_pair_members_agree_on_the_pair_id(self) -> None:
cond = "req-42"
uncond = f"req-42{_CFG_UNCOND_SUFFIX}"
assert _pair_id_from_request(cond) == _pair_id_from_request(uncond)
def test_a_request_ending_in_a_similar_string_is_not_confused(self) -> None:
assert _pair_id_from_request("req-cfg_uncond") == "req-cfg_uncond"
class TestFirstInt:
def test_serving_layer_list_wrapping(self) -> None:
"""tts_params values arrive wrapped in a single-element list."""
assert _first_int([750], DEFAULT_MAX_AUDIO_FRAMES) == 750
def test_bare_value(self) -> None:
assert _first_int(750, DEFAULT_MAX_AUDIO_FRAMES) == 750
def test_missing_falls_back(self) -> None:
assert _first_int(None, DEFAULT_MAX_AUDIO_FRAMES) == DEFAULT_MAX_AUDIO_FRAMES
assert _first_int([], DEFAULT_MAX_AUDIO_FRAMES) == DEFAULT_MAX_AUDIO_FRAMES
def test_nonsense_falls_back_rather_than_raising(self) -> None:
assert _first_int("abc", DEFAULT_MAX_AUDIO_FRAMES) == DEFAULT_MAX_AUDIO_FRAMES
assert _first_int(0, DEFAULT_MAX_AUDIO_FRAMES) == DEFAULT_MAX_AUDIO_FRAMES
assert _first_int(-5, DEFAULT_MAX_AUDIO_FRAMES) == DEFAULT_MAX_AUDIO_FRAMES
def _feedback_plan(
request_ids: list[str],
computed: list[int],
scheduled: list[int],
prompt_tokens: dict[str, int],
has_codes: set[str],
) -> list[int]:
"""Mirror of the talker's feedback row selection.
Returns the flat token offsets whose embedding gets replaced by an audio
frame. Kept as a standalone function so the arithmetic can be checked
without constructing a model.
"""
offsets: list[int] = []
running = 0
for count in scheduled:
offsets.append(running)
running += count
rows: list[int] = []
for row, request_id in enumerate(request_ids):
if request_id not in has_codes:
continue
if scheduled[row] != 1 or computed[row] < prompt_tokens.get(request_id, 0):
continue
rows.append(offsets[row])
return rows
class TestFeedbackRowSelection:
"""The guard that decides which token embeddings become audio frames."""
def test_decode_only_step_replaces_every_row(self) -> None:
ids = ["a", "a__cfg_uncond", "b", "b__cfg_uncond"]
rows = _feedback_plan(
ids,
computed=[120, 120, 90, 90],
scheduled=[1, 1, 1, 1],
prompt_tokens={i: 50 for i in ids},
has_codes=set(ids),
)
assert rows == [0, 1, 2, 3]
def test_prefill_row_is_never_overwritten(self) -> None:
"""A prompt token must keep its real embedding.
The earlier guard compared the flat token count against a row budget,
so a short prompt slipped through and had its first embeddings
replaced with a previous request's audio frame.
"""
rows = _feedback_plan(
["a", "b"],
computed=[0, 300],
scheduled=[8, 1],
prompt_tokens={"a": 8, "b": 40},
has_codes={"b"},
)
# Row 0 occupies flat tokens 0..7 and must be untouched; row 1's single
# decode token sits at flat offset 8.
assert rows == [8]
def test_short_prefill_below_the_row_budget_is_still_safe(self) -> None:
"""The exact shape that defeated the old token-count guard."""
rows = _feedback_plan(
["a"],
computed=[0],
scheduled=[10],
prompt_tokens={"a": 10},
has_codes={"a"},
)
assert rows == []
def test_chunked_prefill_midway_is_not_fed_back(self) -> None:
rows = _feedback_plan(
["a"],
computed=[2048],
scheduled=[2048],
prompt_tokens={"a": 5000},
has_codes={"a"},
)
assert rows == []
def test_final_prefill_chunk_is_not_fed_back(self) -> None:
"""The step that completes a prompt still carries prompt tokens."""
rows = _feedback_plan(
["a"],
computed=[4096],
scheduled=[904],
prompt_tokens={"a": 5000},
has_codes={"a"},
)
assert rows == []
def test_mixed_batch_offsets_follow_the_flat_layout(self) -> None:
"""Tokens are a flat concatenation, so offsets are a running sum."""
ids = ["p", "d1", "d2"]
rows = _feedback_plan(
ids,
computed=[0, 500, 700],
scheduled=[64, 1, 1],
prompt_tokens={"p": 64, "d1": 40, "d2": 40},
has_codes={"d1", "d2"},
)
assert rows == [64, 65]
def test_request_without_codes_yet_is_skipped(self) -> None:
"""The first decode step after prefill has no previous frame."""
rows = _feedback_plan(
["a"],
computed=[50],
scheduled=[1],
prompt_tokens={"a": 50},
has_codes=set(),
)
assert rows == []
def test_reordered_batch_binds_by_request_not_position(self) -> None:
"""The same two requests in swapped rows get swapped offsets.
This is the compaction case: holding codes in a row-indexed buffer
would have handed each request the other's frame.
"""
prompt_tokens = {"a": 50, "b": 50}
first = _feedback_plan(["a", "b"], [100, 200], [1, 1], prompt_tokens, {"a"})
swapped = _feedback_plan(["b", "a"], [200, 100], [1, 1], prompt_tokens, {"a"})
assert first == [0]
assert swapped == [1]
+389
View File
@@ -0,0 +1,389 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Unit tests for the model-neutral CFG request-pairing scheduler patches.
The patches keep a cond/uncond request pair step-locked inside one engine:
admitted together, kept at equal ``num_computed_tokens``, finished together.
They are installed onto a lightweight fake scheduler here; vLLM's real
``Scheduler`` needs a full engine (and a GPU) to construct.
"""
from __future__ import annotations
from collections import deque
from dataclasses import dataclass, field
from types import SimpleNamespace
from typing import Any
import pytest
from vllm_omni.model_executor.models.common import cfg_pairing
from vllm_omni.model_executor.models.common.cfg_pairing import (
_equalize_cfg_pair_progress,
_patch_scheduler,
cfg_uncond_suffixes,
register_cfg_uncond_suffix,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
# ───────── fakes ─────────
class _FakeRequest:
"""Minimal stand-in for ``vllm.v1.request.Request`` (identity equality)."""
def __init__(self, request_id: str, extra_args: dict[str, Any] | None = None, step_tokens: int = 1) -> None:
self.request_id = request_id
self.sampling_params = SimpleNamespace(extra_args=extra_args)
self.num_computed_tokens = 0
self.step_tokens = step_tokens
self.skip_reading_prefix_cache = False
class _FakeWaitingQueue(deque):
"""FCFS waiting queue: vLLM's is also a ``deque`` subclass."""
def remove_requests(self, requests: list[_FakeRequest]) -> None:
keep = [req for req in self if not any(req is dropped for dropped in requests)]
self.clear()
self.extend(keep)
def prepend_request(self, request: _FakeRequest) -> None:
self.appendleft(request)
@dataclass
class _FakeSchedulerOutput:
num_scheduled_tokens: dict[str, int] = field(default_factory=dict)
total_num_scheduled_tokens: int = 0
def _make_scheduler_cls() -> type:
"""Fresh class per test so the in-place patches never stack."""
class FakeScheduler:
def __init__(self) -> None:
self.waiting = _FakeWaitingQueue()
self.running: list[_FakeRequest] = []
self.requests: dict[str, _FakeRequest] = {}
self.finished: list[set[str]] = []
self.observed_waiting: list[list[str]] = []
self.max_num_scheduled_tokens = 512
self.scheduler_config = SimpleNamespace(long_prefill_token_threshold=4096)
def add_request(self, request: _FakeRequest) -> None:
self.requests[request.request_id] = request
self.waiting.append(request)
def finish_requests(self, request_ids: Any, finished_status: Any) -> None:
if request_ids is None:
ids = set(self.requests)
elif isinstance(request_ids, str):
ids = {request_ids}
else:
ids = set(request_ids)
self.finished.append(ids)
self.running = [req for req in self.running if req.request_id not in ids]
for req_id in ids:
self.requests.pop(req_id, None)
def schedule(self) -> _FakeSchedulerOutput:
self.observed_waiting.append([req.request_id for req in self.waiting])
while self.waiting:
self.running.append(self.waiting.popleft())
output = _FakeSchedulerOutput()
for req in self.running:
req.num_computed_tokens += req.step_tokens
output.num_scheduled_tokens[req.request_id] = req.step_tokens
output.total_num_scheduled_tokens += req.step_tokens
return output
return FakeScheduler
@pytest.fixture
def scheduler() -> Any:
cls = _make_scheduler_cls()
_patch_scheduler(cls)
return cls()
@pytest.fixture(autouse=True)
def _restore_suffix_registry():
original = set(cfg_pairing._UNCOND_REQUEST_ID_SUFFIXES)
yield
cfg_pairing._UNCOND_REQUEST_ID_SUFFIXES.clear()
cfg_pairing._UNCOND_REQUEST_ID_SUFFIXES.update(original)
def _add_pair(
scheduler: Any,
pair_id: str | None = None,
base_id: str = "req-1",
suffix: str = "__cfg_uncond",
) -> tuple[_FakeRequest, _FakeRequest]:
cond_extra: dict[str, Any] = {"cfg_role": "cond"}
uncond_extra: dict[str, Any] = {"cfg_role": "uncond"}
if pair_id is not None:
cond_extra["cfg_pair_id"] = pair_id
uncond_extra["cfg_pair_id"] = pair_id
cond = _FakeRequest(base_id, cond_extra)
uncond = _FakeRequest(f"{base_id}{suffix}", uncond_extra)
scheduler.add_request(cond)
scheduler.add_request(uncond)
return cond, uncond
# ───────── pair-id derivation ─────────
def test_pair_id_is_derived_from_request_id_when_absent(scheduler: Any) -> None:
cond, uncond = _add_pair(scheduler)
assert scheduler._cfg_pairs == {"req-1": {"cond": cond.request_id, "uncond": uncond.request_id}}
assert scheduler._cfg_req_to_pair == {cond.request_id: "req-1", uncond.request_id: "req-1"}
def test_registered_companion_suffix_is_stripped(scheduler: Any) -> None:
register_cfg_uncond_suffix("::negative")
assert "::negative" in cfg_uncond_suffixes()
cond, uncond = _add_pair(scheduler, base_id="music-7", suffix="::negative")
assert scheduler._cfg_pairs == {"music-7": {"cond": cond.request_id, "uncond": uncond.request_id}}
def test_register_rejects_empty_suffix() -> None:
with pytest.raises(ValueError):
register_cfg_uncond_suffix("")
def test_uncond_without_known_suffix_keeps_its_own_request_id(scheduler: Any) -> None:
# Degenerate input: the companion never joins the cond pair, so the pair
# stays incomplete and the hold budget eventually releases it.
scheduler.add_request(_FakeRequest("solo-uncond", {"cfg_role": "uncond"}))
assert scheduler._cfg_pairs == {"solo-uncond": {"uncond": "solo-uncond"}}
def test_explicit_pair_id_takes_precedence(scheduler: Any) -> None:
cond, uncond = _add_pair(scheduler, pair_id="pair-A")
assert scheduler._cfg_pairs == {"pair-A": {"cond": cond.request_id, "uncond": uncond.request_id}}
assert cond.request_id not in scheduler._cfg_pairs
assert scheduler._cfg_req_to_pair[uncond.request_id] == "pair-A"
def test_cfg_requests_skip_prefix_cache_reads(scheduler: Any) -> None:
cond, uncond = _add_pair(scheduler)
assert cond.skip_reading_prefix_cache is True
assert uncond.skip_reading_prefix_cache is True
# ───────── admission holding ─────────
def test_lone_member_is_held_then_released(scheduler: Any) -> None:
scheduler.add_request(_FakeRequest("req-1", {"cfg_role": "cond"}))
scheduler.schedule()
assert scheduler.observed_waiting == [[]] # the base scheduler saw nothing
assert [req.request_id for req in scheduler.waiting] == ["req-1"]
assert scheduler.running == []
def test_complete_pair_is_admitted_together(scheduler: Any) -> None:
scheduler.add_request(_FakeRequest("req-1", {"cfg_role": "cond"}))
scheduler.schedule()
scheduler.add_request(_FakeRequest("req-1__cfg_uncond", {"cfg_role": "uncond"}))
scheduler.schedule()
assert scheduler.observed_waiting[-1] == ["req-1", "req-1__cfg_uncond"]
assert [req.request_id for req in scheduler.running] == ["req-1", "req-1__cfg_uncond"]
assert list(scheduler.waiting) == []
def test_hold_budget_expiry_drops_the_stale_pair(scheduler: Any, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(cfg_pairing, "_MAX_PAIR_HOLD_STEPS", 2)
scheduler.add_request(_FakeRequest("req-1", {"cfg_role": "cond"}))
scheduler.schedule()
scheduler.schedule()
assert scheduler.running == []
assert scheduler._cfg_pairs # still hoping for the partner
scheduler.schedule()
assert scheduler._cfg_pairs == {}
assert scheduler._cfg_req_to_pair == {}
assert [req.request_id for req in scheduler.running] == ["req-1"]
def test_waiting_queue_reorder_puts_partners_adjacent(scheduler: Any) -> None:
scheduler.add_request(_FakeRequest("req-1", {"cfg_role": "cond"}))
scheduler.add_request(_FakeRequest("other", None))
scheduler.add_request(_FakeRequest("req-1__cfg_uncond", {"cfg_role": "uncond"}))
scheduler.schedule()
assert scheduler.observed_waiting[-1] == ["req-1", "req-1__cfg_uncond", "other"]
# ───────── progress equalization ─────────
def _equalize_fixture(
cond_computed: int,
uncond_computed: int,
cond_sched: int,
uncond_sched: int,
) -> tuple[Any, _FakeSchedulerOutput, _FakeRequest, _FakeRequest]:
cond = _FakeRequest("req-1", {"cfg_role": "cond"})
uncond = _FakeRequest("req-1__cfg_uncond", {"cfg_role": "uncond"})
cond.num_computed_tokens = cond_computed
uncond.num_computed_tokens = uncond_computed
fake = SimpleNamespace(
_cfg_pairs={"req-1": {"cond": cond.request_id, "uncond": uncond.request_id}},
requests={cond.request_id: cond, uncond.request_id: uncond},
)
output = _FakeSchedulerOutput(
num_scheduled_tokens={cond.request_id: cond_sched, uncond.request_id: uncond_sched},
total_num_scheduled_tokens=cond_sched + uncond_sched,
)
return fake, output, cond, uncond
def test_equalize_pulls_the_ahead_member_back() -> None:
fake, output, cond, uncond = _equalize_fixture(10, 6, 5, 4)
_equalize_cfg_pair_progress(fake, output)
assert cond.num_computed_tokens == 6
assert uncond.num_computed_tokens == 6
assert output.num_scheduled_tokens == {cond.request_id: 1, uncond.request_id: 4}
assert output.total_num_scheduled_tokens == 5
def test_equalize_is_a_noop_when_the_pair_is_already_aligned() -> None:
fake, output, cond, uncond = _equalize_fixture(8, 8, 4, 4)
_equalize_cfg_pair_progress(fake, output)
assert (cond.num_computed_tokens, uncond.num_computed_tokens) == (8, 8)
assert output.total_num_scheduled_tokens == 8
def test_equalize_skips_an_infeasible_rollback() -> None:
# Clawing back 4 tokens would leave the cond row with 0 scheduled tokens,
# which the scheduler output cannot express; leave the pair to _drop_split_pairs.
fake, output, cond, uncond = _equalize_fixture(10, 6, 4, 4)
_equalize_cfg_pair_progress(fake, output)
assert cond.num_computed_tokens == 10
assert output.total_num_scheduled_tokens == 8
# ───────── finish ─────────
def test_finish_requests_finishes_the_partner(scheduler: Any) -> None:
cond, uncond = _add_pair(scheduler)
scheduler.schedule()
scheduler.finish_requests(cond.request_id, "FINISHED_STOPPED")
assert scheduler.finished == [{cond.request_id, uncond.request_id}]
assert scheduler._cfg_pairs == {}
assert scheduler._cfg_req_to_pair == {}
assert scheduler.running == []
def test_finish_all_requests_clears_the_registry(scheduler: Any) -> None:
_add_pair(scheduler)
scheduler.schedule()
scheduler.finish_requests(None, "FINISHED_ABORTED")
assert scheduler._cfg_pairs == {}
assert scheduler._cfg_req_to_pair == {}
# ───────── non-CFG traffic ─────────
def test_plain_requests_are_untouched(scheduler: Any) -> None:
scheduler.add_request(_FakeRequest("plain-1", None))
scheduler.add_request(_FakeRequest("plain-2", {"seed": 3}))
output = scheduler.schedule()
assert scheduler._cfg_pairs == {}
assert scheduler.observed_waiting == [["plain-1", "plain-2"]]
assert output.num_scheduled_tokens == {"plain-1": 1, "plain-2": 1}
assert output.total_num_scheduled_tokens == 2
# No pair ever existed, so the hold bookkeeping is never allocated.
assert not hasattr(scheduler, "_cfg_hold_counts")
def test_default_suffix_matches_the_audex_stage_processor() -> None:
from vllm_omni.model_executor.stage_input_processors.audex import CFG_UNCOND_SUFFIX
assert CFG_UNCOND_SUFFIX in cfg_uncond_suffixes()
def test_audex_cfg_module_still_exports_the_patch_entry_points() -> None:
from vllm_omni.model_executor.models.audex.cfg import apply_cfg_patches, cfg_patches_applied
assert apply_cfg_patches is cfg_pairing.apply_cfg_patches
assert cfg_patches_applied is cfg_pairing.cfg_patches_applied
# ───────── install gate ─────────
def test_gate_triggers_on_a_cfg_logits_processor() -> None:
from vllm_omni.engine.stage_init_utils import _stage_declares_cfg_pairs
class AudexCFGLogitsProcessor:
pass
assert _stage_declares_cfg_pairs(SimpleNamespace(logits_processors=[AudexCFGLogitsProcessor]))
# vLLM keeps unresolved entries as dotted strings.
assert _stage_declares_cfg_pairs(
SimpleNamespace(logits_processors=["vllm_omni.model_executor.models.audex.cfg:AudexCFGLogitsProcessor"])
)
def test_gate_triggers_on_a_declared_cfg_role_default() -> None:
from vllm_omni.engine.stage_init_utils import _stage_declares_cfg_pairs
assert _stage_declares_cfg_pairs(
SimpleNamespace(logits_processors=None, sampling_extra_args_keys=("cfg_role", "cfg_scale"))
)
def test_gate_stays_off_for_non_cfg_stages() -> None:
from vllm_omni.engine.stage_init_utils import _stage_declares_cfg_pairs
assert not _stage_declares_cfg_pairs(SimpleNamespace(logits_processors=[], sampling_extra_args_keys=()))
assert not _stage_declares_cfg_pairs(
SimpleNamespace(logits_processors=["some.module:MinPLogitsProcessor"], sampling_extra_args_keys=("tta_rvq",))
)
assert not _stage_declares_cfg_pairs(SimpleNamespace())
def test_sampling_extra_args_keys_are_derived_from_stage_defaults() -> None:
from vllm_omni.engine.stage_init_utils import _sampling_extra_args_keys
assert _sampling_extra_args_keys({"extra_args": {"cfg_scale": 1.5, "cfg_role": "cond"}}) == (
"cfg_role",
"cfg_scale",
)
assert _sampling_extra_args_keys({"temperature": 0.1}) == ()
assert _sampling_extra_args_keys(None) == ()
+5
View File
@@ -146,6 +146,11 @@ class OmniModelConfig(ModelConfig):
task_type: str | None = None
enable_sleep_mode: bool = False
has_sampling_extra_args: bool = False
# Key names (not values) of the stage's default sampling ``extra_args``.
# Engine-core code runs before any request arrives, so this is the only
# place it can learn which request-shaping conventions a stage uses (e.g.
# ``cfg_role`` for classifier-free-guidance request pairs).
sampling_extra_args_keys: tuple[str, ...] = ()
@property
def registry(self):
+2
View File
@@ -85,6 +85,7 @@ from vllm_omni.model_executor.models.ming_tts.pipeline import (
MING_TTS_PIPELINE,
)
from vllm_omni.model_executor.models.minicpmo_4_5.pipeline import MINICPMO_4_5_PIPELINE
from vllm_omni.model_executor.models.minimax_music3.pipeline import MINIMAX_MUSIC3_PIPELINE
from vllm_omni.model_executor.models.moss_tts.pipeline import (
MOSS_TTS_LOCAL_PIPELINE,
MOSS_TTS_PIPELINE,
@@ -177,6 +178,7 @@ OMNI_PIPELINES: dict[str, PipelineConfig | PipelineResolverFunc] = {
"moss_tts_realtime": MOSS_TTS_REALTIME_PIPELINE,
"moss_tts_local": MOSS_TTS_LOCAL_PIPELINE,
"minicpmo_4_5": MINICPMO_4_5_PIPELINE,
"minimax_music3": MINIMAX_MUSIC3_PIPELINE,
"higgs_audio_v2": HIGGS_AUDIO_V2_PIPELINE,
"higgs_multimodal_qwen3": HIGGS_AUDIO_V3_PIPELINE,
"dynin_omni": DYNIN_OMNI_PIPELINE,
+6
View File
@@ -91,6 +91,11 @@ class OmniPayloadMeta(TypedDict, total=False):
ref_context_included: bool
talker_prefill_offset: int
omni_final_stage_id: int
# Per-request audio seed. Stages that draw their own noise (flow-matching
# / diffusion decoders) need the producing stage's seed to stay
# reproducible, and the producing stage's SamplingParams do not travel
# with the payload.
audio_seed: int
class OmniPayload(TypedDict, total=False):
@@ -189,6 +194,7 @@ class MetaStruct(_StructBase):
codec_left_context_frames: int | None = None
code_flat_numel: int | None = None
omni_final_stage_id: int | None = None
audio_seed: int | None = None
class OmniPayloadStruct(_StructBase):
+130
View File
@@ -0,0 +1,130 @@
# MiniMax-Music3 deploy: Stage 0 (AR talker) -> Stage 1 (acoustic decoder),
# both on one 140 GB card, streaming 200-frame windows over shared memory.
#
# Auto-discovered default for model_type=minimax_music3.
#
# Classifier-free guidance is mandatory for this checkpoint: every request
# decodes as a cond/uncond pair in stage 0, so stage-0 KV, max_num_seqs and
# prefill budget are all sized for TWO rows per user request. The stage-0
# extra_args block below is what turns on has_sampling_extra_args (so per-row
# sampling extra args reach the model's forward) and is also how the CFG
# scheduler patches learn this stage is paired.
pipeline: minimax_music3
async_chunk: true
# The repo-root config.json reports model_type=minimax_music3, which upstream
# transformers does not ship a config class for.
trust_remote_code: true
connectors:
connector_of_shared_memory:
name: SharedMemoryConnector
extra:
# Stream stereo PCM back to the client as stage 1 finishes each window.
codec_streaming: true
# Window geometry is fixed by the checkpoint (200-frame windows on a
# 100-frame hop, models/minimax_music3/constants.py), so there are no
# codec_chunk_frames knobs here: changing the window changes the audio.
shm_threshold_bytes: 65536
connector_get_sleep_s: 0.01
# One window is 200 AR frames = 8 s of music, so stage 1 waits far longer
# for a chunk than a per-step codec stage would. First chunk additionally
# covers stage-0 prefill of both CFG rows.
connector_get_max_wait_first_chunk: 30000
connector_get_max_wait: 10000
stages:
- stage_id: 0
# 16 rows = 8 concurrent guided requests, since CFG doubles every request.
max_num_seqs: 16
# ~16 GB of bf16 Qwen3-8B weights plus KV for 16 full-length rows
# (36 layers x 8 KV heads x 128 head_dim ~= 144 KB/token, so ~1.4 GB per
# 10240-token row) fits comfortably inside 0.6 of a 140 GB card.
gpu_memory_utilization: 0.6
dtype: bfloat16
# The checkpoint's max_position_embeddings is 10240 and the whole song is
# decoded in-context. Note this cannot hold a max-length prompt AND a
# max-length song at the same time: the prompt cap is 5000 tokens and the
# frame budget is 9000, which overruns 10240. That is a property of the
# checkpoint, not of this file; long captions shorten the maximum song.
max_model_len: 10240
# One scheduler step must be able to prefill both rows of a pair
# (2 x 5000 prompt tokens) so the cond/uncond pair stays step-locked.
max_num_batched_tokens: 10240
# Two rows at 8B is a launch-bound decode step, so the backbone is
# captured. FULL_DECODE_ONLY only ever dispatches a graph for a uniform
# decode batch; prefill and mixed steps stay eager, which is what the
# talker's frame-feedback fallback is for. The talker's own sampler and
# depth decoder run after the graph, not inside it.
#
# What makes the forward capturable: the frame feedback writes a
# fixed-size slab in prepare_runner_inputs (outside the captured region)
# and the forward selects over it with torch.where, instead of an
# index_copy against an index tensor whose address and length changed
# every step. The row-to-request binding the sampler reads is likewise
# rebuilt in prepare_runner_inputs, because a replayed step never runs
# the forward's Python body.
enforce_eager: false
compilation_config:
# No compilation. The talker walks the backbone's decoder layers itself,
# so the compiled Qwen3Model.forward is never entered and inductor has
# nothing to do; what the mode still decides is which kernels vLLM
# defaults to. Left at its non-eager default it picks the elementwise
# PyTorch norms over the fused vllm_c ones, which is both slower and
# numerically different: measured here, that alone moved the sampled
# codes off the eager trajectory by frame 4. Graph capture is
# independent of this and still happens.
mode: 0
# 16 rows is the stage ceiling, i.e. 8 guided requests.
cudagraph_capture_sizes: [1, 2, 4, 8, 16]
cudagraph_mode: FULL_DECODE_ONLY
cudagraph_num_of_warmups: 1
# Belt and braces on the same point: the non-eager default is "none",
# which hands the norm and activation ops to elementwise PyTorch.
custom_ops: ["all"]
# A CFG pair must be sampled inside one engine step for the logits blend
# and the cond->uncond token copy to line up.
async_scheduling: false
# Off during bring-up: the uncond row is a long run of identical
# <|audio_cfg|> tokens and would take a much larger prefix-cache hit than
# its cond partner, which is exactly the num_computed_tokens skew the CFG
# pair patches then have to undo.
enable_prefix_caching: false
devices: "0"
output_connectors:
to_stage_1: connector_of_shared_memory
default_sampling_params:
temperature: 1.0
# The frame budget is enforced by the model. This ceiling only has to
# leave room for the extra step that delivers the final window.
max_tokens: 9002
extra_args:
cfg_role: cond
cfg_scale: 1.5
- stage_id: 1
# One row per user request: the uncond companion is stage-0-final and
# never reaches the acoustic stage.
max_num_seqs: 8
# No KV cache on this stage; the budget only covers the DiT and DAV
# weights plus per-window latents and activations.
gpu_memory_utilization: 0.2
# The flow-matching solver degrades measurably in bfloat16
# (SUPPORTED_ACOUSTIC_DTYPES in models/minimax_music3/constants.py).
dtype: float32
enforce_eager: true
# Async scheduling builds per-request payloads against the NEXT step's
# input batch, which breaks when chunk arrival staggers requests.
async_scheduling: false
enable_prefix_caching: false
# One placeholder prompt token per arriving window, so a six-minute song
# (90 windows) needs a tiny context.
max_model_len: 4096
max_num_batched_tokens: 4096
devices: "0"
input_connectors:
from_stage_0: connector_of_shared_memory
default_sampling_params:
temperature: 0.0
max_tokens: 4096
seed: 0
+97
View File
@@ -0,0 +1,97 @@
# MiniMax-Music3 two-GPU layout: AR talker on device 0, acoustic decoder on
# device 1.
#
# Splitting the stages removes the memory-budget compromise of the single-card
# file: the talker's CFG-doubled KV no longer competes with the DiT and DAV
# weights, and the flow-matching solver no longer competes with AR decode for
# SMs. Everything else (window geometry, CFG contract, sampling defaults)
# matches minimax_music3.yaml, so the two files stay behaviorally identical.
pipeline: minimax_music3
async_chunk: true
# The repo-root config.json reports model_type=minimax_music3, which upstream
# transformers does not ship a config class for.
trust_remote_code: true
connectors:
connector_of_shared_memory:
name: SharedMemoryConnector
extra:
# Stream stereo PCM back to the client as stage 1 finishes each window.
codec_streaming: true
# Window geometry is fixed by the checkpoint (200-frame windows on a
# 100-frame hop, models/minimax_music3/constants.py), so there are no
# codec_chunk_frames knobs here: changing the window changes the audio.
shm_threshold_bytes: 65536
connector_get_sleep_s: 0.01
# One window is 200 AR frames = 8 s of music, so stage 1 waits far longer
# for a chunk than a per-step codec stage would. First chunk additionally
# covers stage-0 prefill of both CFG rows.
connector_get_max_wait_first_chunk: 30000
connector_get_max_wait: 10000
stages:
- stage_id: 0
# 32 rows = 16 concurrent guided requests, since CFG doubles every request.
# Twice the single-card figure: this stage now owns its whole device.
max_num_seqs: 32
gpu_memory_utilization: 0.85
dtype: bfloat16
# The checkpoint's max_position_embeddings is 10240 and the whole song is
# decoded in-context. Note this cannot hold a max-length prompt AND a
# max-length song at the same time: the prompt cap is 5000 tokens and the
# frame budget is 9000, which overruns 10240. That is a property of the
# checkpoint, not of this file; long captions shorten the maximum song.
max_model_len: 10240
# One scheduler step must be able to prefill both rows of a pair
# (2 x 5000 prompt tokens) so the cond/uncond pair stays step-locked.
max_num_batched_tokens: 10240
# The talker owns its sampler and drives a depth decoder per AR step;
# leave graph capture off until that path is verified graph-safe.
enforce_eager: true
# A CFG pair must be sampled inside one engine step for the logits blend
# and the cond->uncond token copy to line up.
async_scheduling: false
# Off during bring-up: the uncond row is a long run of identical
# <|audio_cfg|> tokens and would take a much larger prefix-cache hit than
# its cond partner, which is exactly the num_computed_tokens skew the CFG
# pair patches then have to undo.
enable_prefix_caching: false
devices: "0"
output_connectors:
to_stage_1: connector_of_shared_memory
default_sampling_params:
temperature: 1.0
# The frame budget is enforced by the model. This ceiling only has to
# leave room for the extra step that delivers the final window.
max_tokens: 9002
extra_args:
cfg_role: cond
cfg_scale: 1.5
- stage_id: 1
# One row per user request: the uncond companion is stage-0-final and
# never reaches the acoustic stage.
max_num_seqs: 16
# No KV cache on this stage, but it now has a device to itself, so the
# solver can hold more windows in flight than the shared-card profile.
gpu_memory_utilization: 0.6
# The flow-matching solver degrades measurably in bfloat16
# (SUPPORTED_ACOUSTIC_DTYPES in models/minimax_music3/constants.py).
dtype: float32
enforce_eager: true
# Async scheduling builds per-request payloads against the NEXT step's
# input batch, which breaks when chunk arrival staggers requests.
async_scheduling: false
enable_prefix_caching: false
# One placeholder prompt token per arriving window, so a six-minute song
# (90 windows) needs a tiny context.
max_model_len: 4096
max_num_batched_tokens: 4096
devices: "1"
input_connectors:
from_stage_0: connector_of_shared_memory
default_sampling_params:
temperature: 0.0
max_tokens: 4096
seed: 0
+2
View File
@@ -232,6 +232,7 @@ class OmniEngineArgs(EngineArgs):
log_stats: bool = False
custom_pipeline_args: dict[str, Any] | None = None
has_sampling_extra_args: bool = False
sampling_extra_args_keys: tuple[str, ...] = ()
def __post_init__(self) -> None:
if self.worker_cls is None:
@@ -409,6 +410,7 @@ class OmniEngineArgs(EngineArgs):
omni_kv_config=self.omni_kv_config,
task_type=self.task_type,
has_sampling_extra_args=self.has_sampling_extra_args,
sampling_extra_args_keys=tuple(self.sampling_extra_args_keys or ()),
)
return omni_config
+69 -17
View File
@@ -743,23 +743,31 @@ class AsyncOmniEngine:
enqueue_ts=time.perf_counter(),
)
def _enqueue_cfg_companions(
def _build_cfg_companions(
self,
parent_id: str,
original_prompt: Any,
stage0_params: Any,
sampling_params_list: list[Any],
) -> None:
"""Expand prompt into CFG companions, process through InputProcessor, and enqueue."""
try:
expanded = self.prompt_expand_func(original_prompt, stage0_params)
except Exception:
logger.exception("[AsyncOmniEngine] prompt_expand_func failed for req %s", parent_id)
return
) -> list[AddCompanionRequestMessage]:
"""Expand a prompt into its CFG companions, without enqueueing any.
Construction is separated from admission so a guided request is
all-or-nothing. A model whose guidance is mandatory cannot decode a
request whose companion never arrived: the pair never completes, the
request occupies scheduler and KV capacity for the scheduler's whole
hold budget, and then produces no audio. Raising here instead means the
caller learns immediately and nothing was admitted.
Raises:
Exception: Whatever prompt expansion or input processing raised.
The caller is expected to let it reach the client.
"""
expanded = self.prompt_expand_func(original_prompt, stage0_params)
if not expanded:
return
return []
companions: list[AddCompanionRequestMessage] = []
for ep in expanded:
cid = f"{parent_id}{ep.request_id_suffix}"
companion_prompt = ep.prompt
@@ -775,6 +783,14 @@ class AsyncOmniEngine:
params=companion_params,
supported_tasks=self.supported_tasks,
)
# Same restore the parent request gets: the upstream input
# processor drops omni-only prompt fields, so without this the
# companion reaches the worker with no additional_information at
# all. That is where ``global_request_id`` lives, and it is what
# was just injected above, so skipping it silently undoes the
# injection: the model sees the companion row with no id and
# cannot match it to its conditioned partner.
request = upgrade_to_omni_request(request, companion_prompt)
request.external_req_id = cid
# Companions are stage-0-final for ordinary downstream payloads,
# but diffusion still needs their CFG KV caches.
@@ -783,7 +799,7 @@ class AsyncOmniEngine:
# Registration of this companion on stage-0's output processor is
# deferred to Orchestrator._handle_add_companion, which routes
# admission through StagePool.submit_initial(..., affinity_request_id=...).
self.request_queue.sync_q.put(
companions.append(
AddCompanionRequestMessage(
companion_id=cid,
parent_id=parent_id,
@@ -793,11 +809,35 @@ class AsyncOmniEngine:
sampling_params_list=companion_spl,
)
)
return companions
def _enqueue_cfg_companions(
self,
parent_id: str,
original_prompt: Any,
stage0_params: Any,
sampling_params_list: list[Any],
) -> None:
"""Build and enqueue CFG companions, tolerating a build failure.
Kept for callers that admit the parent first and cannot roll it back.
Prefer building with :meth:`_build_cfg_companions` before the parent is
admitted, so the pair is atomic.
"""
try:
companions = self._build_cfg_companions(parent_id, original_prompt, stage0_params, sampling_params_list)
except Exception:
logger.exception("[AsyncOmniEngine] CFG companion build failed for req %s", parent_id)
return
for companion in companions:
self.request_queue.sync_q.put(companion)
if not companions:
return
logger.info(
"[AsyncOmniEngine] CFG expansion for req %s: %d companions",
parent_id,
len(expanded),
len(companions),
)
@staticmethod
@@ -1279,16 +1319,28 @@ class AsyncOmniEngine:
reasoning_ended=reasoning_ended,
resumable=resumable,
)
self.request_queue.sync_q.put(msg)
# CFG companion expansion: create and enqueue companion requests
# so the AR stage also generates their KV caches.
# CFG companions are built before the parent is admitted, so the group
# is all-or-nothing: a build failure raises here, nothing is enqueued,
# and the caller sees the error. Admitting the parent first would leave
# an orphan holding scheduler and KV capacity that can never complete,
# because a model whose guidance is mandatory cannot decode a request
# whose companion never arrived.
companions: list[AddCompanionRequestMessage] = []
if self.prompt_expand_func is not None and final_stage_id > 0:
original_prompt = msg.original_prompt
effective_spl = msg.sampling_params_list
stage0_params = effective_spl[0] if effective_spl else None
if stage0_params is not None:
self._enqueue_cfg_companions(request_id, original_prompt, stage0_params, effective_spl)
companions = self._build_cfg_companions(request_id, msg.original_prompt, stage0_params, effective_spl)
self.request_queue.sync_q.put(msg)
for companion in companions:
self.request_queue.sync_q.put(companion)
if companions:
logger.info(
"[AsyncOmniEngine] CFG expansion for req %s: %d companions",
request_id,
len(companions),
)
async def add_request_async(
self,
+5 -4
View File
@@ -31,7 +31,7 @@ from vllm.v1.engine.utils import (
from vllm_omni.distributed.omni_coordinator import create_stage_coord_client
from vllm_omni.engine import OmniEngineCoreRequest
from vllm_omni.engine.stage_init_utils import (
maybe_apply_audex_cfg_patches,
maybe_apply_cfg_scheduler_patches,
set_death_signal,
)
@@ -134,9 +134,10 @@ class StageEngineCoreProc(EngineCoreProc):
_vllm_engine_core_module.EngineCoreRequest,
)
# Audex CFG scheduler patches must land before EngineCore builds
# its Scheduler; gated on the stage's logits_processors config.
maybe_apply_audex_cfg_patches(kwargs.get("vllm_config"))
# CFG pairing scheduler patches must land before EngineCore builds
# its Scheduler; gated on the stage's logits_processors and its
# default sampling extra_args.
maybe_apply_cfg_scheduler_patches(kwargs.get("vllm_config"))
engine_core = StageEngineCoreProc(
*args,
+48 -9
View File
@@ -871,6 +871,20 @@ def _project_omni_stage_engine_args(
return engine_args
def _sampling_extra_args_keys(default_sampling_params: Any) -> tuple[str, ...]:
"""Key names of a stage's default sampling ``extra_args``, sorted.
Only the keys travel into the engine config: engine-core code needs to know
which request-shaping conventions a stage uses (e.g. CFG request pairing)
before any request exists, while the values stay a serving-layer concern.
"""
extra_args = _to_dict(default_sampling_params or {}).get("extra_args") or {}
try:
return tuple(sorted(str(key) for key in extra_args))
except TypeError:
return ()
def _finalize_engine_args_dict(
engine_args_dict: dict[str, Any],
*,
@@ -880,6 +894,7 @@ def _finalize_engine_args_dict(
stage_connector_spec: dict[str, Any] | None,
cli_tokenizer: str | None,
has_sampling_extra_args: bool,
sampling_extra_args_keys: tuple[str, ...] = (),
) -> dict[str, Any]:
"""Apply representation-independent engine adapter behavior."""
pipeline_model_root = model
@@ -960,6 +975,7 @@ def _finalize_engine_args_dict(
engine_args_dict.setdefault("enable_prefix_caching", False)
engine_args_dict["has_sampling_extra_args"] = has_sampling_extra_args
engine_args_dict["sampling_extra_args_keys"] = sampling_extra_args_keys
# TODO: Remove this after the performance regression is fixed
# Set VLLM_USE_FLASHINFER_MOE_FP16=0 for Qwen3-Omni to avoid performance regression
@@ -990,6 +1006,7 @@ def build_legacy_engine_args_dict(
stage_connector_spec=stage_connector_spec,
cli_tokenizer=cli_tokenizer,
has_sampling_extra_args=bool(default_sp.get("extra_args")),
sampling_extra_args_keys=_sampling_extra_args_keys(default_sp),
)
@@ -1036,6 +1053,7 @@ def build_engine_args_dict_from_omni_stage_config(
stage_connector_spec=stage_connector_spec,
cli_tokenizer=cli_tokenizer,
has_sampling_extra_args=stage_config.model_config.has_sampling_extra_args,
sampling_extra_args_keys=_sampling_extra_args_keys(stage_config.model_config.default_sampling_params),
)
@@ -1445,24 +1463,45 @@ def initialize_diffusion_stage(
return create_diffusion_client(model, od_config, metadata, stage_init_timeout, batch_size, use_inline)
def maybe_apply_audex_cfg_patches(vllm_config: Any) -> None:
"""Install the Audex CFG scheduler patches for CFG-configured engines.
def _stage_declares_cfg_pairs(model_config: Any) -> bool:
"""Whether this stage submits classifier-free-guidance request pairs.
Two independent declarations, because a model may own either side of the
mechanism without the other:
* a CFG logits processor is configured (blending implies pairing), or
* the stage's default sampling ``extra_args`` carry ``cfg_role``, which is
how a model declares paired requests without owning a logits processor.
Only the *key set* survives into the engine config (see
``sampling_extra_args_keys``); the values stay in the serving layer.
"""
processors = getattr(model_config, "logits_processors", None) or []
if any("CFGLogitsProcessor" in getattr(proc, "__name__", str(proc)) for proc in processors):
return True
return "cfg_role" in (getattr(model_config, "sampling_extra_args_keys", None) or ())
def maybe_apply_cfg_scheduler_patches(vllm_config: Any) -> None:
"""Install the CFG pairing scheduler patches for CFG-configured engines.
Must run in the engine-core process BEFORE ``Scheduler`` is constructed:
the patch wraps ``Scheduler.__init__`` to add the pair registry, so a
scheduler built earlier would never become pair-aware. Gated on the
engine's ``logits_processors`` so non-CFG stages stay untouched.
scheduler built earlier would never become pair-aware. Gated so non-CFG
stages stay untouched.
"""
model_config = getattr(vllm_config, "model_config", None)
processors = getattr(model_config, "logits_processors", None) or []
if not any("AudexCFGLogitsProcessor" in getattr(proc, "__name__", str(proc)) for proc in processors):
if model_config is None or not _stage_declares_cfg_pairs(model_config):
return
from vllm_omni.model_executor.models.audex.cfg import apply_cfg_patches
from vllm_omni.model_executor.models.common.cfg_pairing import apply_cfg_patches
apply_cfg_patches()
from vllm.v1.core.sched.scheduler import Scheduler
if not getattr(Scheduler.schedule, "_audex_cfg_patched", False):
raise RuntimeError("Audex CFG scheduler patches failed to install before Scheduler construction")
if not getattr(Scheduler.schedule, "_cfg_pairing_patched", False):
raise RuntimeError("CFG pairing scheduler patches failed to install before Scheduler construction")
# Name kept for callers written against the Audex-only gate.
maybe_apply_audex_cfg_patches = maybe_apply_cfg_scheduler_patches
@@ -111,6 +111,7 @@ from vllm_omni.entrypoints.openai.tts_adapters import ( # noqa: E402,F401
indextts2,
ming_flash_omni_tts,
ming_tts,
minimax_music3,
moss_tts,
omnivoice,
qwen3_tts,
@@ -0,0 +1,163 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""MiniMax Music 3 serving adapter for ``/v1/audio/speech``.
This is a text-to-music model on a speech endpoint, so most of the speech
contract does not apply. ``input`` carries the lyrics and ``instructions`` the
caption that describes genre, instrumentation, tempo and production. There is
no speaker to select, no reference audio, and no temperature: sampling is
fixed by the checkpoint. Unsupported parameters are rejected rather than
silently ignored, so a mistaken request fails immediately instead of quietly
returning something the caller did not ask for.
"""
from typing import TYPE_CHECKING, Any
from vllm_omni.entrypoints.openai.tts_adapters import register_tts_adapter
from vllm_omni.entrypoints.openai.tts_adapters.base import ARTTSAdapter, PreparedRequest
from vllm_omni.model_executor.models.minimax_music3.constants import (
AUDIO_FRAME_RATE,
MAX_AUDIO_FRAMES,
MAX_PROMPT_TOKENS,
)
from vllm_omni.model_executor.models.minimax_music3.prompt import (
build_prompt,
validate_tokenizer_ids,
)
if TYPE_CHECKING:
from vllm_omni.entrypoints.openai.protocol.audio import OpenAICreateSpeechRequest
# Captions are long by design: the checkpoint was trained on structured
# descriptions covering global attributes, emotional progression, vocal detail
# and arrangement. The reference captions run to roughly 5,000 characters, so
# the generic 500-character instructions cap does not apply here. The real
# limit is the tokenized prompt cap, enforced downstream.
_MAX_CAPTION_CHARS = 20_000
# Sampling is fixed by the checkpoint: guidance at 1.5, then a seeded top-k 50
# draw. Accepting these would imply a control the model does not have.
_UNSUPPORTED_SAMPLING_PARAMS = ("temperature", "top_p", "top_k", "repetition_penalty")
# CFG pair plumbing is derived from the request id by the scheduler. A caller
# supplying these would desync the guided pair.
_INTERNAL_CFG_KEYS = ("cfg_role", "cfg_pair_id", "cfg_null_prompt")
@register_tts_adapter
class MiniMaxMusic3Adapter(ARTTSAdapter):
"""Lyrics plus caption in, one 32 kHz stereo song out."""
name = "minimax_music3"
stage_keys = frozenset({"minimax_music3_ar"})
# 9,000 frames at 25 fps is six minutes, the checkpoint's own ceiling.
max_new_tokens_min = 1
max_new_tokens_max = MAX_AUDIO_FRAMES
def __init__(self, ctx: Any) -> None:
super().__init__(ctx)
self._cached_tokenizer: Any = None
def validate(self, request: "OpenAICreateSpeechRequest") -> str | None:
if not request.input or not request.input.strip():
return "MiniMax Music 3 requires non-empty 'input' carrying the lyrics"
instructions = request.instructions
if not isinstance(instructions, str) or not instructions.strip():
return (
"MiniMax Music 3 requires 'instructions' describing the music "
"(genre, instrumentation, tempo, mood); it is what decides the arrangement"
)
if len(instructions) > _MAX_CAPTION_CHARS:
return f"'instructions' exceeds the maximum length of {_MAX_CAPTION_CHARS} characters"
if request.max_new_tokens is not None:
if request.max_new_tokens < self.max_new_tokens_min:
return f"'max_new_tokens' must be at least {self.max_new_tokens_min}"
if request.max_new_tokens > self.max_new_tokens_max:
return (
f"'max_new_tokens' counts audio frames at {AUDIO_FRAME_RATE} per second "
f"and cannot exceed {self.max_new_tokens_max}"
)
voice = (request.voice or "").strip().lower()
if voice not in ("", "default"):
return (
"MiniMax Music 3 has no speaker to select; the vocal comes from "
f"'instructions'. Omit 'voice' or pass 'default'; got {request.voice!r}"
)
if request.ref_audio is not None or request.ref_text is not None:
return "MiniMax Music 3 does not support reference-audio conditioning"
if request.language is not None:
return "MiniMax Music 3 has no language tag; the language follows the lyrics"
if request.task_type is not None:
return "MiniMax Music 3 does not support 'task_type'"
if request.speed is not None and float(request.speed) != 1.0:
return "MiniMax Music 3 only supports speed=1.0; put the tempo in 'instructions', e.g. 'at 92 BPM'"
extra: dict[str, Any] = request.extra_params or {}
for key in _INTERNAL_CFG_KEYS:
if key in extra:
return f"extra_params.{key} is managed internally by the server and cannot be set"
rejected = sorted(key for key in _UNSUPPORTED_SAMPLING_PARAMS if extra.get(key) is not None)
if rejected:
return "MiniMax Music 3 has fixed sampling and does not accept: " + ", ".join(rejected)
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
prompt = build_prompt(request.instructions, request.input)
# Submit token ids, not text. Guidance needs a companion whose prompt is
# the same length with the caption-and-lyrics span replaced, and that
# length match can only be guaranteed on ids. Handing over text would
# leave the expansion with nothing to build the twin from, and a request
# that decodes unguided produces a different song.
token_ids = self._tokenizer().encode(prompt)
if len(token_ids) > MAX_PROMPT_TOKENS:
raise ValueError(
f"MiniMax Music 3 prompt is {len(token_ids)} tokens; "
f"the maximum is {MAX_PROMPT_TOKENS}. Shorten the caption or the lyrics."
)
# The frame budget is enforced by the model, not by the engine's
# max_tokens. The model needs one decode step beyond the last frame to
# deliver the song's final window, so capping the engine at exactly
# this many tokens would cut the tail off.
max_frames = int(request.max_new_tokens or MAX_AUDIO_FRAMES)
# Carried on the prompt rather than through tts_params: the adapter
# path hands PreparedRequest.prompt to the engine untouched, so only
# what is attached here reaches the model's per-request info.
return PreparedRequest(
prompt={
"prompt_token_ids": token_ids,
"additional_information": {"max_audio_frames": [max_frames]},
},
tts_params={"max_audio_frames": [max_frames]},
model_type=self.name,
)
def _tokenizer(self) -> Any:
"""The music tokenizer, loaded once per process.
Its special-token ids are pinned: a checkpoint whose tokenizer
disagrees is not this model, and quietly producing noise is worse than
failing at the first request.
"""
if self._cached_tokenizer is None:
from transformers import AutoTokenizer
stage = getattr(self.ctx.server, "_tts_stage", None)
model_path = getattr(getattr(stage, "engine_args", None), "tokenizer", None)
if not model_path:
model_path = self.ctx.server.engine_client.model_config.tokenizer
tokenizer = AutoTokenizer.from_pretrained(model_path, trust_remote_code=True)
validate_tokenizer_ids(tokenizer)
self._cached_tokenizer = tokenizer
return self._cached_tokenizer
__all__ = ["MiniMaxMusic3Adapter"]
+16 -375
View File
@@ -3,8 +3,7 @@
#
# Adapted from nvidia/Nemotron-Labs-Audex-2B (Apache-2.0):
# inference_scripts_vllm/audiogen_scripts/cfg_logits_processor.py
# inference_scripts_vllm/audiogen_scripts/vllm_cfg_patch.py
# Divergences from the official files are commented at the divergence site.
# Divergences from the official file are commented at the divergence site.
"""Classifier-free guidance for the Audex thinker stage.
CFG pairs a conditional request with an unconditional (null-prompt)
@@ -20,16 +19,13 @@ Requests opt in via ``SamplingParams.extra_args``:
uncond: {"cfg_scale": 1.5, "cfg_role": "uncond", "cfg_pair_id": <id>}
Blending is only correct when both pair members decode the same position in
the same engine step, so ``apply_cfg_patches()`` makes the vLLM scheduler
pair-aware: a lone member waits for its partner, partners stay adjacent,
their ``num_computed_tokens`` are equalized after every schedule, and they
finish together. The patch is a no-op for engines that never see a CFG
request (the pair registry stays empty).
the same engine step. Keeping the pair step-locked is model-neutral and lives
in :mod:`vllm_omni.model_executor.models.common.cfg_pairing`; the scheduler
patches are re-exported here so existing imports keep working.
"""
from __future__ import annotations
from collections import deque
from typing import Any
import torch
@@ -42,18 +38,20 @@ from vllm.v1.sample.logits_processor import (
MoveDirectionality,
)
from vllm_omni.model_executor.models.common.cfg_pairing import (
_COND,
_UNCOND,
apply_cfg_patches,
cfg_patches_applied,
)
logger = init_logger(__name__)
_COND = "cond"
_UNCOND = "uncond"
def _cfg_extra(request: Any, key: str) -> Any:
sampling_params = getattr(request, "sampling_params", None)
extra_args = getattr(sampling_params, "extra_args", None)
if extra_args:
return extra_args.get(key)
return None
__all__ = [
"AudexCFGLogitsProcessor",
"apply_cfg_patches",
"cfg_patches_applied",
]
class AudexCFGLogitsProcessor(LogitsProcessor):
@@ -208,360 +206,3 @@ class AudexCFGLogitsProcessor(LogitsProcessor):
logits[uncond_idx] = blended
return logits
# ---------------------------------------------------------------------------
# Scheduler pair synchronization
# ---------------------------------------------------------------------------
def _wait_queues(scheduler: Any) -> list[Any]:
# Divergence from the official (vLLM 0.20-era) file: vLLM 0.24 parks
# structurally not-ready requests in a second ``skipped_waiting`` queue;
# pair-hold must cover both queues or a lone member parked there would
# be admitted without its partner.
queues = [scheduler.waiting]
skipped = getattr(scheduler, "skipped_waiting", None)
if skipped is not None:
queues.append(skipped)
return queues
def _pair_complete(scheduler: Any, request_id: str, blocked_ids: set[str] | None = None) -> bool:
pair_id = scheduler._cfg_req_to_pair.get(request_id)
if pair_id is None:
return True
roles = scheduler._cfg_pairs.get(pair_id, {})
if len(roles) != 2 or not all(rid in scheduler.requests for rid in roles.values()):
return False
if blocked_ids:
# A partner parked in skipped_waiting (e.g. waiting for remote KVs)
# may fail promotion this step while this member gets scheduled from
# waiting — hold this member until the partner is schedulable.
for rid in roles.values():
if rid != request_id and rid in blocked_ids:
return False
return True
# Schedule steps a lone pair member may wait for its partner. Partners arrive
# within a handful of steps in practice; exhausting this budget means the
# companion was never created (e.g. prompt expansion failed), and the request
# is released to decode unguided instead of hanging forever.
_MAX_PAIR_HOLD_STEPS = 512
def _drop_stale_pair(scheduler: Any, request_id: str) -> None:
pair_id = scheduler._cfg_req_to_pair.get(request_id)
if pair_id is None:
return
for rid in scheduler._cfg_pairs.pop(pair_id, {}).values():
scheduler._cfg_req_to_pair.pop(rid, None)
logger.warning(
"CFG pair %s: partner of %s never arrived after %d schedule steps; releasing the request unguided",
pair_id,
request_id,
_MAX_PAIR_HOLD_STEPS,
)
def _hold_incomplete_pairs(scheduler: Any) -> list[tuple[Any, Any]]:
"""Pull CFG requests whose partner is not yet admitted out of the wait queues.
The engine schedules as soon as the first member of a pair arrives over
IPC; without the hold, the lone member prefills one step early and the
pair is permanently offset by one token. Held requests are re-prepended
after the schedule step.
"""
hold_counts: dict[str, int] = getattr(scheduler, "_cfg_hold_counts", None) or {}
scheduler._cfg_hold_counts = hold_counts
skipped = getattr(scheduler, "skipped_waiting", None)
blocked_ids = {req.request_id for req in list(skipped)} if skipped is not None else set()
held: list[tuple[Any, Any]] = []
for queue in _wait_queues(scheduler):
# A member is only "blocked" for its partner's sake when it sits in
# the OTHER queue; members of the queue being scanned move together.
partner_blockers = blocked_ids if queue is not skipped else set()
held_here = []
for request in list(queue):
if _pair_complete(scheduler, request.request_id, partner_blockers):
hold_counts.pop(request.request_id, None)
continue
count = hold_counts.get(request.request_id, 0) + 1
if count > _MAX_PAIR_HOLD_STEPS:
hold_counts.pop(request.request_id, None)
_drop_stale_pair(scheduler, request.request_id)
continue
hold_counts[request.request_id] = count
held_here.append(request)
if held_here:
queue.remove_requests(held_here)
held.extend((queue, req) for req in held_here)
return held
def _release_held(held: list[tuple[Any, Any]]) -> None:
for queue, request in reversed(held):
queue.prepend_request(request)
def _reorder_waiting_for_cfg(scheduler: Any) -> None:
"""Move CFG pair partners adjacent in the FCFS waiting queue."""
waiting = scheduler.waiting
# Only the FCFS queue (a deque) has caller-controlled ordering; priority
# queues order themselves and pair sync then relies on hold + equalize.
if not isinstance(waiting, deque) or len(waiting) < 2:
return
requests = list(waiting)
waiting.clear()
seen: set[str] = set()
result: list[Any] = []
for request in requests:
rid = request.request_id
if rid in seen:
continue
seen.add(rid)
result.append(request)
pair_id = scheduler._cfg_req_to_pair.get(rid)
if pair_id is None:
continue
for partner_id in scheduler._cfg_pairs.get(pair_id, {}).values():
if partner_id != rid and partner_id not in seen:
for candidate in requests:
if candidate.request_id == partner_id:
seen.add(partner_id)
result.append(candidate)
break
waiting.extend(result)
def _equalize_cfg_pair_progress(scheduler: Any, scheduler_output: Any) -> None:
"""Bring both members of every CFG pair to the same ``num_computed_tokens``.
Prefix-cache hits or unequal chunked-prefill budget can leave one member
ahead after a schedule step; reduce the faster member's allocation so
both land on the same position. Over-allocated KV blocks remain and are
consumed next step — nothing leaks.
"""
for roles in scheduler._cfg_pairs.values():
cond_id = roles.get(_COND)
uncond_id = roles.get(_UNCOND)
if not cond_id or not uncond_id:
continue
cond_sched = scheduler_output.num_scheduled_tokens.get(cond_id, 0)
uncond_sched = scheduler_output.num_scheduled_tokens.get(uncond_id, 0)
if cond_sched == 0 or uncond_sched == 0:
continue
cond_req = scheduler.requests.get(cond_id)
uncond_req = scheduler.requests.get(uncond_id)
if not cond_req or not uncond_req:
continue
if cond_req.num_computed_tokens == uncond_req.num_computed_tokens:
continue
target = min(cond_req.num_computed_tokens, uncond_req.num_computed_tokens)
feasible = all(
req.num_computed_tokens - target < sched
for req, sched in ((cond_req, cond_sched), (uncond_req, uncond_sched))
)
if not feasible:
continue
for req_id, req, orig_sched in (
(cond_id, cond_req, cond_sched),
(uncond_id, uncond_req, uncond_sched),
):
diff = req.num_computed_tokens - target
if diff > 0:
req.num_computed_tokens = target
scheduler_output.num_scheduled_tokens[req_id] = orig_sched - diff
scheduler_output.total_num_scheduled_tokens -= diff
logger.debug(
"CFG equalize %s: %d -> %d scheduled, computed -> %d",
req_id,
orig_sched,
orig_sched - diff,
target,
)
def _drop_split_pairs(scheduler: Any) -> None:
"""Deregister a desynced CFG pair (one member preempted / left behind).
Manual re-preemption surgery is unsafe against the upstream scheduler.
Instead, a pair that split anyway (KV-pressure preemption of exactly one
member, or unrecoverable progress skew) is dropped from the registry:
the cond request continues UNGUIDED (its logits are never blended again)
rather than being re-paired against a desynced partner, and the
companion's discarded output no longer matters.
"""
if not scheduler._cfg_pairs:
return
running_ids = {req.request_id for req in scheduler.running}
to_drop: list[tuple[str, str, str, str]] = []
for pair_id, roles in scheduler._cfg_pairs.items():
cond_id = roles.get(_COND)
uncond_id = roles.get(_UNCOND)
if cond_id is None or uncond_id is None:
continue
if cond_id not in scheduler.requests or uncond_id not in scheduler.requests:
continue
cond_running = cond_id in running_ids
uncond_running = uncond_id in running_ids
if cond_running != uncond_running:
# Split only counts once decoding started: a lone member that has
# computed tokens while its partner was pushed back is desynced.
runner = scheduler.requests.get(cond_id if cond_running else uncond_id)
if runner is not None and runner.num_computed_tokens > 0:
to_drop.append((pair_id, cond_id, uncond_id, "one member preempted"))
continue
if cond_running and uncond_running:
cond_req = scheduler.requests.get(cond_id)
uncond_req = scheduler.requests.get(uncond_id)
if cond_req and uncond_req and cond_req.num_computed_tokens != uncond_req.num_computed_tokens:
logger.warning(
"CFG pair progress mismatch (equalize will retry): %s@%d vs %s@%d",
cond_id,
cond_req.num_computed_tokens,
uncond_id,
uncond_req.num_computed_tokens,
)
for pair_id, cond_id, uncond_id, reason in to_drop:
scheduler._cfg_pairs.pop(pair_id, None)
scheduler._cfg_req_to_pair.pop(cond_id, None)
scheduler._cfg_req_to_pair.pop(uncond_id, None)
logger.warning(
"CFG pair %s split (%s); releasing cond=%s unguided, uncond=%s output is discarded",
pair_id,
reason,
cond_id,
uncond_id,
)
_patches_applied = False
def cfg_patches_applied() -> bool:
"""True once :func:`apply_cfg_patches` has patched the scheduler in this process."""
return _patches_applied
def _patch_scheduler() -> None:
from vllm.v1.core.sched.scheduler import Scheduler
orig_init = Scheduler.__init__
orig_add = Scheduler.add_request
orig_finish = Scheduler.finish_requests
orig_schedule = Scheduler.schedule
def _init(self, *args: Any, **kwargs: Any) -> None:
orig_init(self, *args, **kwargs)
self._cfg_pairs: dict[str, dict[str, str]] = {}
self._cfg_req_to_pair: dict[str, str] = {}
def _add(self, request: Any) -> None:
orig_add(self, request)
pair_id = _cfg_extra(request, "cfg_pair_id")
role = _cfg_extra(request, "cfg_role")
if pair_id and role:
self._cfg_pairs.setdefault(pair_id, {})[role] = request.request_id
self._cfg_req_to_pair[request.request_id] = pair_id
# An asymmetric prefix-cache hit (the cond prompt shares cached
# branches; the <unk> null prompt does not) can advance one
# member past what post-schedule equalization can claw back.
# Skip prefix-cache reads for CFG requests entirely.
if hasattr(request, "skip_reading_prefix_cache"):
request.skip_reading_prefix_cache = True
def _finish(self, request_ids: Any, finished_status: Any) -> Any:
if request_ids is None:
result = orig_finish(self, request_ids, finished_status)
self._cfg_pairs.clear()
self._cfg_req_to_pair.clear()
return result
if isinstance(request_ids, str):
request_ids = {request_ids}
else:
request_ids = set(request_ids)
# Finish both members together: a surviving partner would decode
# unpaired garbage (its logits are never blended again).
partner_ids: set[str] = set()
for req_id in request_ids:
pair_id = self._cfg_req_to_pair.get(req_id)
if pair_id is None:
continue
for rid in self._cfg_pairs.get(pair_id, {}).values():
if rid != req_id and rid in self.requests:
partner_ids.add(rid)
all_ids = request_ids | partner_ids
result = orig_finish(self, all_ids, finished_status)
for req_id in all_ids:
pair_id = self._cfg_req_to_pair.pop(req_id, None)
if pair_id:
self._cfg_pairs.pop(pair_id, None)
return result
def _schedule(self, *args: Any, **kwargs: Any) -> Any:
if not self._cfg_pairs:
return orig_schedule(self, *args, **kwargs)
_reorder_waiting_for_cfg(self)
held = _hold_incomplete_pairs(self)
# Chunked prefill can split one pair member's prompt across steps
# while the other fits in a single step; halving the long-prefill
# threshold makes both chunk, and equalize aligns the remainder.
scheduler_config = getattr(self, "scheduler_config", None)
orig_threshold = getattr(scheduler_config, "long_prefill_token_threshold", None)
if scheduler_config is not None and orig_threshold is not None:
scheduler_config.long_prefill_token_threshold = self.max_num_scheduled_tokens // 2
try:
scheduler_output = orig_schedule(self, *args, **kwargs)
finally:
if scheduler_config is not None and orig_threshold is not None:
scheduler_config.long_prefill_token_threshold = orig_threshold
_release_held(held)
_equalize_cfg_pair_progress(self, scheduler_output)
_drop_split_pairs(self)
return scheduler_output
_schedule._audex_cfg_patched = True # type: ignore[attr-defined]
Scheduler.__init__ = _init
Scheduler.add_request = _add
Scheduler.finish_requests = _finish
Scheduler.schedule = _schedule
def apply_cfg_patches() -> None:
"""Make the vLLM v1 scheduler CFG-pair-aware. Idempotent, per process.
Must run in the engine-core process before ``Scheduler`` is constructed
(the ``__init__`` wrapper installs the pair registry).
"""
global _patches_applied
if _patches_applied:
return
_patches_applied = True
logger.info("Applying Audex CFG scheduler patches to vLLM v1")
_patch_scheduler()
@@ -0,0 +1,460 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
#
# Adapted from nvidia/Nemotron-Labs-Audex-2B (Apache-2.0):
# inference_scripts_vllm/audiogen_scripts/vllm_cfg_patch.py
# Divergences from the official file are commented at the divergence site.
"""Model-neutral classifier-free-guidance request pairing for the vLLM v1 scheduler.
A guided request decodes together with an unconditional companion so their
logits can be blended every step. Blending is only correct when both members
decode the same position in the same engine step, so :func:`apply_cfg_patches`
makes the scheduler pair-aware: a lone member waits for its partner, partners
stay adjacent in the waiting queue, their ``num_computed_tokens`` are equalized
after every schedule, and they finish together.
Requests opt in through ``SamplingParams.extra_args``::
cond: {"cfg_role": "cond", "cfg_pair_id": <id>}
uncond: {"cfg_role": "uncond", "cfg_pair_id": <id>}
``cfg_pair_id`` is optional. A model whose serving layer cannot mint a pair id
(the companion request id is only known inside the engine) may send ``cfg_role``
alone; the pair id is then derived from the request id, stripping the
companion's registered suffix (see :func:`register_cfg_uncond_suffix`).
Logit blending itself is the caller's concern: this module only keeps the two
rows step-locked. The patch is a no-op for engines that never see a CFG request
(the pair registry stays empty).
"""
from __future__ import annotations
from collections import deque
from typing import Any
from vllm.logger import init_logger
logger = init_logger(__name__)
__all__ = [
"apply_cfg_patches",
"cfg_patches_applied",
"cfg_uncond_suffixes",
"register_cfg_uncond_suffix",
]
_COND = "cond"
_UNCOND = "uncond"
# Request-id suffix appended to the companion request by the Audex stage input
# processor (``CFG_UNCOND_SUFFIX`` in
# vllm_omni/model_executor/stage_input_processors/audex.py). Models that mint
# the companion request id differently register their own suffix.
_UNCOND_REQUEST_ID_SUFFIXES: set[str] = {"__cfg_uncond"}
def register_cfg_uncond_suffix(suffix: str) -> None:
"""Register a companion request-id suffix used to derive a missing pair id."""
if not suffix or not isinstance(suffix, str):
raise ValueError(f"CFG uncond request-id suffix must be a non-empty string, got {suffix!r}")
_UNCOND_REQUEST_ID_SUFFIXES.add(suffix)
def cfg_uncond_suffixes() -> frozenset[str]:
"""Companion request-id suffixes registered in this process."""
return frozenset(_UNCOND_REQUEST_ID_SUFFIXES)
def _derive_pair_id(request_id: str, role: str) -> str:
"""Pair id for a request that carries ``cfg_role`` but no ``cfg_pair_id``.
The cond request id IS the pair id; its companion carries the same id plus
a registered suffix, so stripping the suffix recovers it. Longest suffix
first, so a registered suffix extending another still resolves.
"""
if role == _UNCOND:
for suffix in sorted(_UNCOND_REQUEST_ID_SUFFIXES, key=len, reverse=True):
if request_id.endswith(suffix):
return request_id[: -len(suffix)]
return request_id
def _cfg_extra(request: Any, key: str) -> Any:
sampling_params = getattr(request, "sampling_params", None)
extra_args = getattr(sampling_params, "extra_args", None)
if extra_args:
return extra_args.get(key)
return None
def _wait_queues(scheduler: Any) -> list[Any]:
# Divergence from the official (vLLM 0.20-era) file: vLLM 0.24 parks
# structurally not-ready requests in a second ``skipped_waiting`` queue;
# pair-hold must cover both queues or a lone member parked there would
# be admitted without its partner.
queues = [scheduler.waiting]
skipped = getattr(scheduler, "skipped_waiting", None)
if skipped is not None:
queues.append(skipped)
return queues
def _pair_complete(scheduler: Any, request_id: str, blocked_ids: set[str] | None = None) -> bool:
pair_id = scheduler._cfg_req_to_pair.get(request_id)
if pair_id is None:
return True
roles = scheduler._cfg_pairs.get(pair_id, {})
if len(roles) != 2 or not all(rid in scheduler.requests for rid in roles.values()):
return False
if blocked_ids:
# A partner parked in skipped_waiting (e.g. waiting for remote KVs)
# may fail promotion this step while this member gets scheduled from
# waiting; hold this member until the partner is schedulable.
for rid in roles.values():
if rid != request_id and rid in blocked_ids:
return False
return True
# Schedule steps a lone pair member may wait for its partner. Partners arrive
# within a handful of steps in practice; exhausting this budget means the
# companion was never created (e.g. prompt expansion failed), and the request
# is released to decode unguided instead of hanging forever.
_MAX_PAIR_HOLD_STEPS = 512
def _drop_stale_pair(scheduler: Any, request_id: str) -> None:
pair_id = scheduler._cfg_req_to_pair.get(request_id)
if pair_id is None:
return
for rid in scheduler._cfg_pairs.pop(pair_id, {}).values():
scheduler._cfg_req_to_pair.pop(rid, None)
logger.warning(
"CFG pair %s: partner of %s never arrived after %d schedule steps; releasing the request unguided",
pair_id,
request_id,
_MAX_PAIR_HOLD_STEPS,
)
def _hold_incomplete_pairs(scheduler: Any) -> list[tuple[Any, Any]]:
"""Pull CFG requests whose partner is not yet admitted out of the wait queues.
The engine schedules as soon as the first member of a pair arrives over
IPC; without the hold, the lone member prefills one step early and the
pair is permanently offset by one token. Held requests are re-prepended
after the schedule step.
"""
hold_counts: dict[str, int] = getattr(scheduler, "_cfg_hold_counts", None) or {}
scheduler._cfg_hold_counts = hold_counts
skipped = getattr(scheduler, "skipped_waiting", None)
blocked_ids = {req.request_id for req in list(skipped)} if skipped is not None else set()
held: list[tuple[Any, Any]] = []
for queue in _wait_queues(scheduler):
# A member is only "blocked" for its partner's sake when it sits in
# the OTHER queue; members of the queue being scanned move together.
partner_blockers = blocked_ids if queue is not skipped else set()
held_here = []
for request in list(queue):
if _pair_complete(scheduler, request.request_id, partner_blockers):
hold_counts.pop(request.request_id, None)
continue
count = hold_counts.get(request.request_id, 0) + 1
if count > _MAX_PAIR_HOLD_STEPS:
hold_counts.pop(request.request_id, None)
_drop_stale_pair(scheduler, request.request_id)
continue
hold_counts[request.request_id] = count
held_here.append(request)
if held_here:
queue.remove_requests(held_here)
held.extend((queue, req) for req in held_here)
return held
def _release_held(held: list[tuple[Any, Any]]) -> None:
for queue, request in reversed(held):
queue.prepend_request(request)
def _reorder_waiting_for_cfg(scheduler: Any) -> None:
"""Move CFG pair partners adjacent in the FCFS waiting queue."""
waiting = scheduler.waiting
# Only the FCFS queue (a deque) has caller-controlled ordering; priority
# queues order themselves and pair sync then relies on hold + equalize.
if not isinstance(waiting, deque) or len(waiting) < 2:
return
requests = list(waiting)
waiting.clear()
seen: set[str] = set()
result: list[Any] = []
for request in requests:
rid = request.request_id
if rid in seen:
continue
seen.add(rid)
result.append(request)
pair_id = scheduler._cfg_req_to_pair.get(rid)
if pair_id is None:
continue
for partner_id in scheduler._cfg_pairs.get(pair_id, {}).values():
if partner_id != rid and partner_id not in seen:
for candidate in requests:
if candidate.request_id == partner_id:
seen.add(partner_id)
result.append(candidate)
break
waiting.extend(result)
def _equalize_cfg_pair_progress(scheduler: Any, scheduler_output: Any) -> None:
"""Bring both members of every CFG pair to the same ``num_computed_tokens``.
Prefix-cache hits or unequal chunked-prefill budget can leave one member
ahead after a schedule step; reduce the faster member's allocation so
both land on the same position. Over-allocated KV blocks remain and are
consumed next step, so nothing leaks.
"""
for roles in scheduler._cfg_pairs.values():
cond_id = roles.get(_COND)
uncond_id = roles.get(_UNCOND)
if not cond_id or not uncond_id:
continue
cond_sched = scheduler_output.num_scheduled_tokens.get(cond_id, 0)
uncond_sched = scheduler_output.num_scheduled_tokens.get(uncond_id, 0)
if cond_sched == 0 or uncond_sched == 0:
continue
cond_req = scheduler.requests.get(cond_id)
uncond_req = scheduler.requests.get(uncond_id)
if not cond_req or not uncond_req:
continue
if cond_req.num_computed_tokens == uncond_req.num_computed_tokens:
continue
target = min(cond_req.num_computed_tokens, uncond_req.num_computed_tokens)
feasible = all(
req.num_computed_tokens - target < sched
for req, sched in ((cond_req, cond_sched), (uncond_req, uncond_sched))
)
if not feasible:
continue
for req_id, req, orig_sched in (
(cond_id, cond_req, cond_sched),
(uncond_id, uncond_req, uncond_sched),
):
diff = req.num_computed_tokens - target
if diff > 0:
req.num_computed_tokens = target
scheduler_output.num_scheduled_tokens[req_id] = orig_sched - diff
scheduler_output.total_num_scheduled_tokens -= diff
logger.debug(
"CFG equalize %s: %d -> %d scheduled, computed -> %d",
req_id,
orig_sched,
orig_sched - diff,
target,
)
def _drop_split_pairs(scheduler: Any) -> None:
"""Deregister a desynced CFG pair (one member preempted / left behind).
Manual re-preemption surgery is unsafe against the upstream scheduler.
Instead, a pair that split anyway (KV-pressure preemption of exactly one
member, or unrecoverable progress skew) is dropped from the registry:
the cond request continues UNGUIDED (its logits are never blended again)
rather than being re-paired against a desynced partner, and the
companion's discarded output no longer matters.
"""
if not scheduler._cfg_pairs:
return
running_ids = {req.request_id for req in scheduler.running}
to_drop: list[tuple[str, str, str, str]] = []
for pair_id, roles in scheduler._cfg_pairs.items():
cond_id = roles.get(_COND)
uncond_id = roles.get(_UNCOND)
if cond_id is None or uncond_id is None:
continue
if cond_id not in scheduler.requests or uncond_id not in scheduler.requests:
continue
cond_running = cond_id in running_ids
uncond_running = uncond_id in running_ids
if cond_running != uncond_running:
# Split only counts once decoding started: a lone member that has
# computed tokens while its partner was pushed back is desynced.
runner = scheduler.requests.get(cond_id if cond_running else uncond_id)
if runner is not None and runner.num_computed_tokens > 0:
to_drop.append((pair_id, cond_id, uncond_id, "one member preempted"))
continue
if cond_running and uncond_running:
cond_req = scheduler.requests.get(cond_id)
uncond_req = scheduler.requests.get(uncond_id)
if cond_req and uncond_req and cond_req.num_computed_tokens != uncond_req.num_computed_tokens:
logger.warning(
"CFG pair progress mismatch (equalize will retry): %s@%d vs %s@%d",
cond_id,
cond_req.num_computed_tokens,
uncond_id,
uncond_req.num_computed_tokens,
)
for pair_id, cond_id, uncond_id, reason in to_drop:
scheduler._cfg_pairs.pop(pair_id, None)
scheduler._cfg_req_to_pair.pop(cond_id, None)
scheduler._cfg_req_to_pair.pop(uncond_id, None)
logger.warning(
"CFG pair %s split (%s); releasing cond=%s unguided, uncond=%s output is discarded",
pair_id,
reason,
cond_id,
uncond_id,
)
_patches_applied = False
def cfg_patches_applied() -> bool:
"""True once :func:`apply_cfg_patches` has patched the scheduler in this process."""
return _patches_applied
def _patch_scheduler(scheduler_cls: type | None = None) -> None:
"""Wrap the scheduler methods that keep a CFG pair step-locked.
``scheduler_cls`` defaults to vLLM's v1 ``Scheduler``; accepting a class
keeps the wrappers exercisable without building a real engine.
"""
if scheduler_cls is None:
from vllm.v1.core.sched.scheduler import Scheduler
scheduler_cls = Scheduler
orig_init = scheduler_cls.__init__
orig_add = scheduler_cls.add_request
orig_finish = scheduler_cls.finish_requests
orig_schedule = scheduler_cls.schedule
def _init(self, *args: Any, **kwargs: Any) -> None:
orig_init(self, *args, **kwargs)
self._cfg_pairs: dict[str, dict[str, str]] = {}
self._cfg_req_to_pair: dict[str, str] = {}
def _add(self, request: Any) -> None:
orig_add(self, request)
pair_id = _cfg_extra(request, "cfg_pair_id")
role = _cfg_extra(request, "cfg_role")
if role and not pair_id:
# Models that cannot inject a pair id from the serving layer send
# the role alone and let the request id carry the pairing. It has
# to be the user-facing id: the engine renames every request to an
# internal UUID on admission, and the companion suffix survives
# only on ``external_req_id``. Deriving from the UUID would give
# the two members unrelated pair ids, so they would never be held
# for each other and could be scheduled apart.
external_id = getattr(request, "external_req_id", None) or request.request_id
pair_id = _derive_pair_id(str(external_id), role)
if pair_id and role:
self._cfg_pairs.setdefault(pair_id, {})[role] = request.request_id
self._cfg_req_to_pair[request.request_id] = pair_id
# An asymmetric prefix-cache hit (the cond prompt shares cached
# branches; the null prompt does not) can advance one member past
# what post-schedule equalization can claw back. Skip prefix-cache
# reads for CFG requests entirely.
if hasattr(request, "skip_reading_prefix_cache"):
request.skip_reading_prefix_cache = True
def _finish(self, request_ids: Any, finished_status: Any) -> Any:
if request_ids is None:
result = orig_finish(self, request_ids, finished_status)
self._cfg_pairs.clear()
self._cfg_req_to_pair.clear()
return result
if isinstance(request_ids, str):
request_ids = {request_ids}
else:
request_ids = set(request_ids)
# Finish both members together: a surviving partner would decode
# unpaired garbage (its logits are never blended again).
partner_ids: set[str] = set()
for req_id in request_ids:
pair_id = self._cfg_req_to_pair.get(req_id)
if pair_id is None:
continue
for rid in self._cfg_pairs.get(pair_id, {}).values():
if rid != req_id and rid in self.requests:
partner_ids.add(rid)
all_ids = request_ids | partner_ids
result = orig_finish(self, all_ids, finished_status)
for req_id in all_ids:
pair_id = self._cfg_req_to_pair.pop(req_id, None)
if pair_id:
self._cfg_pairs.pop(pair_id, None)
return result
def _schedule(self, *args: Any, **kwargs: Any) -> Any:
if not self._cfg_pairs:
return orig_schedule(self, *args, **kwargs)
_reorder_waiting_for_cfg(self)
held = _hold_incomplete_pairs(self)
# Chunked prefill can split one pair member's prompt across steps
# while the other fits in a single step; halving the long-prefill
# threshold makes both chunk, and equalize aligns the remainder.
scheduler_config = getattr(self, "scheduler_config", None)
orig_threshold = getattr(scheduler_config, "long_prefill_token_threshold", None)
if scheduler_config is not None and orig_threshold is not None:
scheduler_config.long_prefill_token_threshold = self.max_num_scheduled_tokens // 2
try:
scheduler_output = orig_schedule(self, *args, **kwargs)
finally:
if scheduler_config is not None and orig_threshold is not None:
scheduler_config.long_prefill_token_threshold = orig_threshold
_release_held(held)
_equalize_cfg_pair_progress(self, scheduler_output)
_drop_split_pairs(self)
return scheduler_output
_schedule._cfg_pairing_patched = True # type: ignore[attr-defined]
# Legacy marker name kept for callers written against the Audex-only patch.
_schedule._audex_cfg_patched = True # type: ignore[attr-defined]
scheduler_cls.__init__ = _init
scheduler_cls.add_request = _add
scheduler_cls.finish_requests = _finish
scheduler_cls.schedule = _schedule
def apply_cfg_patches() -> None:
"""Make the vLLM v1 scheduler CFG-pair-aware. Idempotent, per process.
Must run in the engine-core process before ``Scheduler`` is constructed
(the ``__init__`` wrapper installs the pair registry).
"""
global _patches_applied
if _patches_applied:
return
_patches_applied = True
logger.info("Applying CFG pairing scheduler patches to vLLM v1")
_patch_scheduler()
@@ -0,0 +1,34 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""MiniMax Music 3 text-to-music.
Stage 0 is a Qwen3 backbone with an eight-codebook RVQ frame; stage 1 turns
the resulting conditioning frames into 32 kHz stereo audio through a
flow-matching transformer and a DAC decoder.
"""
from .constants import (
AR_CHUNK_FRAMES,
AR_CHUNK_HOP_FRAMES,
AR_HIDDEN_SIZE,
MAX_AUDIO_FRAMES,
OUTPUT_SAMPLE_RATE,
)
from .prompt import (
AUDIO_CODE_OFFSET,
SPECIAL_TOKEN_IDS,
build_cfg_null_token_ids,
build_prompt,
)
__all__ = [
"AR_CHUNK_FRAMES",
"AR_CHUNK_HOP_FRAMES",
"AR_HIDDEN_SIZE",
"AUDIO_CODE_OFFSET",
"MAX_AUDIO_FRAMES",
"OUTPUT_SAMPLE_RATE",
"SPECIAL_TOKEN_IDS",
"build_cfg_null_token_ids",
"build_prompt",
]
@@ -0,0 +1,459 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Stage-1 acoustic decoder for MiniMax Music 3.
Consumes the AR stage's conditioning spans and returns 32 kHz stereo audio.
Each span is cut into 200-frame windows that overlap by half; every window is
solved by the flow-matching DiT with the previous window's tail latent pinned
as a prompt, decoded by the vocoder, and cropped so that concatenating the
survivors reconstructs the song exactly once.
The window arithmetic lives in :mod:`.chunking` and is driven entirely by the
span's global frame range, so the streaming path (one window per engine step)
and the non-streaming path (the whole song in one payload) produce identical
audio for the same seed.
The stage runs in float32. TF32 keeps that affordable, and bfloat16 measurably
degrades a 30-step solve.
"""
from __future__ import annotations
import hashlib
from collections.abc import Iterable
from dataclasses import dataclass, field
from typing import Any
import torch
from torch import Tensor, nn
from vllm.config import VllmConfig
from vllm.logger import init_logger
from vllm_omni.model_executor.models.output_templates import OmniOutput
from .chunking import ChunkWindow, chunk_windows, crop_sample_bounds, overlap_mel_length
from .constants import (
AR_CHUNK_HOP_FRAMES,
AR_HIDDEN_SIZE,
DEFAULT_DIT_CFG_SCALE,
DEFAULT_DIT_STEPS,
OUTPUT_CHANNELS,
OUTPUT_SAMPLE_RATE,
)
from .dav import MiniMaxMusic3Vocoder, StreamingResampler, remove_weight_norm
from .dit import MiniMaxMusic3FlowMatchingDiT, remap_transformer_state
from .weights import (
CONDITION_ENCODER_DIR,
TRANSFORMER_DIR,
VOCODER_DIR,
load_component_state,
resolve_repo_root,
)
logger = init_logger(__name__)
__all__ = ["MiniMaxMusic3AcousticForConditionalGeneration"]
# Namespaced so a request seed reused by another component cannot collide with
# the noise draw, and so the draw is reproducible across processes. Python's
# hash() is randomized per interpreter and must not be used here.
_SEED_PERSON = b"minimax-ttm"
_SEED_BYTES = 8
def _derive_seed(seed: int, *parts: object) -> int:
"""Derive a stable 63-bit sub-seed from a request seed and some labels."""
digest = hashlib.blake2b(digest_size=_SEED_BYTES, person=_SEED_PERSON)
digest.update(int(seed).to_bytes(8, "little", signed=False))
for part in parts:
raw = str(part).encode("utf-8")
digest.update(len(raw).to_bytes(4, "little"))
digest.update(raw)
return int.from_bytes(digest.digest(), "little") & ((1 << 63) - 1)
def _meta_int(value: Any, default: int = 0) -> int:
"""Read an int that transport may have wrapped in a tensor or a list."""
if isinstance(value, torch.Tensor):
return int(value.reshape(-1)[0].item()) if value.numel() else default
if isinstance(value, list | tuple):
return _meta_int(value[0], default) if value else default
try:
return int(value) # type: ignore[arg-type]
except (TypeError, ValueError):
return default
def _meta_bool(value: Any) -> bool:
"""Read a bool that transport may have wrapped in a tensor or a list."""
if isinstance(value, torch.Tensor):
return bool(value.reshape(-1)[0].item()) if value.numel() else False
if isinstance(value, list | tuple):
return _meta_bool(value[0]) if value else False
return bool(value)
def _meta_str(value: Any) -> str | None:
"""Read a string that transport may have wrapped in a list."""
if isinstance(value, list | tuple):
return _meta_str(value[0]) if value else None
return value if isinstance(value, str) else None
@dataclass
class _RequestState:
"""Cross-window streaming state, keyed by request id and never by row."""
last_latent: Tensor | None = None
last_condition: Tensor | None = None
next_window_index: int = 0
decoded_windows: int = 0
resampler: StreamingResampler = field(default_factory=StreamingResampler)
def release(self) -> None:
self.last_latent = None
self.last_condition = None
self.resampler.reset()
class MiniMaxMusic3AcousticForConditionalGeneration(nn.Module):
"""Flow-matching DiT plus DAC vocoder, driven by the generation runner."""
input_modalities = "audio"
def __init__(self, *, vllm_config: VllmConfig, prefix: str = "") -> None:
super().__init__()
del prefix
self.vllm_config = vllm_config
self.model_path = vllm_config.model_config.model
self.have_multimodal_outputs = True
self.has_preprocess = False
self.has_postprocess = False
self.enable_update_additional_information = True
self.requires_raw_input_tokens = True
self._configure_backends()
# Skeleton only. vLLM's memory profiler walks the module tree at
# startup, before any weight loader has run.
self.dit = MiniMaxMusic3FlowMatchingDiT()
self.vocoder = MiniMaxMusic3Vocoder()
extra = self._connector_extra_config()
self.dit_steps = max(1, _meta_int(extra.get("dit_steps"), DEFAULT_DIT_STEPS))
cfg_scale = extra.get("dit_cfg_scale")
self.dit_cfg_scale = (
float(cfg_scale)
if isinstance(cfg_scale, int | float) and not isinstance(cfg_scale, bool)
else DEFAULT_DIT_CFG_SCALE
)
self._states: dict[str, _RequestState] = {}
self._loaded = False
@staticmethod
def _configure_backends() -> None:
"""Pin the numeric backends this checkpoint was validated against.
These are process-global switches, which is safe here only because a
stage owns its own engine-core process. Never call this from a module
shared with the AR stage: cuDNN's fused attention is not
bit-reproducible for grouped-query shapes, so flipping it underneath
the backbone makes the same seed produce a different song depending on
which stage loaded first.
cuDNN itself is off because its 1-D convolution paths are slower than
the native ones for the vocoder's dilated stacks, and its SDPA backend
is not needed because the DiT runs the flash path. TF32 keeps
float32's range with a 10-bit mantissa, which is what makes a float32
solve affordable.
"""
torch.backends.cuda.matmul.allow_tf32 = True
torch.set_float32_matmul_precision("high")
if torch.cuda.is_available():
torch.backends.cudnn.enabled = False
torch.backends.cuda.enable_cudnn_sdp(False)
def _connector_extra_config(self) -> dict[str, Any]:
"""Return the stage connector's ``extra`` block, or an empty mapping."""
connector = getattr(self.vllm_config.model_config, "stage_connector_config", None)
if isinstance(connector, dict):
extra = connector.get("extra", connector)
return extra if isinstance(extra, dict) else {}
extra = getattr(connector, "extra", None)
return extra if isinstance(extra, dict) else {}
# ------------------------------------------------------------- vLLM hooks
def embed_input_ids(self, input_ids: Tensor, **_: Any) -> Tensor:
"""Stable dummy embedding: this stage never reads token embeddings."""
if input_ids.numel() == 0:
return torch.empty((0, 1), device=input_ids.device, dtype=torch.float32)
return torch.zeros((input_ids.shape[0], 1), device=input_ids.device, dtype=torch.float32)
def compute_logits(self, hidden_states: Tensor | OmniOutput, sampling_metadata: Any = None) -> None:
del hidden_states, sampling_metadata
return None
def load_weights(self, weights: Iterable[tuple[str, Tensor]]) -> set[str]:
"""Load the three acoustic components from their own folders.
The iterator vLLM hands over covers the stage's own checkpoint folder,
which holds nothing this stage needs. It is drained anyway: the loader
streams from a file handle the caller closes only once the iterator is
exhausted, so abandoning it wedges startup.
"""
try:
for _ in weights:
pass
except RuntimeError as exc:
# The iterator is lazy, so draining it is what actually asks the
# default loader for root-level weight files. This repository keeps
# every component in its own subfolder and has none at the root, so
# that lookup raises. Nothing here needs those weights; any other
# RuntimeError is a real problem and is re-raised.
if "Cannot find any model weights" not in str(exc):
raise
logger.debug("MiniMax Music 3 acoustic stage has no root-level weights, as expected")
return self._load_components()
def _ensure_loaded(self) -> None:
"""Load on first use when the weight loader skipped ``load_weights``.
``load_format=dummy`` (used by warmup and memory profiling) never calls
``load_weights``, which would leave the modules holding their random
skeleton and make the dummy run produce noise or crash.
"""
if not self._loaded:
self._load_components()
def _load_components(self) -> set[str]:
"""Read ``condition_encoder/``, ``transformer/`` and ``vocoder/``.
Every component loads with ``strict=True``. A silently partial load
would produce audio that sounds plausible but is wrong, which is far
worse than a startup failure.
"""
root = resolve_repo_root(self.model_path)
encoder = self.dit.condition_encoder
encoder.load_state_dict(load_component_state(root, CONDITION_ENCODER_DIR), strict=True)
transformer_state = remap_transformer_state(load_component_state(root, TRANSFORMER_DIR))
self.dit.transformer.load_state_dict(transformer_state, strict=True)
self.vocoder.load_state_dict(load_component_state(root, VOCODER_DIR), strict=True)
device = self.vllm_config.device_config.device
self.dit.to(device=device, dtype=torch.float32).eval()
self.vocoder.to(device=device, dtype=torch.float32).eval()
folded = remove_weight_norm(self.vocoder)
self._loaded = True
loaded = {f"dit.{name}" for name, _ in self.dit.named_parameters()}
loaded |= {f"vocoder.{name}" for name, _ in self.vocoder.named_parameters()}
logger.info(
"MiniMax Music 3 acoustic loaded from %s device=%s dit_steps=%d "
"dit_cfg_scale=%.3f attention=%s folded_weight_norms=%d parameters=%d",
root,
device,
self.dit_steps,
self.dit_cfg_scale,
self.dit.transformer.attention_backend_name,
folded,
sum(p.numel() for p in self.parameters()),
)
return loaded
def on_requests_finished(self, finished_req_ids: Iterable[str]) -> None:
"""Drop the streaming state of finished or aborted requests."""
for req_id in finished_req_ids:
state = self._states.pop(str(req_id), None)
if state is not None:
logger.info(
"MiniMax Music 3 acoustic done request=%s windows=%d",
req_id,
state.decoded_windows,
)
state.release()
def make_omni_output(self, model_outputs: Tensor | OmniOutput | tuple, **kwargs: Any) -> OmniOutput:
del kwargs
if isinstance(model_outputs, OmniOutput):
return model_outputs
# The runner may unpack the NamedTuple in transit; rebuild it.
if isinstance(model_outputs, tuple) and len(model_outputs) == len(OmniOutput._fields):
return OmniOutput(*model_outputs)
raise TypeError(f"MiniMaxMusic3AcousticForConditionalGeneration expected OmniOutput, got {type(model_outputs)}")
# --------------------------------------------------------------- decoding
@staticmethod
def _silence() -> Tensor:
"""Placeholder for a step that produced no audio.
Deliberately rank 2. A rank-1 empty tensor mixed with rank-2 chunks
makes the multimodal collector's ``torch.cat(..., dim=-1)`` fall back
to a flattening path that would planar-ize the stereo.
"""
return torch.zeros((OUTPUT_CHANNELS, 0), dtype=torch.float32)
def _decode_window(self, state: _RequestState, hidden: Tensor, window: ChunkWindow, seed: int) -> Tensor:
"""Solve and vocode one window, cropped to its unique contribution.
Everything stays on the device: the crop is a slice and the result is
handed straight to the resampler, so a step costs one device-to-host
copy however many windows it decoded.
"""
device = self.vllm_config.device_config.device
hidden = hidden.unsqueeze(0).to(device=device, dtype=torch.float32)
generator = torch.Generator(device=device).manual_seed(_derive_seed(seed, "dit", window.index))
align = self.dit.aligned_condition(hidden)
latent = self.dit(
align,
generator=generator,
initial_latent=state.last_latent,
initial_condition=state.last_condition,
num_steps=self.dit_steps,
cfg_scale=self.dit_cfg_scale,
)
waveform = self.vocoder(latent)
# Carry the middle of the overlap forward. The solver re-imposes it on
# the next window, so the two windows agree where they meet.
overlap = overlap_mel_length()
start = max(0, latent.shape[-1] - 2 * overlap)
end = max(start, latent.shape[-1] - overlap)
state.last_latent = latent[..., start:end].clone()
state.last_condition = align[..., start:end].clone()
wave = waveform[0].clamp(-1.0, 1.0).float()
left, right = crop_sample_bounds(window)
stop = wave.shape[-1] - right if right else wave.shape[-1]
return wave[:, left:stop]
def _decode_span(self, req_id: str, span: Tensor | None, meta: dict[str, Any]) -> Tensor:
"""Decode every window a span covers and return this step's audio.
A span is normally one window, but the terminal emission carries the
held-back complete window plus the truncated final one, and the
non-streaming path carries the whole song. Window boundaries and the
crop are re-derived from the span's global frame range so all three
cases go through one code path.
Raises:
ValueError: If the span is malformed or lands on a window other
than the one this request is waiting for.
"""
span_start = _meta_int(meta.get("num_processed_tokens"), 0)
is_terminal = _meta_bool(meta.get("last_chunk")) or _meta_bool(meta.get("stream_finished"))
seed = _meta_int(meta.get("audio_seed"), 0)
state = self._states.setdefault(req_id, _RequestState())
# A metadata-only terminal marker carries no conditioning; it decodes
# zero windows and exists only to flush the resampler.
windows = chunk_windows(int(span.shape[0])) if span is not None else []
pieces: list[Tensor] = []
with torch.inference_mode():
for local in windows:
start = span_start + local.start
index = start // AR_CHUNK_HOP_FRAMES
if index != state.next_window_index:
# Each window is prompted by its predecessor's tail, so a
# gap or a replay corrupts the join. Fail the request here
# rather than return audio with a seam in it.
raise ValueError(
f"MiniMax Music 3 acoustic request {req_id} expected window "
f"{state.next_window_index} but received window {index} "
f"(span starts at AR frame {span_start}); the overlap prompt "
f"for this window is stale and the audio would not join"
)
window = ChunkWindow(
index=index,
start=start,
end=span_start + local.end,
is_first=start == 0,
is_last=local.is_last and is_terminal,
)
pieces.append(self._decode_window(state, span[local.start : local.end], window, seed))
state.next_window_index = index + 1
state.decoded_windows += 1
emitted = state.resampler.push(pieces, flush=is_terminal)
return emitted.cpu()
def _span_from_info(self, info: dict[str, Any], req_id: str) -> Tensor | None:
"""Return the conditioning span as ``[T, 32768]``, or ``None``.
``None`` means this step carried no conditioning, which is normal: a
request can be scheduled before its span arrives, and the terminal
marker is metadata only. A span that is present but the wrong shape is
not normal and stops the request.
Raises:
ValueError: If a span is present but is not ``[T, 32768]``.
"""
latent = info.get("latent")
if not isinstance(latent, Tensor) or latent.numel() == 0:
return None
if latent.ndim == 3 and latent.shape[0] == 1:
latent = latent[0]
if latent.ndim != 2 or latent.shape[1] != AR_HIDDEN_SIZE:
raise ValueError(
f"MiniMax Music 3 acoustic request {req_id} carries a conditioning "
f"span of shape {tuple(latent.shape)}; expected [T,{AR_HIDDEN_SIZE}]"
)
return latent
def forward(
self,
input_ids: Tensor | None = None,
positions: Tensor | None = None,
intermediate_tensors: Any = None,
inputs_embeds: Tensor | None = None,
runtime_additional_information: list[dict[str, Any]] | None = None,
**kwargs: Any,
) -> OmniOutput:
"""Decode this step's spans into one stereo waveform per request.
Raises:
ValueError: If a payload is malformed. Failing the step is
deliberate: a request whose conditioning cannot be decoded has
no audio to return, and reporting an error is better than
leaving the caller waiting on a stream that will never end.
"""
del input_ids, positions, intermediate_tensors, inputs_embeds
self._ensure_loaded()
infos = runtime_additional_information
if infos is None:
infos = kwargs.get("model_intermediate_buffer")
infos = infos or []
# One entry per scheduled request, including steps where a request's
# span has not arrived yet.
seq_token_counts = kwargs.get("seq_token_counts")
num_reqs = len(seq_token_counts) if seq_token_counts is not None else len(infos)
audios: list[Tensor] = []
sr_tensor = torch.tensor(OUTPUT_SAMPLE_RATE, dtype=torch.int32)
for index in range(num_reqs):
info = infos[index] if index < len(infos) else {}
info = info if isinstance(info, dict) else {}
meta = info.get("meta")
meta = meta if isinstance(meta, dict) else {}
req_id = _meta_str(meta.get("req_id")) or _meta_str(info.get("req_id"))
has_span = isinstance(info.get("latent"), Tensor) and info["latent"].numel() > 0
is_terminal = _meta_bool(meta.get("last_chunk")) or _meta_bool(meta.get("stream_finished"))
if not has_span and not is_terminal:
audios.append(self._silence())
continue
if req_id is None:
raise ValueError(
"MiniMax Music 3 acoustic payload carries no meta.req_id, so its "
"window cannot be joined to the rest of its request"
)
audios.append(self._decode_span(req_id, self._span_from_info(info, req_id), meta))
return OmniOutput(
text_hidden_states=None,
multimodal_outputs={"model_outputs": audios, "sr": [sr_tensor] * num_reqs},
)
@@ -0,0 +1,96 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Chunk boundary and overlap arithmetic for MiniMax Music 3.
The AR stage emits fixed 200-frame windows that overlap by half. The acoustic
stage decodes each window with the previous window's latent as a prompt, then
crops the overlap back off so the concatenated waveform is seamless. All of
that is pure integer arithmetic and lives here so it can be unit tested
without a GPU.
"""
from __future__ import annotations
from dataclasses import dataclass
from .constants import (
AR_CHUNK_FRAMES,
AR_CHUNK_HOP_FRAMES,
BLEND_MEL_FRAMES,
DAV_HOP_SAMPLES,
HOP_MEL_FRAMES,
)
@dataclass(frozen=True)
class ChunkWindow:
"""One acoustic window, in AR frame coordinates."""
index: int
start: int
end: int
is_first: bool
is_last: bool
@property
def length(self) -> int:
return self.end - self.start
def chunk_windows(frames: int) -> list[ChunkWindow]:
"""Return the fixed-size windows covering ``frames``, overlapping by half.
A song shorter than one window produces a single short window; otherwise
windows advance by ``AR_CHUNK_HOP_FRAMES`` and the final one is truncated
at the end of the song.
"""
frames = int(frames)
if frames <= 0:
return []
if frames <= AR_CHUNK_FRAMES:
return [ChunkWindow(0, 0, frames, True, True)]
windows: list[ChunkWindow] = []
index = 0
start = 0
while start < frames:
end = min(start + AR_CHUNK_FRAMES, frames)
windows.append(
ChunkWindow(
index=index,
start=start,
end=end,
is_first=index == 0,
is_last=end >= frames,
)
)
if end >= frames:
break
index += 1
start += AR_CHUNK_HOP_FRAMES
return windows
def overlap_mel_length() -> int:
"""Latent frames carried forward as the next window's flow-matching prompt."""
return HOP_MEL_FRAMES // 2
def crop_sample_bounds(window: ChunkWindow) -> tuple[int, int]:
"""Return ``(left, right)`` waveform samples to trim from a decoded window.
The first window keeps its head and the last keeps its tail; every other
window is cropped on both sides so that concatenating the survivors
reconstructs the song exactly once.
"""
left = 0 if window.is_first else BLEND_MEL_FRAMES * DAV_HOP_SAMPLES
right = 0 if window.is_last else (HOP_MEL_FRAMES - BLEND_MEL_FRAMES) * DAV_HOP_SAMPLES
return left, right
__all__ = [
"ChunkWindow",
"chunk_windows",
"crop_sample_bounds",
"overlap_mel_length",
]
@@ -0,0 +1,111 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Inference-contract constants for MiniMax Music 3.
These values are fixed by the checkpoint, not tuning knobs. Changing any of
them changes the audio, so they are declared once here and imported
everywhere rather than re-derived per call site.
"""
# --- Generation limits -----------------------------------------------------
# 25 audio frames per second, capped at six minutes of song.
AUDIO_FRAME_RATE = 25
MAX_AUDIO_FRAMES = 9_000
DEFAULT_MAX_AUDIO_FRAMES = 9_000
# The backbone was trained with a 10,240-token context; the prompt is capped
# well below it so there is room for the audio frames that follow.
MAX_PROMPT_TOKENS = 5_000
# --- Frame layout ----------------------------------------------------------
# Eight RVQ codebooks per frame: c0 lives in the backbone vocabulary, c1..c7
# in the depth decoder's own 1024-entry books.
NUM_CODEBOOKS = 8
C0_VOCAB_SIZE = 16_384
AUDIO_VOCAB_SIZE = 1_024
# The acoustic stage is conditioned on the backbone hidden state concatenated
# with the seven depth-decoder hidden states.
BACKBONE_HIDDEN_SIZE = 4_096
AR_HIDDEN_SIZE = BACKBONE_HIDDEN_SIZE * NUM_CODEBOOKS # 32768
# --- Sampling --------------------------------------------------------------
# Classifier-free guidance is not optional and has no request field: this is
# how the reference implementation samples this checkpoint.
AR_CFG_SCALE = 1.5
AR_CFG_TOP_K = 50
AR_TOP_K = 50
# --- Acoustic stage --------------------------------------------------------
DEFAULT_DIT_STEPS = 30
DEFAULT_DIT_CFG_SCALE = 1.7
DIT_LATENT_CHANNELS = 128
# Windowing: the AR stage hands the acoustic stage 200-frame windows that
# overlap by half, and the acoustic stage crops the overlap back off.
AR_CHUNK_FRAMES = 200
AR_CHUNK_HOP_FRAMES = 100
# DAV runs at 44.1 kHz; the response is resampled to 32 kHz stereo.
DAV_SAMPLE_RATE = 44_100
OUTPUT_SAMPLE_RATE = 32_000
OUTPUT_CHANNELS = 2
# Condition resampling: AR frames land on a 24 kHz / 960-hop grid and are
# stretched onto the DAV latent's 44.1 kHz / 512-hop grid.
CONDITION_INPUT_SAMPLE_RATE = 24_000
CONDITION_INPUT_HOP = 960
CONDITION_OUTPUT_SAMPLE_RATE = 44_100
CONDITION_OUTPUT_HOP = 512
# Mel frames per AR frame: 44100/24000 * 960/512 = 3.4453125. So a 100-frame
# hop is 344 mel frames and a 200-frame window is 689.
#
# HOP_MEL_FRAMES is the hop, not the window: the crop and carry-forward
# arithmetic is expressed in units of the hop, and a window is two of them
# plus one frame of rounding. Reading this as the window length is the easy
# mistake here, and it is off by a factor of two.
HOP_MEL_FRAMES = 344
BLEND_MEL_FRAMES = HOP_MEL_FRAMES // 4
DAV_HOP_SAMPLES = 512
# Only these two dtypes are supported for the acoustic stage. float32 is the
# default: TF32 keeps it affordable, and bfloat16 measurably degrades the
# solver.
SUPPORTED_ACOUSTIC_DTYPES = frozenset({"float32", "bfloat16"})
__all__ = [
"AR_CFG_SCALE",
"AR_CFG_TOP_K",
"AR_CHUNK_FRAMES",
"AR_CHUNK_HOP_FRAMES",
"AR_HIDDEN_SIZE",
"AR_TOP_K",
"AUDIO_FRAME_RATE",
"AUDIO_VOCAB_SIZE",
"BACKBONE_HIDDEN_SIZE",
"BLEND_MEL_FRAMES",
"C0_VOCAB_SIZE",
"CONDITION_INPUT_HOP",
"CONDITION_INPUT_SAMPLE_RATE",
"CONDITION_OUTPUT_HOP",
"CONDITION_OUTPUT_SAMPLE_RATE",
"DAV_HOP_SAMPLES",
"DAV_SAMPLE_RATE",
"DEFAULT_DIT_CFG_SCALE",
"DEFAULT_DIT_STEPS",
"DEFAULT_MAX_AUDIO_FRAMES",
"DIT_LATENT_CHANNELS",
"HOP_MEL_FRAMES",
"MAX_AUDIO_FRAMES",
"MAX_PROMPT_TOKENS",
"NUM_CODEBOOKS",
"OUTPUT_CHANNELS",
"OUTPUT_SAMPLE_RATE",
"SUPPORTED_ACOUSTIC_DTYPES",
]
@@ -0,0 +1,294 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""DAC-style vocoder for MiniMax Music 3.
Only the decoder half of the autoencoder ships in the checkpoint, which is all
inference needs: the flow-matching stage produces the latent directly, so the
encoder and the quantizer never run.
Stereo is folded into the batch. A ``[B, 128, T]`` latent is read as two
64-channel mono latents per item, decoded as ``2B`` independent mono streams,
and unfolded again at the end. The four transposed-convolution blocks upsample
by 8, 8, 4 and 2, so one latent frame becomes 512 samples at 44.1 kHz.
Module names match the ``vocoder/`` component folder key for key, so its state
dict loads with ``strict=True`` and no remapping. The convolutions keep the
checkpoint's weight-norm parameterization (``weight_g``/``weight_v``) through
loading; :func:`remove_weight_norm` folds it away afterwards, since the
factorization is fixed once the weights stop training.
:class:`StreamingResampler` carries the 44.1 kHz output down to the 32 kHz
response rate across chunk boundaries, which is not something a plain
per-chunk resample can do.
"""
from __future__ import annotations
import math
from collections.abc import Sequence
import torch
from torch import Tensor, nn
from vllm.logger import init_logger
from .constants import DAV_SAMPLE_RATE, DIT_LATENT_CHANNELS, OUTPUT_CHANNELS, OUTPUT_SAMPLE_RATE
logger = init_logger(__name__)
__all__ = ["MiniMaxMusic3Vocoder", "StreamingResampler", "remove_weight_norm", "snake"]
# Fixed by vocoder/config.json.
_DECODER_INPUT_DIM = 1_024
_DECODER_HIDDEN_DIM = 1_536
_UPSAMPLING_RATIOS = (8, 8, 4, 2)
_RESIDUAL_DILATIONS = (1, 3, 9)
_KERNEL_SIZE = 7
# Each vocoder stream sees half the latent channels; the other half is the
# opposite stereo channel.
_STREAM_CHANNELS = DIT_LATENT_CHANNELS // OUTPUT_CHANNELS
def snake(x: Tensor, alpha: Tensor) -> Tensor:
"""Periodic ``x + sin^2(alpha x) / alpha`` activation.
The reshape to three dimensions is what the reference does and is a no-op
for the rank-3 activations used here; it is kept so the numerics are
identical for any rank.
"""
shape = x.shape
flat = x.reshape(shape[0], shape[1], -1)
flat = flat + (alpha + 1e-9).reciprocal() * torch.sin(alpha * flat).pow(2)
return flat.reshape(shape)
class Snake1d(nn.Module):
"""Snake activation with one learned frequency per channel."""
def __init__(self, channels: int) -> None:
super().__init__()
self.alpha = nn.Parameter(torch.ones(1, channels, 1))
def forward(self, x: Tensor) -> Tensor:
return snake(x, self.alpha)
def _wn_conv(*args: object, **kwargs: object) -> nn.Module:
"""A weight-normalized ``Conv1d``, named ``weight_g``/``weight_v``."""
return nn.utils.weight_norm(nn.Conv1d(*args, **kwargs)) # type: ignore[arg-type]
def _wn_conv_transpose(*args: object, **kwargs: object) -> nn.Module:
"""A weight-normalized ``ConvTranspose1d``, named ``weight_g``/``weight_v``."""
return nn.utils.weight_norm(nn.ConvTranspose1d(*args, **kwargs)) # type: ignore[arg-type]
def remove_weight_norm(module: nn.Module) -> int:
"""Fold weight normalization into every convolution weight in place.
Returns:
How many submodules were folded.
"""
removed = 0
for child in module.modules():
try:
nn.utils.remove_weight_norm(child)
except (ValueError, RuntimeError):
continue
removed += 1
return removed
class ResidualUnit(nn.Module):
"""Dilated snake-gated residual block; preserves length and width."""
def __init__(self, dim: int, dilation: int) -> None:
super().__init__()
pad = (_KERNEL_SIZE - 1) * dilation // 2
self.snake1 = Snake1d(dim)
self.conv1 = _wn_conv(dim, dim, kernel_size=_KERNEL_SIZE, dilation=dilation, padding=pad)
self.snake2 = Snake1d(dim)
self.conv2 = _wn_conv(dim, dim, kernel_size=1)
def forward(self, x: Tensor) -> Tensor:
y = self.conv2(self.snake2(self.conv1(self.snake1(x))))
if y.shape[-1] != x.shape[-1]:
# Defensive centre crop. The padding above makes this a no-op for
# every dilation the checkpoint ships.
pad = (x.shape[-1] - y.shape[-1]) // 2
x = x[..., pad : x.shape[-1] - pad]
return x + y
class DecoderBlock(nn.Module):
"""One upsampling stage: activate, stretch by ``stride``, then refine."""
def __init__(self, input_dim: int, output_dim: int, stride: int) -> None:
super().__init__()
self.snake1 = Snake1d(input_dim)
self.conv_t1 = _wn_conv_transpose(
input_dim,
output_dim,
kernel_size=2 * stride,
stride=stride,
padding=math.ceil(stride / 2),
)
self.res_unit1 = ResidualUnit(output_dim, _RESIDUAL_DILATIONS[0])
self.res_unit2 = ResidualUnit(output_dim, _RESIDUAL_DILATIONS[1])
self.res_unit3 = ResidualUnit(output_dim, _RESIDUAL_DILATIONS[2])
def forward(self, x: Tensor) -> Tensor:
x = self.conv_t1(self.snake1(x))
return self.res_unit3(self.res_unit2(self.res_unit1(x)))
class MiniMaxMusic3Vocoder(nn.Module):
"""Latent to waveform: ``[B, 128, T] -> [B, 2, T * 512]`` at 44.1 kHz."""
def __init__(self) -> None:
super().__init__()
self.dec_in_proj = nn.Conv1d(_STREAM_CHANNELS, _DECODER_INPUT_DIM, kernel_size=1)
self.conv_in = _wn_conv(
_DECODER_INPUT_DIM,
_DECODER_HIDDEN_DIM,
kernel_size=_KERNEL_SIZE,
padding=_KERNEL_SIZE // 2,
)
blocks: list[nn.Module] = []
output_dim = _DECODER_HIDDEN_DIM
for index, stride in enumerate(_UPSAMPLING_RATIOS):
input_dim = _DECODER_HIDDEN_DIM // (2**index)
output_dim = _DECODER_HIDDEN_DIM // (2 ** (index + 1))
blocks.append(DecoderBlock(input_dim, output_dim, stride))
self.blocks = nn.ModuleList(blocks)
self.snake_out = Snake1d(output_dim)
self.conv_out = _wn_conv(output_dim, 1, kernel_size=_KERNEL_SIZE, padding=_KERNEL_SIZE // 2)
@property
def upsampling_factor(self) -> int:
"""Waveform samples produced per latent frame."""
return math.prod(_UPSAMPLING_RATIOS)
@torch.inference_mode()
def forward(self, latent: Tensor) -> Tensor:
"""Decode a latent to interleaved stereo.
Args:
latent: ``[B, 128, T]`` vocoder latent.
Returns:
``[B, 2, T * 512]`` waveform at 44.1 kHz, unclamped.
Raises:
ValueError: If the latent is not ``[B, 128, T]``.
"""
if latent.ndim != 3 or latent.shape[1] != DIT_LATENT_CHANNELS:
raise ValueError(
f"MiniMax Music 3 vocoder latent must be [B,{DIT_LATENT_CHANNELS},T], got {tuple(latent.shape)}"
)
bsz, _, frames = latent.shape
folded = latent.reshape(bsz * OUTPUT_CHANNELS, _STREAM_CHANNELS, frames)
h = self.conv_in(self.dec_in_proj(folded))
for block in self.blocks:
h = block(h)
wave = torch.tanh(self.conv_out(self.snake_out(h)))
return wave.reshape(bsz, OUTPUT_CHANNELS, -1)
class StreamingResampler:
"""Resample a chunked stream to the response rate without a seam.
A polyphase resampler is time-invariant but not offset-invariant: it only
reproduces the same output grid when the input offset is a whole multiple
of ``orig // gcd(orig, new)`` samples, and it zero-pads both ends of every
call. Resampling each decoded chunk on its own therefore shifts the grid
by a fraction of a sample at every join and tapers the signal across it.
Measured against a single whole-stream resample on 20 s of broadband
noise, that costs a peak error of 2.2 (full scale) and drifts the length.
This buffers instead. Only whole blocks are emitted, so every boundary
lands on an exact output sample; the previously emitted tail is prepended
as real left context and the not-yet-emitted head appended as real right
context, and both are dropped from the result. On the same measurement
the emitted stream is bit-identical to the whole-stream resample.
One instance per request. It holds at most a couple of blocks of audio.
"""
def __init__(
self,
*,
orig_freq: int = DAV_SAMPLE_RATE,
new_freq: int = OUTPUT_SAMPLE_RATE,
channels: int = OUTPUT_CHANNELS,
context_blocks: int = 1,
margin_blocks: int = 1,
) -> None:
self.orig_freq = int(orig_freq)
self.new_freq = int(new_freq)
self.channels = int(channels)
# The output grid repeats every this many input samples.
self._block = self.orig_freq // math.gcd(self.orig_freq, self.new_freq)
self._context_len = self._block * context_blocks
self._margin = self._block * margin_blocks
self._pending: Tensor | None = None
self._context: Tensor | None = None
def _empty(self, like: Tensor | None) -> Tensor:
if like is None:
return torch.zeros((self.channels, 0), dtype=torch.float32)
return like.new_zeros((self.channels, 0))
def reset(self) -> None:
"""Drop all buffered audio."""
self._pending = None
self._context = None
@torch.inference_mode()
def push(self, chunks: Sequence[Tensor], *, flush: bool = False) -> Tensor:
"""Append source-rate audio and return whatever is now emittable.
Args:
chunks: New ``[channels, T]`` segments at ``orig_freq``, in order.
flush: Emit the remainder too, because the stream has ended.
Returns:
``[channels, T]`` at ``new_freq``; empty when nothing is ready.
"""
import torchaudio.functional as audio_functional
parts = [chunk for chunk in chunks if chunk.numel()]
if self._pending is not None:
parts.insert(0, self._pending)
self._pending = None
if not parts:
return self._empty(self._context)
buffered = torch.cat(parts, dim=-1) if len(parts) > 1 else parts[0]
total = int(buffered.shape[-1])
if flush:
emit_len, lookahead = total, 0
else:
emit_len = max(0, ((total - self._margin) // self._block) * self._block)
lookahead = min(self._margin, total - emit_len)
if emit_len == 0:
self._pending = buffered
return self._empty(buffered)
padded = buffered[..., : emit_len + lookahead]
start = 0
if self._context is not None:
padded = torch.cat((self._context, padded), dim=-1)
# Exact because the context is a whole number of blocks.
start = int(self._context.shape[-1]) * self.new_freq // self.orig_freq
resampled = audio_functional.resample(padded.float(), self.orig_freq, self.new_freq)
# Round up, matching how a whole-stream resample sizes its output. This
# only bites on the flush, where the remainder is not a whole block;
# every other emission divides exactly.
stop = start + -(-emit_len * self.new_freq // self.orig_freq)
if flush:
self.reset()
else:
self._context = buffered[..., max(0, emit_len - self._context_len) : emit_len].clone()
self._pending = buffered[..., emit_len:].clone() if emit_len < total else None
return resampled[..., start:stop]
@@ -0,0 +1,137 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""CUDA graph capture for the RVQ depth loop.
The depth loop is seven sequential forwards of a four-layer block over a
sequence that grows from two to eight positions, plus a guided draw after each
one. At the batch sizes this stage runs, none of those kernels is anywhere near
large enough to hide its own launch: measured in isolation on one L20X, the
loop costs 8.4 ms per frame when driven from Python and 4.0 ms when replayed
from a graph, and that figure barely moves between one and eight guided pairs.
It is launch-bound, so it is worth recording.
Capture is lazy and keyed on the exact pair count, never padded. That keeps the
recorded kernels the same ones the eager loop selected for that shape, which is
what makes a replay bit-identical to the loop it replaces rather than merely
close to it.
"""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
import torch
from vllm.logger import init_logger
from vllm.platforms import current_platform
logger = init_logger(__name__)
__all__ = ["DepthGraphCache"]
# Inputs, in the order the eager loop takes them.
_DepthInputs = tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]
_DepthOutputs = tuple[torch.Tensor, torch.Tensor]
@dataclass
class _CapturedDepth:
"""One recorded depth loop and the buffers it reads and writes."""
graph: torch.cuda.CUDAGraph
inputs: _DepthInputs
codes: torch.Tensor
depth_hidden: torch.Tensor
class DepthGraphCache:
"""Replay the depth loop from a graph once its shape has settled.
Args:
run_eager: The loop itself, taking ``(cond_hidden, uncond_hidden, c0,
seeds, positions)`` and returning ``(codes, depth_hidden)``.
warmup_steps: How many times a pair count runs eagerly before it is
recorded.
"""
def __init__(
self,
run_eager: Callable[..., _DepthOutputs],
*,
warmup_steps: int = 2,
) -> None:
self._run_eager = run_eager
self._warmup_steps = warmup_steps
self._captured: dict[int, _CapturedDepth] = {}
self._eager_steps: dict[int, int] = {}
self._disabled: set[int] = set()
def run(self, *inputs: torch.Tensor) -> _DepthOutputs:
"""Run one depth loop, recording or replaying it where possible."""
pairs = int(inputs[2].shape[0])
entry = self._captured.get(pairs)
if entry is None:
if not self._should_capture(pairs, inputs[0]):
return self._run_eager(*inputs)
entry = self._capture(pairs, inputs)
if entry is None:
return self._run_eager(*inputs)
for static, live in zip(entry.inputs, inputs, strict=True):
static.copy_(live)
entry.graph.replay()
# The codes are fed back as the next frame's input and appended to the
# request's trajectory, so they outlive the step; the graph overwrites
# its output buffer on the next replay. The depth hidden states are
# consumed inside this step by the concatenation that builds the
# conditioning frame, which allocates, so that one can be handed back
# as is.
return entry.codes.clone(), entry.depth_hidden
def _should_capture(self, pairs: int, sample: torch.Tensor) -> bool:
if pairs in self._disabled or not sample.is_cuda:
return False
if torch.cuda.is_current_stream_capturing():
# Already inside somebody else's capture; nested graphs are not a
# thing and the outer recording covers this work anyway.
return False
count = self._eager_steps.get(pairs, 0) + 1
self._eager_steps[pairs] = count
# Let a new pair count run eagerly a few times first. Capture records
# whatever the kernels do at that moment, and the first call into a
# cuBLAS or SDPA path for an unseen shape can allocate a workspace or
# pick an algorithm, neither of which belongs inside the recording.
return count > self._warmup_steps
def _capture(self, pairs: int, inputs: _DepthInputs) -> _CapturedDepth | None:
try:
static = tuple(torch.empty_like(tensor) for tensor in inputs)
for target, live in zip(static, inputs, strict=True):
target.copy_(live)
graph = torch.cuda.CUDAGraph()
with (
torch.inference_mode(),
torch.cuda.graph(
graph,
pool=current_platform.get_global_graph_pool(),
# The engine's connector threads keep their own streams
# busy while this runs. They never touch the depth loop's
# buffers, so restrict the legality check to this thread
# rather than failing the capture on their activity.
capture_error_mode="thread_local",
),
):
codes, depth_hidden = self._run_eager(*static)
entry = _CapturedDepth(graph=graph, inputs=static, codes=codes, depth_hidden=depth_hidden)
self._captured[pairs] = entry
logger.info("MiniMax Music 3 captured the RVQ depth CUDA graph for %d guided pair(s)", pairs)
return entry
except Exception as exc: # noqa: BLE001 - any capture failure must fall back, not crash
self._disabled.add(pairs)
logger.warning(
"MiniMax Music 3 RVQ depth CUDA graph disabled for %d guided pair(s), "
"falling back to the eager loop: %s",
pairs,
exc,
)
return None
@@ -0,0 +1,484 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Condition encoder and flow-matching transformer for MiniMax Music 3.
Stage 1 turns the AR stage's conditioning frames into a DAC latent in two
steps. The condition encoder collapses the eight 4096-wide layer slices of a
frame into one 2048-wide vector and stretches the result from the AR frame
grid onto the vocoder's latent grid. The transformer is then solved as a
flow-matching velocity field: a fixed-step Euler integrator walks Gaussian
noise to the latent the vocoder expects, under classifier-free guidance.
Module names mirror the checkpoint's ``condition_encoder/`` and
``transformer/`` component folders key for key, with one deliberate
exception: the per-block ``to_q``/``to_k``/``to_v`` projections are fused into
a single ``to_qkv`` so each block runs one GEMM instead of three.
:func:`remap_transformer_state` performs that fusion, and is the only place
the checkpoint's layout is reinterpreted.
Attention runs through the project's own diffusion attention layer, so this
model picks up the same backend selection, sequence-parallel plumbing and
KV-quant policy as every other transformer in the tree. That layer resolves
its backend from the diffusion config, which an ``LLM_GENERATION`` stage such
as this one does not set; it tolerates that and falls through to the platform
default. If it cannot be built at all the block runs plain SDPA instead, a
choice made once when the blocks are built and never inside ``forward``.
"""
from __future__ import annotations
import math
import torch
from torch import Tensor, nn
from torch.nn import functional as F
from vllm.logger import init_logger
from .constants import (
AR_HIDDEN_SIZE,
BACKBONE_HIDDEN_SIZE,
CONDITION_INPUT_HOP,
CONDITION_INPUT_SAMPLE_RATE,
CONDITION_OUTPUT_HOP,
CONDITION_OUTPUT_SAMPLE_RATE,
DEFAULT_DIT_CFG_SCALE,
DEFAULT_DIT_STEPS,
DIT_LATENT_CHANNELS,
NUM_CODEBOOKS,
)
logger = init_logger(__name__)
__all__ = [
"MiniMaxMusic3ConditionEncoder",
"MiniMaxMusic3FlowMatchingDiT",
"MiniMaxMusic3Transformer1DModel",
"remap_transformer_state",
]
# Fixed by transformer/config.json. Declared rather than read from the file so
# the skeleton can be built before any checkpoint is on disk, which is what
# vLLM's memory profiler needs at startup.
_DIM = 2_048
_NUM_LAYERS = 36
_HEAD_DIM = 64
_FF_INNER_DIM = 8_192
_FOURIER_DIM = 256
_ROTARY_DIM = 32
_ROTARY_BASE = 10_000.0
_CONDITION_DIM = 2_048
# The solver feeds the transformer the current latent, a zero block of the
# same width, and the condition: 128 + 128 + 2048 = 2304.
_TRANSFORMER_IN_DIM = DIT_LATENT_CHANNELS * 2 + _CONDITION_DIM
# The prompt interpolation floor from the reference sampler. Sigma never
# reaches exactly zero, so the noise term never fully vanishes.
_SIGMA_FLOOR = 1e-6
class FourierFeatures(nn.Module):
"""Random-Fourier timestep features: ``[B, 1] -> [B, out_features]``."""
def __init__(self, in_features: int, out_features: int) -> None:
super().__init__()
self.weight = nn.Parameter(torch.empty(out_features // 2, in_features))
def forward(self, value: Tensor) -> Tensor:
f = 2.0 * math.pi * value @ self.weight.T
return torch.cat((f.cos(), f.sin()), dim=-1)
class TimestepEmbedding(nn.Module):
"""The checkpoint's ``time_embed``: Linear, SiLU, Linear."""
def __init__(self, in_dim: int, dim: int) -> None:
super().__init__()
self.linear_1 = nn.Linear(in_dim, dim)
self.act = nn.SiLU()
self.linear_2 = nn.Linear(dim, dim)
def forward(self, x: Tensor) -> Tensor:
return self.linear_2(self.act(self.linear_1(x)))
def _rotate_half(x: Tensor) -> Tensor:
x1, x2 = x.chunk(2, dim=-1)
return torch.cat((-x2, x1), dim=-1)
def _apply_rope(x: Tensor, rope_cos: Tensor, rope_sin: Tensor) -> Tensor:
"""Rotate the leading ``rope_cos.shape[-1]`` channels of ``[B, S, H, D]``.
Only the first 32 of each head's 64 channels are rotated; the rest pass
through untouched, which is what this checkpoint was trained with. The
tables are cached at the exact sequence length, so there is nothing to
slice off the front.
"""
rot_dim = rope_cos.shape[-1]
rotated = x[..., :rot_dim]
rotated = rotated * rope_cos + _rotate_half(rotated) * rope_sin
return torch.cat((rotated, x[..., rot_dim:]), dim=-1)
# Set to the failure reason the first time the native attention layer cannot be
# built, so the 36 blocks agree on one path and the reason is logged once.
_NATIVE_ATTENTION_UNAVAILABLE: str | None = None
def _build_native_attention(*, num_heads: int, head_size: int, softmax_scale: float, prefix: str) -> nn.Module | None:
"""Build the project's diffusion attention layer, or ``None`` to use SDPA.
Anything that goes wrong here is a legitimate reason to run plain SDPA,
and is reported once rather than once per block.
"""
global _NATIVE_ATTENTION_UNAVAILABLE
if _NATIVE_ATTENTION_UNAVAILABLE is not None:
return None
try:
from vllm_omni.diffusion.attention.layer import Attention as DiffusionAttention
return DiffusionAttention(
num_heads=num_heads,
head_size=head_size,
causal=False,
softmax_scale=softmax_scale,
prefix=prefix,
role="self",
role_category="self",
)
except Exception as exc: # noqa: BLE001 - any failure means: use SDPA
_NATIVE_ATTENTION_UNAVAILABLE = repr(exc)
logger.warning(
"MiniMax Music 3 DiT could not build the diffusion attention layer "
"(%s); running torch SDPA instead. The stage decodes in float32, "
"for which the diffusion layer dispatches to SDPA anyway, so this "
"changes performance bookkeeping and not the audio.",
exc,
)
return None
class Attention(nn.Module):
"""Bidirectional self-attention with partial rotary position embeddings.
Q, K and V are kept in ``[B, S, H, D]``, which is the layout the diffusion
attention backends consume and return.
"""
def __init__(self, dim: int, *, head_dim: int, prefix: str = "") -> None:
super().__init__()
self.num_heads = dim // head_dim
self.head_dim = head_dim
self.softmax_scale = float(head_dim**-0.5)
# Fused from the checkpoint's separate to_q/to_k/to_v; see
# remap_transformer_state for the concatenation order.
self.to_qkv = nn.Linear(dim, dim * 3, bias=False)
self.to_out = nn.Linear(dim, dim, bias=False)
self.backend = _build_native_attention(
num_heads=self.num_heads,
head_size=head_dim,
softmax_scale=self.softmax_scale,
prefix=prefix,
)
self.backend_name = self.backend.attn_backend.get_name() if self.backend is not None else "TORCH_SDPA"
def _attend(self, q: Tensor, k: Tensor, v: Tensor) -> Tensor:
if self.backend is not None:
return self.backend(q, k, v)
q, k, v = (t.transpose(1, 2) for t in (q, k, v))
out = F.scaled_dot_product_attention(q, k, v, is_causal=False, scale=self.softmax_scale)
return out.transpose(1, 2)
def forward(self, x: Tensor, rope_cos: Tensor, rope_sin: Tensor) -> Tensor:
bsz, seq, dim = x.shape
q, k, v = self.to_qkv(x).chunk(3, dim=-1)
q = q.reshape(bsz, seq, self.num_heads, self.head_dim)
k = k.reshape(bsz, seq, self.num_heads, self.head_dim)
v = v.reshape(bsz, seq, self.num_heads, self.head_dim)
q = _apply_rope(q, rope_cos, rope_sin)
k = _apply_rope(k, rope_cos, rope_sin)
out = self._attend(q, k, v)
return self.to_out(out.contiguous().reshape(bsz, seq, dim))
class TransformerBlock(nn.Module):
"""Pre-norm attention followed by a pre-norm gated feed-forward."""
def __init__(self, dim: int, *, head_dim: int, inner_dim: int, prefix: str = "") -> None:
super().__init__()
self.norm1 = nn.LayerNorm(dim)
self.attn = Attention(dim, head_dim=head_dim, prefix=prefix)
self.norm2 = nn.LayerNorm(dim)
# One fused projection produces the GLU's value and gate halves.
self.ff_in = nn.Linear(dim, inner_dim * 2)
self.ff_out = nn.Linear(inner_dim, dim)
def forward(self, x: Tensor, rope_cos: Tensor, rope_sin: Tensor) -> Tensor:
x = x + self.attn(self.norm1(x), rope_cos, rope_sin)
value, gate = self.ff_in(self.norm2(x)).chunk(2, dim=-1)
return x + self.ff_out(value * F.silu(gate))
class MiniMaxMusic3Transformer1DModel(nn.Module):
"""The 36-block velocity field: ``([B,128,T], [B], [B,2048,T]) -> [B,128,T]``."""
def __init__(self) -> None:
super().__init__()
self.preprocess_conv = nn.Conv1d(_TRANSFORMER_IN_DIM, _TRANSFORMER_IN_DIM, 1, bias=False)
self.postprocess_conv = nn.Conv1d(DIT_LATENT_CHANNELS, DIT_LATENT_CHANNELS, 1, bias=False)
self.proj_in = nn.Linear(_TRANSFORMER_IN_DIM, _DIM, bias=False)
self.proj_out = nn.Linear(_DIM, DIT_LATENT_CHANNELS, bias=False)
self.time_proj = FourierFeatures(1, _FOURIER_DIM)
self.time_embed = TimestepEmbedding(_FOURIER_DIM, _DIM)
self.transformer_blocks = nn.ModuleList(
TransformerBlock(
_DIM,
head_dim=_HEAD_DIM,
inner_dim=_FF_INNER_DIM,
prefix=f"transformer_blocks.{index}.attn",
)
for index in range(_NUM_LAYERS)
)
inv_freq = 1.0 / (_ROTARY_BASE ** (torch.arange(0, _ROTARY_DIM, 2).float() / _ROTARY_DIM))
self.register_buffer("inv_freq", inv_freq, persistent=False)
self._rope_cache: dict[tuple[int, torch.dtype, torch.device], tuple[Tensor, Tensor]] = {}
@property
def attention_backend_name(self) -> str:
"""The attention path the blocks actually run, for startup logging."""
return self.transformer_blocks[0].attn.backend_name
def _rope(self, seq_len: int, *, dtype: torch.dtype, device: torch.device) -> tuple[Tensor, Tensor]:
"""Return cached ``(cos, sin)`` shaped ``[seq_len, 1, 32]``.
The head axis is left as a singleton so the tables broadcast over
``[B, S, H, D]`` without transposing Q and K.
"""
key = (seq_len, dtype, device)
cached = self._rope_cache.get(key)
if cached is None:
t = torch.arange(seq_len, device=self.inv_freq.device, dtype=torch.float32)
freqs = torch.einsum("i,j->ij", t, self.inv_freq)
freqs = torch.cat((freqs, freqs), dim=-1).to(dtype=dtype, device=device).unsqueeze(1)
cached = (freqs.cos(), freqs.sin())
self._rope_cache[key] = cached
return cached
def forward(self, x: Tensor, t: Tensor, condition: Tensor) -> Tensor:
"""Predict the flow velocity at latent ``x`` and time ``t``.
Args:
x: Current latent, ``[B, 128, T_mel]``.
t: Flow time in ``[0, 1)``, ``[B]``.
condition: Aligned condition, ``[B, 2048, T_mel]``.
Returns:
The velocity, ``[B, 128, T_mel]``.
"""
full = torch.cat((x, torch.zeros_like(x), condition), dim=1)
full = self.preprocess_conv(full) + full
timestep_embed = self.time_embed(self.time_proj(t[:, None]))
h = self.proj_in(full.transpose(1, 2))
# The timestep rides as an extra leading position and is dropped again
# after the stack, so it shifts every rotary phase by one step.
h = torch.cat((timestep_embed.unsqueeze(1), h), dim=1)
rope_cos, rope_sin = self._rope(h.shape[1], dtype=h.dtype, device=h.device)
for block in self.transformer_blocks:
h = block(h, rope_cos, rope_sin)
out = self.proj_out(h[:, 1:]).transpose(1, 2)
return self.postprocess_conv(out) + out
def remap_transformer_state(state: dict[str, Tensor]) -> dict[str, Tensor]:
"""Rewrite ``transformer/`` checkpoint keys onto this module tree.
Two rewrites, both confined to the attention block:
* ``attn.to_q/to_k/to_v.weight`` are concatenated along the output axis
into ``attn.to_qkv.weight``. ``nn.Linear`` output feature ``i`` reads
weight row ``i``, and :meth:`Attention.forward` splits the projection
with ``chunk(3, dim=-1)`` into ``q, k, v``, so query rows come first and
value rows last.
* ``attn.to_out.0.weight`` loses its index: the checkpoint models
``to_out`` as a two-element list whose second element is a parameterless
dropout, and inference only needs the projection.
Every other key is copied through untouched, including the fused ``ff_in``
whose halves are consumed as ``value, gate`` in that order.
Raises:
ValueError: If a block is missing one of its three projections.
"""
remapped: dict[str, Tensor] = {}
for key, value in state.items():
if key.endswith(".attn.to_out.0.weight"):
remapped[key.replace(".to_out.0.weight", ".to_out.weight")] = value
continue
if ".attn.to_q.weight" in key:
prefix = key[: -len("to_q.weight")]
try:
fused = [state[f"{prefix}to_{name}.weight"] for name in "qkv"]
except KeyError as exc:
raise ValueError(
f"MiniMax Music 3 transformer block {prefix!r} is missing attention projection {exc}"
) from exc
remapped[f"{prefix}to_qkv.weight"] = torch.cat(fused, dim=0)
continue
if ".attn.to_k.weight" in key or ".attn.to_v.weight" in key:
continue
remapped[key] = value
return remapped
class MiniMaxMusic3ConditionEncoder(nn.Module):
"""Collapse an AR frame's eight layer slices and resample onto mel time."""
def __init__(self) -> None:
super().__init__()
self.layer_weight_logits = nn.Parameter(torch.zeros(NUM_CODEBOOKS))
self.layer_scale = nn.Parameter(torch.ones(1))
self.proj = nn.Conv1d(BACKBONE_HIDDEN_SIZE, _CONDITION_DIM, kernel_size=3, padding=1)
def aligned_mel_length(self, frames: int) -> int:
"""Return how many vocoder latent frames ``frames`` AR frames cover.
AR frames sit on a 24 kHz / 960-hop grid (25 fps) and the vocoder
latent on a 44.1 kHz / 512-hop grid (about 86 fps), so one AR frame is
roughly 3.445 latent frames. The result is truncated, never rounded.
"""
return max(
1,
int(
frames
* CONDITION_OUTPUT_SAMPLE_RATE
/ CONDITION_INPUT_SAMPLE_RATE
* CONDITION_INPUT_HOP
/ CONDITION_OUTPUT_HOP
),
)
def condition(self, hidden: Tensor) -> Tensor:
"""Project ``[B, T, 32768]`` AR frames to ``[B, 2048, T]``.
Raises:
ValueError: If the conditioning is not ``[B, T, 32768]``.
"""
if hidden.ndim != 3 or hidden.shape[-1] != AR_HIDDEN_SIZE:
raise ValueError(f"MiniMax Music 3 condition must be [B,T,{AR_HIDDEN_SIZE}], got {tuple(hidden.shape)}")
hidden_cf = hidden.transpose(1, 2)
bsz, _, frames = hidden_cf.shape
hidden_cf = hidden_cf.reshape(bsz, NUM_CODEBOOKS, BACKBONE_HIDDEN_SIZE, frames)
weights = torch.softmax(self.layer_weight_logits, dim=0).to(hidden_cf.dtype)
hidden_cf = torch.einsum("blht,l->bht", hidden_cf, weights)
hidden_cf = self.layer_scale.to(hidden_cf.dtype) * hidden_cf
return self.proj(hidden_cf)
def aligned_condition(self, hidden: Tensor) -> Tensor:
"""Project AR hidden states and resample them onto the latent grid."""
hidden = hidden.to(dtype=next(self.parameters()).dtype)
align = self.condition(hidden)
mel_len = self.aligned_mel_length(hidden.shape[1])
return F.interpolate(align, size=mel_len, mode="nearest")
class MiniMaxMusic3FlowMatchingDiT(nn.Module):
"""Condition encoder plus velocity field, with the Euler solver on top."""
def __init__(self) -> None:
super().__init__()
self.condition_encoder = MiniMaxMusic3ConditionEncoder()
self.transformer = MiniMaxMusic3Transformer1DModel()
def aligned_mel_length(self, frames: int) -> int:
return self.condition_encoder.aligned_mel_length(frames)
def condition(self, hidden: Tensor) -> Tensor:
return self.condition_encoder.condition(hidden)
def aligned_condition(self, hidden: Tensor) -> Tensor:
return self.condition_encoder.aligned_condition(hidden)
@torch.inference_mode()
def forward(
self,
align: Tensor,
*,
generator: torch.Generator,
initial_latent: Tensor | None = None,
initial_condition: Tensor | None = None,
num_steps: int = DEFAULT_DIT_STEPS,
cfg_scale: float = DEFAULT_DIT_CFG_SCALE,
) -> Tensor:
"""Solve an aligned condition into a vocoder latent ``[1, 128, T_mel]``.
The previous window's latent is re-imposed at every step rather than
only at the start: the leading ``left`` frames are pinned to the exact
noise-to-latent interpolation the solver would have produced had it
generated them, so the two windows join without a seam.
Args:
align: Aligned condition, ``[1, 2048, T_mel]``. Overwritten in
place over the prompt span when ``initial_condition`` is
given, which the caller relies on when it saves the condition
for the next window.
generator: Seeded generator for the initial noise draw.
initial_latent: Previous window's tail latent, or ``None``.
initial_condition: Previous window's tail condition, or ``None``.
num_steps: Euler steps.
cfg_scale: Classifier-free guidance weight.
Returns:
The solved latent, ``[1, 128, T_mel]``.
Raises:
ValueError: If ``num_steps`` is not positive.
"""
if num_steps < 1:
raise ValueError("MiniMax Music 3 DiT num_steps must be positive")
mel_len = align.shape[-1]
x = torch.randn(
(align.shape[0], DIT_LATENT_CHANNELS, mel_len),
device=align.device,
dtype=align.dtype,
generator=generator,
)
left = 0
latent_prompt: Tensor | None = None
noise_prompt: Tensor | None = None
if initial_latent is not None:
left = min(initial_latent.shape[-1], mel_len)
if left <= 0:
left = 0
else:
latent_prompt = initial_latent[..., :left].to(x)
noise_prompt = x[..., :left].clone()
if initial_condition is not None:
align[..., :left] = initial_condition[..., :left].to(align)
dt = 1.0 / num_steps
# Row 0 carries the condition, row 1 stays zero: one batched forward
# evaluates both guidance branches.
cond_cfg = torch.zeros((2, *align.shape[1:]), device=align.device, dtype=align.dtype)
cond_cfg[0].copy_(align[0])
# Every timestep in one host-to-device copy. Built from the same
# Python divisions the per-step construction would have used, so the
# values are identical, but the solver loop then touches the host only
# to launch kernels.
schedule = torch.tensor(
[step / num_steps for step in range(num_steps)],
device=align.device,
dtype=align.dtype,
)
for step in range(num_steps):
t = schedule[step].expand(x.shape[0])
if left and latent_prompt is not None and noise_prompt is not None:
x[..., :left] = (1.0 - (1.0 - _SIGMA_FLOOR) * t[0]) * noise_prompt + t[0] * latent_prompt
d = self.transformer(x.expand(2, -1, -1), t.expand(2), cond_cfg)
d = cfg_scale * d[:1] + (1.0 - cfg_scale) * d[1:2]
x = x + dt * d
if left and latent_prompt is not None:
x[..., :left] = latent_prompt
return x
@@ -0,0 +1,158 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Per-request AR state for MiniMax Music 3.
The talker accumulates one 32768-dimensional conditioning frame per decode
step and hands the acoustic stage overlapping 200-frame windows. Windows are
emitted one frame late on purpose: a window can only be declared non-final
once a further frame proves the song did not end at its boundary, and the
tail is flushed when the request finishes.
State is keyed by request id rather than batch row, because a row's identity
is not stable across engine steps.
"""
from __future__ import annotations
from dataclasses import dataclass, field
import torch
from .chunking import ChunkWindow
from .constants import AR_CHUNK_FRAMES, AR_CHUNK_HOP_FRAMES, NUM_CODEBOOKS
class FrameBuffer:
"""Rolling buffer of conditioning frames addressed by absolute frame index.
Frames before an emitted window's hop point are dropped, so peak memory is
bounded by the window size rather than by the length of the song.
"""
def __init__(self) -> None:
self.base_frame = 0
self._frames: list[torch.Tensor] = []
self.peak_frames = 0
@property
def end_frame(self) -> int:
return self.base_frame + len(self._frames)
def append(self, frame: torch.Tensor) -> None:
"""Append one ``[1, hidden]`` frame."""
self._frames.append(frame)
self.peak_frames = max(self.peak_frames, len(self._frames))
def slice(self, start: int, end: int) -> torch.Tensor:
"""Return frames ``[start, end)`` stacked as ``[end - start, hidden]``.
Raises:
RuntimeError: If the range has already been discarded or has not
been produced yet.
"""
rel_start = start - self.base_frame
rel_end = end - self.base_frame
if rel_start < 0 or rel_end > len(self._frames):
raise RuntimeError(
f"MiniMax Music 3 frame range [{start}, {end}) is unavailable; "
f"the buffer holds [{self.base_frame}, {self.end_frame})"
)
return torch.cat(self._frames[rel_start:rel_end], dim=0)
def discard_before(self, frame: int) -> None:
drop = min(max(frame - self.base_frame, 0), len(self._frames))
if drop:
del self._frames[:drop]
self.base_frame += drop
def clear(self) -> None:
self._frames.clear()
self.base_frame = 0
@dataclass
class RequestARState:
"""Everything the talker tracks for one conditioned request."""
seed: int
max_frames: int
# Prompt length in tokens, used to tell a decode row apart from a row that
# is still working through a chunked prefill.
prompt_tokens: int = 0
sampling_position: int = 0
generated_frames: int = 0
emitted_windows: int = 0
finished: bool = False
# The engine is told to stop one step after the song ends. Payloads are
# built from the buffer that ``postprocess`` filled on the previous step,
# so without that extra step the terminal span would be written and then
# discarded with the request.
pending_stop: bool = False
finish_reason: str = "length"
frames: FrameBuffer = field(default_factory=FrameBuffer)
codes: list[torch.Tensor] = field(default_factory=list)
codes_base_frame: int = 0
# The previous frame's eight codes, fed back as the next step's input
# embedding. Held on the request rather than on a batch row, because the
# row a request occupies is not stable between steps.
last_codes: torch.Tensor | None = None
def advance_position(self) -> int:
"""Return this frame's base draw position and reserve the frame's slot.
Each frame consumes ``NUM_CODEBOOKS`` positions so that the ``c0`` draw
and the seven depth draws never share an RNG stream.
"""
position = self.sampling_position
self.sampling_position += NUM_CODEBOOKS
return position
def ready_window(self) -> ChunkWindow | None:
"""Return the next window that a lookahead frame has proven non-final."""
start = self.emitted_windows * AR_CHUNK_HOP_FRAMES
if self.frames.end_frame <= start + AR_CHUNK_FRAMES:
return None
return ChunkWindow(
index=self.emitted_windows,
start=start,
end=start + AR_CHUNK_FRAMES,
is_first=self.emitted_windows == 0,
is_last=False,
)
def take_window(self, window: ChunkWindow, *, is_final: bool) -> torch.Tensor:
"""Materialize ``window`` and release the frames it retires."""
chunk = self.frames.slice(window.start, window.end)
self.emitted_windows = max(self.emitted_windows, window.index + 1)
if not is_final:
self.frames.discard_before(window.start + AR_CHUNK_HOP_FRAMES)
return chunk
def take_codes(self, start: int, end: int) -> torch.Tensor | None:
"""Return the sampled codes for frames ``[start, end)``, if retained.
Codes are carried alongside the conditioning frames so a downstream
stage or a debugging harness can recover the exact RVQ trajectory.
They are dropped rather than raising when a span predates what is
still buffered, since nothing in the audio path depends on them.
"""
if not self.codes or end <= start:
return None
offset = self.codes_base_frame
lo, hi = start - offset, end - offset
if lo < 0 or hi > len(self.codes):
return None
return torch.cat(self.codes[lo:hi], dim=0)
def discard_codes_before(self, frame: int) -> None:
drop = min(max(frame - self.codes_base_frame, 0), len(self.codes))
if drop:
del self.codes[:drop]
self.codes_base_frame += drop
def release(self) -> None:
self.frames.clear()
self.codes.clear()
__all__ = ["FrameBuffer", "RequestARState"]
@@ -0,0 +1,103 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""MiniMax Music 3 pipeline topology: AR talker -> acoustic decoder.
Stage 0 ``minimax_music3_ar``: a Qwen3 backbone (the repo's ``language_model/``
subfolder is a plain ``Qwen3ForCausalLM``) plus a depth decoder, wrapped by a
class that owns its sampler. It emits 200-frame windows of frame hidden states
and the matching 8-codebook RVQ codes.
Stage 1 ``minimax_music3_acoustic``: flow-matching DiT plus DAV vocoder. Pure
compute, no KV cache, weights loaded from the repo's ``condition_encoder/``,
``transformer/`` and ``vocoder/`` subfolders. Output is 32 kHz stereo.
Classifier-free guidance is mandatory for this checkpoint: every request
decodes as a cond/uncond pair, and the uncond row is produced by
``prompt_expand_func``. The deploy YAMLs therefore carry
``default_sampling_params.extra_args`` with ``cfg_role`` and ``cfg_scale`` so
that ``has_sampling_extra_args`` is on for the stage and per-row extra args
reach the model's forward.
"""
from vllm_omni.config.stage_config import (
PipelineConfig,
StageExecutionType,
StagePipelineConfig,
)
_PROC = "vllm_omni.model_executor.stage_input_processors.minimax_music3"
# ``<|audio_end|>`` in the checkpoint tokenizer (pinned by
# ``models/minimax_music3/prompt.SPECIAL_TOKEN_IDS``). The talker's own sampler
# forces this at the frame budget; the stop id is the engine-side safety net.
MINIMAX_MUSIC3_AUDIO_END_TOKEN_ID = 151670
# MiniMax captions carry the full style description and run to roughly 5000
# characters, an order of magnitude past the 500-character serving default.
# ``tts_args`` is a pipeline-level stage extra, not a deploy-YAML field: it
# reaches the serving layer through ``StageConfig.yaml_extras``, which is where
# ``ServingSpeech._compute_max_instructions_length`` reads it from.
_MAX_INSTRUCTIONS_LENGTH = 20_000
MINIMAX_MUSIC3_PIPELINE = PipelineConfig(
model_type="minimax_music3",
model_arch="MiniMaxMusic3TalkerForConditionalGeneration",
hf_architectures=("MiniMaxMusic3ForConditionalGeneration",),
default_deploy_config_name="minimax_music3.yaml",
stages=(
StagePipelineConfig(
stage_id=0,
model_stage="minimax_music3_ar",
execution_type=StageExecutionType.LLM_AR,
input_sources=(),
owns_tokenizer=True,
engine_output_type="latent",
# The repo root config.json only names the composite architecture;
# the AR weights and the tokenizer live in their own subfolders.
model_subdir="language_model",
tokenizer_subdir="tokenizer",
# detokenize: the sampled stream is audio codes, never text.
# stop_token_ids: <|audio_end|> closes the song.
sampling_constraints={
"detokenize": False,
"stop_token_ids": [MINIMAX_MUSIC3_AUDIO_END_TOKEN_ID],
},
# CFG is mandatory here, so this expands on essentially every
# request rather than only on an opt-in flag.
prompt_expand_func=f"{_PROC}.expand_cfg_prompts",
async_chunk_process_next_stage_input_func=f"{_PROC}.ar2acoustic_async_chunk",
# retains_state_across_chunks stays False. It is read only by
# OmniGenerationScheduler, so it is a no-op on an LLM_AR stage;
# more to the point, stage 0 produces chunks rather than parking on
# them, so it never holds a runner slot while waiting for one.
extras={"tts_args": {"max_instructions_length": _MAX_INSTRUCTIONS_LENGTH}},
),
StagePipelineConfig(
stage_id=1,
model_stage="minimax_music3_acoustic",
execution_type=StageExecutionType.LLM_GENERATION,
input_sources=(0,),
final_output=True,
final_output_type="audio",
# "audio" (not "latent"): the accumulator concatenates audio over
# time, whereas the latent path would stack the two stereo channels.
engine_output_type="audio",
model_arch="MiniMaxMusic3AcousticForConditionalGeneration",
# This stage reads the repo ROOT so it can reach condition_encoder/,
# transformer/ and vocoder/, and the root ships no tokenizer files.
tokenizer_subdir="tokenizer",
# The acoustic stage carries the previous window's latent forward as
# the next window's flow-matching prompt, so a request parked between
# chunks still owns its model state and must keep counting against
# max_num_seqs.
retains_state_across_chunks=True,
sync_process_input_func=f"{_PROC}.ar2acoustic",
sampling_constraints={"detokenize": True},
),
),
)
__all__ = [
"MINIMAX_MUSIC3_AUDIO_END_TOKEN_ID",
"MINIMAX_MUSIC3_PIPELINE",
]
@@ -0,0 +1,172 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
#
# Prompt normalization follows the MiniMaxAI/MiniMax-Music3 input contract.
# The transformations are reproduced byte-for-byte because any divergence
# shifts token positions and therefore changes the generated audio.
"""MiniMax Music 3 prompt construction.
The prompt is a single flat string, not a chat template::
<|im_start|><|caption_start|>{caption}<|caption_end|>
<|lyrics_start|>{lyrics}<|lyrics_end|><|im_end|><|audio_start|>
The classifier-free-guidance twin reuses the same token ids with the whole
caption-and-lyrics span (delimiters included) overwritten by
``<|audio_cfg|>``, so only the chat markers and ``<|audio_start|>`` survive.
Both rows therefore have identical length and identical audio-start position.
"""
from __future__ import annotations
import re
from typing import Any
# Pinned by ``validate_tokenizer_ids``: a checkpoint whose tokenizer disagrees
# is not this model, and silently producing noise is worse than failing.
SPECIAL_TOKEN_IDS: dict[str, int] = {
"<|im_start|>": 151644,
"<|im_end|>": 151645,
"<|audio_cfg|>": 151654,
"<|audio_start|>": 151669,
"<|audio_end|>": 151670,
"<|caption_start|>": 151671,
"<|caption_end|>": 151672,
"<|lyrics_start|>": 151673,
"<|lyrics_end|>": 151674,
}
# c0 codes occupy the backbone vocabulary immediately above the text tokens.
AUDIO_CODE_OFFSET = 151675
_SPECIAL_TAG_RE = re.compile(r"<\|([^|]*)\|>")
_LEADING_TAGS_RE = re.compile(r"^[ \t]*((?:\[[^\]]+\][ \t]*)+)")
def _strip_markdown_residuals(text: str) -> str:
"""Strip the Markdown syntax the model input contract does not accept."""
if not text:
return text
lines_out: list[str] = []
for raw_line in text.splitlines():
line = raw_line
line = re.sub(r"^\s{0,3}#{1,6}\s+", "", line)
line = re.sub(r"^\s*[*+-]\s+", "", line)
line = re.sub(r"^\s*\*\s+", "", line)
while "**" in line:
updated = re.sub(r"\*\*([^*]+)\*\*", r"\1", line)
if updated == line:
break
line = updated
line = re.sub(r"(?<!\*)\*([^*\n]+)\*(?!\*)", r"\1", line)
lines_out.append(line.rstrip())
text = "\n".join(lines_out)
return re.sub(r"^\s*[-*_]{3,}\s*$", "", text, flags=re.MULTILINE)
def _remove_markdown_format(text: str) -> str:
return _strip_markdown_residuals(text).replace("• ", "").replace(" ", "")
def clean_caption(caption: str) -> str:
"""Apply the source order: special tokens, then Markdown, then newlines.
``<|tag value|>`` forms are rewritten to ``tag is value`` so a caption
cannot inject a real special token into the prompt.
"""
def _special(match: re.Match[str]) -> str:
inner = match.group(1).strip()
parts = inner.split(None, 1)
return f"{parts[0]} is {parts[1]}" if len(parts) == 2 else inner
text = _SPECIAL_TAG_RE.sub(_special, caption)
text = _remove_markdown_format(text)
# The source collapses runs of two or more newlines, not three or more;
# this shifts blank-line token positions and therefore the audio.
return re.sub(r"\n{2,}", "\n", text)
def _strip_text_after_leading_tags(text: str) -> str:
"""Keep only the consecutive structural tags that start a line.
Lyrics written on the same line as a ``[Verse]`` tag are dropped, which is
the documented contract: a tag must sit on its own line.
"""
if not text:
return text
output: list[str] = []
for line in text.split("\n"):
match = _LEADING_TAGS_RE.match(line)
output.append(match.group(1).strip() if match else line)
return "\n".join(output)
def normalize_lyrics(lyrics: str) -> str:
"""Normalize lyrics to the form the checkpoint was trained on."""
text = _strip_text_after_leading_tags(lyrics)
text = text.replace("] ", "]\n")
text = text.replace(" [", "\n[")
text = text.replace(" ^ ", "\n")
text = re.sub(r"\[([^\]]+)\]", lambda match: f"[{match.group(1).lower()}]", text)
return f"[start]\n{text}"
def build_prompt(caption: str, lyrics: str) -> str:
"""Build the conditioned prompt string."""
return (
"<|im_start|><|caption_start|>"
f"{clean_caption(caption)}"
"<|caption_end|><|lyrics_start|>"
f"{normalize_lyrics(lyrics)}"
"<|lyrics_end|><|im_end|><|audio_start|>"
)
def build_cfg_null_token_ids(prompt_token_ids: list[int]) -> list[int]:
"""Return the unconditioned twin's token ids for a conditioned prompt.
Everything between the leading ``<|im_start|>`` and the trailing
``<|im_end|><|audio_start|>`` becomes ``<|audio_cfg|>``. Length and the
audio-start position are preserved, which is what lets the two rows decode
in lockstep.
Raises:
ValueError: If the prompt is too short to carry a caption and lyrics,
or does not have the expected framing tokens.
"""
if len(prompt_token_ids) < 4:
raise ValueError(f"MiniMax Music 3 prompt is too short to build a CFG twin: {len(prompt_token_ids)} tokens")
expected = (
SPECIAL_TOKEN_IDS["<|im_start|>"],
SPECIAL_TOKEN_IDS["<|im_end|>"],
SPECIAL_TOKEN_IDS["<|audio_start|>"],
)
actual = (prompt_token_ids[0], prompt_token_ids[-2], prompt_token_ids[-1])
if actual != expected:
raise ValueError(
"MiniMax Music 3 prompt framing is wrong: expected "
f"(im_start, im_end, audio_start)={expected}, got {actual}"
)
null_ids = list(prompt_token_ids)
null_ids[1:-2] = [SPECIAL_TOKEN_IDS["<|audio_cfg|>"]] * (len(null_ids) - 3)
return null_ids
def validate_tokenizer_ids(tokenizer: Any) -> None:
"""Fail fast when a tokenizer is not the supported music tokenizer."""
for token, expected in SPECIAL_TOKEN_IDS.items():
token_id = tokenizer.convert_tokens_to_ids(token)
if token_id != expected:
raise ValueError(f"MiniMax Music 3 tokenizer mismatch for {token}: expected {expected}, got {token_id}")
__all__ = [
"AUDIO_CODE_OFFSET",
"SPECIAL_TOKEN_IDS",
"build_cfg_null_token_ids",
"build_prompt",
"clean_caption",
"normalize_lyrics",
"validate_tokenizer_ids",
]
@@ -0,0 +1,179 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""RVQ depth decoder for MiniMax Music 3 (codebooks c1..c7).
Each audio frame is eight codebooks deep. The backbone's own ``lm_head``
emits ``c0``; this four-layer decoder walks ``c1..c7``, conditioning each code
on the previous ones. Its per-codebook hidden states are also the acoustic
stage's conditioning signal, so this module returns them alongside the codes.
The sequence fed to the decoder is::
[proj(backbone_hidden), proj(embed(c0)), proj(embed(c1)), ...]
and after step ``i`` the head ``audio_heads[i - 1]`` predicts ``c_i``. The
whole thing runs over at most eight positions, so it is written as a dense
causal block rather than anything with a KV cache.
"""
from __future__ import annotations
import torch
from torch import Tensor, nn
from torch.nn import functional as F
from torch.nn.attention import SDPBackend, sdpa_kernel
from .constants import AUDIO_VOCAB_SIZE, BACKBONE_HIDDEN_SIZE, NUM_CODEBOOKS
# cuDNN's fused attention is not bit-reproducible on this hardware: two
# identical replays of the same input can differ by one bfloat16 ULP, which is
# enough to flip a sampled code and send the rest of the song somewhere else.
# The choice is pinned per call rather than through a process-global switch so
# it cannot be undone by whatever else shares the process.
_SDPA_BACKENDS = [SDPBackend.FLASH_ATTENTION, SDPBackend.MATH]
class DepthDecoderBlock(nn.Module):
"""Pre-norm transformer block with fused QKV and a SwiGLU MLP."""
def __init__(self, hidden_size: int, num_heads: int, intermediate_size: int) -> None:
super().__init__()
self.num_heads = num_heads
self.head_dim = hidden_size // num_heads
self.input_layernorm = nn.RMSNorm(hidden_size, eps=1e-6)
# Stored fused so the three projections are one matmul; the checkpoint
# ships them split and ``load_checkpoint_state`` concatenates.
self.qkv_proj = nn.Linear(hidden_size, hidden_size * 3, bias=False)
self.o_proj = nn.Linear(hidden_size, hidden_size, bias=False)
self.post_attention_layernorm = nn.RMSNorm(hidden_size, eps=1e-6)
self.gate_proj = nn.Linear(hidden_size, intermediate_size, bias=False)
self.up_proj = nn.Linear(hidden_size, intermediate_size, bias=False)
self.down_proj = nn.Linear(intermediate_size, hidden_size, bias=False)
def forward(self, x: Tensor) -> Tensor:
h = self.input_layernorm(x)
bsz, seq, hidden = h.shape
q, k, v = self.qkv_proj(h).chunk(3, dim=-1)
q = q.reshape(bsz, seq, self.num_heads, self.head_dim).transpose(1, 2)
k = k.reshape(bsz, seq, self.num_heads, self.head_dim).transpose(1, 2)
v = v.reshape(bsz, seq, self.num_heads, self.head_dim).transpose(1, 2)
with sdpa_kernel(_SDPA_BACKENDS):
h = F.scaled_dot_product_attention(q, k, v, is_causal=True)
h = h.transpose(1, 2).contiguous().reshape(bsz, seq, hidden)
x = x + self.o_proj(h)
h = self.post_attention_layernorm(x)
h = self.down_proj(F.silu(self.gate_proj(h)) * self.up_proj(h))
return x + h
class RVQDepthDecoder(nn.Module):
"""Depth-autoregressive decoder over the seven residual codebooks."""
def __init__(
self,
*,
hidden_size: int = BACKBONE_HIDDEN_SIZE,
num_layers: int = 4,
num_heads: int = 16,
intermediate_size: int = 6144,
audio_vocab_size: int = AUDIO_VOCAB_SIZE,
num_codebooks: int = NUM_CODEBOOKS,
max_seq_len: int = 16,
) -> None:
super().__init__()
self.hidden_size = hidden_size
self.audio_vocab_size = audio_vocab_size
self.num_codebooks = num_codebooks
self.projection = nn.Linear(hidden_size, hidden_size, bias=False)
self.pos_embedding = nn.Embedding(max_seq_len, hidden_size)
self.layers = nn.ModuleList(
DepthDecoderBlock(hidden_size, num_heads, intermediate_size) for _ in range(num_layers)
)
self.norm = nn.RMSNorm(hidden_size, eps=1e-6)
self.audio_heads = nn.ModuleList(
nn.Linear(hidden_size, audio_vocab_size, bias=False) for _ in range(num_codebooks - 1)
)
def forward(self, seq: Tensor) -> Tensor:
positions = torch.arange(seq.shape[1], device=seq.device)
x = seq + self.pos_embedding(positions).unsqueeze(0)
for layer in self.layers:
x = layer(x)
return self.norm(x)
def load_checkpoint_state(self, state: dict[str, Tensor]) -> None:
"""Load the component checkpoint, failing loudly on any mismatch.
The checkpoint keeps ``q``/``k``/``v`` split and names attention
projections ``attn.to_*``; both are reshaped here. A silent partial
load would produce plausible-sounding but wrong audio, so every key is
accounted for and anything left over is an error.
Args:
state: Tensors from the ``rvq_depth_decoder`` component, with the
``audio_embeddings.weight`` entry already removed by the
caller.
Raises:
ValueError: If any expected key is missing, any shape disagrees,
or the checkpoint carries keys this module does not consume.
"""
loaded: set[str] = set()
missing: list[str] = []
mismatched: list[str] = []
def take(key: str) -> Tensor | None:
value = state.get(key)
if value is None:
missing.append(key)
return value
def assign(key: str, target: Tensor, source: Tensor, keys: tuple[str, ...]) -> None:
if tuple(source.shape) != tuple(target.shape):
mismatched.append(f"{key}: checkpoint={tuple(source.shape)} model={tuple(target.shape)}")
return
target.data.copy_(source.to(dtype=target.dtype))
loaded.update(keys)
def copy_one(key: str, target: Tensor) -> None:
source = take(key)
if source is not None:
assign(key, target, source, (key,))
copy_one("projection.weight", self.projection.weight)
copy_one("pos_embedding.weight", self.pos_embedding.weight)
copy_one("norm.weight", self.norm.weight)
for index, head in enumerate(self.audio_heads):
copy_one(f"audio_heads.{index}.weight", head.weight)
for index, layer in enumerate(self.layers):
prefix = f"layers.{index}."
copy_one(prefix + "input_layernorm.weight", layer.input_layernorm.weight)
copy_one(
prefix + "post_attention_layernorm.weight",
layer.post_attention_layernorm.weight,
)
qkv_keys = tuple(prefix + f"attn.to_{name}.weight" for name in ("q", "k", "v"))
parts = [take(key) for key in qkv_keys]
if all(part is not None for part in parts):
assign(
prefix + "attn.qkv",
layer.qkv_proj.weight,
torch.cat(parts, dim=0),
qkv_keys,
)
copy_one(prefix + "attn.to_out.weight", layer.o_proj.weight)
copy_one(prefix + "gate_proj.weight", layer.gate_proj.weight)
copy_one(prefix + "up_proj.weight", layer.up_proj.weight)
copy_one(prefix + "down_proj.weight", layer.down_proj.weight)
unexpected = sorted(set(state) - loaded)
if missing or mismatched or unexpected:
raise ValueError(
"MiniMax Music 3 RVQ depth decoder failed to load: "
f"missing={missing[:8]}, mismatched={mismatched[:8]}, "
f"unexpected={unexpected[:8]}"
)
__all__ = ["DepthDecoderBlock", "RVQDepthDecoder"]
@@ -0,0 +1,223 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Deterministic guided sampling for MiniMax Music 3.
Two things happen per code, in this order:
1. **Classifier-free guidance.** Every request decodes twice, once on the real
prompt and once on a prompt whose caption and lyrics have been replaced by
``<|audio_cfg|>``. Codes are drawn from ``uncond + (cond - uncond) * 1.5``.
For ``c0`` the candidate set is additionally masked to the conditioned
row's own top 50, so the guided draw cannot wander outside what the
conditioned branch already considered plausible.
2. **Seeded top-k.** A batched, order-independent categorical draw: each row
gets its own RNG stream keyed by ``(seed, position)``, so a request's audio
depends only on its own seed and never on who else happens to share the
batch.
The RNG is a MurmurHash3 x86_32 of ``(seed_lo, seed_hi, position, column)``
turned into Gumbel noise, and the draw is an argmax over ``logits + gumbel``.
That is mathematically a categorical sample from ``softmax(logits)``, but
unlike ``torch.multinomial`` it needs no per-row generator state, so it is
CUDA-graph safe and reproducible across batch compositions.
"""
from __future__ import annotations
import torch
from torch import Tensor
from .constants import AR_CFG_SCALE, AR_CFG_TOP_K, AR_TOP_K
_UINT32_MASK = 0xFFFFFFFF
_UINT32_MAX = 4_294_967_295
# MurmurHash3 x86_32 constants.
_C1 = 0xCC9E2D51
_C2 = 0x1B873593
_R1 = 15
_R2 = 13
_M = 5
_N = 0xE6546B64
def _rotl32(x: Tensor, r: int) -> Tensor:
return ((x << r) | (x >> (32 - r))) & _UINT32_MASK
def _murmur3_mix(h: Tensor, k: Tensor) -> Tensor:
"""Mix one 32-bit key into the running hash state."""
k = (k * _C1) & _UINT32_MASK
k = _rotl32(k, _R1)
k = (k * _C2) & _UINT32_MASK
h = h ^ k
h = _rotl32(h, _R2)
return (h * _M + _N) & _UINT32_MASK
def _fmix32(h: Tensor) -> Tensor:
"""MurmurHash3 finalizer."""
h = h ^ (h >> 16)
h = (h * 0x85EBCA6B) & _UINT32_MASK
h = h ^ (h >> 13)
h = (h * 0xC2B2AE35) & _UINT32_MASK
return h ^ (h >> 16)
# Memoized ``k`` term of the column block's mix. The column block is the same
# ``arange(width)`` on every draw, so its three ops are hoisted out of the per
# draw work and kept per (device, width). Small and bounded: two widths exist,
# the c0 candidate set and the residual codebook.
_COLUMN_MIX_CACHE: dict[tuple[torch.device, int], Tensor] = {}
def _column_mix(num_columns: int, device: torch.device) -> Tensor:
"""Return the mixed column block, ``[cols]`` int64 holding uint32."""
key = (device, num_columns)
cached = _COLUMN_MIX_CACHE.get(key)
if cached is None:
columns = torch.arange(num_columns, dtype=torch.int64, device=device) & _UINT32_MASK
k = (columns * _C1) & _UINT32_MASK
k = _rotl32(k, _R1)
cached = (k * _C2) & _UINT32_MASK
_COLUMN_MIX_CACHE[key] = cached
return cached
def _absorb_mixed_key(h: Tensor, k: Tensor) -> Tensor:
"""Absorb a key whose own three-op transform is already applied."""
h = h ^ k
h = _rotl32(h, _R2)
return (h * _M + _N) & _UINT32_MASK
def row_hash_state(seeds: Tensor, positions: Tensor) -> Tensor:
"""Hash state after the seed and position blocks, before the columns.
Args:
seeds: ``[rows]`` per-request seeds.
positions: ``[rows]`` or ``[rows, draws]`` draw identifiers. The extra
dimension lets every codebook of a frame be hashed in one batch
instead of one dispatch per codebook; the arithmetic is elementwise
integer, so a batched call is bit-identical to the loop it replaces.
Returns:
A tensor shaped like ``positions``, int64 holding uint32.
"""
seeds = seeds.to(torch.int64)
while seeds.dim() < positions.dim():
seeds = seeds.unsqueeze(-1)
h = torch.zeros((), dtype=torch.int64, device=seeds.device)
h = _murmur3_mix(h, seeds & _UINT32_MASK)
h = _murmur3_mix(h, (seeds >> 32) & _UINT32_MASK)
return _murmur3_mix(h, positions.to(torch.int64) & _UINT32_MASK)
def murmur_hash32(seeds: Tensor, positions: Tensor, columns: Tensor) -> Tensor:
"""Hash ``(seed, position, column)`` to a uint32 held in int64.
The 64-bit seed is consumed as two 32-bit blocks, then the position and
the column, for a hashed length of 16 bytes.
Args:
seeds: ``[rows]`` per-request seeds.
positions: ``[rows]`` draw identifiers, unique per (frame, codebook).
columns: ``[cols]`` candidate indices, which must be ``arange(cols)``.
Returns:
``[rows, cols]`` int64 tensor holding uint32 values.
"""
h = row_hash_state(seeds, positions).unsqueeze(-1)
return _fmix32(_absorb_mixed_key(h, _column_mix(int(columns.shape[0]), h.device)) ^ 16)
def gumbel_noise(seeds: Tensor, positions: Tensor, num_columns: int) -> Tensor:
"""Return the Gumbel noise for one draw per position, ``[..., cols]``.
Args:
seeds: ``[rows]`` per-request seeds.
positions: ``[rows]`` or ``[rows, draws]`` draw identifiers.
num_columns: Candidate count, i.e. the width of the logits.
Returns:
float64 noise shaped ``positions.shape + (num_columns,)``.
"""
h = row_hash_state(seeds, positions).unsqueeze(-1)
hashed = _fmix32(_absorb_mixed_key(h, _column_mix(num_columns, h.device)) ^ 16)
# float64 throughout: the double log is numerically unstable in float32 and
# a single flipped draw changes every frame that follows it.
uniform = hashed.to(torch.float64) / _UINT32_MAX
# Clamp both ends. uniform == 1 gives +inf Gumbel, which turns a -inf
# masked entry into NaN; the upper cap is the hash spacing, so a saturated
# bucket ties with its neighbour instead of always winning.
gumbel = uniform.log().clamp_(min=torch.finfo(torch.float64).min, max=-(2.0**-32)).neg_()
return gumbel.log().neg_()
def draw_from_gumbel(logits: Tensor, gumbel: Tensor, *, top_k: int = AR_TOP_K) -> Tensor:
"""Argmax of ``top_k``-masked logits plus precomputed Gumbel noise.
Args:
logits: ``[rows, vocab]`` unnormalized scores.
gumbel: ``[rows, vocab]`` float64 noise from :func:`gumbel_noise`.
top_k: Candidates retained per row before the draw.
Returns:
``[rows]`` sampled column indices.
"""
values = torch.nan_to_num(logits.float(), nan=-1e9, posinf=1e9, neginf=-1e9)
k = min(int(top_k), values.shape[-1])
threshold = torch.topk(values, k, dim=-1).values[..., -1, None]
values = values.masked_fill(values < threshold, -float("inf"))
return (values.to(torch.float64) + gumbel).argmax(dim=-1)
def sample_topk_seeded(
logits: Tensor,
seeds: Tensor,
positions: Tensor,
*,
top_k: int = AR_TOP_K,
) -> Tensor:
"""Draw one column per row from its own top-k, under its own RNG stream.
Args:
logits: ``[rows, vocab]`` unnormalized scores.
seeds: ``[rows]`` per-request sampling seeds.
positions: ``[rows]`` draw identifiers, unique per (frame, codebook).
top_k: Candidates retained per row before the draw.
Returns:
``[rows]`` sampled column indices.
"""
gumbel = gumbel_noise(seeds, positions, int(logits.shape[-1]))
return draw_from_gumbel(logits, gumbel, top_k=top_k)
def apply_c0_cfg(cond: Tensor, uncond: Tensor, *, scale: float = AR_CFG_SCALE) -> Tensor:
"""Guide ``cond`` by ``uncond``, restricted to ``cond``'s own top candidates.
The mask is a second, separate restriction from the top-k the sampler
applies afterwards. Dropping it widens the distribution the model draws
from and audibly loosens how closely the output follows the caption.
"""
guided = uncond + (cond - uncond) * scale
k = min(AR_CFG_TOP_K, cond.shape[-1])
threshold = torch.topk(cond, k, dim=-1).values[..., -1, None]
return guided.masked_fill(cond < threshold, -float("inf"))
def apply_depth_cfg(cond: Tensor, uncond: Tensor, *, scale: float = AR_CFG_SCALE) -> Tensor:
"""Guide the depth heads. Unlike ``c0`` these are not additionally masked."""
return uncond + (cond - uncond) * scale
__all__ = [
"apply_c0_cfg",
"apply_depth_cfg",
"draw_from_gumbel",
"gumbel_noise",
"murmur_hash32",
"row_hash_state",
"sample_topk_seeded",
]
@@ -0,0 +1,66 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Pinned host staging for the small per-step index vectors.
Building a device tensor from a Python list (``torch.tensor(rows,
device="cuda")``) stages through pageable host memory, which makes the copy
synchronous: the host blocks until the copy retires, and because the copy is
queued behind the step's kernels that means blocking until the whole forward
has finished. On this model that turned four one-line conveniences into the
single largest entry in a CPU profile of the decode loop.
Writing into a pinned block and pushing it with one non-blocking copy costs
one transfer for the whole step and leaves the host free to keep enqueueing.
"""
from __future__ import annotations
from collections.abc import Sequence
import torch
__all__ = ["PinnedIndexStage"]
class PinnedIndexStage:
"""A fixed ``[rows, capacity]`` int64 block staged host-to-device.
The device view returned by :meth:`push` is only populated once the stream
reaches the copy, so it may be consumed by kernels on that stream and by
nothing else. The host side is double-buffered: the next step writes the
slot the device is not reading, so refilling cannot corrupt a copy that is
still in flight.
"""
def __init__(self, rows: int, capacity: int, device: torch.device) -> None:
self._capacity = capacity
self._host = [torch.zeros((rows, capacity), dtype=torch.int64, pin_memory=True) for _ in range(2)]
self._device = torch.zeros((rows, capacity), dtype=torch.int64, device=device)
self._slot = 0
@property
def capacity(self) -> int:
return self._capacity
def push(self, values: Sequence[Sequence[int]], count: int) -> torch.Tensor:
"""Stage ``count`` entries of each row and return the device view.
Args:
values: One sequence of Python ints per row of the block.
count: How many entries of each row are meaningful this step.
Returns:
``[rows, count]`` int64 view of the device block.
Raises:
ValueError: If ``count`` exceeds the reserved capacity.
"""
if count > self._capacity:
raise ValueError(f"staged {count} entries but only {self._capacity} were reserved")
self._slot ^= 1
host = self._host[self._slot]
view = host.numpy()
for index, row in enumerate(values):
view[index, :count] = row
self._device.copy_(host, non_blocking=True)
return self._device[:, :count]
@@ -0,0 +1,990 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Stage-0 talker for MiniMax Music 3.
A Qwen3 backbone predicts one audio frame per decode step. Each frame is
eight RVQ codebooks deep: the backbone's own ``lm_head`` emits ``c0`` and the
four-layer depth decoder walks ``c1..c7``. The conditioning signal handed to
the acoustic stage is the backbone hidden state concatenated with the seven
depth hidden states, so 8 x 4096 = 32768 dimensions per frame.
Two things make this model unusual inside vLLM:
* **Every request decodes as two rows.** Classifier-free guidance is not
optional here. The conditioned row sees the real prompt and the
unconditioned row sees the same prompt with the caption-and-lyrics span
replaced by ``<|audio_cfg|>``. Codes are drawn from the guided blend and
then fed back to *both* rows, so the pair stays token-identical. Rows are
paired by ``cfg_pair_id`` rather than by adjacency, because batch position
is not stable across engine steps.
* **The decode input is not a token embedding.** After prefill, each step's
input is the composed embedding of the previous frame's eight codes. The
sampled ``c0`` token id still flows through vLLM's normal machinery so that
stop handling and accounting work, but the embedding it would produce is
replaced before the backbone runs.
"""
from __future__ import annotations
from collections.abc import Iterable
from dataclasses import dataclass
from typing import Any
import torch
from torch import nn
from vllm.config import VllmConfig
from vllm.logger import init_logger
from vllm.model_executor.layers.logits_processor import LogitsProcessor
from vllm.model_executor.layers.vocab_parallel_embedding import ParallelLMHead
from vllm.model_executor.models.qwen3 import Qwen3Model
from vllm.model_executor.models.utils import AutoWeightsLoader
from vllm.v1.outputs import SamplerOutput
from vllm_omni.model_executor.models.output_templates import OmniOutput
from .chunking import chunk_windows
from .constants import (
AR_CHUNK_HOP_FRAMES,
AUDIO_VOCAB_SIZE,
BACKBONE_HIDDEN_SIZE,
C0_VOCAB_SIZE,
DEFAULT_MAX_AUDIO_FRAMES,
NUM_CODEBOOKS,
)
from .depth_graph import DepthGraphCache
from .frame_state import RequestARState
from .prompt import AUDIO_CODE_OFFSET, SPECIAL_TOKEN_IDS
from .rvq_decoder import RVQDepthDecoder
from .sampling import (
apply_c0_cfg,
apply_depth_cfg,
draw_from_gumbel,
gumbel_noise,
sample_topk_seeded,
)
from .staging import PinnedIndexStage
from .weights import load_depth_decoder_weights
logger = init_logger(__name__)
__all__ = ["MiniMaxMusic3TalkerForConditionalGeneration"]
_COND = "cond"
_UNCOND = "uncond"
_STOP_TOKEN = SPECIAL_TOKEN_IDS["<|audio_end|>"]
# Emitted on the step a song ends, to keep the request alive for one more step
# while its terminal span is delivered. It is never fed back as audio: the
# state is already marked finished, so no frame is composed from it.
_HOLD_TOKEN = AUDIO_CODE_OFFSET
# The unconditioned twin's request id is the conditioned one plus this suffix,
# which is what lets a pair be recognized without a serving-layer injection.
_CFG_UNCOND_SUFFIX = "__cfg_uncond"
@dataclass(frozen=True)
class _RowConstants:
"""Per-request values that never change once the request is admitted."""
request_id: str
role: str
pair_id: str
seed: int
max_frames: int
def _request_key(value: Any) -> str:
"""Normalize a request id to the form used to key per-request state.
The engine injects ``global_request_id`` as a single-element list, while
``on_requests_finished`` receives the bare string. Keying state on the
unwrapped value keeps allocation and release on the same key; getting this
wrong leaks a song's worth of frames per request.
"""
if isinstance(value, (list, tuple)):
return str(value[0]) if value else ""
return "" if value is None else str(value)
def _pair_id_from_request(request_id: str) -> str:
"""Derive the CFG pair id when the request does not carry one."""
if request_id.endswith(_CFG_UNCOND_SUFFIX):
return request_id[: -len(_CFG_UNCOND_SUFFIX)]
return request_id
def _role_from_request(request_id: str) -> str:
"""Derive the CFG role from the companion suffix."""
return _UNCOND if request_id.endswith(_CFG_UNCOND_SUFFIX) else _COND
def _first_int(value: Any, default: int) -> int:
"""Read an int that the serving layer wraps in a single-element list."""
if isinstance(value, (list, tuple)):
value = value[0] if value else None
if value is None:
return default
try:
parsed = int(value)
except (TypeError, ValueError):
return default
return parsed if parsed > 0 else default
class MiniMaxMusic3TalkerForConditionalGeneration(nn.Module):
"""Qwen3 backbone plus the audio embedding table and RVQ depth decoder."""
# Sampling, feedback and window emission are all model-owned.
prefer_model_sampler: bool = True
have_multimodal_outputs: bool = True
has_postprocess: bool = True
skips_model_sampler_output_token_history: bool = True
postprocess_uses_hidden_states: bool = False
postprocess_uses_multimodal_outputs: bool = False
postprocess_uses_req_infos: bool = True
supports_omni_query_start_loc: bool = True
# A row still completing a chunked prefill reaches the sampler but its
# token is discarded; drawing a frame for it would corrupt the song.
requires_request_sample_eligibility: bool = True
# The acoustic stage consumes the composed 32768-d frame, never the raw
# backbone hidden state, so suppress the runner's automatic "hidden" key.
omni_pooler_payload_include_hidden: bool = False
def __init__(self, *, vllm_config: VllmConfig, prefix: str = "") -> None:
super().__init__()
self.vllm_config = vllm_config
config = vllm_config.model_config.hf_config
self.config = config
hidden_size = int(getattr(config, "hidden_size", BACKBONE_HIDDEN_SIZE))
self.model = Qwen3Model(
vllm_config=vllm_config,
prefix=f"{prefix}.model" if prefix else "model",
)
if getattr(config, "tie_word_embeddings", False):
self.lm_head = self.model.embed_tokens
else:
self.lm_head = ParallelLMHead(
int(config.vocab_size),
hidden_size,
prefix=f"{prefix}.lm_head" if prefix else "lm_head",
)
self.logits_processor = LogitsProcessor(int(config.vocab_size))
# c1..c7 share one table; codebook i occupies rows [i*1024, (i+1)*1024).
self.audio_embeddings = nn.Embedding(AUDIO_VOCAB_SIZE * (NUM_CODEBOOKS - 1), hidden_size)
self.rvq_decoder = RVQDepthDecoder(hidden_size=hidden_size)
self.register_buffer(
"audio_embedding_offsets",
(torch.arange(NUM_CODEBOOKS - 1) * AUDIO_VOCAB_SIZE).unsqueeze(0),
persistent=False,
)
# The only token ids c0 sampling can return, in vocabulary order:
# <|audio_end|> first, then the c0 code range. Column 0 therefore means
# stop and column code+1 means code.
self.register_buffer(
"c0_logit_ids",
torch.cat(
(
torch.tensor([_STOP_TOKEN], dtype=torch.long),
torch.arange(
AUDIO_CODE_OFFSET,
AUDIO_CODE_OFFSET + C0_VOCAB_SIZE,
dtype=torch.long,
),
)
),
persistent=False,
)
self.frame_embedding_scale = float(NUM_CODEBOOKS) ** -0.5
scheduler_config = getattr(vllm_config, "scheduler_config", None)
# Guidance doubles the rows, so the batch is twice the admitted
# request count.
self._max_rows = max(64, 2 * int(getattr(scheduler_config, "max_num_seqs", 16)))
# Per-step scratch, rebuilt every step from request-keyed state.
# Nothing here may survive a step: vLLM compacts and reorders the
# persistent batch, so a row index is only meaningful within the step
# that produced it.
self._last_logits_hidden: torch.Tensor | None = None
self._step_engine_ids: list[str] = []
self._row_request_ids: list[str] = []
self._row_roles: list[str] = []
self._row_pair_ids: list[str] = []
self._row_seeds: list[int] = []
self._row_max_frames: list[int] = []
self._row_prompt_tokens: list[int] = []
self._row_sample_eligible: list[bool] = []
self._feedback_positions: torch.Tensor | None = None
self._feedback_codes: torch.Tensor | None = None
# Per-step index vectors are staged through pinned memory rather than
# built with ``torch.tensor(..., device="cuda")``; see
# ``staging.PinnedIndexStage`` for why that matters here. Allocated on
# first use because the device is not known at construction time.
# Rows are (cond_row, uncond_row, seed, position).
self._sample_stage: PinnedIndexStage | None = None
# One row, the flat token offsets of this step's decoding rows.
self._feedback_stage: PinnedIndexStage | None = None
# Depth-loop draw offsets, ``[1..NUM_CODEBOOKS)``. Held as a buffer so
# the per-step position arithmetic is one broadcast add rather than one
# scalar add per codebook.
self.register_buffer(
"depth_draw_offsets",
torch.arange(1, NUM_CODEBOOKS, dtype=torch.int64),
persistent=False,
)
# Frame feedback slab. A captured decode graph replays fixed addresses
# and fixed shapes, so the rows to overwrite cannot be expressed as a
# variable-length ``index_copy`` against a freshly built index tensor.
# Instead every row of the reserved slab carries a codes entry and a
# flag, both written in place before the graph runs, and the forward
# selects with ``torch.where`` over the whole row set. Sized to the
# widest decode batch; wider prefill batches take the eager path in
# ``_apply_frame_feedback``, and prefill is never graph-replayed.
self.register_buffer(
"frame_feedback_codes",
torch.zeros((self._max_rows, NUM_CODEBOOKS), dtype=torch.long),
persistent=False,
)
self.register_buffer(
"frame_feedback_active",
torch.zeros(self._max_rows, dtype=torch.bool),
persistent=False,
)
# Cross-step state. Keyed by CFG pair id, because a guided request is
# two engine rows sharing one song. The engine renames requests to
# internal UUIDs on admission, so the pair id is derived from the
# user-facing id and the engine ids are mapped onto it separately.
self._states: dict[str, RequestARState] = {}
self._pair_of_engine_id: dict[str, str] = {}
# Per-request constants, keyed by engine id. ``forward`` is the only
# hook the engine hands the sampling extra args to, but its Python body
# does not run on a step replayed from a CUDA graph, so what it learns
# is cached here and re-bound to rows in ``prepare_runner_inputs``.
self._row_constants: dict[str, _RowConstants] = {}
self._row_binding_complete = False
self._incomplete_steps = 0
# The depth loop is recorded per pair count and replayed after that.
self._depth_graphs = DepthGraphCache(self._depth_decode_eager)
# ---------------------------------------------------------------- forward
def prepare_runner_inputs(
self,
*,
req_ids: list[str],
num_computed_tokens: Any,
num_scheduled_tokens: Any,
input_ids: torch.Tensor,
positions: torch.Tensor,
**_: Any,
) -> tuple[torch.Tensor, torch.Tensor]:
"""Bind this step's batch rows to requests, before the forward runs.
This is the only hook that sees the authoritative row-to-request
mapping for the step about to execute. vLLM compacts and reorders the
persistent batch whenever a request finishes or is preempted, so any
state indexed by row number from a previous step is stale by the time
it is read. Everything the model carries across steps is therefore
keyed by request id and gathered into row order here.
It also resolves which rows are decoding. A row is decoding when its
prompt is already computed and it is advancing by a single token; a
row still working through a chunked prefill is not, and must keep its
real prompt embeddings.
Python runs here on every step, including steps whose forward is
replayed from a CUDA graph rather than executed, so this is also where
the row-to-request binding that ``sample`` reads has to be rebuilt.
"""
self._step_engine_ids = [str(rid) for rid in req_ids]
computed = [int(value) for value in num_computed_tokens]
scheduled = [int(value) for value in num_scheduled_tokens]
# Prompt length for a row that is completing its prefill on this step.
# Only meaningful when the row is about to produce its first logit,
# which is the only moment state is created.
self._row_prompt_tokens = [computed[row] + scheduled[row] for row in range(len(req_ids))]
# Flat token offset of each row's first scheduled token.
offsets: list[int] = []
running = 0
for count in scheduled:
offsets.append(running)
running += count
feedback_rows: list[int] = []
feedback_codes: list[torch.Tensor] = []
for row, engine_id in enumerate(self._step_engine_ids):
# Both members of a guided pair are fed the same frame, so state
# is keyed by pair rather than by request.
pair_id = self._pair_of_engine_id.get(engine_id)
state = self._states.get(pair_id) if pair_id else None
if state is None or state.last_codes is None:
continue
# A decode row advances by exactly one token past a complete
# prompt. A row still working through a chunked prefill keeps its
# real prompt embeddings.
if scheduled[row] != 1 or computed[row] < state.prompt_tokens:
continue
feedback_rows.append(offsets[row])
feedback_codes.append(state.last_codes)
self._write_frame_feedback(feedback_rows, feedback_codes, input_ids.device)
self._bind_rows(computed, scheduled)
return input_ids, positions
def _write_frame_feedback(
self,
feedback_rows: list[int],
feedback_codes: list[torch.Tensor],
device: torch.device,
) -> None:
"""Publish this step's frame feedback for the forward to consume.
Two forms are written, because two forwards can consume it. The slab
is what a captured decode graph reads: fixed address, fixed shape,
selected row by row with a flag. The loose index and code tensors are
the eager fallback for a batch wider than the slab, which is only ever
a prefill batch and therefore never a replayed one.
"""
if self._feedback_stage is None:
self._feedback_stage = PinnedIndexStage(1, self._max_rows, device)
active = self.frame_feedback_active
if not feedback_rows:
self._feedback_positions = None
self._feedback_codes = None
active.zero_()
return
codes = torch.cat(feedback_codes, dim=0).to(device)
if max(feedback_rows) < self._max_rows:
positions = self._feedback_stage.push([feedback_rows], len(feedback_rows))[0]
self.frame_feedback_codes.index_copy_(0, positions, codes)
active.zero_()
active.index_fill_(0, positions, True)
else:
# Too wide for the slab. Nothing reads it on this step, but a stale
# flag would corrupt the next graph replay if the batch shrank.
positions = torch.tensor(feedback_rows, dtype=torch.long, device=device)
active.zero_()
self._feedback_positions = positions
self._feedback_codes = codes
def _bind_rows(self, computed: list[int], scheduled: list[int]) -> None:
"""Rebuild the row-order request metadata from the per-request cache.
``forward`` learns these values from the engine's own kwargs, but its
Python body is skipped whenever the step is replayed from a CUDA graph,
so the binding is re-established here. A row whose request has not been
seen by ``forward`` yet leaves the binding incomplete; that only
happens on the step a request is admitted, which is a prefill step, and
prefill is never replayed, so ``forward`` fills it in before the
sampler runs.
"""
constants = [self._row_constants.get(engine_id) for engine_id in self._step_engine_ids]
if not constants or any(entry is None for entry in constants):
self._row_binding_complete = False
return
self._row_request_ids = [entry.request_id for entry in constants]
self._row_roles = [entry.role for entry in constants]
self._row_pair_ids = [entry.pair_id for entry in constants]
self._row_seeds = [entry.seed for entry in constants]
self._row_max_frames = [entry.max_frames for entry in constants]
# A row is eligible to draw a frame once it has computed its whole
# prompt. Mirrors the engine's own rule; the prompt length is taken
# from the pair's state, which exists for every row whose request has
# already produced a logit.
eligible: list[bool] = []
for row, entry in enumerate(constants):
state = self._states.get(entry.pair_id)
if state is None:
self._row_binding_complete = False
return
eligible.append(computed[row] + scheduled[row] >= state.prompt_tokens)
self._row_sample_eligible = eligible
self._row_binding_complete = True
def forward(
self,
input_ids: torch.Tensor,
positions: torch.Tensor,
intermediate_tensors: Any | None = None,
inputs_embeds: torch.Tensor | None = None,
**kwargs: Any,
) -> torch.Tensor:
del intermediate_tensors
self._capture_row_metadata(kwargs)
if inputs_embeds is None:
hidden_states = self.model.embed_tokens(input_ids)
hidden_states = self._apply_frame_feedback(hidden_states)
else:
hidden_states = inputs_embeds
residual: torch.Tensor | None = None
for layer in self.model.layers:
hidden_states, residual = layer(positions, hidden_states, residual)
norm_out = self.model.norm(hidden_states, residual)
if isinstance(norm_out, tuple):
norm_out = norm_out[0]
return norm_out
def _capture_row_metadata(self, kwargs: dict[str, Any]) -> None:
"""Read this step's per-row CFG role, pair id and seed.
Row order here matches ``prepare_runner_inputs``: both are built from
``input_batch.req_ids``. The request ids resolved there are preferred,
and the info dicts are only a fallback for paths that do not route
through that hook.
"""
if self._row_binding_complete:
# Every row was resolved from the per-request cache before the
# forward, so there is nothing here the sampler still needs. Bail
# out rather than rebuild it: this runs on every eager decode step.
return
infos = kwargs.get("model_intermediate_buffer")
if infos is None:
infos = kwargs.get("runtime_additional_information")
extra_args = kwargs.get("sampling_extra_args")
eligible = kwargs.get("request_sample_eligible")
self._row_sample_eligible = [bool(flag) for flag in eligible] if eligible else []
if infos is None and extra_args is None:
# Warm-up dummy run: no omni kwargs are supplied.
self._row_pair_ids = []
return
infos = infos or []
extra_args = extra_args or []
rows = max(len(infos), len(extra_args), len(self._step_engine_ids))
request_ids: list[str] = []
roles: list[str] = []
pair_ids: list[str] = []
seeds: list[int] = []
max_frames: list[int] = []
for index in range(rows):
info = infos[index] if index < len(infos) else {}
info = info if isinstance(info, dict) else {}
extra = extra_args[index] if index < len(extra_args) else {}
extra = extra if isinstance(extra, dict) else {}
# The user-facing id. The engine renames a request to an internal
# UUID on admission, so this is the only id that both the info
# dicts and postprocess agree on, and the only one that carries
# the companion suffix a pair is recognized by.
request_id = _request_key(info.get("global_request_id")) or f"row-{index}"
pair_id = str(extra.get("cfg_pair_id") or _pair_id_from_request(request_id))
request_ids.append(request_id)
roles.append(str(extra.get("cfg_role") or _role_from_request(request_id)))
pair_ids.append(pair_id)
seeds.append(int(extra.get("tts_local_seed") or 0))
max_frames.append(_first_int(info.get("max_audio_frames"), DEFAULT_MAX_AUDIO_FRAMES))
# Remember which pair each engine-side row belongs to, so the next
# step's feedback can be gathered before the info dicts are seen,
# and cache the whole row constant so a graph-replayed step can be
# bound without the info dicts at all.
if index < len(self._step_engine_ids):
engine_id = self._step_engine_ids[index]
self._pair_of_engine_id[engine_id] = pair_id
self._row_constants[engine_id] = _RowConstants(
request_id=request_id,
role=roles[index],
pair_id=pair_id,
seed=seeds[index],
max_frames=max_frames[index],
)
self._row_request_ids = request_ids
self._row_roles = roles
self._row_pair_ids = pair_ids
self._row_seeds = seeds
self._row_max_frames = max_frames
def _apply_frame_feedback(self, hidden_states: torch.Tensor) -> torch.Tensor:
"""Replace each decoding row's token embedding with its frame embedding.
The rows to replace and the codes to write were resolved in
``prepare_runner_inputs`` against this step's own batch, so a mixed
prefill and decode step touches only the decoding rows and a prompt
token is never overwritten.
Which of the two forms is read depends only on the row count, which is
a constant inside a captured graph: a decode batch fits the reserved
slab and is selected with ``torch.where`` over fixed buffers, while a
prefill batch wider than the slab falls back to a variable-length
``index_copy``. Prefill is never replayed, so the fallback never has to
be graph-safe.
"""
rows = hidden_states.shape[0]
if rows <= self._max_rows:
frame_embeds = self.embed_frames(self.frame_feedback_codes[:rows]).to(dtype=hidden_states.dtype)
return torch.where(
self.frame_feedback_active[:rows].unsqueeze(1),
frame_embeds,
hidden_states,
)
positions = self._feedback_positions
codes = self._feedback_codes
if positions is None or codes is None:
return hidden_states
frame_embeds = self.embed_frames(codes).to(dtype=hidden_states.dtype)
return hidden_states.index_copy(0, positions, frame_embeds)
def embed_frames(self, codes: torch.Tensor) -> torch.Tensor:
"""Compose one conditioning embedding per row from all eight codebooks.
Args:
codes: ``[rows, 8]`` codebook indices, ``c0`` zero-based.
Returns:
``[rows, hidden]`` in the embedding table's dtype.
"""
c0 = self.model.embed_tokens(codes[:, 0] + AUDIO_CODE_OFFSET)
extra = self.audio_embeddings(codes[:, 1:] + self.audio_embedding_offsets)
return (c0 + extra.sum(dim=1).to(c0.dtype)) * self.frame_embedding_scale
def compute_logits(self, hidden_states: torch.Tensor, sampling_metadata: Any = None) -> torch.Tensor:
# sample() has no other route to the backbone hidden state.
self._last_logits_hidden = hidden_states
return self.logits_processor(self.lm_head, hidden_states)
def embed_input_ids(self, input_ids: torch.Tensor, **_: Any) -> torch.Tensor:
return self.model.embed_tokens(input_ids)
# ----------------------------------------------------------------- sample
def sample(self, logits: torch.Tensor, sampling_metadata: Any) -> SamplerOutput | None:
"""Draw one frame per conditioned request and feed it back to both rows."""
del sampling_metadata
hidden = self._last_logits_hidden
self._last_logits_hidden = None
if hidden is None or not self._row_pair_ids:
return None
rows = int(logits.shape[0])
pairs = self._resolve_pairs(rows)
if not pairs:
# Nothing to draw this step. Emit a benign audio code so every row
# stays alive until its partner is admitted; returning None would
# hand the rows to the stock sampler, whose token would be read as
# a real code.
held = torch.full((rows, 1), _HOLD_TOKEN, dtype=torch.long, device=logits.device)
return SamplerOutput(sampled_token_ids=held, logprobs_tensors=None)
states = [self._state_for(self._row_pair_ids[pair[0]], pair[0]) for pair in pairs]
cond_rows, uncond_rows, seeds, positions = self._stage_step_vectors(
[pair[0] for pair in pairs],
[pair[1] for pair in pairs],
[state.seed for state in states],
[state.advance_position() for state in states],
logits.device,
)
narrowed = logits.index_select(1, self.c0_logit_ids).float()
c0_logits = apply_c0_cfg(narrowed.index_select(0, cond_rows), narrowed.index_select(0, uncond_rows))
drawn = sample_topk_seeded(c0_logits, seeds, positions)
stopped = drawn == 0
c0 = (drawn - 1).clamp_min(0)
codes, depth_hidden = self._depth_decode(
hidden.index_select(0, cond_rows),
hidden.index_select(0, uncond_rows),
c0,
seeds,
positions,
)
frame_hidden = torch.cat((hidden.index_select(0, cond_rows), depth_hidden), dim=-1)
stopped_flags = stopped.tolist()
drain_rows: list[int] = []
for index, (cond_row, uncond_row) in enumerate(pairs):
state = states[index]
if state.finished:
# The drain step. The terminal span written last step is on
# its way out in this step's payload, so the pair can stop now.
state.pending_stop = False
drain_rows.extend((index, cond_row, uncond_row))
continue
if stopped_flags[index]:
state.finished = True
state.pending_stop = True
state.finish_reason = "stop"
state.last_codes = None
continue
# Both rows of the pair are fed the same frame next step; the
# codes live on the request, not on a row, because the row this
# request occupies may change before then.
state.last_codes = codes[index : index + 1]
state.frames.append(frame_hidden[index : index + 1])
state.codes.append(codes[index : index + 1])
state.generated_frames += 1
if state.generated_frames >= state.max_frames:
state.finished = True
state.pending_stop = True
state.finish_reason = "length"
del cond_row, uncond_row
# Rows that resolved into no pair keep this value. A hold token rather
# than id 0, which is an arbitrary text token: a row waiting for its
# partner should wait, not emit something the vocabulary reads as
# prose. It is never fed back as audio, because feedback is gated on
# the pair's own state.
token_ids = torch.full((rows, 1), _HOLD_TOKEN, dtype=torch.long, device=logits.device)
sampled = self.c0_logit_ids[drawn]
token_ids[cond_rows, 0] = sampled
token_ids[uncond_rows, 0] = sampled
# The stop token is deferred by exactly one step. A pair that ends this
# step must stay alive so that the terminal span ``postprocess`` is
# about to write can ride out on the next step's payload; a pair that
# already published its tail stops now.
#
# This covers the two ways a song ends on its own: the audio-end token,
# and the request's frame budget, which reaches the model as
# ``max_audio_frames``. It does not cover the engine ending a request
# underneath the model, because the payload for a step is fixed before
# the model is told. Hitting ``max_model_len`` mid-song therefore drops
# the final window. In practice the frame budget is reached first; a
# request can only hit the context limit with a prompt long enough that
# the two limits collide, which the deploy config documents.
for index, (cond_row, uncond_row) in enumerate(pairs):
state = states[index]
if state.pending_stop:
token_ids[cond_row, 0] = _HOLD_TOKEN
token_ids[uncond_row, 0] = _HOLD_TOKEN
for _, cond_row, uncond_row in zip(drain_rows[0::3], drain_rows[1::3], drain_rows[2::3], strict=True):
token_ids[cond_row, 0] = _STOP_TOKEN
token_ids[uncond_row, 0] = _STOP_TOKEN
return SamplerOutput(sampled_token_ids=token_ids, logprobs_tensors=None)
def _stage_step_vectors(
self,
cond_rows: list[int],
uncond_rows: list[int],
seeds: list[int],
positions: list[int],
device: torch.device,
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
"""Move this step's four small int64 vectors to the device in one copy.
The copy is asynchronous, so the four returned views are only valid
once the stream reaches them. Every consumer is a kernel on that same
stream, which is what makes this safe.
"""
if self._sample_stage is None:
self._sample_stage = PinnedIndexStage(4, self._max_rows, device)
block = self._sample_stage.push([cond_rows, uncond_rows, seeds, positions], len(cond_rows))
return block[0], block[1], block[2], block[3]
def _resolve_pairs(self, rows: int) -> list[tuple[int, int]]:
"""Map ``cfg_pair_id`` to its ``(cond_row, uncond_row)`` batch indices.
Guidance is mandatory for this checkpoint, so an incomplete pair is a
hard error rather than something to route around. Skipping the step
would leave the conditioned request producing no frames and the client
waiting forever, which is the failure shape this module has been bitten
by before; raising surfaces it to the caller instead.
Raises:
RuntimeError: If any pair is missing a member in this step.
"""
by_pair: dict[str, dict[str, int]] = {}
limit = min(rows, len(self._row_pair_ids))
for row in range(limit):
# A row still finishing a chunked prefill is handed to the sampler
# but its token is discarded, so drawing a frame for it would add
# a bogus frame and desync the seeded RNG.
if row < len(self._row_sample_eligible) and not self._row_sample_eligible[row]:
continue
by_pair.setdefault(self._row_pair_ids[row], {})[self._row_roles[row]] = row
pairs: list[tuple[int, int]] = []
incomplete: list[str] = []
for pair_id, roles in by_pair.items():
if _COND in roles and _UNCOND in roles:
pairs.append((roles[_COND], roles[_UNCOND]))
else:
incomplete.append(f"{pair_id}({'+'.join(sorted(roles))})")
if incomplete:
# A member can be absent for a step while its partner is still
# being admitted. Holding the present row costs one wasted decode
# step; raising would abort the whole engine step for every other
# request in the batch, so the lone row simply does not sample this
# step and waits. A pair that never converges is bounded by the
# request's own token budget rather than hanging forever.
self._incomplete_steps += 1
if self._incomplete_steps in (1, 64, 512):
logger.warning(
"MiniMax Music 3 waiting on incomplete CFG pair(s) %s "
"(step %d). If this does not resolve, the companion was "
"never enqueued: check that the stage declares "
"extra_args.cfg_role in its deploy config.",
incomplete[:4],
self._incomplete_steps,
)
return pairs
def _depth_decode(
self,
cond_hidden: torch.Tensor,
uncond_hidden: torch.Tensor,
c0: torch.Tensor,
seeds: torch.Tensor,
positions: torch.Tensor,
) -> tuple[torch.Tensor, torch.Tensor]:
"""Walk ``c1..c7``, replaying the loop from a CUDA graph where possible.
Seven four-layer forwards over at most eight positions is far too
little work per kernel to cover its own launch, so the loop is recorded
once per pair count and replayed after that. The cache falls back to
the eager loop for any shape it has not recorded or could not record.
"""
return self._depth_graphs.run(cond_hidden, uncond_hidden, c0, seeds, positions)
def _depth_decode_eager(
self,
cond_hidden: torch.Tensor,
uncond_hidden: torch.Tensor,
c0: torch.Tensor,
seeds: torch.Tensor,
positions: torch.Tensor,
) -> tuple[torch.Tensor, torch.Tensor]:
"""Walk ``c1..c7``, returning the codes and the stacked depth hiddens.
Both branches run so the depth heads can be guided the same way the
``c0`` head is; the drawn code is shared, so the two branches stay
aligned.
"""
decoder = self.rvq_decoder
pair_hidden = torch.cat((cond_hidden, uncond_hidden), dim=0)
c0_paired = c0.repeat(2)
c0_embed = self.model.embed_tokens(c0_paired + AUDIO_CODE_OFFSET)
seq = [
decoder.projection(pair_hidden).unsqueeze(1),
decoder.projection(c0_embed).unsqueeze(1),
]
n_pairs = int(c0.shape[0])
codes = [c0]
depth_parts: list[torch.Tensor] = []
# Every residual draw's noise comes from the same hash of
# ``(seed, position + codebook, candidate)``, so all seven are produced
# in one batch here instead of re-running the twenty-odd integer ops
# per codebook inside the loop. The arithmetic is elementwise, so the
# values are the same ones the per-codebook calls produced.
depth_gumbel = gumbel_noise(
seeds,
positions.unsqueeze(1) + self.depth_draw_offsets,
decoder.audio_vocab_size,
)
for index in range(1, NUM_CODEBOOKS):
out = decoder(torch.cat(seq, dim=1))[:, -1]
depth_parts.append(out[:n_pairs].detach())
head_logits = decoder.audio_heads[index - 1](out).float()
guided = apply_depth_cfg(head_logits[:n_pairs], head_logits[n_pairs:])
code = draw_from_gumbel(guided, depth_gumbel[:, index - 1])
codes.append(code)
if index < NUM_CODEBOOKS - 1:
embedding = self.audio_embeddings(code.repeat(2) + (index - 1) * AUDIO_VOCAB_SIZE)
seq.append(decoder.projection(embedding).unsqueeze(1))
return torch.stack(codes, dim=1), torch.cat(depth_parts, dim=-1)
def _state_for(self, pair_id: str, row: int) -> RequestARState:
"""Fetch or create the state for a guided pair.
Created on the step the pair first produces a logit, which is the step
its prefill completes, so the recorded prompt length is the real one.
"""
state = self._states.get(pair_id)
if state is None:
seed = self._row_seeds[row] if row < len(self._row_seeds) else 0
state = RequestARState(
seed=seed,
max_frames=self._row_max_frames[row] if row < len(self._row_max_frames) else DEFAULT_MAX_AUDIO_FRAMES,
prompt_tokens=self._row_prompt_tokens[row] if row < len(self._row_prompt_tokens) else 0,
)
self._states[pair_id] = state
logger.info(
"MiniMax Music 3 AR start pair=%s seed=%d max_frames=%d prompt_tokens=%d",
pair_id,
state.seed,
state.max_frames,
state.prompt_tokens,
)
return state
# ------------------------------------------------------------ emit / free
def postprocess(
self,
hidden_states_slice: torch.Tensor,
multimodal_outputs: Any = None,
**req_infos: Any,
) -> dict[str, Any]:
"""Publish this request's next span of conditioning frames.
Mid-stream this is one window, released a frame late so that a window
handed downstream is provably not the last. On the final step it is
instead a single contiguous span covering every window not yet
emitted, because one step produces exactly one payload and the tail
cannot be split across two.
"""
del hidden_states_slice, multimodal_outputs
request_id = _request_key(req_infos.get("global_request_id"))
# The pair's two rows share one song, so only the conditioned row
# publishes it. The companion would otherwise emit a duplicate span
# and the acoustic stage would decode the song twice.
if _role_from_request(request_id) == _UNCOND:
return {}
state = self._states.get(_pair_id_from_request(request_id))
if state is None:
return {}
if state.finished:
return self._terminal_payload(state)
window = state.ready_window()
if window is None:
return {}
chunk = state.take_window(window, is_final=False)
return self._span_payload(state, chunk, start_frame=window.start, is_final=False)
def _terminal_payload(self, state: RequestARState) -> dict[str, Any]:
"""Emit every remaining frame as one span, once."""
start = state.emitted_windows * AR_CHUNK_HOP_FRAMES
end = state.frames.end_frame
if end <= start:
return {}
chunk = state.frames.slice(start, end)
state.emitted_windows = len(chunk_windows(state.generated_frames))
state.frames.clear()
return self._span_payload(state, chunk, start_frame=start, is_final=True)
def _span_payload(
self,
state: RequestARState,
chunk: torch.Tensor,
*,
start_frame: int,
is_final: bool,
) -> dict[str, Any]:
"""Build the cross-stage payload for one contiguous frame span.
``num_processed_tokens`` is the span's first global AR frame index, so
the acoustic stage can re-derive window boundaries and therefore which
window is first and which is last.
"""
frames = int(chunk.shape[0])
codes = state.take_codes(start_frame, start_frame + frames)
payload: dict[str, Any] = {
"latent": chunk.contiguous(),
"meta": {
"num_processed_tokens": start_frame,
"codec_chunk_frames": frames,
"last_chunk": is_final,
"audio_seed": state.seed,
},
}
if codes is not None:
payload["codes"] = {"audio": codes}
return payload
def make_omni_output(self, model_outputs: torch.Tensor | OmniOutput, **kwargs: Any) -> OmniOutput:
if isinstance(model_outputs, OmniOutput):
return model_outputs
infos = kwargs.get("model_intermediate_buffer")
if infos is None:
infos = kwargs.get("runtime_additional_information")
if not infos:
return OmniOutput(text_hidden_states=model_outputs, multimodal_outputs=None)
latents: list[torch.Tensor] = []
codes: list[torch.Tensor] = []
metas: list[dict[str, Any]] = []
any_payload = False
for info in infos:
info = info if isinstance(info, dict) else {}
latent = info.get("latent")
if isinstance(latent, torch.Tensor) and latent.numel():
latents.append(latent)
sub = info.get("codes")
codes.append(sub.get("audio") if isinstance(sub, dict) else torch.empty(0))
meta = info.get("meta")
metas.append(dict(meta) if isinstance(meta, dict) else {})
any_payload = True
# The runner merges postprocess output into this buffer and
# never clears it, so a window left behind would be re-emitted
# on every later step and the acoustic stage would decode the
# same conditioning again and again. Consume it here.
info.pop("latent", None)
info.pop("codes", None)
info.pop("meta", None)
else:
latents.append(torch.empty(0))
codes.append(torch.empty(0))
metas.append({})
if not any_payload:
return OmniOutput(text_hidden_states=model_outputs, multimodal_outputs=None)
payload: dict[str, Any] = {"latent": latents}
if any(isinstance(entry, torch.Tensor) and entry.numel() for entry in codes):
payload["codes"] = {"audio": codes}
# The span's start frame and terminal flag ride along with it: without
# them the acoustic stage cannot tell which window it is looking at,
# and therefore cannot crop it correctly.
#
# Every field is a list with one entry per batch row, in the same order
# as ``latent``. The payload is partitioned per request by indexing
# list values, so a scalar here would be broadcast to the whole batch
# and every request would decode with the last request's seed, window
# offset and terminal flag.
meta_keys = {key for meta in metas for key in meta}
if meta_keys:
payload["meta"] = {key: [meta.get(key) for meta in metas] for key in sorted(meta_keys)}
return OmniOutput(text_hidden_states=model_outputs, multimodal_outputs=payload)
def on_requests_finished(self, finished_req_ids: Iterable[str]) -> None:
"""Release per-request state once the engine has retired the request.
The key must be normalized the same way it was when the state was
created, or every finished song leaks its frame buffer.
"""
for engine_id in finished_req_ids:
# These are engine-side ids, so translate through the mapping the
# forward pass built. A pair's state is released when its second
# member retires.
self._row_constants.pop(str(engine_id), None)
pair_id = self._pair_of_engine_id.pop(str(engine_id), None)
if pair_id is None or any(pair_id == value for value in self._pair_of_engine_id.values()):
continue
request_id = pair_id
state = self._states.pop(pair_id, None)
if state is not None:
logger.info(
"MiniMax Music 3 AR done request=%s frames=%d reason=%s peak_buffer=%d",
request_id,
state.generated_frames,
state.finish_reason,
state.frames.peak_frames,
)
state.release()
# --------------------------------------------------------------- weights
def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
"""Load the Qwen3 backbone, then the audio modules from their component.
The backbone comes from the stage's own checkpoint folder and loads
through vLLM. The audio embedding table and the depth decoder live in
a separate component of the repo and are loaded explicitly.
"""
loader = AutoWeightsLoader(self, skip_prefixes=["audio_embeddings.", "rvq_decoder."])
loaded = loader.load_weights(weights)
loaded |= load_depth_decoder_weights(
self.vllm_config,
audio_embeddings=self.audio_embeddings,
rvq_decoder=self.rvq_decoder,
)
return loaded
@@ -0,0 +1,133 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Component weight loading for MiniMax Music 3.
The checkpoint is a multi-component repo. The Qwen3 backbone is a normal
``Qwen3ForCausalLM`` under ``language_model/`` and loads through vLLM. The
audio embedding table and the depth decoder live in ``rvq_depth_decoder/``,
and the acoustic stage's three components live in their own folders. None of
those are things vLLM's loader knows how to map, so they are read directly.
Every loader here is strict. A silently partial load would produce audio that
sounds plausible but is wrong, which is far worse than a startup failure.
"""
from __future__ import annotations
import os
from pathlib import Path
import torch
from torch import nn
from vllm.config import VllmConfig
from vllm.logger import init_logger
from .constants import AUDIO_VOCAB_SIZE, NUM_CODEBOOKS
logger = init_logger(__name__)
_AUDIO_EMBEDDING_KEY = "audio_embeddings.weight"
_COMPONENT_WEIGHT_NAME = "diffusion_pytorch_model.safetensors"
# Component folders, relative to the repository root.
DEPTH_DECODER_DIR = "rvq_depth_decoder"
CONDITION_ENCODER_DIR = "condition_encoder"
TRANSFORMER_DIR = "transformer"
VOCODER_DIR = "vocoder"
_ROOT_MARKERS = (DEPTH_DECODER_DIR, TRANSFORMER_DIR, VOCODER_DIR)
def resolve_repo_root(model_path: str | os.PathLike[str]) -> Path:
"""Find the multi-component repository root from a stage's model path.
A stage that declares ``model_subdir`` sees the subfolder as its model
path, so the root is usually the parent. Both are checked, and the search
walks up a couple of levels for snapshot layouts.
Raises:
FileNotFoundError: If no ancestor looks like the repository root.
"""
path = Path(model_path).expanduser().resolve()
candidates = [path, *path.parents[:3]]
for candidate in candidates:
if all((candidate / marker).is_dir() for marker in _ROOT_MARKERS):
return candidate
raise FileNotFoundError(
f"MiniMax Music 3 could not locate the repository root from {path}; expected sibling folders {_ROOT_MARKERS}"
)
def load_component_state(root: Path, component: str, *, device: str = "cpu") -> dict[str, torch.Tensor]:
"""Read one component's safetensors, sharded or not.
Raises:
FileNotFoundError: If the component has no weight files.
"""
from safetensors.torch import load_file
folder = root / component
single = folder / _COMPONENT_WEIGHT_NAME
if single.exists():
return load_file(str(single), device=device)
shards = sorted(folder.glob(f"{Path(_COMPONENT_WEIGHT_NAME).stem}-*.safetensors"))
if not shards:
raise FileNotFoundError(f"MiniMax Music 3 component {component!r} has no weights under {folder}")
state: dict[str, torch.Tensor] = {}
for shard in shards:
state.update(load_file(str(shard), device=device))
return state
def load_depth_decoder_weights(
vllm_config: VllmConfig,
*,
audio_embeddings: nn.Embedding,
rvq_decoder: nn.Module,
) -> set[str]:
"""Load the audio embedding table and the RVQ depth decoder in place.
Args:
vllm_config: The stage's config, used to locate the checkpoint.
audio_embeddings: The ``c1..c7`` embedding table to populate.
rvq_decoder: The depth decoder to populate.
Returns:
The model-side parameter names that were loaded.
Raises:
ValueError: If the audio embedding is absent or the wrong shape.
"""
root = resolve_repo_root(vllm_config.model_config.model)
state = load_component_state(root, DEPTH_DECODER_DIR)
weight = state.pop(_AUDIO_EMBEDDING_KEY, None)
expected = (AUDIO_VOCAB_SIZE * (NUM_CODEBOOKS - 1), audio_embeddings.embedding_dim)
if weight is None or tuple(weight.shape) != expected:
raise ValueError(
f"MiniMax Music 3 audio embedding mismatch: expected {expected}, got "
f"{None if weight is None else tuple(weight.shape)}"
)
target = audio_embeddings.weight
target.data.copy_(weight.to(device=target.device, dtype=target.dtype))
rvq_decoder.load_checkpoint_state(state)
logger.info(
"MiniMax Music 3 loaded audio embedding and depth decoder from %s",
root / DEPTH_DECODER_DIR,
)
loaded = {"audio_embeddings.weight"}
loaded.update(f"rvq_decoder.{name}" for name, _ in rvq_decoder.named_parameters())
return loaded
__all__ = [
"CONDITION_ENCODER_DIR",
"DEPTH_DECODER_DIR",
"TRANSFORMER_DIR",
"VOCODER_DIR",
"load_component_state",
"load_depth_decoder_weights",
"resolve_repo_root",
]
@@ -457,6 +457,17 @@ _OMNI_MODELS = {
"qwen3_vl",
"AuraQwen3VLForConditionalGeneration",
),
## MiniMax-Music3 (text-to-music; AR talker -> flow-matching acoustic decoder)
"MiniMaxMusic3TalkerForConditionalGeneration": (
"minimax_music3",
"talker",
"MiniMaxMusic3TalkerForConditionalGeneration",
),
"MiniMaxMusic3AcousticForConditionalGeneration": (
"minimax_music3",
"acoustic",
"MiniMaxMusic3AcousticForConditionalGeneration",
),
}
@@ -0,0 +1,544 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Stage-input processors for MiniMax Music 3: AR talker -> acoustic decoder.
Three entry points, all referenced from
``vllm_omni/model_executor/models/minimax_music3/pipeline.py``:
* ``expand_cfg_prompts`` (stage 0 ``prompt_expand_func``) builds the
unconditioned twin of a conditioned request. Classifier-free guidance is
mandatory for this checkpoint, so in practice every request expands.
* ``ar2acoustic_async_chunk`` (stage 0 ``async_chunk_process_next_stage_input_func``)
forwards one AR frame span per call to stage 1, and is also the flush point
for the request's tail.
* ``ar2acoustic`` (stage 1 ``sync_process_input_func``) builds the stage-1
engine input for the non-streaming path, where the whole song arrives at
once after stage 0 finishes.
Stage-0 emit contract
---------------------
Per emission the talker publishes a multimodal output shaped like::
{
"latent": FloatTensor[1, T, 32768] or FloatTensor[T, 32768],
"codes": {"audio": LongTensor[T, 8]},
"meta": {
"num_processed_tokens": int, # global AR frame index of frame 0
"last_chunk": bool, # True only on the terminal emission
"audio_seed": int, # optional
},
}
The span is normally one window: ``T == AR_CHUNK_FRAMES``, windows advance by
``AR_CHUNK_HOP_FRAMES``, and window ``k`` starts at AR frame
``k * AR_CHUNK_HOP_FRAMES``. The terminal emission is the exception. A window
cannot be proven non-final until a further frame exists, so the talker holds
the most recently completed window back and, on the step where it knows the
request is ending, publishes the whole remaining frame span at once. That span
covers up to two windows (the held-back complete one plus the truncated final
one), so ``T`` can reach ``AR_CHUNK_FRAMES + AR_CHUNK_HOP_FRAMES``. Stage 1
re-derives the window boundaries inside a span from
``meta.num_processed_tokens`` and the span length through
``models/minimax_music3/chunking.chunk_windows``.
``meta.num_processed_tokens`` is required rather than derived: once a terminal
span carries more than one window, a running ``chunk_seq * hop`` count no
longer identifies the span's first frame. The derived value is kept only as a
fallback for pre-terminal emissions.
``hidden_states.output`` is accepted as an alias for ``latent`` because both
are legal homes for a float payload; ``latent`` is canonical and matches the
stage's ``engine_output_type``.
Stage-0 -> stage-1 payload
--------------------------
Every key below is a declared field of ``OmniPayloadStruct`` /
``MetaStruct``, which are ``forbid_unknown_fields=True`` msgspec structs, so
an undeclared key is a construction error rather than silent data loss::
latent FloatTensor[T, 32768] span conditioning, bf16
codes.audio LongTensor[T, 8] RVQ codes for the span
meta.chunk_seq int emission index; is_first == (chunk_seq == 0)
meta.num_processed_tokens int absolute AR frame index of the span start
meta.codec_chunk_frames int T; span end (exclusive) = start + T
meta.last_chunk bool terminal span
meta.stream_finished BoolTensor terminal marker that survives transport
meta.audio_seed int seed for the acoustic stage's noise draw
meta.req_id list[str] single-element external request id
meta.finished BoolTensor overwritten by the chunk adapter
``codes.audio`` is rank 2 on purpose: the chunk transfer adapter reroutes rank-1
code tensors onto the token path (``.tolist()``), while rank-2 tensors ride the
payload channel and leave a single ``[0]`` placeholder prompt token per chunk.
``meta.finished`` is stripped before the payload reaches the stage-1 model, so
``meta.stream_finished`` and ``meta.last_chunk`` are what stage 1 reads.
The overlap crop is deliberately NOT in the payload. Stage 1 recovers it from
the span's global frame range and ``last_chunk`` through
``models/minimax_music3/chunking.crop_sample_bounds``, which is the single
owner of that arithmetic; duplicating it here would let the two copies drift.
"""
from __future__ import annotations
from typing import Any
import torch
from vllm.logger import init_logger
from vllm_omni.data_entry_keys import CodesStruct, MetaStruct, OmniPayloadStruct
from vllm_omni.inputs.data import OmniTokensPrompt
from vllm_omni.model_executor.models.minimax_music3.constants import (
AR_CFG_SCALE,
AR_CHUNK_HOP_FRAMES,
AR_HIDDEN_SIZE,
NUM_CODEBOOKS,
)
from vllm_omni.model_executor.models.minimax_music3.prompt import build_cfg_null_token_ids
from vllm_omni.model_executor.stage_input_processors.bagel import ExpandedPrompt
__all__ = [
"CFG_UNCOND_SUFFIX",
"ar2acoustic",
"ar2acoustic_async_chunk",
"expand_cfg_prompts",
]
logger = init_logger(__name__)
CFG_UNCOND_SUFFIX = "__cfg_uncond"
_COND = "cond"
_UNCOND = "uncond"
# Per-request forwarder state, parked on the transfer manager's request map.
_STATE_KEY = "_minimax_music3_async_state"
# The window conditioning is emitted in bfloat16. Each chunk is its own
# /dev/shm segment and consecutive windows overlap by half, so a float32
# 200-frame window would move 26 MB of mostly-redundant bytes per hop against
# 13 MB in bfloat16. The backbone runs in bfloat16 anyway, so this discards no
# information the AR stage actually produced.
_LATENT_TRANSPORT_DTYPE = torch.bfloat16
# ---------------------------------------------------------------------------
# Stage 0: classifier-free-guidance prompt expansion
# ---------------------------------------------------------------------------
def _prompt_token_ids(prompt: dict[str, Any] | str | Any) -> list[int] | None:
"""Return the prompt's token ids, or ``None`` when only text is available."""
token_ids: Any = None
if isinstance(prompt, dict):
token_ids = prompt.get("prompt_token_ids")
elif isinstance(prompt, (list, tuple)):
token_ids = prompt
if token_ids is None:
return None
if isinstance(token_ids, torch.Tensor):
token_ids = token_ids.reshape(-1).tolist()
if not isinstance(token_ids, (list, tuple)) or not token_ids:
return None
return [int(t) for t in token_ids]
def _uncond_extra_args(extra_args: dict[str, Any], cfg_scale: float) -> dict[str, Any]:
"""Clone the conditioned row's extra_args for the unconditioned twin.
Every key is carried through, not only the ``cfg_*`` ones: the stage runs
with ``has_sampling_extra_args`` on, so extra_args reach the model's
``forward`` per row, and dropping a key here would make the two rows
execute under different settings and decode out of lockstep.
``cfg_null_prompt`` is the exception: the companion IS the null prompt.
"""
companion = dict(extra_args)
companion.pop("cfg_null_prompt", None)
companion["cfg_role"] = _UNCOND
companion["cfg_scale"] = cfg_scale
return companion
def expand_cfg_prompts(
prompt: dict[str, Any] | str,
sampling_params: Any,
) -> list[ExpandedPrompt]:
"""Emit the unconditioned companion for a guided MiniMax Music 3 request.
Classifier-free guidance is not optional for this checkpoint, and the
deploy YAMLs set ``cfg_role: cond`` / ``cfg_scale: 1.5`` as stage-0
defaults, so an ordinary request expands into exactly one companion and
the pair decodes in lockstep.
The companion's token ids come from ``build_cfg_null_token_ids``, which
overwrites the caption-and-lyrics span with ``<|audio_cfg|>`` while keeping
the framing tokens. Token ids are preferred over text because the two rows
are only blendable when they have identical length and an identical
``<|audio_start|>`` position, and only the token-id form guarantees that
without a tokenizer round trip.
Args:
prompt: The raw user prompt. A dict carrying ``prompt_token_ids`` is
the preferred form; a text-only prompt must supply a pre-built,
length-matched ``cfg_null_prompt`` in ``extra_args``.
sampling_params: Stage-0 sampling params for the conditioned row.
Returns:
A one-element list with the unconditioned companion, or an empty list
when the request is not CFG-guided.
Raises:
ValueError: If the request is guided but neither prompt token ids nor
a pre-built ``cfg_null_prompt`` is available.
"""
extra_args = getattr(sampling_params, "extra_args", None) or {}
if extra_args.get("cfg_role") != _COND:
# Not guided, or this IS the companion. Either way, nothing to expand.
return []
try:
cfg_scale = float(extra_args.get("cfg_scale", AR_CFG_SCALE))
except (TypeError, ValueError):
logger.warning(
"MiniMax Music 3: ignoring non-numeric cfg_scale %r; request decodes unguided",
extra_args.get("cfg_scale"),
)
return []
if cfg_scale <= 1.0:
return []
token_ids = _prompt_token_ids(prompt)
companion_prompt: dict[str, Any] | str
if token_ids is not None:
null_ids = build_cfg_null_token_ids(token_ids)
if isinstance(prompt, dict):
companion_prompt = {**prompt, "prompt_token_ids": null_ids}
# Drop the text form so the input processor does not re-tokenize
# and hand back a row of a different length.
companion_prompt.pop("prompt", None)
companion_prompt.pop("prompt_embeds", None)
else:
companion_prompt = {"prompt_token_ids": null_ids}
else:
null_prompt = extra_args.get("cfg_null_prompt")
if not isinstance(null_prompt, str) or not null_prompt:
raise ValueError(
"MiniMax Music 3 CFG request carries neither prompt_token_ids nor a "
"pre-built cfg_null_prompt in extra_args. Submit the prompt as token "
"ids (preferred; the null twin is then built by "
"models.minimax_music3.prompt.build_cfg_null_token_ids) or attach a "
"length-matched cfg_null_prompt string."
)
if isinstance(prompt, dict):
companion_prompt = {**prompt, "prompt": null_prompt}
companion_prompt.pop("prompt_token_ids", None)
companion_prompt.pop("prompt_embeds", None)
else:
companion_prompt = null_prompt
return [
ExpandedPrompt(
prompt=companion_prompt,
role=_UNCOND,
request_id_suffix=CFG_UNCOND_SUFFIX,
sampling_params_override={"extra_args": _uncond_extra_args(extra_args, cfg_scale)},
)
]
# ---------------------------------------------------------------------------
# Stage 0 -> stage 1: async-chunk window forwarder
# ---------------------------------------------------------------------------
def _payload_section(payload: Any, key: str) -> dict[str, Any]:
"""Return a nested payload section as a dict (empty when absent)."""
if not isinstance(payload, dict):
return {}
section = payload.get(key)
return section if isinstance(section, dict) else {}
def _window_latent(payload: Any) -> torch.Tensor | None:
"""Return this window's conditioning as ``[T, 32768]``, or ``None``.
``latent`` is the canonical key; ``hidden_states.output`` is accepted for
talkers that publish through the hidden-state channel instead. A leading
singleton batch dimension is squeezed so stage 1 sees a stable rank.
"""
if not isinstance(payload, dict):
return None
tensor = payload.get("latent")
if not isinstance(tensor, torch.Tensor):
tensor = _payload_section(payload, "hidden_states").get("output")
if not isinstance(tensor, torch.Tensor) or tensor.numel() == 0:
return None
if tensor.ndim == 3:
if tensor.shape[0] != 1:
raise ValueError(
f"MiniMax Music 3 window latent must have a singleton batch dim; got {tuple(tensor.shape)}"
)
tensor = tensor[0]
if tensor.ndim != 2:
raise ValueError(f"MiniMax Music 3 window latent must be rank 2 or 3; got {tuple(tensor.shape)}")
if tensor.shape[1] != AR_HIDDEN_SIZE:
raise ValueError(f"MiniMax Music 3 window latent has {tensor.shape[1]} features; expected {AR_HIDDEN_SIZE}")
return tensor
def _window_codes(payload: Any) -> torch.Tensor | None:
"""Return this window's RVQ codes as ``[T, 8]``, or ``None``."""
codes = _payload_section(payload, "codes").get("audio")
if not isinstance(codes, torch.Tensor) or codes.numel() == 0:
return None
if codes.ndim == 1:
if codes.numel() % NUM_CODEBOOKS != 0:
raise ValueError(
f"MiniMax Music 3 flat code payload of length {codes.numel()} is not a "
f"multiple of {NUM_CODEBOOKS} codebooks"
)
codes = codes.reshape(-1, NUM_CODEBOOKS)
if codes.ndim != 2 or codes.shape[1] != NUM_CODEBOOKS:
raise ValueError(f"MiniMax Music 3 codes must be [T, {NUM_CODEBOOKS}]; got {tuple(codes.shape)}")
return codes.to(torch.long)
def _request_audio_seed(request: Any, payload: Any) -> int | None:
"""Resolve the seed the acoustic stage draws its noise from.
A seed published by the talker wins, because a talker that already
consumed the request seed knows which value it used. Otherwise fall back
to the stage-0 request's own sampling seed.
"""
published = _payload_section(payload, "meta").get("audio_seed")
if isinstance(published, int):
return published
seed = getattr(getattr(request, "sampling_params", None), "seed", None)
return int(seed) if isinstance(seed, int) else None
def _forwarder_state(transfer_manager: Any, request_id: str) -> dict[str, Any]:
"""Return (creating if needed) the per-request forwarder state."""
request_state = transfer_manager.request_payload.get(request_id)
if not isinstance(request_state, dict) or _STATE_KEY not in request_state:
request_state = {_STATE_KEY: {"chunk_seq": 0, "terminal_sent": False}}
transfer_manager.request_payload[request_id] = request_state
return request_state[_STATE_KEY]
def ar2acoustic_async_chunk(
transfer_manager: Any,
multimodal_output: dict[str, Any] | None,
request: Any,
is_finished: bool = False,
) -> OmniPayloadStruct | None:
"""Forward one AR frame span to the acoustic stage.
The talker publishes whole spans, so this is a relay plus bookkeeping
rather than an accumulator: it stamps the emission index, the span's global
start frame, its length and the terminal flags, then hands the tensors to
the connector.
This hook is the flush point for the request's tail. The AR scheduler calls
``save_async`` on the step that stops the request, including when that
step produced no inter-stage output, so this function is guaranteed one
call with ``is_finished=True`` (omni_ar_scheduler.py:551, base
chunk_transfer_adapter.py:341). Whatever the talker published on that step
therefore reaches stage 1 in the same hop.
Returns ``None`` on steps where the talker released no span and the request
is still running.
"""
request_id = request.external_req_id
finished = bool(is_finished or request.is_finished())
state = _forwarder_state(transfer_manager, request_id)
latent = _window_latent(multimodal_output)
codes = _window_codes(multimodal_output)
published_meta = _payload_section(multimodal_output, "meta")
published_last = published_meta.get("last_chunk") is True
if state["terminal_sent"]:
if latent is not None:
logger.error(
"MiniMax Music 3 talker emitted %d more frames for request %s after the "
"terminal span; the downstream stream is already closed and they are dropped",
int(latent.shape[0]),
request_id,
)
return None
if latent is None:
if not finished:
return None
# Terminal step with nothing left to send: still deliver the marker so
# stage 1 can flush and complete instead of polling until timeout.
state["terminal_sent"] = True
# The unconditioned twin never publishes a span: the pair shares one
# song and the conditioned row emits it. Its silence is the contract,
# not a failure.
if state["chunk_seq"] == 0 and not request_id.endswith(CFG_UNCOND_SUFFIX):
logger.error("MiniMax Music 3 talker produced no audio for request %s", request_id)
return OmniPayloadStruct(
meta=MetaStruct(
chunk_seq=state["chunk_seq"],
last_chunk=True,
stream_finished=torch.tensor(True, dtype=torch.bool),
finished=torch.tensor(True, dtype=torch.bool),
req_id=[request_id],
)
)
span_frames = int(latent.shape[0])
if codes is not None and codes.shape[0] != span_frames:
raise ValueError(
f"MiniMax Music 3 span mismatch for request {request_id}: latent has "
f"{span_frames} frames but codes have {codes.shape[0]}"
)
chunk_seq = int(state["chunk_seq"])
state["chunk_seq"] = chunk_seq + 1
# A span is terminal when the engine stopped the request or the talker said
# so. Only the engine's verdict closes the stream, so a talker that flags
# a span last while generation continues is reported rather than obeyed.
terminal = bool(finished or published_last)
if published_last and not finished:
logger.warning(
"MiniMax Music 3 talker flagged a terminal span for request %s while the "
"engine still has the request running",
request_id,
)
state["terminal_sent"] = terminal
span_start = published_meta.get("num_processed_tokens")
if not isinstance(span_start, int):
# Fallback for talkers that publish one hop-aligned window per step.
# It is only sound before the terminal span, which may cover more than
# one window and therefore break the running count.
span_start = chunk_seq * AR_CHUNK_HOP_FRAMES
if terminal and span_frames > AR_CHUNK_HOP_FRAMES:
logger.warning(
"MiniMax Music 3 terminal span for request %s carries %d frames but no "
"meta.num_processed_tokens; the derived start frame %d assumes a single "
"hop-aligned window and may misplace the tail",
request_id,
span_frames,
span_start,
)
finished_flag = torch.tensor(terminal, dtype=torch.bool)
meta = MetaStruct(
chunk_seq=chunk_seq,
num_processed_tokens=int(span_start),
codec_chunk_frames=span_frames,
last_chunk=terminal,
# meta.finished is stripped before the payload reaches the stage-1
# model; stream_finished is what survives and drives the final flush.
stream_finished=finished_flag,
finished=finished_flag,
req_id=[request_id],
)
audio_seed = _request_audio_seed(request, multimodal_output)
if audio_seed is not None:
meta.audio_seed = audio_seed
payload = OmniPayloadStruct(
latent=latent.detach().to(_LATENT_TRANSPORT_DTYPE).contiguous(),
meta=meta,
)
if codes is not None:
payload.codes = CodesStruct(audio=codes.detach().contiguous())
return payload
# ---------------------------------------------------------------------------
# Stage 1: non-streaming engine-input builder
# ---------------------------------------------------------------------------
def _empty_acoustic_prompt() -> OmniTokensPrompt:
"""Zero-length stage-1 request.
Stage 1 decodes it to empty audio, which the serving layer's no-audio
guard turns into a request-level error. That is preferable to raising
here, where the failure would surface as a stalled request.
"""
return OmniTokensPrompt(
prompt_token_ids=[],
multi_modal_data=None,
mm_processor_kwargs=None,
additional_information=None,
)
def ar2acoustic(
source_outputs: list[Any],
prompt: Any = None,
_requires_multimodal_data: bool = False,
) -> list[OmniTokensPrompt]:
"""Build stage-1 engine inputs for the non-streaming path.
With ``async_chunk: false`` the whole song arrives once stage 0 has
finished, so the song is handed over as a single logical window and stage
1 does its own windowing through ``chunking.chunk_windows``. The payload
layout matches the streaming one key for key, so the acoustic stage reads
one contract in both modes.
The prompt is ``[0] * num_windows``: the generation scheduler sizes the
request from ``prompt_token_ids``, and one placeholder per acoustic window
keeps that budget proportional to the work without shipping the
conditioning through the token path.
"""
del prompt, _requires_multimodal_data
acoustic_inputs: list[OmniTokensPrompt] = []
for ar_output in source_outputs:
if not ar_output.finished:
continue
output = ar_output.outputs[0]
multimodal_output = getattr(output, "multimodal_output", None)
try:
latent = _window_latent(multimodal_output)
codes = _window_codes(multimodal_output)
except ValueError as exc:
logger.warning("Skipping malformed MiniMax Music 3 stage-0 output: %s", exc)
acoustic_inputs.append(_empty_acoustic_prompt())
continue
if latent is None:
logger.error("MiniMax Music 3 talker produced no audio for request %s", ar_output.request_id)
acoustic_inputs.append(_empty_acoustic_prompt())
continue
total_frames = int(latent.shape[0])
meta: dict[str, Any] = {
"chunk_seq": 0,
"num_processed_tokens": 0,
"codec_chunk_frames": total_frames,
"last_chunk": True,
"stream_finished": torch.tensor(True, dtype=torch.bool),
"req_id": [ar_output.request_id],
}
published_seed = _payload_section(multimodal_output, "meta").get("audio_seed")
if isinstance(published_seed, int):
meta["audio_seed"] = published_seed
additional_information: dict[str, Any] = {
"latent": latent.detach().to(_LATENT_TRANSPORT_DTYPE).contiguous(),
"meta": meta,
}
if codes is not None:
additional_information["codes"] = {"audio": codes.detach().contiguous()}
num_windows = max(1, -(-total_frames // AR_CHUNK_HOP_FRAMES))
acoustic_inputs.append(
OmniTokensPrompt(
prompt_token_ids=[0] * num_windows,
multi_modal_data=None,
mm_processor_kwargs=None,
additional_information=additional_information,
)
)
return acoustic_inputs
@@ -23,6 +23,7 @@ _CLASS_TO_MODULE: dict[str, str] = {
"DotsTTSConfig": "vllm_omni.transformers_utils.configs.dots_tts",
"VoxtralTTSConfig": "vllm_omni.transformers_utils.configs.voxtral_tts",
"CosyVoice3Config": "vllm_omni.transformers_utils.configs.cosyvoice3",
"MiniMaxMusic3Config": "vllm_omni.transformers_utils.configs.minimax_music3",
"OmniVoiceConfig": "vllm_omni.transformers_utils.configs.omnivoice",
"BailingMoeV2Config": "vllm_omni.transformers_utils.configs.ming_flash_omni",
"BailingMM2Config": "vllm_omni.transformers_utils.configs.ming_flash_omni",
@@ -46,6 +47,7 @@ __all__ = [
"DotsTTSConfig",
"VoxtralTTSConfig",
"CosyVoice3Config",
"MiniMaxMusic3Config",
"OmniVoiceConfig",
"BailingMoeV2Config",
"BailingMM2Config",
@@ -78,6 +80,7 @@ from vllm_omni.transformers_utils.configs import glm_tts as _glm_tts # noqa: F4
from vllm_omni.transformers_utils.configs import higgs_audio_v3 as _higgs_audio_v3 # noqa: F401, E402
from vllm_omni.transformers_utils.configs import mammoth_moda2 as _mammoth_moda2 # noqa: F401, E402
from vllm_omni.transformers_utils.configs import ming_flash_omni as _ming_flash_omni # noqa: F401, E402
from vllm_omni.transformers_utils.configs import minimax_music3 as _minimax_music3 # noqa: F401, E402
from vllm_omni.transformers_utils.configs import omnivoice as _omnivoice # noqa: F401, E402
from vllm_omni.transformers_utils.configs import voxcpm2 as _voxcpm2 # noqa: F401, E402
from vllm_omni.transformers_utils.configs import voxtral_tts as _voxtral_tts # noqa: F401, E402
@@ -0,0 +1,65 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""HuggingFace config for the MiniMax Music 3 composite repository.
The checkpoint root carries only::
{"architectures": ["MiniMaxMusic3ForConditionalGeneration"],
"model_type": "minimax_music3"}
and ships no modelling code, so ``trust_remote_code`` has nothing to load and
``AutoConfig`` rejects the repository outright. Stage 0 sidesteps this by
declaring ``model_subdir="language_model"`` and reading the plain
``Qwen3ForCausalLM`` config, but the acoustic stage must open the root to reach
``condition_encoder/``, ``transformer/`` and ``vocoder/``. Registering this
class is what lets that stage build a ``ModelConfig`` at all.
The generic transformer fields describe the AR backbone, because the composite
root has no transformer of its own: they are metadata for vLLM's config
plumbing, not a description of the acoustic stage. The acoustic stage holds no
vLLM attention layers, so no KV cache is derived from them; it reads its real
geometry from ``models/minimax_music3/constants.py`` and its weights from the
component folders.
"""
from __future__ import annotations
from typing import Any
from transformers import AutoConfig
from transformers.configuration_utils import PretrainedConfig
class MiniMaxMusic3Config(PretrainedConfig):
"""Root config for the multi-component MiniMax Music 3 repository."""
model_type = "minimax_music3"
def __init__(self, **kwargs: Any) -> None:
# AR backbone geometry (language_model/config.json). Kept in sync with
# the checkpoint so max_model_len validation sees the real context.
kwargs.setdefault("hidden_size", 4096)
kwargs.setdefault("num_hidden_layers", 36)
kwargs.setdefault("num_attention_heads", 32)
kwargs.setdefault("num_key_value_heads", 8)
kwargs.setdefault("head_dim", 128)
kwargs.setdefault("intermediate_size", 12288)
kwargs.setdefault("vocab_size", 200000)
kwargs.setdefault("max_position_embeddings", 10240)
kwargs.setdefault("tie_word_embeddings", False)
super().__init__(**kwargs)
# Audio contract, mirroring models/minimax_music3/constants.py. These
# are fixed by the checkpoint; they are exposed here only so a caller
# inspecting the config sees the same numbers the model uses.
self.audio_frame_rate = 25
self.num_codebooks = 8
self.audio_vocab_size = 1024
self.c0_vocab_size = 16384
self.sample_rate = 32000
self.audio_channels = 2
AutoConfig.register("minimax_music3", MiniMaxMusic3Config)
__all__ = ["MiniMaxMusic3Config"]