[Bugfix][MRV2] Run cudagraph memory profiling in a throwaway graph pool (#53682)
Signed-off-by: Nick Hill <nickhill123@gmail.com>
Nick Hill committed
Aug 25, 2026 at 11:52 UTC
e6cc0899b6e29ad613efae572eb98e0d776afe4d
2 files changed
+131
-58
tests/v1/worker/test_gpu_model_runner_v2_cudagraph_profiling.py
+56
-5
@@ -74,17 +74,28 @@ def _make_profiling_runner(
74
return runner
75
76
77
+class _FakePlatform:
78
+ """Stands in for current_platform; the global graph pool is a class attr,
79
+ matching vllm.platforms.Platform's lazy singleton."""
80
+
81
+ _global_graph_pool: Any = GLOBAL_POOL
82
+
83
+ @staticmethod
84
+ def graph_pool_handle() -> Any:
85
+ return THROWAWAY_POOL
86
+
87
+ def get_global_graph_pool(self) -> Any:
88
+ return type(self)._global_graph_pool
89
+
90
+
91
def _patch_module(monkeypatch) -> None:
92
@contextlib.contextmanager
93
def _fake_set_current_vllm_config(_cfg):
94
yield
95
96
+ _FakePlatform._global_graph_pool = GLOBAL_POOL
97
monkeypatch.setattr(cgu, "set_current_vllm_config", _fake_set_current_vllm_config)
83
- monkeypatch.setattr(
84
- cgu,
85
- "current_platform",
86
- SimpleNamespace(graph_pool_handle=lambda: THROWAWAY_POOL),
87
- )
98
+ monkeypatch.setattr(cgu, "current_platform", _FakePlatform())
99
monkeypatch.setattr(
100
cgu, "_init_minimal_kv_cache_for_profiling", lambda r: r.events.append("init")
101
)
@@ -284,3 +295,43 @@ def test_profile_cudagraph_memory_redirects_wrapper_pools(monkeypatch):
295
assert wrapper.graph_pool == GLOBAL_POOL
296
finally:
297
cgu.CUDAGraphWrapper._all_instances.discard(wrapper)
298
+
299
+
300
+def test_profile_cudagraph_memory_swaps_and_drops_speculator_managers(monkeypatch):
301
+ """Speculator cudagraph managers must also capture into the throwaway pool.
302
+
303
+ They are created during the profiling KV-cache bootstrap, binding the
304
+ (swapped) global graph pool, and are re-created by the real
305
+ initialize_kv_cache; profiling-captured graphs must be dropped at
306
+ teardown rather than released against the persistent global pool, which
307
+ would trip the c10 create_or_incref_pool assert at the real capture.
308
+ """
309
+ _patch_module(monkeypatch)
310
+ runner = _make_profiling_runner(CUDAGraphMode.FULL_AND_PIECEWISE)
311
+
312
+ def _init(r):
313
+ r.events.append("init")
314
+ # Mirror production: the speculator's cudagraph managers are created
315
+ # during the profiling KV-cache bootstrap and bind the global pool
316
+ # (which profiling has already pointed at the throwaway pool).
317
+ manager = cgu.CudaGraphManager.__new__(cgu.CudaGraphManager)
318
+ manager.pool = cgu.current_platform.get_global_graph_pool()
319
+ r.speculator = SimpleNamespace(cudagraph_manager=manager)
320
+
321
+ monkeypatch.setattr(cgu, "_init_minimal_kv_cache_for_profiling", _init)
322
+
323
+ pools_seen: list[Any] = []
324
+ capture_model = runner.capture_model
325
+
326
+ def _capture_model() -> int:
327
+ pools_seen.append(runner.speculator.cudagraph_manager.pool)
328
+ return capture_model()
329
+
330
+ runner.capture_model = _capture_model
331
+
332
+ cgu.profile_cudagraph_memory(runner)
333
+
334
+ assert pools_seen == [THROWAWAY_POOL]
335
+ assert runner.speculator.cudagraph_manager is None
336
+ # The real global pool is restored afterwards.
337
+ assert _FakePlatform._global_graph_pool == GLOBAL_POOL
vllm/v1/worker/gpu/cudagraph_utils.py
+75
-53
@@ -723,62 +723,84 @@ def profile_cudagraph_memory(runner: "GPUModelRunner") -> int:
723
gc.collect()
724
torch.accelerator.empty_cache()
725
726
- with set_current_vllm_config(runner.vllm_config):
727
- _init_minimal_kv_cache_for_profiling(runner)
726
+ # Run the whole profiling phase against a throwaway CUDA graph pool by
727
+ # pointing the global graph pool singleton at it: objects that bind the
728
+ # pool lazily during profiling (speculator cudagraph managers, breakable
729
+ # runners created mid-capture) then land on the throwaway pool too. Pools
730
+ # bound before profiling (piecewise wrappers) are swapped explicitly in
731
+ # the inner block. Profiling graphs captured into the persistent global
732
+ # pool and then discarded would drop its use_count to 0, tripping the c10
733
+ # allocator's create_or_incref_pool assert when the real capture reuses
734
+ # that pool ("use_count > 0 INTERNAL ASSERT FAILED").
735
+ platform_cls = type(current_platform)
736
+ saved_global_pool = platform_cls._global_graph_pool
737
+ throwaway_pool = current_platform.graph_pool_handle()
738
+ platform_cls._global_graph_pool = throwaway_pool
739
729
- manager = runner.cudagraph_manager
730
- assert manager is not None
731
-
732
- # Don't count profiling captures; the real capture_model() runs later.
733
- saved_num_cudagraph_captured = compilation_counter.num_cudagraph_captured
734
- saved_capture_triggers = compilation_counter.num_gpu_runner_capture_triggers
735
- all_wrappers: list[Any] = []
736
- original_pools: dict[int, Any] = {}
740
try:
738
- if not manager.needs_capture():
739
- return 0
740
- # Capture all profiling graphs into a throwaway pool so their memory
741
- # is reclaimed on teardown rather than retained by the persistent
742
- # global pool (which the real capture reuses). This must include the
743
- # piecewise wrappers, not just the FULL-graph manager pool: graphs
744
- # captured into the global pool and then discarded drop its use_count
745
- # to 0, and the real capture on the same pool trips the c10 allocator's
746
- # create_or_incref_pool assert ("use_count > 0 INTERNAL ASSERT FAILED").
747
- manager.pool = current_platform.graph_pool_handle()
748
- if manager.use_breakable_cg:
749
- # The breakable runner is otherwise created lazily during capture,
750
- # after the pool swap below, and would capture into the global
751
- # pool. Create it now so its pool gets swapped too.
752
- manager.init_breakable_cg_runner(runner.model)
753
- all_wrappers = list(CUDAGraphWrapper._all_instances) + list(
754
- BreakableCUDAGraphWrapper._all_instances
755
- )
756
- for wrapper in all_wrappers:
757
- original_pools[id(wrapper)] = wrapper.graph_pool
758
- wrapper.graph_pool = manager.pool
759
- manager._max_full_descs_to_capture = _FULL_GRAPH_PROFILING_SAMPLES
760
- mem_samples: list[int] = []
761
- manager._capture_mem_samples = mem_samples
762
-
763
- measured = int(runner.capture_model())
764
-
765
- # The measured delta covers PIECEWISE, encoder and speculator graphs
766
- # plus the sampled FULL graphs; swap the sampled FULL cost for the
767
- # extrapolated total. FULL and PIECEWISE share one pool here just as
768
- # they share the global pool at runtime, so the overlap is not
769
- # double-counted.
770
- num_full_graphs = len(manager._capture_descs.get(CUDAGraphMode.FULL, []))
771
- full_estimate = _extrapolate_full_graph_memory(mem_samples, num_full_graphs)
772
- return max(measured - sum(mem_samples) + full_estimate, 0)
741
+ with set_current_vllm_config(runner.vllm_config):
742
+ _init_minimal_kv_cache_for_profiling(runner)
743
+
744
+ manager = runner.cudagraph_manager
745
+ assert manager is not None
746
+
747
+ # Don't count profiling captures; the real capture_model() runs later.
748
+ saved_num_cudagraph_captured = compilation_counter.num_cudagraph_captured
749
+ saved_capture_triggers = compilation_counter.num_gpu_runner_capture_triggers
750
+ all_wrappers: list[Any] = []
751
+ original_pools: dict[int, Any] = {}
752
+ speculator = getattr(runner, "speculator", None)
753
+ spec_managers: list[tuple[str, CudaGraphManager]] = []
754
+ try:
755
+ if not manager.needs_capture():
756
+ return 0
757
+ manager.pool = throwaway_pool
758
+ if manager.use_breakable_cg:
759
+ # Create the breakable runner before the wrapper pool swap so
760
+ # its pool is covered as well.
761
+ manager.init_breakable_cg_runner(runner.model)
762
+ all_wrappers = list(CUDAGraphWrapper._all_instances) + list(
763
+ BreakableCUDAGraphWrapper._all_instances
764
+ )
765
+ for wrapper in all_wrappers:
766
+ original_pools[id(wrapper)] = wrapper.graph_pool
767
+ wrapper.graph_pool = throwaway_pool
768
+ if speculator is not None:
769
+ spec_managers = [
770
+ (name, value)
771
+ for name, value in vars(speculator).items()
772
+ if isinstance(value, CudaGraphManager)
773
+ ]
774
+ manager._max_full_descs_to_capture = _FULL_GRAPH_PROFILING_SAMPLES
775
+ mem_samples: list[int] = []
776
+ manager._capture_mem_samples = mem_samples
777
+
778
+ measured = int(runner.capture_model())
779
+
780
+ # The measured delta covers PIECEWISE, encoder and speculator graphs
781
+ # plus the sampled FULL graphs; swap the sampled FULL cost for the
782
+ # extrapolated total. FULL and PIECEWISE share one pool here just as
783
+ # they share the global pool at runtime, so the overlap is not
784
+ # double-counted.
785
+ num_full_graphs = len(manager._capture_descs.get(CUDAGraphMode.FULL, []))
786
+ full_estimate = _extrapolate_full_graph_memory(mem_samples, num_full_graphs)
787
+ return max(measured - sum(mem_samples) + full_estimate, 0)
788
+ finally:
789
+ compilation_counter.num_cudagraph_captured = saved_num_cudagraph_captured
790
+ compilation_counter.num_gpu_runner_capture_triggers = saved_capture_triggers
791
+ CUDAGraphWrapper.clear_all_graphs()
792
+ BreakableCUDAGraphWrapper.clear_all_graphs()
793
+ for wrapper in all_wrappers:
794
+ if id(wrapper) in original_pools:
795
+ wrapper.graph_pool = original_pools[id(wrapper)]
796
+ # Drop the speculator's cudagraph managers; the real
797
+ # initialize_kv_cache re-creates them. Their profiling graphs
798
+ # release the throwaway pool here rather than after the real init.
799
+ for name, _ in spec_managers:
800
+ setattr(speculator, name, None)
801
+ _teardown_profiling_state(runner)
802
finally:
774
- compilation_counter.num_cudagraph_captured = saved_num_cudagraph_captured
775
- compilation_counter.num_gpu_runner_capture_triggers = saved_capture_triggers
776
- CUDAGraphWrapper.clear_all_graphs()
777
- BreakableCUDAGraphWrapper.clear_all_graphs()
778
- for wrapper in all_wrappers:
779
- if id(wrapper) in original_pools:
780
- wrapper.graph_pool = original_pools[id(wrapper)]
781
- _teardown_profiling_state(runner)
803
+ platform_cls._global_graph_pool = saved_global_pool
804
805
806
def _extrapolate_full_graph_memory(mem_samples: list[int], total_graphs: int) -> int: