[Profiler] Add minimal Triton Proton profiling backend (#48789)

Signed-off-by: Luossu <zhluosuu@outlook.com>

鐘天楽 committed Aug 11, 2026 at 14:51 UTC a367cdbca0824c8c428fe15b4552fbc4b824d718
7 files changed +638 -21
docs/contributing/profiling.md
+64
@@ -84,6 +84,70 @@ curl -X POST http://localhost:8000/v1/chat/completions \
84 $ curl -X POST http://localhost:8000/stop_profile
85 ```
86
87 +## Profile with Triton Proton
88 +
89 +[Proton](https://github.com/triton-lang/triton/tree/main/third_party/proton)
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.
94 +
95 +Start a server with a local output directory:
96 +
97 +```bash
98 +vllm serve meta-llama/Llama-3.1-8B-Instruct \
99 + --enforce-eager \
100 + --profiler-config '{
101 + "profiler": "proton",
102 + "proton_profiler_dir": "./proton_profile",
103 + "proton_output_format": "hatchet",
104 + "proton_hook": "triton"
105 + }'
106 +```
107 +
108 +Then use `/start_profile` and `/stop_profile` as shown above, or pass
109 +`--profile` to a vLLM benchmark. Each worker uses a topology- and
110 +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.
115 +
116 +The Proton-specific options are:
117 +
118 +- `proton_context`: `shadow` (default) or `python`
119 +- `proton_data`: `tree` (default) or `trace`
120 +- `proton_backend`: `cupti` or automatic
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 +
125 +`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 +
129 +Automatic backend selection is recommended. vLLM currently supports Proton's
130 +`cupti` backend on NVIDIA GPUs. ROCm support is not yet available. vLLM does not
131 +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.
139 +
140 +Inspect tree profiles with:
141 +
142 +```bash
143 +proton-viewer -m time/ns \
144 + proton_profile/proton_dp0_pp0_tp0_dcp0_ep0_rank0_pid1234_0123456789abcdef0123456789abcdef_run0.hatchet
145 +```
146 +
147 +Chrome traces (`proton_data: "trace"`) can be opened in
148 +<https://ui.perfetto.dev/>. Proton is imported lazily, so selecting another
149 +profiler does not require a Proton-capable Triton installation.
150 +
151 ## Profile with NVIDIA Nsight Systems
152
153 Nsight systems is an advanced tool that exposes more profiling details, such as register and shared memory usage, annotated code regions and low-level CUDA APIs and events.
tests/v1/worker/test_gpu_profiler.py
+325 -4
@@ -1,12 +1,23 @@
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 -from unittest.mock import MagicMock
3 +import os
4 +from contextlib import nullcontext
5 +from types import SimpleNamespace
6 +from unittest.mock import MagicMock, Mock, call, patch
7 +from uuid import UUID
8
9 import pytest
6 -
7 -from vllm.config import CUDAGraphMode, ProfilerConfig
10 +from pydantic import ValidationError
11 +
12 +from vllm.config import (
13 + CompilationConfig,
14 + CUDAGraphMode,
15 + ProfilerConfig,
16 + VllmConfig,
17 +)
18 from vllm.config.profiler import _is_uri_path
9 -from vllm.profiler.wrapper import WorkerProfiler
19 +from vllm.platforms import current_platform
20 +from vllm.profiler.wrapper import ProtonProfilerWrapper, WorkerProfiler
21 from vllm.v1.core.sched.output import CachedRequestData
22 from vllm.v1.worker.gpu_model_runner import GPUModelRunner
23 from vllm.v1.worker.gpu_worker import Worker
@@ -281,6 +292,16 @@ class TestAnnotateProfile:
292 "execute_5_context_1(sq4sk4sqsq16sqsk16)_generation_1(sq1sk11sqsq1sqsk11)"
293 )
294
295 + def test_skips_annotation_work_after_profiler_stops(self):
296 + worker = MagicMock()
297 + worker.profiler.is_running = False
298 +
299 + context = Worker.annotate_profile(worker, scheduler_output=None)
300 +
301 + worker.profiler.step.assert_called_once_with()
302 + worker.profiler.annotate_context_manager.assert_not_called()
303 + assert isinstance(context, nullcontext)
304 +
305
306 def test_profiler_entered_during_capture():
307 """Profiler is used as a context manager in _warmup_and_capture,
@@ -298,3 +319,303 @@ def test_profiler_entered_during_capture():
319
320 mock_profiler.__enter__.assert_called_once()
321 mock_profiler.__exit__.assert_called_once()
322 +
323 +
324 +def make_proton(session_id: int | None = 7):
325 + return SimpleNamespace(
326 + start=Mock(return_value=session_id),
327 + activate=Mock(),
328 + deactivate=Mock(),
329 + finalize=Mock(),
330 + scope=Mock(return_value=nullcontext()),
331 + )
332 +
333 +
334 +def make_proton_wrapper(
335 + tmp_path, proton=None, triton_version="3.6.0", **config_overrides
336 +):
337 + proton = proton or make_proton()
338 + config = ProfilerConfig(
339 + profiler="proton",
340 + proton_profiler_dir=str(tmp_path),
341 + **config_overrides,
342 + )
343 +
344 + def import_module(name):
345 + if name == "triton.profiler":
346 + return proton
347 + assert name == "triton"
348 + return SimpleNamespace(__version__=triton_version)
349 +
350 + with patch(
351 + "vllm.profiler.wrapper.importlib.import_module", side_effect=import_module
352 + ):
353 + wrapper = ProtonProfilerWrapper(config, worker_name="rank_3")
354 + return wrapper, proton
355 +
356 +
357 +_requires_cuda_for_proton = pytest.mark.skipif(
358 + not current_platform.is_cuda(),
359 + reason="Proton profiling tests require an NVIDIA CUDA platform.",
360 +)
361 +
362 +
363 +@_requires_cuda_for_proton
364 +class TestProtonConfig:
365 + def test_normalizes_local_output_directory(self, tmp_path, monkeypatch):
366 + monkeypatch.chdir(tmp_path)
367 + config = ProfilerConfig(profiler="proton", proton_profiler_dir="profiles")
368 + assert config.proton_profiler_dir == os.path.join(tmp_path, "profiles")
369 +
370 + @pytest.mark.parametrize(
371 + ("options", "message"),
372 + [
373 + ({"proton_profiler_dir": ""}, "must be set"),
374 + ({"proton_profiler_dir": "s3://bucket/profiles"}, "local directory"),
375 + (
376 + {"proton_data": "tree", "proton_output_format": "chrome_trace"},
377 + "requires proton_data",
378 + ),
379 + (
380 + {"proton_data": "trace", "proton_output_format": "hatchet"},
381 + "requires proton_data",
382 + ),
383 + (
384 + {
385 + "proton_data": "trace",
386 + "proton_output_format": "hatchet_msgpack",
387 + },
388 + "requires proton_data",
389 + ),
390 + ],
391 + )
392 + def test_rejects_invalid_option_combinations(self, tmp_path, options, message):
393 + kwargs = {"proton_profiler_dir": str(tmp_path), **options}
394 + with pytest.raises(ValueError, match=message):
395 + ProfilerConfig(profiler="proton", **kwargs)
396 +
397 + @pytest.mark.parametrize(
398 + "field",
399 + [
400 + "proton_context",
401 + "proton_data",
402 + "proton_backend",
403 + "proton_hook",
404 + "proton_output_format",
405 + ],
406 + )
407 + def test_rejects_invalid_typed_options(self, field, tmp_path):
408 + with pytest.raises(ValidationError):
409 + ProfilerConfig(
410 + profiler="proton",
411 + proton_profiler_dir=str(tmp_path),
412 + **{field: "invalid"},
413 + )
414 +
415 + @pytest.mark.parametrize(
416 + ("field", "value"),
417 + [
418 + ("proton_profiler_dir", "profiles"),
419 + ("proton_context", "python"),
420 + ("proton_data", "trace"),
421 + ("proton_backend", "cupti"),
422 + ("proton_mode", "pcsampling"),
423 + ("proton_hook", "triton"),
424 + ("proton_output_format", "chrome_trace"),
425 + ],
426 + )
427 + def test_rejects_proton_options_for_other_profilers(self, field, value):
428 + with pytest.raises(ValueError, match=f"{field} only applicable"):
429 + ProfilerConfig(**{field: value})
430 +
431 + def test_allows_proton_when_cuda_graphs_are_disabled(self, tmp_path):
432 + config = VllmConfig(
433 + profiler_config=ProfilerConfig(
434 + profiler="proton", proton_profiler_dir=str(tmp_path)
435 + ),
436 + compilation_config=CompilationConfig(cudagraph_mode=CUDAGraphMode.NONE),
437 + )
438 +
439 + assert config.compilation_config.cudagraph_mode == CUDAGraphMode.NONE
440 +
441 + def test_rejects_proton_on_non_cuda_platforms(self, tmp_path):
442 + with (
443 + patch("vllm.platforms.current_platform.is_cuda", return_value=False),
444 + pytest.raises(ValueError, match="supports NVIDIA CUDA only"),
445 + ):
446 + VllmConfig(
447 + profiler_config=ProfilerConfig(
448 + profiler="proton", proton_profiler_dir=str(tmp_path)
449 + ),
450 + compilation_config=CompilationConfig(cudagraph_mode=CUDAGraphMode.NONE),
451 + )
452 +
453 + def test_rejects_proton_when_cuda_graphs_are_enabled(self, tmp_path):
454 + with pytest.raises(ValueError, match="requires CUDA graphs to be disabled"):
455 + VllmConfig(
456 + profiler_config=ProfilerConfig(
457 + profiler="proton", proton_profiler_dir=str(tmp_path)
458 + ),
459 + compilation_config=CompilationConfig(
460 + cudagraph_mode=CUDAGraphMode.PIECEWISE
461 + ),
462 + )
463 +
464 +
465 +@_requires_cuda_for_proton
466 +class TestProtonProfilerWrapper:
467 + def test_passes_config_and_global_rank_name_to_proton(self, tmp_path):
468 + wrapper, proton = make_proton_wrapper(
469 + tmp_path,
470 + proton_context="python",
471 + proton_data="trace",
472 + proton_backend="cupti",
473 + proton_mode="pcsampling",
474 + proton_hook="triton",
475 + proton_output_format="chrome_trace",
476 + )
477 +
478 + wrapper.start()
479 +
480 + start_args = proton.start.call_args.kwargs
481 + assert start_args["name"].startswith(os.path.join(tmp_path, "proton_rank_3_"))
482 + assert start_args["name"].endswith("_run0")
483 + assert start_args | {"name": None} == {
484 + "name": None,
485 + "context": "python",
486 + "data": "trace",
487 + "backend": "cupti",
488 + "mode": "pcsampling",
489 + "hook": "triton",
490 + }
491 + wrapper.stop()
492 + proton.finalize.assert_called_once_with(session=7, output_format="chrome_trace")
493 + assert tmp_path.is_dir()
494 +
495 + def test_finalizes_each_profile_with_unique_output_names(self, tmp_path):
496 + proton = make_proton()
497 + proton.start.side_effect = [7, 8]
498 + wrapper, proton = make_proton_wrapper(tmp_path, proton)
499 +
500 + wrapper.start()
501 + wrapper.start()
502 + wrapper.stop()
503 + wrapper.start()
504 + wrapper.stop()
505 +
506 + names = [c.kwargs["name"] for c in proton.start.call_args_list]
507 + assert len(names) == len(set(names)) == 2
508 + assert names[0].endswith("_run0")
509 + assert names[1].endswith("_run1")
510 + assert proton.deactivate.call_count == 2
511 + assert proton.finalize.call_args_list == [call(session=7), call(session=8)]
512 +
513 + def test_output_names_are_unique_across_worker_restarts(self, tmp_path):
514 + with patch(
515 + "vllm.profiler.wrapper.uuid4",
516 + side_effect=[UUID(int=1), UUID(int=2)],
517 + ):
518 + first, first_proton = make_proton_wrapper(tmp_path)
519 + second, second_proton = make_proton_wrapper(tmp_path)
520 +
521 + first.start()
522 + second.start()
523 +
524 + first_name = first_proton.start.call_args.kwargs["name"]
525 + second_name = second_proton.start.call_args.kwargs["name"]
526 + assert first_name != second_name
527 + assert first_name.endswith(f"_{UUID(int=1).hex}_run0")
528 + assert second_name.endswith(f"_{UUID(int=2).hex}_run0")
529 +
530 + @pytest.mark.parametrize(
531 + ("option", "value", "feature"),
532 + [
533 + ("proton_output_format", "hatchet_msgpack", "hatchet_msgpack"),
534 + ("proton_mode", "periodic_flushing", "periodic flushing"),
535 + ],
536 + )
537 + def test_newer_features_reject_triton_3_6(self, tmp_path, option, value, feature):
538 + with pytest.raises(RuntimeError, match=feature):
539 + make_proton_wrapper(tmp_path, **{option: value})
540 +
541 + @pytest.mark.parametrize(
542 + ("option", "value"),
543 + [
544 + ("proton_output_format", "hatchet_msgpack"),
545 + ("proton_mode", "periodic_flushing"),
546 + ],
547 + )
548 + def test_triton_3_7_features(self, tmp_path, option, value):
549 + make_proton_wrapper(tmp_path, triton_version="3.7.0", **{option: value})
550 +
551 + def test_rejects_output_format_when_finalize_lacks_capability(self, tmp_path):
552 + proton = make_proton()
553 + proton.finalize = lambda session=None: None
554 +
555 + with pytest.raises(RuntimeError, match="does not support selecting"):
556 + make_proton_wrapper(tmp_path, proton, proton_output_format="hatchet")
557 +
558 + def test_missing_proton_has_actionable_error(self, tmp_path):
559 + config = ProfilerConfig(profiler="proton", proton_profiler_dir=str(tmp_path))
560 + with (
561 + patch(
562 + "vllm.profiler.wrapper.importlib.import_module",
563 + side_effect=ImportError,
564 + ),
565 + pytest.raises(RuntimeError, match="requires a Triton installation"),
566 + ):
567 + ProtonProfilerWrapper(config, worker_name="rank_0")
568 +
569 + def test_scope_annotations_delegate_to_proton(self, tmp_path):
570 + wrapper, proton = make_proton_wrapper(tmp_path)
571 + wrapper.start()
572 +
573 + context = wrapper.annotate_context_manager("decode")
574 +
575 + proton.scope.assert_called_once_with("decode")
576 + assert context is not None
577 +
578 +
579 +@_requires_cuda_for_proton
580 +def test_gpu_worker_creates_proton_profiler():
581 + worker = MagicMock()
582 + worker.rank = 1
583 + worker.profiler = None
584 + worker.profiler_config.profiler = "proton"
585 +
586 + with (
587 + patch(
588 + "vllm.distributed.utils.get_worker_rank_suffix",
589 + return_value="rank1",
590 + ),
591 + patch("vllm.v1.worker.gpu_worker.ProtonProfilerWrapper") as wrapper,
592 + ):
593 + Worker.profile(worker)
594 +
595 + wrapper.assert_called_once_with(worker.profiler_config, worker_name="rank1")
596 + worker.profiler.start.assert_called_once_with()
597 +
598 +
599 +@_requires_cuda_for_proton
600 +def test_gpu_worker_recreates_proton_profiler_for_each_run():
601 + worker = MagicMock()
602 + worker.rank = 1
603 + worker.profiler = None
604 + worker.profiler_config.profiler = "proton"
605 +
606 + with (
607 + patch(
608 + "vllm.distributed.utils.get_worker_rank_suffix",
609 + return_value="rank1",
610 + ),
611 + patch("vllm.v1.worker.gpu_worker.ProtonProfilerWrapper") as wrapper,
612 + ):
613 + Worker.profile(worker, profile_prefix="first")
614 + Worker.profile(worker, is_start=False)
615 + Worker.profile(worker, profile_prefix="second")
616 +
617 + assert wrapper.call_args_list == [
618 + call(worker.profiler_config, worker_name="first_rank1"),
619 + call(worker.profiler_config, worker_name="second_rank1"),
620 + ]
621 + assert wrapper.return_value.start.call_count == 2
vllm/benchmarks/latency.py
+5
@@ -147,6 +147,11 @@ def main(args: argparse.Namespace):
147 )
148 elif profiler_config.profiler == "cuda":
149 print("Profiling with cuda profiler ...")
150 + elif profiler_config.profiler == "proton":
151 + print(
152 + "Profiling with Proton (results will be saved under"
153 + f" {profiler_config.proton_profiler_dir})..."
154 + )
155 run_to_completion(do_profile=True)
156 return
157
vllm/config/profiler.py
+80 -7
@@ -13,7 +13,12 @@ from vllm.utils.hashing import safe_hash
13
14 logger = init_logger(__name__)
15
16 -ProfilerKind = Literal["torch", "cuda"]
16 +ProfilerKind = Literal["torch", "cuda", "proton"]
17 +ProtonBackend = Literal["cupti"]
18 +ProtonContext = Literal["shadow", "python"]
19 +ProtonData = Literal["tree", "trace"]
20 +ProtonHook = Literal["triton"]
21 +ProtonOutputFormat = Literal["hatchet", "hatchet_msgpack", "chrome_trace"]
22
23
24 def _is_uri_path(path: str) -> bool:
@@ -38,13 +43,39 @@ class ProfilerConfig:
43 """Which profiler to use. Defaults to None. Options are:
44
45 - 'torch': Use PyTorch profiler.
41 - - 'cuda': Use CUDA profiler."""
46 + - 'cuda': Use CUDA profiler.
47 + - 'proton': Use Triton Proton profiler."""
48
49 torch_profiler_dir: str = ""
50 """Directory to save torch profiler traces. Both AsyncLLM's CPU traces and
51 worker's traces (CPU & GPU) will be saved under this directory. Note that
52 it must be an absolute path."""
53
54 + proton_profiler_dir: str = ""
55 + """Directory to save Triton Proton profiles. Each worker writes a
56 + separate rank-qualified file."""
57 +
58 + proton_context: ProtonContext = "shadow"
59 + """Proton context source. ``shadow`` records explicit scopes with low
60 + overhead; ``python`` records Python call stacks."""
61 +
62 + proton_data: ProtonData = "tree"
63 + """Proton output type. ``tree`` produces Hatchet data and ``trace``
64 + produces a Chrome trace."""
65 +
66 + proton_backend: ProtonBackend | None = None
67 + """Proton GPU backend. ``None`` lets Proton select CUPTI automatically."""
68 +
69 + proton_mode: str | None = None
70 + """Optional backend-specific Proton mode string, such as ``pcsampling``."""
71 +
72 + proton_hook: ProtonHook | None = None
73 + """Optional Proton hook. Use ``triton`` to add Triton launch metadata."""
74 +
75 + proton_output_format: ProtonOutputFormat | None = None
76 + """Optional format passed to Proton when finalizing a profile. ``None``
77 + uses the default format for ``proton_data``."""
78 +
79 torch_profiler_with_stack: bool = True
80 """If `True`, enables stack tracing in the torch profiler. Enabled by default
81 as it is useful for debugging. Can be disabled via
@@ -142,18 +173,60 @@ class ProfilerConfig:
173 "while ignore_frontend is False may result in high overhead."
174 )
175
145 - profiler_dir = self.torch_profiler_dir
146 - if profiler_dir and self.profiler != "torch":
176 + torch_profiler_dir = self.torch_profiler_dir
177 + if torch_profiler_dir and self.profiler != "torch":
178 raise ValueError(
179 "torch_profiler_dir is only applicable when profiler is set to 'torch'"
180 )
150 - if self.profiler == "torch" and not profiler_dir:
181 + if self.profiler == "torch" and not torch_profiler_dir:
182 raise ValueError("torch_profiler_dir must be set when profiler is 'torch'")
183
184 # Support any URI scheme (gs://, s3://, hdfs://, etc.)
185 # These paths should not be converted to absolute paths
155 - if profiler_dir and not _is_uri_path(profiler_dir):
156 - self.torch_profiler_dir = os.path.abspath(os.path.expanduser(profiler_dir))
186 + if torch_profiler_dir and not _is_uri_path(torch_profiler_dir):
187 + self.torch_profiler_dir = os.path.abspath(
188 + os.path.expanduser(torch_profiler_dir)
189 + )
190 +
191 + proton_profiler_dir = self.proton_profiler_dir
192 + non_default_proton_options = [
193 + name
194 + for name, value, default in (
195 + ("proton_profiler_dir", proton_profiler_dir, ""),
196 + ("proton_context", self.proton_context, "shadow"),
197 + ("proton_data", self.proton_data, "tree"),
198 + ("proton_backend", self.proton_backend, None),
199 + ("proton_mode", self.proton_mode, None),
200 + ("proton_hook", self.proton_hook, None),
201 + ("proton_output_format", self.proton_output_format, None),
202 + )
203 + if value != default
204 + ]
205 + if self.profiler != "proton" and non_default_proton_options:
206 + options = ", ".join(non_default_proton_options)
207 + raise ValueError(
208 + f"{options} only applicable when profiler is set to 'proton'"
209 + )
210 + if self.profiler == "proton" and not proton_profiler_dir:
211 + raise ValueError(
212 + "proton_profiler_dir must be set when profiler is 'proton'"
213 + )
214 + if proton_profiler_dir:
215 + if _is_uri_path(proton_profiler_dir):
216 + raise ValueError("proton_profiler_dir must be a local directory")
217 + self.proton_profiler_dir = os.path.abspath(
218 + os.path.expanduser(proton_profiler_dir)
219 + )
220 +
221 + if self.profiler == "proton":
222 + output_format = self.proton_output_format
223 + if output_format == "chrome_trace" and self.proton_data != "trace":
224 + raise ValueError("chrome_trace output requires proton_data='trace'")
225 + if (
226 + output_format in ("hatchet", "hatchet_msgpack")
227 + and self.proton_data != "tree"
228 + ):
229 + raise ValueError(f"{output_format} output requires proton_data='tree'")
230
231 if self.capture_torch_profiler and self.profiler != "torch":
232 raise ValueError(
vllm/config/vllm.py
+12
@@ -1269,6 +1269,18 @@ class VllmConfig:
1269 self.compilation_config.mode = CompilationMode.NONE
1270 self.compilation_config.cudagraph_mode = CUDAGraphMode.NONE
1271
1272 + if self.profiler_config.profiler == "proton":
1273 + if not current_platform.is_cuda():
1274 + raise ValueError(
1275 + "The Proton profiler currently supports NVIDIA CUDA only"
1276 + )
1277 + if self.compilation_config.cudagraph_mode != CUDAGraphMode.NONE:
1278 + raise ValueError(
1279 + "The Proton profiler requires CUDA graphs to be disabled. "
1280 + "Use --enforce-eager or set "
1281 + "--compilation-config.cudagraph_mode=none."
1282 + )
1283 +
1284 if os.environ.get("TORCH_COMPILE_DISABLE") == "1":
1285 logger.warning_once(
1286 "TORCH_COMPILE_DISABLE is set, disabling torch.compile. "
vllm/profiler/wrapper.py
+129
@@ -1,13 +1,18 @@
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 +import importlib
5 +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
11 from typing import Literal
12 +from uuid import uuid4
13
14 import torch
15 +from packaging.version import InvalidVersion, Version
16 from typing_extensions import override
17
18 import vllm.version
@@ -17,6 +22,8 @@ from vllm.logger import init_logger
22
23 logger = init_logger(__name__)
24
25 +_TRITON_PROTON_3_7_VERSION = Version("3.7.0")
26 +
27
28 class WorkerProfiler(ABC):
29 def __init__(self, profiler_config: ProfilerConfig) -> None:
@@ -43,6 +50,11 @@ class WorkerProfiler(ABC):
50 self._profiling_for_iters = 0
51 self._running = False
52
53 + @property
54 + def is_running(self) -> bool:
55 + """Whether the underlying profiler is currently collecting data."""
56 + return self._running
57 +
58 @abstractmethod
59 def _start(self) -> None:
60 """Start the profiler."""
@@ -340,6 +352,123 @@ class TorchProfilerWrapper(WorkerProfiler):
352 return torch.profiler.record_function(name)
353
354
355 +class ProtonProfilerWrapper(WorkerProfiler):
356 + """Worker profiler backed by :mod:`triton.profiler` (Proton)."""
357 +
358 + def __init__(
359 + self,
360 + profiler_config: ProfilerConfig,
361 + worker_name: str,
362 + ) -> None:
363 + super().__init__(profiler_config)
364 +
365 + try:
366 + self._proton = importlib.import_module("triton.profiler")
367 + triton = importlib.import_module("triton")
368 + except ImportError as exc:
369 + raise RuntimeError(
370 + "The Proton profiler requires a Triton installation with "
371 + "triton.profiler support."
372 + ) from exc
373 +
374 + self._output_dir = profiler_config.proton_profiler_dir
375 + self._output_path = os.path.join(self._output_dir, f"proton_{worker_name}")
376 + self._context = profiler_config.proton_context
377 + self._data = profiler_config.proton_data
378 + self._backend = profiler_config.proton_backend
379 + self._mode = profiler_config.proton_mode
380 + self._hook = profiler_config.proton_hook
381 + self._output_format = profiler_config.proton_output_format
382 + self._triton_version_string = getattr(triton, "__version__", "unknown")
383 + try:
384 + self._triton_version = Version(self._triton_version_string)
385 + except InvalidVersion:
386 + self._triton_version = None
387 + self._validate_capabilities()
388 + self._session_id: int | None = None
389 + # Qualify output names by process and wrapper instance so a new
390 + # worker cannot overwrite profiles left by an earlier server process.
391 + self._instance_id = f"pid{os.getpid()}_{uuid4().hex}"
392 + self._run_id = 0
393 +
394 + logger.info_once(
395 + "Proton profiling enabled. Output will be saved under: %s",
396 + self._output_dir,
397 + )
398 +
399 + def _require_triton_version(self, feature: str, minimum: Version) -> None:
400 + if self._triton_version is None or self._triton_version < minimum:
401 + raise RuntimeError(
402 + f"Proton {feature} requires Triton >= {minimum}; found "
403 + f"{self._triton_version_string}."
404 + )
405 +
406 + def _validate_capabilities(self) -> None:
407 + if self._output_format is not None:
408 + parameters = inspect.signature(self._proton.finalize).parameters
409 + supports_output_format = "output_format" in parameters or any(
410 + parameter.kind == inspect.Parameter.VAR_KEYWORD
411 + for parameter in parameters.values()
412 + )
413 + if not supports_output_format:
414 + raise RuntimeError(
415 + "The installed Triton Proton does not support selecting "
416 + "an output format during finalize."
417 + )
418 +
419 + if self._output_format == "hatchet_msgpack":
420 + self._require_triton_version(
421 + "hatchet_msgpack output", _TRITON_PROTON_3_7_VERSION
422 + )
423 + if self._mode and self._mode.split(":", 1)[0] == "periodic_flushing":
424 + self._require_triton_version(
425 + "periodic flushing", _TRITON_PROTON_3_7_VERSION
426 + )
427 +
428 + def _create_session(self, output_path: str) -> int:
429 + os.makedirs(self._output_dir, exist_ok=True)
430 + session_id = self._proton.start(
431 + name=output_path,
432 + context=self._context,
433 + data=self._data,
434 + backend=self._backend,
435 + mode=self._mode,
436 + hook=self._hook,
437 + )
438 + if session_id is None:
439 + raise RuntimeError("Proton did not create a profiling session")
440 + return session_id
441 +
442 + @override
443 + def _start(self) -> None:
444 + output_path = f"{self._output_path}_{self._instance_id}_run{self._run_id}"
445 + self._session_id = self._create_session(output_path)
446 + self._run_id += 1
447 +
448 + @override
449 + def _stop(self) -> None:
450 + assert self._session_id is not None
451 + session_id = self._session_id
452 + try:
453 + self._proton.deactivate(session=session_id)
454 + finally:
455 + try:
456 + if self._output_format is None:
457 + self._proton.finalize(session=session_id)
458 + else:
459 + self._proton.finalize(
460 + session=session_id, output_format=self._output_format
461 + )
462 + finally:
463 + self._session_id = None
464 +
465 + @override
466 + def annotate_context_manager(self, name: str):
467 + if not self._running:
468 + return nullcontext()
469 + return self._proton.scope(name)
470 +
471 +
472 class CudaProfilerWrapper(WorkerProfiler):
473 def __init__(self, profiler_config: ProfilerConfig) -> None:
474 super().__init__(profiler_config)
vllm/v1/worker/gpu_worker.py
+23 -10
@@ -55,7 +55,11 @@ from vllm.lora.request import LoRARequest
55 from vllm.model_executor.warmup.kernel_warmup import kernel_warmup
56 from vllm.multimodal.gpu_ipc_memory import reserve_mm_ipc_gpu_memory
57 from vllm.platforms import current_platform
58 -from vllm.profiler.wrapper import CudaProfilerWrapper, TorchProfilerWrapper
58 +from vllm.profiler.wrapper import (
59 + CudaProfilerWrapper,
60 + ProtonProfilerWrapper,
61 + TorchProfilerWrapper,
62 +)
63 from vllm.sequence import IntermediateTensors
64 from vllm.tasks import SupportedTask
65 from vllm.tracing import instrument
@@ -162,16 +166,12 @@ class Worker(WorkerBase):
166 self._weight_update_active = False
167 self._weight_update_is_draft = False
168
165 - # Torch/CUDA profiler. Enabled and configured through profiler_config.
169 + # Worker profiler. Enabled and configured through profiler_config.
170 # Profiler wrapper is created lazily in profile() when start is called,
171 # so we have all the information needed for proper trace naming.
172 self.profiler: Any | None = None
173 self.profiler_config = vllm_config.profiler_config
174
171 - # Only validate profiler config is valid, don't instantiate yet
172 - if self.profiler_config.profiler not in ("torch", "cuda", None):
173 - raise ValueError(f"Unknown profiler type: {self.profiler_config.profiler}")
174 -
175 self.use_v2_model_runner = vllm_config.use_v2_model_runner
176 # pending non-blocking PP send work from the previous iteration
177 self._pp_send_work: list[Handle] = []
@@ -909,6 +909,8 @@ class Worker(WorkerBase):
909 return nullcontext()
910
911 self.profiler.step()
912 + if not self.profiler.is_running:
913 + return nullcontext()
914
915 iteration_details = compute_iteration_details(scheduler_output)
916
@@ -1125,6 +1127,7 @@ class Worker(WorkerBase):
1127 )
1128
1129 if is_start:
1130 + profiler_type = self.profiler_config.profiler
1131 # Generate the trace name by combining prefix with comprehensive rank suffix
1132 from vllm.distributed.utils import get_worker_rank_suffix
1133
@@ -1138,7 +1141,6 @@ class Worker(WorkerBase):
1141
1142 # Create the profiler wrapper only on the first start call
1143 if self.profiler is None:
1141 - profiler_type = self.profiler_config.profiler
1144 if profiler_type == "torch":
1145 self.profiler = TorchProfilerWrapper(
1146 self.profiler_config,
@@ -1152,20 +1154,31 @@ class Worker(WorkerBase):
1154 elif profiler_type == "cuda":
1155 self.profiler = CudaProfilerWrapper(self.profiler_config)
1156 logger.debug("Starting CUDA profiler")
1157 + elif profiler_type == "proton":
1158 + self.profiler = ProtonProfilerWrapper(
1159 + self.profiler_config, worker_name=trace_name
1160 + )
1161 + logger.debug(
1162 + "Starting Proton profiler with trace name: %s", trace_name
1163 + )
1164 else:
1165 # Config validation should prevent this code being reached
1166 raise ValueError(
1167 f"Invalid profiler value of {self.profiler_config.profiler}"
1168 )
1169
1161 - # If profiler already initialized, restart profiling but keep
1162 - # the original trace name from the first initialization.
1170 self.profiler.start()
1171 else:
1172 if self.profiler is None:
1173 logger.warning("Profiler was not started, nothing to stop.")
1174 return
1168 - self.profiler.stop()
1175 + try:
1176 + self.profiler.stop()
1177 + finally:
1178 + if self.profiler_config.profiler == "proton":
1179 + # Proton output names are fixed when the wrapper is constructed.
1180 + # Recreate it so the next profile_prefix is honored.
1181 + self.profiler = None
1182
1183 def execute_dummy_batch(self) -> None:
1184 num_tokens = getattr(self.model_runner, "uniform_decode_query_len", 1)