[Bugfix][MiniCPM-o] Retire CFM DiT CUDA graphs a generation at a time (#6587)
Signed-off-by: BruceLoveDecimal <liuqihao970610@gmail.com> Co-authored-by: amy-why-3459 <wuhaiyan17@huawei.com>
This commit is contained in:
1 parent
f8f8aa8eb7
commit
ab8d45d3cc
3 files changed
+419
-79
No files matched your search
@@ -0,0 +1,92 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""What is allowed to keep the CFM wrapper from capturing a graph.
|
||||
|
||||
These run without a GPU: ``_capture`` is mocked, so nothing here touches a
|
||||
CUDA context. The point is the decision made *before* capture, not the capture.
|
||||
"""
|
||||
|
||||
from typing import NamedTuple
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.model_executor.models.minicpmo_4_5.cuda_graph_wrapper import (
|
||||
CFMGraphWrapper,
|
||||
_format_memory_delta,
|
||||
_memory_snapshot,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
class _InputSignature(NamedTuple):
|
||||
"""The three properties ``replay`` reads from an input before capturing.
|
||||
|
||||
Everything ``replay`` does ahead of the capture decision -- the device
|
||||
check and the cache key -- comes from these, and ``_capture`` is mocked
|
||||
out, so the tests need no CUDA tensors and no GPU.
|
||||
"""
|
||||
|
||||
shape: tuple[int, ...]
|
||||
dtype: torch.dtype
|
||||
device: torch.device
|
||||
|
||||
|
||||
def _cfm_input_signatures(chunk_size: int) -> tuple[_InputSignature, ...]:
|
||||
device = torch.device("cuda:0")
|
||||
shapes = ((2, 16, chunk_size), (2, 1, 8), (2, 2, 8, 2), (2, 2, 1, 4, 8), (2, 2, 8, 2), (2, 2, 1, 12, 8))
|
||||
return tuple(_InputSignature(shape, torch.float32, device) for shape in shapes)
|
||||
|
||||
|
||||
def _wrapper(monkeypatch: pytest.MonkeyPatch, *, max_graphs: int = 4) -> CFMGraphWrapper:
|
||||
monkeypatch.setattr(torch.cuda, "is_current_stream_capturing", lambda: False)
|
||||
wrapper = object.__new__(CFMGraphWrapper)
|
||||
wrapper.max_graphs = max_graphs
|
||||
wrapper.enabled = True
|
||||
wrapper.graph_fn = Mock(return_value=torch.tensor([42.0]))
|
||||
wrapper.device = torch.device("cuda:0")
|
||||
wrapper._cache = {}
|
||||
wrapper._unsupported = set()
|
||||
wrapper._stats = {"calls": 0, "hits": 0, "captures": 0, "flushes": 0, "eager": 0}
|
||||
wrapper._capture = Mock(return_value=None)
|
||||
return wrapper
|
||||
|
||||
|
||||
def test_capture_is_not_gated_on_free_device_memory(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Whether to capture must not be decided from free device memory.
|
||||
|
||||
A capture is served by the caching allocator out of its private graph
|
||||
pool, and the allocator falls back on memory it has already reserved. Free
|
||||
device memory is therefore neither an upper nor a lower bound on what a
|
||||
capture can draw, and a threshold on it decides the wrong question -- one
|
||||
whose answer also silently varies with card size and with whatever else
|
||||
shares the device. Anything consulting it here would fail this test.
|
||||
"""
|
||||
import vllm_omni.platforms as platforms_module
|
||||
|
||||
def _saturated(device: torch.device | None = None) -> int:
|
||||
raise AssertionError("capture must not depend on device-level free memory")
|
||||
|
||||
# Setting the attribute on the module shadows the lazy ``__getattr__``, so
|
||||
# this neither builds nor requires a real platform singleton.
|
||||
monkeypatch.setattr(
|
||||
platforms_module,
|
||||
"current_omni_platform",
|
||||
Mock(get_free_memory=_saturated),
|
||||
raising=False,
|
||||
)
|
||||
|
||||
wrapper = _wrapper(monkeypatch)
|
||||
wrapper.replay(*_cfm_input_signatures(10))
|
||||
|
||||
wrapper._capture.assert_called_once()
|
||||
|
||||
|
||||
def test_memory_reporting_is_inert_off_cuda() -> None:
|
||||
"""Capture logging must not break the platforms that have no such counters."""
|
||||
assert _memory_snapshot(torch.device("cpu")) is None
|
||||
assert _format_memory_delta(None, (1, 2)) == ""
|
||||
assert _format_memory_delta((1, 2), None) == ""
|
||||
assert "allocated" in _format_memory_delta((0, 0), (1 << 20, 2 << 20))
|
||||
@@ -1,7 +1,7 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from functools import lru_cache
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
@@ -10,6 +10,7 @@ import torch
|
||||
import torch.nn as nn
|
||||
from vllm.platforms import current_platform
|
||||
|
||||
import vllm_omni.model_executor.models.minicpmo_4_5.cuda_graph_wrapper as wrapper_module
|
||||
from vllm_omni.model_executor.models.cosyvoice3.code2wav_core.hifigan import (
|
||||
HiFTGenerator,
|
||||
)
|
||||
@@ -248,50 +249,203 @@ def test_cfm_graph_replay_matches_eager_for_uncached_and_cached_shapes(
|
||||
torch.testing.assert_close(graph_cnn, eager_inputs[4], rtol=1e-4, atol=1e-5)
|
||||
torch.testing.assert_close(graph_att, eager_inputs[5], rtol=1e-4, atol=1e-5)
|
||||
|
||||
wrapper._capture_graph.cache_clear()
|
||||
wrapper._flush()
|
||||
|
||||
|
||||
def test_importing_wrapper_does_not_resolve_platform() -> None:
|
||||
"""Importing this module must not build the OmniPlatform singleton.
|
||||
|
||||
The NPU platform's ``__init__`` patches Code2Wav, which imports
|
||||
``batched_token2wav`` -> ``cuda_graph_wrapper``. Resolving
|
||||
``current_omni_platform`` at module scope re-enters
|
||||
``platforms.__getattr__`` while the singleton is still under construction
|
||||
and the import graph deadlocks. The module reaches for nothing on
|
||||
``vllm_omni.platforms`` today; this keeps it that way.
|
||||
"""
|
||||
import importlib
|
||||
|
||||
import vllm_omni.platforms as platforms_module
|
||||
|
||||
name = "vllm_omni.model_executor.models.minicpmo_4_5.cuda_graph_wrapper"
|
||||
importlib.import_module(name)
|
||||
saved = platforms_module._current_omni_platform
|
||||
try:
|
||||
del sys.modules[name]
|
||||
platforms_module._current_omni_platform = None
|
||||
importlib.import_module(name)
|
||||
assert platforms_module._current_omni_platform is None
|
||||
finally:
|
||||
platforms_module._current_omni_platform = saved
|
||||
|
||||
|
||||
def _cfm_mock_wrapper(monkeypatch: pytest.MonkeyPatch, *, max_graphs: int = 1) -> CFMGraphWrapper:
|
||||
monkeypatch.setattr(torch.cuda, "is_current_stream_capturing", lambda: False)
|
||||
wrapper = object.__new__(CFMGraphWrapper)
|
||||
wrapper.max_graphs = max_graphs
|
||||
wrapper.enabled = True
|
||||
wrapper.graph_fn = Mock(return_value=torch.tensor([42.0]))
|
||||
wrapper.device = torch.device("cuda")
|
||||
|
||||
@lru_cache(maxsize=max_graphs)
|
||||
def _capture_graph(key: tuple):
|
||||
return wrapper._do_capture(key)
|
||||
|
||||
wrapper._capture_graph = _capture_graph
|
||||
wrapper._do_capture = Mock(return_value=None)
|
||||
wrapper._cache = {}
|
||||
wrapper._unsupported = set()
|
||||
wrapper._stats = {"calls": 0, "hits": 0, "captures": 0, "flushes": 0, "eager": 0}
|
||||
wrapper._capture = Mock(return_value=None)
|
||||
return wrapper
|
||||
|
||||
|
||||
def test_cfm_unseen_shape_is_lazily_captured(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
wrapper = _cfm_mock_wrapper(monkeypatch)
|
||||
wrapper._capture_graph = Mock(return_value=None)
|
||||
|
||||
inputs = _cfm_inputs(2, 10, 0)
|
||||
wrapper.replay(*inputs)
|
||||
|
||||
wrapper._capture_graph.assert_called_once()
|
||||
wrapper._capture.assert_called_once()
|
||||
wrapper.graph_fn.assert_called_once()
|
||||
|
||||
|
||||
def test_cfm_capture_failure_falls_back_to_eager(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_cfm_returning_no_entry_falls_back_to_eager(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""A capture that yields no entry must still serve the request eagerly.
|
||||
|
||||
``_capture`` is mocked here, so this says nothing about ``_disable``; see
|
||||
``test_cfm_capture_failure_disables_further_capture`` for that.
|
||||
"""
|
||||
wrapper = _cfm_mock_wrapper(monkeypatch)
|
||||
wrapper._capture_graph = lru_cache(maxsize=1)(lambda key: None)
|
||||
|
||||
inputs = _cfm_inputs(2, 10, 0)
|
||||
result = wrapper.replay(*inputs)
|
||||
|
||||
wrapper.graph_fn.assert_called_once()
|
||||
assert result[0] is wrapper.graph_fn.return_value
|
||||
assert wrapper._stats["eager"] == 1
|
||||
|
||||
|
||||
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA is required")
|
||||
def test_cfm_lru_eviction(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Verify that LRU cache evicts oldest entries when full."""
|
||||
def test_cfm_capture_failure_disables_further_capture(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""A real capture failure must stop the wrapper capturing for good.
|
||||
|
||||
A failed capture can leave the capture stream current and the allocator
|
||||
still routing into the graph pool, so the next shape must not try again.
|
||||
"""
|
||||
pool = torch.cuda.graph_pool_handle()
|
||||
monkeypatch.setattr(current_platform, "get_global_graph_pool", lambda: pool)
|
||||
|
||||
torch.manual_seed(0)
|
||||
estimator = _MiniDiT().eval().cuda()
|
||||
wrapper = CFMGraphWrapper(graph_fn=estimator.blocks_forward_chunk, max_graphs=4)
|
||||
|
||||
def _explode(*args: object, **kwargs: object) -> None:
|
||||
raise RuntimeError("capture failed")
|
||||
|
||||
monkeypatch.setattr(torch.cuda, "graph", _explode)
|
||||
|
||||
with torch.inference_mode():
|
||||
wrapper.replay(*_cfm_inputs(2, 10, 0))
|
||||
assert wrapper.enabled is False
|
||||
assert wrapper._cache == {}
|
||||
|
||||
captures_after_failure = wrapper._stats["captures"]
|
||||
wrapper.replay(*_cfm_inputs(2, 12, 0))
|
||||
|
||||
assert wrapper._stats["captures"] == captures_after_failure
|
||||
assert wrapper._stats["eager"] == 2
|
||||
|
||||
|
||||
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA is required")
|
||||
def test_cfm_unsupported_dtype_eagers_only_that_shape(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""A key that cannot round-trip is a property of one shape, not of the GPU.
|
||||
|
||||
It must not disable the wrapper or retire the generation the way a capture
|
||||
failure does.
|
||||
"""
|
||||
pool = torch.cuda.graph_pool_handle()
|
||||
monkeypatch.setattr(current_platform, "get_global_graph_pool", lambda: pool)
|
||||
|
||||
torch.manual_seed(0)
|
||||
estimator = _MiniDiT().eval().cuda()
|
||||
wrapper = CFMGraphWrapper(graph_fn=estimator.blocks_forward_chunk, max_graphs=4)
|
||||
|
||||
unbuildable_width = 10
|
||||
real_tensors_from_key = wrapper_module._tensors_from_key
|
||||
|
||||
def _reject_one_shape(key: tuple) -> tuple:
|
||||
if key[1][0][2] == unbuildable_width:
|
||||
raise KeyError("torch.int64")
|
||||
return real_tensors_from_key(key)
|
||||
|
||||
monkeypatch.setattr(wrapper_module, "_tensors_from_key", _reject_one_shape)
|
||||
|
||||
with torch.inference_mode():
|
||||
wrapper.replay(*_cfm_inputs(2, unbuildable_width, 0))
|
||||
assert wrapper.enabled is True
|
||||
assert wrapper._stats["captures"] == 0
|
||||
|
||||
# a capturable shape still gets a graph
|
||||
wrapper.replay(*_cfm_inputs(2, 12, 0))
|
||||
assert wrapper._stats["captures"] == 1
|
||||
|
||||
# and the rejected shape stays eager without retrying the capture
|
||||
wrapper.replay(*_cfm_inputs(2, unbuildable_width, 0))
|
||||
assert wrapper._stats["captures"] == 1
|
||||
assert wrapper._stats["eager"] == 2
|
||||
|
||||
wrapper._flush()
|
||||
|
||||
|
||||
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA is required")
|
||||
def test_hift_replay_survives_a_cfm_generation_flush(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Retiring a CFM generation must not disturb the vocoder.
|
||||
|
||||
Both wrappers capture into ``get_global_graph_pool()``, so the HiFT graphs
|
||||
are exactly the live graphs a CFM flush could strand.
|
||||
"""
|
||||
pool = torch.cuda.graph_pool_handle()
|
||||
monkeypatch.setattr(current_platform, "get_global_graph_pool", lambda: pool)
|
||||
|
||||
torch.manual_seed(0)
|
||||
hift = _small_hift()
|
||||
token2wav = SimpleNamespace(
|
||||
hift=hift,
|
||||
flow=SimpleNamespace(
|
||||
encoder=SimpleNamespace(pre_lookahead_layer=SimpleNamespace(pre_lookahead_len=3)),
|
||||
token_mel_ratio=2,
|
||||
),
|
||||
mel_cache_len=2,
|
||||
source_cache_len=960,
|
||||
)
|
||||
hift_wrapper = HiFTGraphWrapper(
|
||||
token2wav,
|
||||
connector_config={"codec_chunk_frames": 2, "codec_left_context_frames": 3},
|
||||
capture_batch_sizes=[1],
|
||||
)
|
||||
hift_wrapper.capture()
|
||||
|
||||
estimator = _MiniDiT().eval().cuda()
|
||||
cfm = CFMGraphWrapper(graph_fn=estimator.blocks_forward_chunk, max_graphs=2)
|
||||
|
||||
speech_feat = torch.randn(1, 80, 4, device="cuda")
|
||||
cache_source = torch.zeros(1, 1, 0, device="cuda")
|
||||
|
||||
with torch.inference_mode():
|
||||
expected_speech, expected_source = hift.inference(speech_feat, cache_source)
|
||||
|
||||
for width in (10, 12, 14, 16):
|
||||
cfm.replay(*_cfm_inputs(2, width, 0))
|
||||
assert cfm._stats["flushes"] >= 1, "cache never overflowed; the test proves nothing"
|
||||
|
||||
actual_speech, actual_source = hift_wrapper.replay(speech_feat, cache_source)
|
||||
|
||||
torch.testing.assert_close(actual_speech, expected_speech, rtol=1e-4, atol=1e-5)
|
||||
torch.testing.assert_close(actual_source, expected_source, rtol=1e-4, atol=1e-5)
|
||||
|
||||
cfm._flush()
|
||||
|
||||
|
||||
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA is required")
|
||||
def test_cfm_cache_flushes_whole_generation(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""A full cache is retired all at once, never one graph at a time.
|
||||
|
||||
Destroying a single graph while its peers stay live strands them, so the
|
||||
cache must never hold a graph that outlived one of its generation-mates.
|
||||
"""
|
||||
pool = torch.cuda.graph_pool_handle()
|
||||
monkeypatch.setattr(current_platform, "get_global_graph_pool", lambda: pool)
|
||||
|
||||
@@ -302,26 +456,32 @@ def test_cfm_lru_eviction(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
with torch.inference_mode():
|
||||
inputs_a = _cfm_inputs(2, 10, 0)
|
||||
wrapper.replay(*inputs_a)
|
||||
assert wrapper._capture_graph.cache_info().currsize == 1
|
||||
assert len(wrapper._cache) == 1
|
||||
|
||||
inputs_b = _cfm_inputs(2, 12, 0)
|
||||
wrapper.replay(*inputs_b)
|
||||
assert wrapper._capture_graph.cache_info().currsize == 2
|
||||
assert len(wrapper._cache) == 2
|
||||
|
||||
# A hit must not grow the cache or trigger a flush.
|
||||
wrapper.replay(*inputs_a)
|
||||
assert wrapper._capture_graph.cache_info().currsize == 2
|
||||
assert len(wrapper._cache) == 2
|
||||
assert wrapper._stats["flushes"] == 0
|
||||
assert wrapper._stats["hits"] == 1
|
||||
|
||||
# The third distinct shape flushes the generation, then captures alone.
|
||||
inputs_c = _cfm_inputs(2, 14, 0)
|
||||
wrapper.replay(*inputs_c)
|
||||
assert wrapper._capture_graph.cache_info().currsize == 2
|
||||
assert wrapper._stats["flushes"] == 1
|
||||
assert len(wrapper._cache) == 1
|
||||
|
||||
wrapper.replay(*inputs_b)
|
||||
hits_before = wrapper._capture_graph.cache_info().hits
|
||||
wrapper.replay(*inputs_b)
|
||||
hits_after = wrapper._capture_graph.cache_info().hits
|
||||
assert hits_after == hits_before + 1
|
||||
# The flushed shapes are gone, so they capture again rather than hit.
|
||||
hits_before = wrapper._stats["hits"]
|
||||
wrapper.replay(*inputs_a)
|
||||
assert wrapper._stats["hits"] == hits_before
|
||||
wrapper.replay(*inputs_a)
|
||||
assert wrapper._stats["hits"] == hits_before + 1
|
||||
|
||||
wrapper._capture_graph.cache_clear()
|
||||
wrapper._flush()
|
||||
|
||||
|
||||
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA is required")
|
||||
|
||||
Reference in new issue
Block a user