[Bugfix][Core] Keep sample-rate snapshots bounded in delta output (#7448)

Signed-off-by: luozijian <luozijian0924@gmail.com>
This commit is contained in:
LOGO127 authored and GitHub committed 2026-09-15 16:50:32 -07:00
1 parent bbf77cce69
commit 65693b30fb
2 files changed
+157 -3

No files matched your search

@@ -0,0 +1,143 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Snapshot metadata must not accumulate as if it were generated content."""
import pytest
import torch
from vllm.outputs import PoolingRequestOutput
from vllm.sampling_params import RequestOutputKind
from vllm.v1.engine import FinishReason
from vllm_omni.outputs.mm_outputs import MultimodalPayload
from vllm_omni.outputs.output_modality import OutputModality, TensorAccumulationStrategy
from vllm_omni.outputs.output_processor import MultimodalOutputProcessor, OmniRequestState
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
RATE_KEYS = ("sr", "sample_rate", "audio_sample_rate")
def _state(kind: RequestOutputKind) -> OmniRequestState:
return OmniRequestState(
request_id="audio",
external_req_id="audio",
parent_req=None,
request_index=0,
lora_request=None,
prompt=None,
prompt_token_ids=[0],
prompt_embeds=None,
logprobs_processor=None,
detokenizer=None,
max_tokens_param=None,
arrival_time=0.0,
queue=None,
log_stats=False,
stream_interval=1,
output_kind=kind,
)
@pytest.mark.parametrize("key", RATE_KEYS)
@pytest.mark.parametrize("representation", ["integer", "scalar_tensor", "vector_tensor", "metadata_tensor"])
def test_rate_is_latest_snapshot_before_consolidation(key, representation):
payload = MultimodalPayload()
for index, rate in enumerate((16000, 24000, 48000)):
value = (
rate if representation == "integer" else torch.tensor([rate] if representation == "vector_tensor" else rate)
)
incoming = (
MultimodalPayload(tensors={"audio": torch.full((2,), index)}, metadata={key: value})
if representation == "metadata_tensor"
else MultimodalPayload.from_dict({"audio": torch.full((2,), index), key: value})
)
assert incoming is not None
payload = payload.merged_with(incoming)
snapshot = payload[key]
assert not isinstance(snapshot, list)
assert int(snapshot) == rate
assert payload.get(key) is payload.to_dict()[key]
payload = payload.merged_with(MultimodalPayload(tensors={"audio": torch.full((2,), 3)}))
assert int(payload[key]) == 48000 # Missing metadata means retain, not clear.
payload.consolidate_tensors(TensorAccumulationStrategy.CONCAT_LAST)
payload.consolidate_metadata()
torch.testing.assert_close(payload["audio"], torch.arange(4).repeat_interleave(2))
@pytest.mark.parametrize("key", RATE_KEYS)
@pytest.mark.parametrize("tensor_first", [False, True])
def test_rate_representation_change_removes_stale_partition(key, tensor_first):
first = torch.tensor(16000) if tensor_first else 16000
last = 24000 if tensor_first else torch.tensor(24000)
payload = MultimodalPayload.from_dict({key: first})
incoming = MultimodalPayload.from_dict({key: last})
assert payload is not None and incoming is not None
incoming_keys = (set(incoming.tensors), set(incoming.metadata))
payload = payload.merged_with(incoming)
assert int(payload[key]) == 24000
assert payload[key] is payload.to_dict()[key]
assert (key in payload.tensors) != (key in payload.metadata)
assert incoming[key] is last
assert incoming_keys == (set(incoming.tensors), set(incoming.metadata))
@pytest.mark.parametrize("key", RATE_KEYS)
def test_self_merge_retains_snapshot_and_content(key):
payload = MultimodalPayload.from_dict({key: torch.tensor(24000), "audio": torch.tensor([1.0, 2.0])})
assert payload is not None
payload = payload.merged_with(payload)
assert int(payload[key]) == 24000
payload.consolidate_tensors(TensorAccumulationStrategy.CONCAT_LAST)
torch.testing.assert_close(payload["audio"], torch.tensor([1.0, 2.0, 1.0, 2.0]))
@pytest.mark.parametrize("key", RATE_KEYS)
@pytest.mark.parametrize("kind", [RequestOutputKind.DELTA, RequestOutputKind.CUMULATIVE])
def test_long_stream_keeps_rate_bounded_without_losing_audio(key, kind):
state = _state(kind)
for index in range(1000):
state.add_multimodal_tensor(
{"model_outputs": torch.full((3,), index / 1000), key: torch.tensor(24000)}, "audio"
)
output = state.make_request_output([], None, None, None)
assert output is not None and not isinstance(output, PoolingRequestOutput)
payload = output.outputs[0].multimodal_output
assert isinstance(payload[key], torch.Tensor) and payload[key].numel() == 1
audio = payload["audio"]
assert audio.numel() == (3 if kind == RequestOutputKind.DELTA else 3 * (index + 1))
torch.testing.assert_close(audio[-3:], torch.full((3,), index / 1000))
assert ("audio" in state.mm_accumulated) is (kind == RequestOutputKind.CUMULATIVE)
final = state.make_request_output([], None, FinishReason.STOP, None)
assert final is not None and final.finished
if kind == RequestOutputKind.DELTA:
assert "audio" not in final.outputs[0].multimodal_output
@pytest.mark.parametrize("pending", [0, 3])
def test_abort_flushes_only_pending_content_with_latest_rate(pending):
state = _state(RequestOutputKind.DELTA)
processor = MultimodalOutputProcessor(tokenizer=None, log_stats=False, output_modality=OutputModality.AUDIO)
processor.request_states[state.request_id] = state
processor.external_req_ids[state.external_req_id] = [state.request_id]
for rate in (16000, 24000):
state.add_multimodal_tensor({"model_outputs": torch.ones(3), "sr": torch.tensor(rate)}, "audio")
output = state.make_request_output([], None, None, None)
assert output is not None
if pending:
state.add_multimodal_tensor({"model_outputs": torch.ones(pending)}, "audio")
aborted, outputs = processor.abort_requests_collecting_outputs([state.request_id], internal=True)
assert aborted == [state.request_id] and len(outputs) == 1 and outputs[0].finished
payload = outputs[0].outputs[0].multimodal_output
assert int(payload["sr"]) == 24000
assert (payload["audio"].numel() if "audio" in payload else 0) == pending
assert state.request_id not in processor.request_states
def test_non_snapshot_tensors_still_accumulate_and_empty_merge_keeps_identity():
first = MultimodalPayload.from_dict({"latent": torch.tensor([1.0]), "audio": torch.tensor([2.0])})
second = MultimodalPayload.from_dict({"latent": torch.tensor([3.0]), "audio": torch.tensor([4.0])})
assert first is not None and second is not None
assert MultimodalPayload().merged_with(first) is first
merged = first.merged_with(second)
merged.consolidate_tensors(TensorAccumulationStrategy.CONCAT_LAST)
torch.testing.assert_close(merged["latent"], torch.tensor([1.0, 3.0]))
torch.testing.assert_close(merged["audio"], torch.tensor([2.0, 4.0]))
+14 -3
View File
@@ -1,3 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Multimodal output data structures for vLLM-Omni.
This module defines structured types for multimodal outputs.
@@ -113,7 +115,7 @@ class MultimodalPayload(Mapping):
return self.tensors[key]
return self.metadata.get(key, default)
def __contains__(self, key: str) -> bool:
def __contains__(self, key: object) -> bool:
return key in self.tensors or key in self.metadata
def __getitem__(self, key: str) -> Any:
@@ -145,15 +147,24 @@ class MultimodalPayload(Mapping):
def merged_with(self, incoming: MultimodalPayload) -> MultimodalPayload:
"""Merge *incoming* onto this payload and return the result.
Tensor values accumulate into lists for deferred concatenation;
non-tensor values are replaced with the latest. When this payload
Content tensors accumulate into lists for deferred concatenation;
known sample-rate keys are snapshots replaced immediately, including
in DELTA streams that do not consolidate each emission. Missing keys
retain their previous value. Other values keep the existing merge
behavior. When this payload
is empty, *incoming* is returned as-is, so callers should use the
return value: ``accumulated = accumulated.merged_with(incoming)``.
"""
if self.is_empty:
return incoming
# Capture before merging: incoming may be this payload. Store these
# snapshots in only one partition even if their representation changes.
snapshots = {key: incoming[key] for key in _METADATA_TENSOR_KEYS if key in incoming}
_append_entries(self.tensors, incoming.tensors)
_append_entries(self.metadata, incoming.metadata)
for key, value in snapshots.items():
self.tensors.pop(key, None)
self.metadata[key] = value
return self
def consolidate_tensors(self, strategy: TensorAccumulationStrategy) -> None: