[Profiler] Add Proton CUDA graph attribution for MRV2 (#51084)

Signed-off-by: Luossu <zhluosuu@outlook.com> Signed-off-by: Nick Hill <nickhill123@gmail.com> Co-authored-by: Nick Hill <nickhill123@gmail.com>

鐘天楽 committed Sep 14, 2026 at 15:02 UTC 0a5747d410da9b08145dba2b6f3e6614bab35753
8 files changed +657 -45
docs/contributing/profiling.md
+32 -14
@@ -90,18 +90,18 @@ $ curl -X POST http://localhost:8000/stop_profile
90 is Triton's GPU profiler. It can collect a low-overhead aggregate tree or a
91 Chrome trace and works through the same vLLM profiling controls as the PyTorch
92 and CUDA profilers. Proton currently supports NVIDIA GPUs through CUPTI and
93 -requires eager execution.
93 +supports CUDA graph attribution.
94
95 -Start a server with a local output directory:
95 +Start a server with a local output directory and graph attribution:
96
97 ```bash
98 vllm serve meta-llama/Llama-3.1-8B-Instruct \
99 - --enforce-eager \
99 --profiler-config '{
100 "profiler": "proton",
101 "proton_profiler_dir": "./proton_profile",
102 "proton_output_format": "hatchet",
104 - "proton_hook": "triton"
103 + "proton_hook": "triton",
104 + "proton_graph_attribution": true
105 }'
106 ```
107
@@ -111,7 +111,7 @@ rank-qualified output name, such as
111 `proton_dp0_pp0_tp0_dcp0_ep0_rank0_pid1234_0123456789abcdef0123456789abcdef_run0.hatchet`,
112 so distributed workers, restarted servers, and repeated profiling runs do not
113 overwrite one another. A `profile_prefix` is included when supplied. Each
114 -profile is finalized by `/stop_profile` and is ready to inspect immediately.
114 +profile is written by `/stop_profile` and is ready to inspect immediately.
115
116 The Proton-specific options are:
117
@@ -121,21 +121,39 @@ The Proton-specific options are:
121 - `proton_mode`: an optional backend mode string
122 - `proton_hook`: `triton` to record Triton launch metadata, or unset
123 - `proton_output_format`: `hatchet`, `hatchet_msgpack`, `chrome_trace`, or unset
124 +- `proton_graph_attribution`: observe CUDA graph capture for replay attribution;
125 + disabled by default and requires `proton_data: "tree"`
126
127 `hatchet` and `hatchet_msgpack` require `proton_data: "tree"`, while
126 -`chrome_trace` requires `proton_data: "trace"`. CUDA graph profiling is not yet
127 -supported, so `--enforce-eager` is required for every Proton mode.
128 +`chrome_trace` requires `proton_data: "trace"`.
129
130 Automatic backend selection is recommended. vLLM currently supports Proton's
131 `cupti` backend on NVIDIA GPUs. ROCm support is not yet available. vLLM does not
132 expose Proton's experimental instrumentation backend because current upstream
132 -Triton builds can produce profiles without timing metrics. Backend-specific
133 -modes, including `pcsampling`, can be selected with `proton_mode`.
134 -
135 -Triton 3.6 supports explicit `hatchet` and `chrome_trace` output. The
136 -`hatchet_msgpack` format and `periodic_flushing` mode require Triton 3.7 or
137 -newer; vLLM rejects these options with the detected version before starting a
138 -session.
133 +can produce profiles without timing metrics. When `proton_graph_attribution` is
134 +enabled, Proton observes vLLM's CUDA graph capture with the configured profiling
135 +session active, then deactivates that same session until profiling starts. This
136 +lets later profiles attribute replayed kernels without retaining model-startup
137 +activity. Backend-specific modes can be selected with `proton_mode`;
138 +`pcsampling` synchronizes the CUDA context and therefore requires
139 +`--enforce-eager`. When CUDA graphs are enabled (including encoder graphs),
140 +Proton requires `proton_graph_attribution: true` to collect replayed kernels.
141 +For Chrome traces, disable CUDA graphs with `--enforce-eager`.
142 +
143 +CUDA graph-attributed profiles support repeated `start_profile`/`stop_profile`
144 +runs. Each stop flushes and writes one tree-data phase while preserving the
145 +graph-aware session. Without `proton_graph_attribution`, each `stop_profile`
146 +instead finalizes and writes an independent Proton session.
147 +
148 +CUDA graph attribution requires Triton 3.7 or newer. It uses the phase data API
149 +to discard graph-capture activity and separate profiling runs. The `hatchet_msgpack`
150 +output format and `periodic_flushing` mode also require Triton 3.7 or newer.
151 +`periodic_flushing` cannot be combined with graph attribution because both
152 +manage the session's data phases. Ordinary Proton profiling remains available
153 +with Triton 3.6.
154 +
155 +Graph attribution retains capture metadata for the worker lifetime. With eager
156 +execution or no graphs to capture, profiling uses ordinary independent sessions.
157
158 Inspect tree profiles with:
159
tests/v1/worker/test_gpu_profiler.py
+393 -5
@@ -350,17 +350,24 @@ def test_profiler_entered_during_capture():
350
351
352 def make_proton(session_id: int | None = 7):
353 + data = SimpleNamespace(
354 + advance_phase=Mock(side_effect=range(1, 100)),
355 + clear=Mock(),
356 + get=Mock(return_value={"traceEvents": []}),
357 + get_msgpack=Mock(return_value=b"profile"),
358 + )
359 return SimpleNamespace(
360 start=Mock(return_value=session_id),
361 activate=Mock(),
362 deactivate=Mock(),
363 finalize=Mock(),
364 scope=Mock(return_value=nullcontext()),
365 + data=data,
366 )
367
368
369 def make_proton_wrapper(
363 - tmp_path, proton=None, triton_version="3.6.0", **config_overrides
370 + tmp_path, proton=None, triton_version="3.7.0", **config_overrides
371 ):
372 proton = proton or make_proton()
373 config = ProfilerConfig(
@@ -415,6 +422,13 @@ class TestProtonConfig:
422 },
423 "requires proton_data",
424 ),
425 + (
426 + {
427 + "proton_data": "trace",
428 + "proton_graph_attribution": True,
429 + },
430 + "requires proton_data='tree'",
431 + ),
432 ],
433 )
434 def test_rejects_invalid_option_combinations(self, tmp_path, options, message):
@@ -450,6 +464,7 @@ class TestProtonConfig:
464 ("proton_mode", "pcsampling"),
465 ("proton_hook", "triton"),
466 ("proton_output_format", "chrome_trace"),
467 + ("proton_graph_attribution", True),
468 ],
469 )
470 def test_rejects_proton_options_for_other_profilers(self, field, value):
@@ -478,17 +493,103 @@ class TestProtonConfig:
493 compilation_config=CompilationConfig(cudagraph_mode=CUDAGraphMode.NONE),
494 )
495
481 - def test_rejects_proton_when_cuda_graphs_are_enabled(self, tmp_path):
482 - with pytest.raises(ValueError, match="requires CUDA graphs to be disabled"):
496 + @pytest.mark.parametrize("encoder_only", [False, True])
497 + @pytest.mark.parametrize("attribution", [False, True])
498 + def test_cuda_graphs_require_attribution(self, tmp_path, encoder_only, attribution):
499 + # Encoder graphs are independent of the decoder's cudagraph_mode.
500 + expected = (
501 + nullcontext()
502 + if attribution
503 + else pytest.raises(
504 + ValueError, match="requires proton_graph_attribution=True"
505 + )
506 + )
507 + with expected:
508 + VllmConfig(
509 + profiler_config=ProfilerConfig(
510 + profiler="proton",
511 + proton_profiler_dir=str(tmp_path),
512 + proton_graph_attribution=attribution,
513 + ),
514 + compilation_config=CompilationConfig(
515 + cudagraph_mode=(
516 + CUDAGraphMode.NONE if encoder_only else CUDAGraphMode.FULL
517 + ),
518 + cudagraph_mm_encoder=encoder_only,
519 + ),
520 + )
521 +
522 + def test_validates_default_cuda_graph_mode_after_resolution(self, tmp_path):
523 + with pytest.raises(ValueError, match="requires proton_graph_attribution=True"):
524 VllmConfig(
525 profiler_config=ProfilerConfig(
526 profiler="proton", proton_profiler_dir=str(tmp_path)
527 ),
528 + )
529 +
530 + @pytest.mark.parametrize(
531 + "mode",
532 + [
533 + "periodic_flushing",
534 + "periodic_flushing:format=hatchet",
535 + "PERIODIC_FLUSHING:format=hatchet",
536 + ],
537 + )
538 + def test_graph_attribution_rejects_periodic_flushing(self, tmp_path, mode):
539 + # Reject before Proton's native phase manager can abort the worker.
540 + with pytest.raises(ValueError, match="incompatible with periodic_flushing"):
541 + ProfilerConfig(
542 + profiler="proton",
543 + proton_profiler_dir=str(tmp_path),
544 + proton_graph_attribution=True,
545 + proton_mode=mode,
546 + )
547 +
548 + @pytest.mark.parametrize("attribution", [False, True])
549 + @pytest.mark.parametrize(
550 + "mode", ["pcsampling", "pcsampling:interval=100", "PcSampling:interval=100"]
551 + )
552 + @pytest.mark.parametrize("encoder_only", [False, True])
553 + def test_pcsampling_requires_graphs_disabled(
554 + self, tmp_path, attribution, mode, encoder_only
555 + ):
556 + with pytest.raises(ValueError, match="PC sampling requires CUDA graphs"):
557 + VllmConfig(
558 + profiler_config=ProfilerConfig(
559 + profiler="proton",
560 + proton_profiler_dir=str(tmp_path),
561 + proton_graph_attribution=attribution,
562 + proton_mode=mode,
563 + ),
564 compilation_config=CompilationConfig(
488 - cudagraph_mode=CUDAGraphMode.PIECEWISE
565 + cudagraph_mode=CUDAGraphMode.NONE
566 + if encoder_only
567 + else CUDAGraphMode.FULL,
568 + cudagraph_mm_encoder=encoder_only,
569 ),
570 )
571
572 + def test_ordinary_proton_keeps_mrv1_graph_restriction(self, tmp_path, monkeypatch):
573 + monkeypatch.setenv("VLLM_USE_V2_MODEL_RUNNER", "0")
574 + with pytest.raises(ValueError, match="requires proton_graph_attribution=True"):
575 + VllmConfig(
576 + profiler_config=ProfilerConfig(
577 + profiler="proton", proton_profiler_dir=str(tmp_path)
578 + ),
579 + compilation_config=CompilationConfig(cudagraph_mode=CUDAGraphMode.FULL),
580 + )
581 +
582 + def test_graph_attribution_rejects_mrv1(self, tmp_path, monkeypatch):
583 + monkeypatch.setenv("VLLM_USE_V2_MODEL_RUNNER", "0")
584 + with pytest.raises(ValueError, match="requires the V2 model runner"):
585 + VllmConfig(
586 + profiler_config=ProfilerConfig(
587 + profiler="proton",
588 + proton_profiler_dir=str(tmp_path),
589 + proton_graph_attribution=True,
590 + )
591 + )
592 +
593
594 @_requires_cuda_for_proton
595 class TestProtonProfilerWrapper:
@@ -564,7 +665,20 @@ class TestProtonProfilerWrapper:
665 )
666 def test_newer_features_reject_triton_3_6(self, tmp_path, option, value, feature):
667 with pytest.raises(RuntimeError, match=feature):
567 - make_proton_wrapper(tmp_path, **{option: value})
668 + make_proton_wrapper(tmp_path, triton_version="3.6.0", **{option: value})
669 +
670 + @pytest.mark.parametrize("version", ["3.6.0", "unknown"])
671 + def test_graph_attribution_requires_phase_api(self, tmp_path, version):
672 + with pytest.raises(RuntimeError, match="requires Triton >= 3.7"):
673 + make_proton_wrapper(
674 + tmp_path, triton_version=version, proton_graph_attribution=True
675 + )
676 +
677 + def test_ordinary_profiling_still_supports_triton_3_6(self, tmp_path):
678 + wrapper, proton = make_proton_wrapper(tmp_path, triton_version="3.6.0")
679 + wrapper.start()
680 + wrapper.stop()
681 + proton.finalize.assert_called_once_with(session=7)
682
683 @pytest.mark.parametrize(
684 ("option", "value"),
@@ -583,6 +697,125 @@ class TestProtonProfilerWrapper:
697 with pytest.raises(RuntimeError, match="does not support selecting"):
698 make_proton_wrapper(tmp_path, proton, proton_output_format="hatchet")
699
700 + def test_cuda_graph_tree_phase_is_written_at_stop(self, tmp_path):
701 + wrapper, proton = make_proton_wrapper(
702 + tmp_path,
703 + proton_context="python",
704 + proton_graph_attribution=True,
705 + )
706 + with wrapper.capture_cuda_graphs():
707 + proton.start.assert_called_once()
708 + proton.deactivate.assert_not_called()
709 +
710 + capture_args = proton.start.call_args.kwargs
711 + assert capture_args["context"] == "python"
712 + assert capture_args["data"] == "tree"
713 + proton.data.advance_phase.assert_called_once_with(7)
714 + proton.deactivate.assert_called_once_with(session=7, flushing=True)
715 + proton.data.clear.assert_called_once_with(7, 0)
716 +
717 + wrapper.start()
718 + wrapper.stop()
719 + wrapper.start()
720 + wrapper.stop()
721 +
722 + assert proton.data.get.call_args_list == [call(7, 1), call(7, 2)]
723 + proton.data.clear.assert_has_calls([call(7, 0), call(7, 1), call(7, 2)])
724 + output_names = sorted(tmp_path.glob("proton_rank_3_*.hatchet"))
725 + assert len(output_names) == 2
726 + assert output_names[0].name.endswith("_run0.hatchet")
727 + assert output_names[1].name.endswith("_run1.hatchet")
728 +
729 + def test_capture_is_noop_without_opt_in(self, tmp_path):
730 + wrapper, proton = make_proton_wrapper(tmp_path)
731 + with wrapper.capture_cuda_graphs():
732 + pass
733 + proton.start.assert_not_called()
734 +
735 + @pytest.mark.parametrize("delay", [0, 2])
736 + def test_duplicate_start_preserves_output_prefix(self, tmp_path, delay):
737 + wrapper, proton = make_proton_wrapper(
738 + tmp_path, proton_graph_attribution=True, delay_iterations=delay
739 + )
740 + with wrapper.capture_cuda_graphs():
741 + pass
742 + wrapper.set_output_name("first")
743 + wrapper.start()
744 + wrapper.set_output_name("duplicate")
745 + wrapper.start()
746 + for _ in range(delay):
747 + wrapper.step()
748 + wrapper.stop()
749 + assert len(list(tmp_path.glob("proton_first_*.hatchet"))) == 1
750 + assert not list(tmp_path.glob("proton_duplicate_*"))
751 + wrapper.set_output_name("second")
752 + wrapper.start()
753 + for _ in range(delay):
754 + wrapper.step()
755 + wrapper.stop()
756 + assert len(list(tmp_path.glob("proton_second_*.hatchet"))) == 1
757 +
758 + def test_failed_export_clears_activity_and_allows_next_interval(self, tmp_path):
759 + wrapper, proton = make_proton_wrapper(tmp_path, proton_graph_attribution=True)
760 + with wrapper.capture_cuda_graphs():
761 + pass
762 + proton.data.get.side_effect = [OSError("disk full"), []]
763 + wrapper.start()
764 + wrapper.stop()
765 + proton.data.clear.assert_called_with(7, 1)
766 + wrapper.start()
767 + wrapper.stop()
768 + proton.data.clear.assert_called_with(7, 2)
769 + assert len(list(tmp_path.glob("*_run1.hatchet"))) == 1
770 +
771 + def test_cuda_graph_context_deactivates_after_capture_error(self, tmp_path):
772 + wrapper, proton = make_proton_wrapper(tmp_path, proton_graph_attribution=True)
773 +
774 + with (
775 + pytest.raises(RuntimeError, match="capture failed"),
776 + wrapper.capture_cuda_graphs(),
777 + ):
778 + raise RuntimeError("capture failed")
779 +
780 + proton.deactivate.assert_called_once_with(session=7, flushing=True)
781 +
782 + def test_cuda_graph_capture_deactivates_when_phase_advance_fails(self, tmp_path):
783 + wrapper, proton = make_proton_wrapper(tmp_path, proton_graph_attribution=True)
784 + proton.data.advance_phase.side_effect = RuntimeError("advance failed")
785 +
786 + with (
787 + pytest.raises(RuntimeError, match="advance failed"),
788 + wrapper.capture_cuda_graphs(),
789 + ):
790 + pass
791 +
792 + proton.deactivate.assert_called_once_with(session=7, flushing=True)
793 + proton.data.clear.assert_not_called()
794 +
795 + def test_cuda_graph_stop_deactivates_when_phase_advance_fails(self, tmp_path):
796 + wrapper, proton = make_proton_wrapper(tmp_path, proton_graph_attribution=True)
797 + with wrapper.capture_cuda_graphs():
798 + pass
799 + proton.data.clear.reset_mock()
800 + proton.data.advance_phase.side_effect = RuntimeError("advance failed")
801 +
802 + wrapper._start()
803 + with pytest.raises(RuntimeError, match="advance failed"):
804 + wrapper._stop()
805 +
806 + proton.deactivate.assert_called_with(session=7, flushing=True)
807 + proton.data.clear.assert_not_called()
808 +
809 + def test_shutdown_finalizes_cuda_graph_capture_session(self, tmp_path):
810 + wrapper, proton = make_proton_wrapper(tmp_path, proton_graph_attribution=True)
811 + with wrapper.capture_cuda_graphs():
812 + pass
813 +
814 + wrapper.shutdown()
815 + wrapper.shutdown()
816 +
817 + proton.finalize.assert_called_once_with(session=7)
818 +
819 def test_missing_proton_has_actionable_error(self, tmp_path):
820 config = ProfilerConfig(profiler="proton", proton_profiler_dir=str(tmp_path))
821 with (
@@ -638,6 +871,7 @@ def test_gpu_worker_recreates_proton_profiler_for_each_run():
871 ),
872 patch("vllm.v1.worker.gpu_worker.ProtonProfilerWrapper") as wrapper,
873 ):
874 + wrapper.return_value.has_cuda_graph_session = False
875 Worker.profile(worker, profile_prefix="first")
876 Worker.profile(worker, is_start=False)
877 Worker.profile(worker, profile_prefix="second")
@@ -647,3 +881,157 @@ def test_gpu_worker_recreates_proton_profiler_for_each_run():
881 call(worker.profiler_config, worker_name="second_rank1"),
882 ]
883 assert wrapper.return_value.start.call_count == 2
884 +
885 +
886 +@_requires_cuda_for_proton
887 +def test_gpu_worker_reuses_cuda_graph_proton_session():
888 + worker = MagicMock()
889 + worker.rank = 1
890 + worker.profiler = MagicMock(spec=ProtonProfilerWrapper)
891 + worker.profiler.has_cuda_graph_session = True
892 + worker.profiler_config.profiler = "proton"
893 +
894 + with patch(
895 + "vllm.distributed.utils.get_worker_rank_suffix",
896 + return_value="rank1",
897 + ):
898 + Worker.profile(worker, profile_prefix="first")
899 + Worker.profile(worker, is_start=False)
900 +
901 + worker.profiler.set_output_name.assert_called_once_with("first_rank1")
902 + worker.profiler.start.assert_called_once_with()
903 + worker.profiler.stop.assert_called_once_with()
904 +
905 +
906 +@_requires_cuda_for_proton
907 +@pytest.mark.parametrize("runner", ["attribution_off", "v1", "no_capture"])
908 +def test_proton_not_initialized_without_capture(runner):
909 + worker = MagicMock()
910 + worker.profiler = None
911 + worker.profiler_config.profiler = "proton"
912 + worker.profiler_config.proton_graph_attribution = runner != "attribution_off"
913 + worker.use_v2_model_runner = runner != "v1"
914 + worker.model_runner.needs_cudagraph_capture.return_value = runner != "no_capture"
915 +
916 + context = Worker._get_cudagraph_capture_context(worker)
917 +
918 + assert worker.profiler is None
919 + with context:
920 + pass
921 +
922 +
923 +@_requires_cuda_for_proton
924 +def test_proton_initializes_before_cuda_graph_capture():
925 + class FakeProtonProfiler:
926 + def __init__(self, config, worker_name):
927 + self.config = config
928 + self.worker_name = worker_name
929 + self.capture_context = nullcontext()
930 +
931 + def capture_cuda_graphs(self):
932 + return self.capture_context
933 +
934 + worker = MagicMock()
935 + worker.rank = 2
936 + worker.profiler = None
937 + worker.profiler_config.profiler = "proton"
938 + worker.profiler_config.proton_graph_attribution = True
939 + worker.use_v2_model_runner = True
940 + worker.model_runner.needs_cudagraph_capture.return_value = True
941 +
942 + with (
943 + patch(
944 + "vllm.distributed.utils.get_worker_rank_suffix",
945 + return_value="rank2",
946 + ),
947 + patch(
948 + "vllm.profiler.wrapper.ProtonProfilerWrapper",
949 + FakeProtonProfiler,
950 + ),
951 + ):
952 + context = Worker._get_cudagraph_capture_context(worker)
953 +
954 + assert worker.profiler.config is worker.profiler_config
955 + assert worker.profiler.worker_name == "rank2"
956 + assert context is worker.profiler.capture_context
957 +
958 +
959 +@_requires_cuda_for_proton
960 +@pytest.mark.parametrize("context", ["shadow", "python"])
961 +@pytest.mark.parametrize("output_format", ["hatchet", "hatchet_msgpack"])
962 +def test_proton_cuda_graph_replay_attribution_on_gpu(tmp_path, context, output_format):
963 + """Both intervals contain replay kernels, without capture-only activity."""
964 + import json
965 +
966 + import torch
967 + import triton
968 + import triton.profiler as proton
969 + from packaging.version import Version
970 +
971 + if Version(triton.__version__) < Version("3.7"):
972 + pytest.skip("Graph attribution requires Triton >= 3.7")
973 +
974 + wrapper = ProtonProfilerWrapper(
975 + ProfilerConfig(
976 + profiler="proton",
977 + proton_profiler_dir=str(tmp_path),
978 + proton_graph_attribution=True,
979 + proton_context=context,
980 + proton_output_format=output_format,
981 + ),
982 + worker_name="gpu",
983 + )
984 + worker = SimpleNamespace(
985 + profiler=wrapper,
986 + use_v2_model_runner=True,
987 + )
988 + x = torch.ones(1024, device="cuda")
989 + graph = torch.cuda.CUDAGraph()
990 +
991 + def capture_only():
992 + x.add_(1)
993 +
994 + def captured_add():
995 + x.add_(1)
996 +
997 + def kernel_metrics(value):
998 + if isinstance(value, list):
999 + return [metric for child in value for metric in kernel_metrics(child)]
1000 + if isinstance(value, dict):
1001 + metrics = value.get("metrics", {})
1002 + return ([metrics] if metrics.get("count", 0) else []) + [
1003 + metric for child in value.values() for metric in kernel_metrics(child)
1004 + ]
1005 + return []
1006 +
1007 + try:
1008 + with Worker._get_cudagraph_capture_context(worker):
1009 + with proton.scope("capture_only"):
1010 + capture_only()
1011 + torch.accelerator.synchronize()
1012 + with torch.cuda.graph(graph), proton.scope("captured_add"):
1013 + captured_add()
1014 + for run in range(2):
1015 + wrapper.start()
1016 + with wrapper.annotate_context_manager(f"replay_{run}"):
1017 + graph.replay()
1018 + wrapper.stop()
1019 + (path,) = tmp_path.glob(f"*_run{run}.{output_format}")
1020 + if output_format == "hatchet_msgpack":
1021 + import msgpack
1022 +
1023 + data = msgpack.unpackb(path.read_bytes())
1024 + else:
1025 + data = json.loads(path.read_text())
1026 + serialized = json.dumps(data)
1027 + assert "<captured_at>" in serialized
1028 + assert "captured_add" in serialized
1029 + assert "capture_only" not in serialized
1030 + assert f"replay_{1 - run}" not in serialized
1031 + metrics = kernel_metrics(data)
1032 + assert sum(metric["count"] for metric in metrics) == 1
1033 + assert sum(metric["time (ns)"] for metric in metrics) > 0
1034 + torch.testing.assert_close(x, torch.full_like(x, 4))
1035 + finally:
1036 + wrapper.shutdown()
1037 + assert not list(tmp_path.glob(".proton_cuda_graph_session*"))
vllm/config/profiler.py
+16
@@ -76,6 +76,10 @@ class ProfilerConfig:
76 """Optional format passed to Proton when finalizing a profile. ``None``
77 uses the default format for ``proton_data``."""
78
79 + proton_graph_attribution: bool = False
80 + """Observe CUDA graph capture so replayed kernels can be attributed.
81 + Requires Triton >= 3.7 and ``proton_data='tree'``."""
82 +
83 torch_profiler_with_stack: bool = True
84 """If `True`, enables stack tracing in the torch profiler. Enabled by default
85 as it is useful for debugging. Can be disabled via
@@ -199,6 +203,7 @@ class ProfilerConfig:
203 ("proton_mode", self.proton_mode, None),
204 ("proton_hook", self.proton_hook, None),
205 ("proton_output_format", self.proton_output_format, None),
206 + ("proton_graph_attribution", self.proton_graph_attribution, False),
207 )
208 if value != default
209 ]
@@ -220,6 +225,17 @@ class ProfilerConfig:
225
226 if self.profiler == "proton":
227 output_format = self.proton_output_format
228 + if self.proton_graph_attribution and self.proton_data != "tree":
229 + raise ValueError("proton_graph_attribution requires proton_data='tree'")
230 + if (
231 + self.proton_graph_attribution
232 + and self.proton_mode
233 + and self.proton_mode.split(":", 1)[0].lower() == "periodic_flushing"
234 + ):
235 + raise ValueError(
236 + "proton_graph_attribution is incompatible with periodic_flushing: "
237 + "both manage Proton data phases."
238 + )
239 if output_format == "chrome_trace" and self.proton_data != "trace":
240 raise ValueError("chrome_trace output requires proton_data='trace'")
241 if (
vllm/config/vllm.py
+39 -12
@@ -1551,18 +1551,6 @@ class VllmConfig:
1551 self.compilation_config.mode = CompilationMode.NONE
1552 self.compilation_config.cudagraph_mode = CUDAGraphMode.NONE
1553
1554 - if self.profiler_config.profiler == "proton":
1555 - if not current_platform.is_cuda():
1556 - raise ValueError(
1557 - "The Proton profiler currently supports NVIDIA CUDA only"
1558 - )
1559 - if self.compilation_config.cudagraph_mode != CUDAGraphMode.NONE:
1560 - raise ValueError(
1561 - "The Proton profiler requires CUDA graphs to be disabled. "
1562 - "Use --enforce-eager or set "
1563 - "--compilation-config.cudagraph_mode=none."
1564 - )
1565 -
1554 if os.environ.get("TORCH_COMPILE_DISABLE") == "1":
1555 logger.warning_once(
1556 "TORCH_COMPILE_DISABLE is set, disabling torch.compile. "
@@ -1860,6 +1848,7 @@ class VllmConfig:
1848 else:
1849 self._validate_v1_model_runner()
1850
1851 + self._validate_profiler_config()
1852 self._validate_batch_sharded_sampling()
1853 self._validate_adaptive_verification()
1854
@@ -3042,6 +3031,44 @@ class VllmConfig:
3031
3032 return unsupported
3033
3034 + def _validate_profiler_config(self) -> None:
3035 + if self.profiler_config.profiler != "proton":
3036 + return
3037 +
3038 + from vllm.platforms import current_platform
3039 +
3040 + if not current_platform.is_cuda():
3041 + raise ValueError("The Proton profiler currently supports NVIDIA CUDA only")
3042 + if (
3043 + self.profiler_config.proton_graph_attribution
3044 + and not self.use_v2_model_runner
3045 + ):
3046 + raise ValueError(
3047 + "Proton CUDA graph attribution requires the V2 model runner."
3048 + )
3049 +
3050 + has_cuda_graphs = (
3051 + self.compilation_config.cudagraph_mode != CUDAGraphMode.NONE
3052 + or (
3053 + self.compilation_config.cudagraph_mm_encoder
3054 + and not (self.model_config and self.model_config.enforce_eager)
3055 + )
3056 + )
3057 + if not has_cuda_graphs:
3058 + return
3059 + mode = self.profiler_config.proton_mode
3060 + if mode and mode.split(":", 1)[0].lower() == "pcsampling":
3061 + raise ValueError(
3062 + "Proton PC sampling requires CUDA graphs to be disabled. "
3063 + "Use --enforce-eager."
3064 + )
3065 + if not self.profiler_config.proton_graph_attribution:
3066 + raise ValueError(
3067 + "Proton profiling with CUDA graphs requires "
3068 + "proton_graph_attribution=True to capture replayed kernels. "
3069 + "Enable attribution or use --enforce-eager."
3070 + )
3071 +
3072 def _validate_v2_model_runner(self) -> None:
3073 """Check for features not yet supported by the V2 model runner."""
3074 if not HAS_TRITON:
vllm/profiler/wrapper.py
+138 -11
@@ -6,8 +6,9 @@ import inspect
6 import json
7 import os
8 from abc import ABC, abstractmethod
9 -from collections.abc import Callable
10 -from contextlib import nullcontext
9 +from collections.abc import Callable, Iterator
10 +from contextlib import AbstractContextManager, contextmanager, nullcontext, suppress
11 +from glob import glob
12 from typing import Literal
13 from uuid import uuid4
14
@@ -160,6 +161,15 @@ class WorkerProfiler(ABC):
161 if self._running:
162 self.stop()
163
164 + @property
165 + def has_cuda_graph_session(self) -> bool:
166 + """Whether a capture-time session is retained to attribute replays."""
167 + return False
168 +
169 + def capture_cuda_graphs(self) -> AbstractContextManager[None]:
170 + """Observe graph creation for backends that attribute replay activity."""
171 + return nullcontext()
172 +
173 def annotate_context_manager(self, name: str):
174 """Return a context manager to annotate profiler traces."""
175 return nullcontext()
@@ -387,6 +397,7 @@ class ProtonProfilerWrapper(WorkerProfiler):
397 self._mode = profiler_config.proton_mode
398 self._hook = profiler_config.proton_hook
399 self._output_format = profiler_config.proton_output_format
400 + self._graph_attribution = profiler_config.proton_graph_attribution
401 self._triton_version_string = getattr(triton, "__version__", "unknown")
402 try:
403 self._triton_version = Version(self._triton_version_string)
@@ -398,6 +409,13 @@ class ProtonProfilerWrapper(WorkerProfiler):
409 # worker cannot overwrite profiles left by an earlier server process.
410 self._instance_id = f"pid{os.getpid()}_{uuid4().hex}"
411 self._run_id = 0
412 + self._graph_session = False
413 + self._phase = 0
414 + self._active_output_path: str | None = None
415 + self._session_storage_path = os.path.join(
416 + self._output_dir,
417 + f".proton_cuda_graph_session_{worker_name}_{self._instance_id}",
418 + )
419
420 logger.info_once(
421 "Proton profiling enabled. Output will be saved under: %s",
@@ -412,6 +430,10 @@ class ProtonProfilerWrapper(WorkerProfiler):
430 )
431
432 def _validate_capabilities(self) -> None:
433 + if self._graph_attribution:
434 + self._require_triton_version(
435 + "CUDA graph attribution", _TRITON_PROTON_3_7_VERSION
436 + )
437 if self._output_format is not None:
438 parameters = inspect.signature(self._proton.finalize).parameters
439 supports_output_format = "output_format" in parameters or any(
@@ -428,7 +450,7 @@ class ProtonProfilerWrapper(WorkerProfiler):
450 self._require_triton_version(
451 "hatchet_msgpack output", _TRITON_PROTON_3_7_VERSION
452 )
431 - if self._mode and self._mode.split(":", 1)[0] == "periodic_flushing":
453 + if self._mode and self._mode.split(":", 1)[0].lower() == "periodic_flushing":
454 self._require_triton_version(
455 "periodic flushing", _TRITON_PROTON_3_7_VERSION
456 )
@@ -447,28 +469,112 @@ class ProtonProfilerWrapper(WorkerProfiler):
469 raise RuntimeError("Proton did not create a profiling session")
470 return session_id
471
472 + @property
473 + def has_cuda_graph_session(self) -> bool:
474 + return self._graph_session
475 +
476 + def set_output_name(self, worker_name: str) -> None:
477 + """Set the next run's output name after startup graph capture."""
478 + if self._active:
479 + return
480 + self._output_path = os.path.join(self._output_dir, f"proton_{worker_name}")
481 +
482 + @override
483 + @contextmanager
484 + def capture_cuda_graphs(self) -> Iterator[None]:
485 + """Keep a Proton session active while vLLM captures CUDA graphs."""
486 + if not self._graph_attribution:
487 + yield
488 + return
489 + if self._session_id is None:
490 + self._session_id = self._create_session(self._session_storage_path)
491 + else:
492 + self._proton.activate(session=self._session_id)
493 + self._graph_session = True
494 +
495 + try:
496 + yield
497 + finally:
498 + captured_phase = self._phase
499 + try:
500 + self._phase = self._proton.data.advance_phase(self._session_id)
501 + finally:
502 + self._proton.deactivate(session=self._session_id, flushing=True)
503 + self._proton.data.clear(self._session_id, captured_phase)
504 +
505 @override
506 def _start(self) -> None:
452 - output_path = f"{self._output_path}_{self._instance_id}_run{self._run_id}"
453 - self._session_id = self._create_session(output_path)
507 + self._active_output_path = (
508 + f"{self._output_path}_{self._instance_id}_run{self._run_id}"
509 + )
510 self._run_id += 1
511 + if self._graph_session:
512 + assert self._session_id is not None
513 + self._proton.activate(session=self._session_id)
514 + else:
515 + self._session_id = self._create_session(self._active_output_path)
516 +
517 + def _write_graph_phase(self, phase: int) -> None:
518 + assert self._active_output_path is not None
519 + output_format = self._output_format or "hatchet"
520 + output_path = f"{self._active_output_path}.{output_format}"
521 + if output_format == "hatchet_msgpack":
522 + with open(output_path, "wb") as output_file:
523 + output_file.write(
524 + self._proton.data.get_msgpack(self._session_id, phase)
525 + )
526 + else:
527 + with open(output_path, "w", encoding="utf-8") as output_file:
528 + json.dump(self._proton.data.get(self._session_id, phase), output_file)
529 +
530 + def _finalize_session(self, session_id: int) -> None:
531 + if self._output_format is None:
532 + self._proton.finalize(session=session_id)
533 + else:
534 + self._proton.finalize(session=session_id, output_format=self._output_format)
535
536 @override
537 def _stop(self) -> None:
538 assert self._session_id is not None
539 session_id = self._session_id
540 + if self._graph_session:
541 + completed_phase = self._phase
542 + try:
543 + self._phase = self._proton.data.advance_phase(session_id)
544 + finally:
545 + self._proton.deactivate(session=session_id, flushing=True)
546 + try:
547 + self._write_graph_phase(completed_phase)
548 + finally:
549 + self._proton.data.clear(session_id, completed_phase)
550 + self._active_output_path = None
551 + return
552 +
553 try:
554 self._proton.deactivate(session=session_id)
555 finally:
556 try:
464 - if self._output_format is None:
465 - self._proton.finalize(session=session_id)
466 - else:
467 - self._proton.finalize(
468 - session=session_id, output_format=self._output_format
469 - )
557 + self._finalize_session(session_id)
558 finally:
559 self._session_id = None
560 + self._active_output_path = None
561 +
562 + @override
563 + def shutdown(self) -> None:
564 + super().shutdown()
565 + if self._graph_session and self._session_id is not None:
566 + session_id = self._session_id
567 + self._session_id = None
568 + try:
569 + self._finalize_session(session_id)
570 + except Exception:
571 + logger.exception(
572 + "Failed to finalize Proton CUDA graph session during shutdown."
573 + )
574 + finally:
575 + for output_path in glob(f"{self._session_storage_path}.*"):
576 + with suppress(FileNotFoundError):
577 + os.remove(output_path)
578
579 @override
580 def annotate_context_manager(self, name: str):
@@ -496,3 +602,24 @@ class CudaProfilerWrapper(WorkerProfiler):
602 @override
603 def annotate_context_manager(self, name: str):
604 return torch.cuda.nvtx.range(name)
605 +
606 +
607 +def create_graph_capture_profiler(
608 + profiler_config: ProfilerConfig, global_rank: int
609 +) -> WorkerProfiler | None:
610 + """Create a profiler to observe CUDA graph capture, if configured.
611 +
612 + Applies only to backends that attribute graph replay activity which
613 + need a session around capture (Proton with ``proton_graph_attribution``).
614 + """
615 + if (
616 + profiler_config.profiler == "proton"
617 + and profiler_config.proton_graph_attribution
618 + ):
619 + from vllm.distributed.utils import get_worker_rank_suffix
620 +
621 + return ProtonProfilerWrapper(
622 + profiler_config,
623 + worker_name=get_worker_rank_suffix(global_rank=global_rank),
624 + )
625 + return None
vllm/v1/worker/gpu/model_runner.py
+10
@@ -987,6 +987,16 @@ class GPUModelRunner(LoRAModelRunnerMixin):
987 """Estimate the GPU memory required to capture CUDA graphs."""
988 return _profile_cudagraph_memory(self)
989
990 + def needs_cudagraph_capture(self) -> bool:
991 + """Whether capture_model() has any CUDA graphs left to capture."""
992 + return (
993 + self.model_state.supports_mm_inputs
994 + and self.model_state.encoder_runner.has_cudagraph()
995 + ) or (
996 + self.cudagraph_manager is not None
997 + and self.cudagraph_manager.needs_capture()
998 + )
999 +
1000 @torch.inference_mode()
1001 def capture_model(self, *, profile_only: bool = False) -> int:
1002 assert self.cudagraph_manager is not None
vllm/v1/worker/gpu_worker.py
+26 -3
@@ -9,7 +9,7 @@ from collections.abc import Callable
9 from contextlib import AbstractContextManager, contextmanager, nullcontext
10 from datetime import timedelta
11 from types import NoneType
12 -from typing import TYPE_CHECKING, Any
12 +from typing import TYPE_CHECKING, Any, cast
13
14 import numpy as np
15 import regex as re
@@ -64,6 +64,7 @@ from vllm.profiler.wrapper import (
64 CudaProfilerWrapper,
65 ProtonProfilerWrapper,
66 TorchProfilerWrapper,
67 + create_graph_capture_profiler,
68 )
69 from vllm.sequence import IntermediateTensors
70 from vllm.tasks import SupportedTask
@@ -142,6 +143,7 @@ def maybe_rocm_profiling_fallback(profile_result: MemoryProfilingResult) -> int
143 if TYPE_CHECKING:
144 from vllm.device_allocator.sleep_mode_backend import SleepModeBackend
145 from vllm.model_executor.model_loader.tensorizer import TensorizerConfig
146 + from vllm.v1.worker.gpu.model_runner import GPUModelRunner as GPUModelRunnerV2
147 from vllm.v1.worker.gpu_model_runner import GPUModelRunner
148
149
@@ -810,7 +812,8 @@ class Worker(WorkerBase):
812
813 cuda_graph_memory_bytes = 0
814 if not self.model_config.enforce_eager:
813 - cuda_graph_memory_bytes = self.model_runner.capture_model()
815 + with self._get_cudagraph_capture_context():
816 + cuda_graph_memory_bytes = self.model_runner.capture_model()
817
818 # Compare actual vs estimated CUDA graph memory (if we did profiling)
819 if (
@@ -949,6 +952,21 @@ class Worker(WorkerBase):
952 encoder=self.compilation_config.encoder_compilation_time,
953 )
954
955 + def _get_cudagraph_capture_context(self) -> AbstractContextManager[None]:
956 + """Let the configured profiler observe CUDA graph capture."""
957 + if not self.use_v2_model_runner:
958 + return nullcontext()
959 + if self.profiler is None:
960 + model_runner = cast("GPUModelRunnerV2", self.model_runner)
961 + if not model_runner.needs_cudagraph_capture():
962 + return nullcontext()
963 + self.profiler = create_graph_capture_profiler(
964 + self.profiler_config, global_rank=self.rank
965 + )
966 + if self.profiler is None:
967 + return nullcontext()
968 + return self.profiler.capture_cuda_graphs()
969 +
970 def reset_mm_cache(self) -> None:
971 self.model_runner.reset_mm_cache()
972
@@ -1248,6 +1266,9 @@ class Worker(WorkerBase):
1266 else:
1267 trace_name = rank_suffix
1268
1269 + if profiler_type == "proton" and self.profiler is not None:
1270 + self.profiler.set_output_name(trace_name)
1271 +
1272 # Create the profiler wrapper only on the first start call
1273 if self.profiler is None:
1274 if profiler_type == "torch":
@@ -1284,7 +1305,9 @@ class Worker(WorkerBase):
1305 try:
1306 self.profiler.stop()
1307 finally:
1287 - if self.profiler_config.profiler == "proton":
1308 + if self.profiler_config.profiler == "proton" and not (
1309 + self.profiler.has_cuda_graph_session
1310 + ):
1311 # Proton output names are fixed when the wrapper is constructed.
1312 # Recreate it so the next profile_prefix is honored.
1313 self.profiler = None
vllm/v1/worker/mm_encoder_model_runner.py
+3
@@ -70,6 +70,9 @@ class MMEncoderModelRunner(GPUModelRunner):
70 def capture_model(self, *, profile_only: bool = False) -> int:
71 return 0
72
73 + def needs_cudagraph_capture(self) -> bool:
74 + return False
75 +
76 def _dummy_run(
77 self, *args: Any, **kwargs: Any
78 ) -> tuple[torch.Tensor, torch.Tensor]: