main
py 722 lines 26.7 KB
Raw
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, Iterator, Sequence
10 from contextlib import AbstractContextManager, contextmanager, nullcontext, suppress
11 from glob import glob
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
19 from vllm.config import ProfilerConfig
20 from vllm.config.profiler import ProfilerKind, TorchProfilerActivity, _is_uri_path
21 from vllm.logger import init_logger
22 from vllm.platforms import current_platform
23
24 logger = init_logger(__name__)
25
26 _TRITON_PROTON_3_7_VERSION = Version("3.7.0")
27
28
29 class WorkerProfiler(ABC):
30 def __init__(self, profiler_config: ProfilerConfig) -> None:
31 self._delay_iters = profiler_config.delay_iterations
32 if self._delay_iters > 0:
33 logger.info_once(
34 "GPU profiling will start "
35 f"{self._delay_iters} steps after start_profile."
36 )
37
38 self._max_iters = profiler_config.max_iterations
39 if self._max_iters > 0:
40 logger.info_once(
41 "GPU profiling will stop "
42 f"after {self._max_iters} worker steps, "
43 "or when stop_profile is received."
44 )
45
46 # Track when the profiler gets triggered by start_profile
47 self._active_iteration_count = 0
48 self._active = False
49
50 # Track when the profiler is actually running
51 self._profiling_for_iters = 0
52 self._running = False
53
54 @property
55 def is_running(self) -> bool:
56 """Whether the underlying profiler is currently collecting data."""
57 return self._running
58
59 @property
60 def should_annotate(self) -> bool:
61 """Whether worker iterations should receive profiler annotations."""
62 return self._running
63
64 @abstractmethod
65 def _start(self) -> None:
66 """Start the profiler."""
67 pass
68
69 @abstractmethod
70 def _stop(self) -> None:
71 """Stop the profiler."""
72 pass
73
74 def _call_start(self) -> None:
75 """Call _start with error handling but no safeguards."""
76 try:
77 self._start()
78 self._running = True # Only mark as running if start succeeds
79 except Exception as e:
80 logger.warning("Failed to start profiler: %s", e)
81
82 def _call_stop(self) -> None:
83 """Call _stop with error handling but no safeguards."""
84 try:
85 self._stop()
86 logger.info_once("Profiler stopped successfully.")
87 except Exception as e:
88 logger.warning("Failed to stop profiler: %s", e)
89 self._running = False # Always mark as not running, assume stop worked
90
91 def start(self) -> None:
92 """Attempt to start the profiler, accounting for delayed starts."""
93 if self._active:
94 logger.debug(
95 "start_profile received when profiler is already active. "
96 "Ignoring request."
97 )
98 return
99 self._active = True
100 if self._delay_iters == 0:
101 self._call_start()
102
103 def step(self) -> None:
104 """Update the profiler state at each worker step,
105 to handle delayed starts and max iteration limits."""
106 if not self._active:
107 return
108
109 self._active_iteration_count += 1
110
111 if (
112 not self._running
113 and self._delay_iters > 0
114 and self._active_iteration_count == self._delay_iters
115 ):
116 logger.info_once("Starting profiler after delay...")
117 self._call_start()
118
119 # Call profiler step for schedule-based profiling
120 # Only count iterations where data is actually recorded (not warmup)
121 if self._running and self._profiler_step():
122 self._profiling_for_iters += 1
123
124 if (
125 self._max_iters > 0
126 and self._running
127 and self._profiling_for_iters > self._max_iters
128 ):
129 # Automatically stop the profiler after max iters. Go through the
130 # public stop() (not _call_stop() directly) so _active and the
131 # iteration counters reset too -- otherwise a later start_profile
132 # is silently ignored forever, since start() bails out early
133 # whenever _active is still True.
134 logger.info_once("Max profiling iterations reached. Stopping profiler...")
135 self.stop()
136 return
137
138 def _profiler_step(self) -> bool:
139 """Called each step when profiler is running.
140 Override in subclasses to handle schedule-based profiling.
141
142 Returns:
143 True if the step was an active profiling step (data recorded),
144 False if the step was a warmup step (data discarded).
145
146 """
147 return True
148
149 def stop(self) -> None:
150 """Attempt to stop the profiler, accounting for overlapped calls."""
151 if not self._active:
152 logger.debug(
153 "stop_profile received when profiler is not active. Ignoring request."
154 )
155 return
156 self._active = False
157 self._active_iteration_count = 0
158 self._profiling_for_iters = 0
159
160 if self._running:
161 self._call_stop()
162
163 def shutdown(self) -> None:
164 """Ensure profiler is stopped when shutting down."""
165 logger.info_once("Shutting down profiler")
166 if self._running:
167 self.stop()
168
169 @property
170 def has_cuda_graph_session(self) -> bool:
171 """Whether a capture-time session is retained to attribute replays."""
172 return False
173
174 def capture_cuda_graphs(self) -> AbstractContextManager[None]:
175 """Observe graph creation for backends that attribute replay activity."""
176 return nullcontext()
177
178 def annotate_context_manager(self, name: str):
179 """Return a context manager to annotate profiler traces."""
180 return nullcontext()
181
182
183 TorchProfilerActivityMap = {
184 "CPU": torch.profiler.ProfilerActivity.CPU,
185 "CUDA": torch.profiler.ProfilerActivity.CUDA,
186 "PrivateUse1": torch.profiler.ProfilerActivity.PrivateUse1,
187 "XPU": torch.profiler.ProfilerActivity.XPU,
188 }
189
190
191 class TorchProfilerWrapper(WorkerProfiler):
192 def __init__(
193 self,
194 profiler_config: ProfilerConfig,
195 worker_name: str,
196 local_rank: int,
197 activities: Sequence[TorchProfilerActivity],
198 on_trace_ready: Callable[[torch.profiler.profile], None] | None = None,
199 ) -> None:
200 super().__init__(profiler_config)
201
202 self.local_rank = local_rank
203 self.profiler_config = profiler_config
204 torch_profiler_trace_dir = profiler_config.torch_profiler_dir
205 if local_rank in (None, 0):
206 logger.info_once(
207 "Torch profiling enabled. Traces will be saved to: %s",
208 torch_profiler_trace_dir,
209 )
210 logger.debug(
211 "Profiler config: record_shapes=%s,"
212 "profile_memory=%s,with_stack=%s,with_flops=%s",
213 profiler_config.torch_profiler_record_shapes,
214 profiler_config.torch_profiler_with_memory,
215 profiler_config.torch_profiler_with_stack,
216 profiler_config.torch_profiler_with_flops,
217 )
218
219 # Determine trace handler: use custom handler if provided,
220 # otherwise default to tensorboard trace handler
221 if on_trace_ready is not None:
222 trace_handler = on_trace_ready
223 else:
224 trace_handler = torch.profiler.tensorboard_trace_handler(
225 torch_profiler_trace_dir,
226 worker_name=worker_name,
227 use_gzip=profiler_config.torch_profiler_use_gzip,
228 )
229
230 self._records_cpu_activity = "CPU" in activities
231 self.dump_device_time_total = (
232 any(activity != "CPU" for activity in activities)
233 and profiler_config.torch_profiler_dump_cuda_time_total
234 )
235 self.dump_cpu_time_total = self._records_cpu_activity and len(activities) == 1
236
237 # Create profiler schedule if warmup or wait iterations are configured
238 profiler_schedule = None
239 if profiler_config.warmup_iterations > 0 or profiler_config.wait_iterations > 0:
240 profiler_schedule = torch.profiler.schedule(
241 skip_first=0,
242 wait=profiler_config.wait_iterations,
243 warmup=profiler_config.warmup_iterations,
244 active=profiler_config.active_iterations,
245 repeat=1,
246 )
247 if local_rank in (None, 0):
248 logger.info_once(
249 "Profiler schedule configured: wait=%d, warmup=%d, active=%d",
250 profiler_config.wait_iterations,
251 profiler_config.warmup_iterations,
252 profiler_config.active_iterations,
253 )
254
255 self._profiler_kwargs = dict(
256 activities=[TorchProfilerActivityMap[activity] for activity in activities],
257 schedule=profiler_schedule,
258 record_shapes=profiler_config.torch_profiler_record_shapes,
259 profile_memory=profiler_config.torch_profiler_with_memory,
260 with_stack=profiler_config.torch_profiler_with_stack,
261 with_flops=profiler_config.torch_profiler_with_flops,
262 on_trace_ready=trace_handler,
263 )
264 self.profiler: torch.profiler.profile
265
266 # Track if we're using a schedule (need to call step())
267 self._uses_schedule = profiler_schedule is not None
268 self._warmup_iterations = profiler_config.warmup_iterations
269 # Subtract 1 because profiler.start() already consumes step 0
270 # (WAIT or WARMUP), so only wait + warmup - 1 non-active steps
271 # remain to be advanced through via profiler.step() calls.
272 self._initial_warmup_steps_remaining = max(
273 profiler_config.wait_iterations + profiler_config.warmup_iterations - 1,
274 0,
275 )
276 self._warmup_steps_remaining = self._initial_warmup_steps_remaining
277 self._version_metadata_added = False
278
279 def _build_profiler_table(
280 self,
281 sort_key: str,
282 row_limit: int | None = None,
283 ) -> str:
284 group_by_input_shape = (
285 current_platform.is_cpu()
286 and self.profiler_config.torch_profiler_record_shapes
287 )
288 averages = self.profiler.key_averages(group_by_input_shape=group_by_input_shape)
289 if row_limit is None: # use profiler default row limit of 100
290 return averages.table(sort_by=sort_key)
291 return averages.table(
292 sort_by=sort_key,
293 row_limit=row_limit,
294 )
295
296 def _write_profiler_table(self, rank: int, table: str) -> None:
297 profiler_dir = self.profiler_config.torch_profiler_dir
298
299 # Skip file write for URI paths (gs://, s3://, etc.)
300 # as standard file I/O doesn't work with URI schemes
301 if not _is_uri_path(profiler_dir):
302 profiler_out_file = f"{profiler_dir}/profiler_out_{rank}.txt"
303 with open(profiler_out_file, "w") as f:
304 print(table, file=f)
305
306 def _maybe_add_version_metadata(self) -> None:
307 """Stamp the vLLM version (which embeds the git commit) into the trace.
308
309 add_metadata_json is a no-op until Kineto is initialized, which with a
310 schedule only happens after the WAIT phase, so stamp once it's live.
311 """
312 if self._version_metadata_added:
313 return
314 # None while the schedule is still in the WAIT phase.
315 if self.profiler.profiler is None:
316 return
317 try:
318 self.profiler.add_metadata_json(
319 "vllm_version", json.dumps(vllm.version.__version__)
320 )
321 self.profiler.add_metadata_json(
322 "vllm_version_tuple",
323 json.dumps([str(p) for p in vllm.version.__version_tuple__]),
324 )
325 except Exception as e:
326 logger.warning("Failed to add vLLM version to profiler metadata: %s", e)
327 # Mark done even on failure, to avoid retrying every step.
328 self._version_metadata_added = True
329
330 @override
331 def _start(self) -> None:
332 self.profiler = torch.profiler.profile(**self._profiler_kwargs)
333 self._warmup_steps_remaining = self._initial_warmup_steps_remaining
334 self._version_metadata_added = False
335 self.profiler.start()
336 # No-schedule case: Kineto is live immediately. With a schedule this
337 # no-ops and _profiler_step stamps it once WAIT ends.
338 self._maybe_add_version_metadata()
339
340 @override
341 def _stop(self) -> None:
342 self.profiler.stop()
343
344 rank = self.local_rank
345 if self.dump_device_time_total:
346 table = self._build_profiler_table(sort_key="self_device_time_total")
347 self._write_profiler_table(rank, table)
348
349 # only print profiler results on rank 0
350 if rank == 0:
351 print(table)
352
353 if self.dump_cpu_time_total:
354 table = self._build_profiler_table(
355 sort_key="self_cpu_time_total", row_limit=50
356 )
357 self._write_profiler_table(rank, table)
358
359 # only print profiler results on rank 0
360 if rank == 0:
361 print(table)
362
363 @override
364 def _profiler_step(self) -> bool:
365 """Call profiler.step() when using schedule-based profiling.
366
367 Returns:
368 True if the step was an active profiling step (data recorded),
369 False if the step was a warmup step (data discarded).
370
371 """
372 if self._uses_schedule:
373 self.profiler.step()
374 # Stamp once the schedule leaves WAIT and Kineto is live.
375 self._maybe_add_version_metadata()
376 # Track warmup steps - only count active steps toward max_iterations
377 if self._warmup_steps_remaining > 0:
378 self._warmup_steps_remaining -= 1
379 return False
380 return True
381
382 @property
383 @override
384 def should_annotate(self) -> bool:
385 return self._running and self._records_cpu_activity
386
387 @override
388 def annotate_context_manager(self, name: str):
389 if not self.should_annotate:
390 return nullcontext()
391 return torch.profiler.record_function(name)
392
393
394 class ProtonProfilerWrapper(WorkerProfiler):
395 """Worker profiler backed by :mod:`triton.profiler` (Proton)."""
396
397 def __init__(
398 self,
399 profiler_config: ProfilerConfig,
400 worker_name: str,
401 ) -> None:
402 super().__init__(profiler_config)
403
404 try:
405 self._proton = importlib.import_module("triton.profiler")
406 triton = importlib.import_module("triton")
407 except ImportError as exc:
408 raise RuntimeError(
409 "The Proton profiler requires a Triton installation with "
410 "triton.profiler support."
411 ) from exc
412
413 self._output_dir = profiler_config.proton_profiler_dir
414 self._output_path = os.path.join(self._output_dir, f"proton_{worker_name}")
415 self._context = profiler_config.proton_context
416 self._data = profiler_config.proton_data
417 self._backend = profiler_config.proton_backend
418 self._mode = profiler_config.proton_mode
419 self._hook = profiler_config.proton_hook
420 self._output_format = profiler_config.proton_output_format
421 self._graph_attribution = profiler_config.proton_graph_attribution
422 self._triton_version_string = getattr(triton, "__version__", "unknown")
423 try:
424 self._triton_version = Version(self._triton_version_string)
425 except InvalidVersion:
426 self._triton_version = None
427 self._validate_capabilities()
428 self._session_id: int | None = None
429 # Qualify output names by process and wrapper instance so a new
430 # worker cannot overwrite profiles left by an earlier server process.
431 self._instance_id = f"pid{os.getpid()}_{uuid4().hex}"
432 self._run_id = 0
433 self._graph_session = False
434 self._phase = 0
435 self._active_output_path: str | None = None
436 self._session_storage_path = os.path.join(
437 self._output_dir,
438 f".proton_cuda_graph_session_{worker_name}_{self._instance_id}",
439 )
440
441 logger.info_once(
442 "Proton profiling enabled. Output will be saved under: %s",
443 self._output_dir,
444 )
445
446 def _require_triton_version(self, feature: str, minimum: Version) -> None:
447 if self._triton_version is None or self._triton_version < minimum:
448 raise RuntimeError(
449 f"Proton {feature} requires Triton >= {minimum}; found "
450 f"{self._triton_version_string}."
451 )
452
453 def _validate_capabilities(self) -> None:
454 if self._graph_attribution:
455 self._require_triton_version(
456 "CUDA graph attribution", _TRITON_PROTON_3_7_VERSION
457 )
458 if self._output_format is not None:
459 parameters = inspect.signature(self._proton.finalize).parameters
460 supports_output_format = "output_format" in parameters or any(
461 parameter.kind == inspect.Parameter.VAR_KEYWORD
462 for parameter in parameters.values()
463 )
464 if not supports_output_format:
465 raise RuntimeError(
466 "The installed Triton Proton does not support selecting "
467 "an output format during finalize."
468 )
469
470 if self._output_format == "hatchet_msgpack":
471 self._require_triton_version(
472 "hatchet_msgpack output", _TRITON_PROTON_3_7_VERSION
473 )
474 if self._mode and self._mode.split(":", 1)[0].lower() == "periodic_flushing":
475 self._require_triton_version(
476 "periodic flushing", _TRITON_PROTON_3_7_VERSION
477 )
478
479 def _create_session(self, output_path: str) -> int:
480 os.makedirs(self._output_dir, exist_ok=True)
481 session_id = self._proton.start(
482 name=output_path,
483 context=self._context,
484 data=self._data,
485 backend=self._backend,
486 mode=self._mode,
487 hook=self._hook,
488 )
489 if session_id is None:
490 raise RuntimeError("Proton did not create a profiling session")
491 return session_id
492
493 @property
494 def has_cuda_graph_session(self) -> bool:
495 return self._graph_session
496
497 def set_output_name(self, worker_name: str) -> None:
498 """Set the next run's output name after startup graph capture."""
499 if self._active:
500 return
501 self._output_path = os.path.join(self._output_dir, f"proton_{worker_name}")
502
503 @override
504 @contextmanager
505 def capture_cuda_graphs(self) -> Iterator[None]:
506 """Keep a Proton session active while vLLM captures CUDA graphs."""
507 if not self._graph_attribution:
508 yield
509 return
510 if self._session_id is None:
511 self._session_id = self._create_session(self._session_storage_path)
512 else:
513 self._proton.activate(session=self._session_id)
514 self._graph_session = True
515
516 try:
517 yield
518 finally:
519 captured_phase = self._phase
520 try:
521 self._phase = self._proton.data.advance_phase(self._session_id)
522 finally:
523 self._proton.deactivate(session=self._session_id, flushing=True)
524 self._proton.data.clear(self._session_id, captured_phase)
525
526 @override
527 def _start(self) -> None:
528 self._active_output_path = (
529 f"{self._output_path}_{self._instance_id}_run{self._run_id}"
530 )
531 self._run_id += 1
532 if self._graph_session:
533 assert self._session_id is not None
534 self._proton.activate(session=self._session_id)
535 else:
536 self._session_id = self._create_session(self._active_output_path)
537
538 def _write_graph_phase(self, phase: int) -> None:
539 assert self._active_output_path is not None
540 output_format = self._output_format or "hatchet"
541 output_path = f"{self._active_output_path}.{output_format}"
542 if output_format == "hatchet_msgpack":
543 with open(output_path, "wb") as output_file:
544 output_file.write(
545 self._proton.data.get_msgpack(self._session_id, phase)
546 )
547 else:
548 with open(output_path, "w", encoding="utf-8") as output_file:
549 json.dump(self._proton.data.get(self._session_id, phase), output_file)
550
551 def _finalize_session(self, session_id: int) -> None:
552 if self._output_format is None:
553 self._proton.finalize(session=session_id)
554 else:
555 self._proton.finalize(session=session_id, output_format=self._output_format)
556
557 @override
558 def _stop(self) -> None:
559 assert self._session_id is not None
560 session_id = self._session_id
561 if self._graph_session:
562 completed_phase = self._phase
563 try:
564 self._phase = self._proton.data.advance_phase(session_id)
565 finally:
566 self._proton.deactivate(session=session_id, flushing=True)
567 try:
568 self._write_graph_phase(completed_phase)
569 finally:
570 self._proton.data.clear(session_id, completed_phase)
571 self._active_output_path = None
572 return
573
574 try:
575 self._proton.deactivate(session=session_id)
576 finally:
577 try:
578 self._finalize_session(session_id)
579 finally:
580 self._session_id = None
581 self._active_output_path = None
582
583 @override
584 def shutdown(self) -> None:
585 super().shutdown()
586 if self._graph_session and self._session_id is not None:
587 session_id = self._session_id
588 self._session_id = None
589 try:
590 self._finalize_session(session_id)
591 except Exception:
592 logger.exception(
593 "Failed to finalize Proton CUDA graph session during shutdown."
594 )
595 finally:
596 for output_path in glob(f"{self._session_storage_path}.*"):
597 with suppress(FileNotFoundError):
598 os.remove(output_path)
599
600 @override
601 def annotate_context_manager(self, name: str):
602 if not self._running:
603 return nullcontext()
604 return self._proton.scope(name)
605
606
607 class CudaProfilerWrapper(WorkerProfiler):
608 def __init__(self, profiler_config: ProfilerConfig) -> None:
609 super().__init__(profiler_config)
610 # Note: lazy import to avoid dependency issues if CUDA is not available.
611 import torch.cuda.profiler as cuda_profiler
612
613 self._cuda_profiler = cuda_profiler
614
615 @override
616 def _start(self) -> None:
617 self._cuda_profiler.start()
618
619 @override
620 def _stop(self) -> None:
621 self._cuda_profiler.stop()
622
623 @override
624 def annotate_context_manager(self, name: str):
625 return torch.cuda.nvtx.range(name)
626
627
628 _DEFAULT_TORCH_PROFILER_ACTIVITIES: dict[str, tuple[TorchProfilerActivity, ...]] = {
629 "cpu": ("CPU",),
630 "cuda": ("CPU", "CUDA"),
631 "xpu": ("CPU", "XPU"),
632 }
633 _SUPPORTED_TORCH_PROFILER_ACTIVITIES = {
634 device: frozenset(activities)
635 for device, activities in _DEFAULT_TORCH_PROFILER_ACTIVITIES.items()
636 }
637 _SUPPORTED_PROFILER_KINDS: dict[str, frozenset[ProfilerKind]] = {
638 "cpu": frozenset(("torch",)),
639 "cuda": frozenset(("torch", "cuda", "proton")),
640 "xpu": frozenset(("torch",)),
641 }
642
643
644 def validate_worker_profiler_config(profiler_config: ProfilerConfig) -> None:
645 """Validate profiler selections against the worker's capabilities."""
646 profiler_type = profiler_config.profiler
647 if profiler_type is None:
648 return
649 device_type = current_platform.device_type
650 if device_type not in _SUPPORTED_PROFILER_KINDS:
651 raise ValueError(f"Unsupported profiler device type: {device_type}")
652 supported_kinds = _SUPPORTED_PROFILER_KINDS[device_type]
653 if profiler_type not in supported_kinds:
654 supported_names = ", ".join(sorted(supported_kinds))
655 raise ValueError(
656 f"Unsupported profiler type for {device_type}: "
657 f"{profiler_type}. Supported profiler types: {supported_names}."
658 )
659 if profiler_type == "torch":
660 default_activities = _DEFAULT_TORCH_PROFILER_ACTIVITIES[device_type]
661 supported_activities = _SUPPORTED_TORCH_PROFILER_ACTIVITIES[device_type]
662 configured = profiler_config.torch_profiler_activities
663 activities = default_activities if configured is None else configured
664 unsupported = set(activities) - supported_activities
665 if unsupported:
666 unsupported_names = ", ".join(sorted(unsupported))
667 supported_names = ", ".join(sorted(supported_activities))
668 raise ValueError(
669 f"Unsupported torch profiler activities for "
670 f"{device_type}: {unsupported_names}. "
671 f"Supported activities: {supported_names}."
672 )
673
674
675 def create_worker_profiler(
676 profiler_config: ProfilerConfig,
677 *,
678 worker_name: str,
679 local_rank: int,
680 ) -> WorkerProfiler:
681 """Create a profiler using a validated config and platform defaults."""
682 profiler_type = profiler_config.profiler
683 if profiler_type == "torch":
684 default_activities = _DEFAULT_TORCH_PROFILER_ACTIVITIES[
685 current_platform.device_type
686 ]
687 configured = profiler_config.torch_profiler_activities
688 logger.debug("Starting torch profiler with trace name: %s", worker_name)
689 return TorchProfilerWrapper(
690 profiler_config,
691 worker_name=worker_name,
692 local_rank=local_rank,
693 activities=default_activities if configured is None else tuple(configured),
694 )
695 if profiler_type == "cuda":
696 logger.debug("Starting CUDA profiler")
697 return CudaProfilerWrapper(profiler_config)
698
699 assert profiler_type == "proton", f"Unknown profiler type: {profiler_type}"
700 logger.debug("Starting Proton profiler with trace name: %s", worker_name)
701 return ProtonProfilerWrapper(profiler_config, worker_name=worker_name)
702
703
704 def create_graph_capture_profiler(
705 profiler_config: ProfilerConfig, global_rank: int
706 ) -> WorkerProfiler | None:
707 """Create a profiler to observe CUDA graph capture, if configured.
708
709 Applies only to backends that attribute graph replay activity which
710 need a session around capture (Proton with ``proton_graph_attribution``).
711 """
712 if (
713 profiler_config.profiler == "proton"
714 and profiler_config.proton_graph_attribution
715 ):
716 from vllm.distributed.utils import get_worker_rank_suffix
717
718 return ProtonProfilerWrapper(
719 profiler_config,
720 worker_name=get_worker_rank_suffix(global_rank=global_rank),
721 )
722 return None