Repository navigation
Expand file tree
/
Copy pathworker_groups.py
More file actions
1340 lines (1167 loc) · 58.4 KB
/
Copy pathworker_groups.py
File metadata and controls
1340 lines (1167 loc) · 58.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import importlib
import math
import os
import sys
import time
from copy import deepcopy
from dataclasses import dataclass
from typing import Any, Callable, Optional, Union
import ray
from ray.util.placement_group import PlacementGroup
from ray.util.scheduling_strategies import PlacementGroupSchedulingStrategy
from tqdm import tqdm
from nemo_rl.distributed.named_sharding import NamedSharding
from nemo_rl.distributed.ray_actor_environment_registry import (
get_actor_python_env,
)
from nemo_rl.distributed.virtual_cluster import RayVirtualCluster
from nemo_rl.distributed.worker_group_utils import recursive_merge_options
from nemo_rl.telemetry.host_memory import (
forget_worker_group_nodes,
register_worker_group_placement,
)
from nemo_rl.telemetry.vocabulary import WORKER_GROUP_ENV
from nemo_rl.utils.venvs import (
add_hf_modules_cache_to_pythonpath,
create_local_venv_on_each_node,
)
@ray.remote(num_cpus=0, num_gpus=0)
def _log_gpu_state_on_bundle(label: str, local_rank: int) -> None:
"""Read one bundle's GPU from the node that holds it. See log_worker_gpu_state.
Module level, not defined inside the method that dispatches it: a ``@ray.remote``
decorator in a function body registers a new remote function with the GCS every call.
``num_gpus=0`` deliberately. The task is pinned to the bundle, not given the device --
asking for the GPU would queue behind whatever is still holding it, which is precisely
the state being measured.
"""
import os
from nemo_rl.utils.nvml import log_gpu_memory_diagnostics
# How the helper resolves which device to read when CUDA is not initialised.
os.environ.setdefault("LOCAL_RANK", str(local_rank))
log_gpu_memory_diagnostics(
label=label, worker_type="generation", device_id=local_rank
)
def _check_dp_leader_worker_indices(
requested: list[int], tied_group_leaders: list[int]
) -> None:
"""Reject DP leaders that are not an ascending subset of the group leaders.
A DP leader has to be a model owner -- it is the worker every shard-level
call is routed to. Checking against the tied group leaders turns a wrong
layout into a startup error instead of requests landing on a worker that
never built a model.
"""
unknown = set(requested) - set(tied_group_leaders)
if unknown or requested != sorted(requested):
raise ValueError(
f"dp_leader_worker_indices {requested} must be an ascending subset "
f"of the tied group leaders {tied_group_leaders}."
)
def _get_initializer_env_vars(env_vars: dict[str, str]) -> dict[str, str]:
"""Build the environment needed to unpickle worker constructor arguments."""
initializer_env_vars = {
key: env_vars[key] if key in env_vars else os.environ[key]
for key in ("HF_HOME", "HF_MODULES_CACHE", "PYTHONPATH")
if key in env_vars or key in os.environ
}
return add_hf_modules_cache_to_pythonpath(initializer_env_vars)
@dataclass
class MultiWorkerFuture:
"""Container for Ray futures with associated worker information."""
futures: list[ray.ObjectRef]
return_from_workers: Optional[list[int]] = None
called_workers: Optional[list[int]] = None
def get_results(
self, worker_group: "RayWorkerGroup", return_generators_as_proxies: bool = False
) -> list[Any]:
"""Get results from the futures, optionally respecting tied workers.
The method uses worker_group.worker_to_tied_group_index to identify which tied
worker group each worker belongs to, then selects only the first result from each group.
Args:
worker_group: The RayWorkerGroup that spawned the futures. The
mapping contained in worker_group.worker_to_tied_group_index
is required for the deduplication path.
return_generators_as_proxies: If True, and a future is an ObjectRefGenerator,
return the ObjectRefGenerator itself instead of consuming it.
Returns:
List of results
"""
from ray import ObjectRef, ObjectRefGenerator
if return_generators_as_proxies:
# Directly return the futures, which are expected to be ObjectRefGenerators (or other proxies).
# No ray.get() is called on them. The consumer is responsible for handling the proxies.
if self.return_from_workers is None:
return self.futures
if self.called_workers is not None:
map_called_worker_to_future_idx = {
global_idx: i for i, global_idx in enumerate(self.called_workers)
}
final_proxies = []
for global_worker_to_return in self.return_from_workers:
if global_worker_to_return in map_called_worker_to_future_idx:
future_idx = map_called_worker_to_future_idx[
global_worker_to_return
]
if future_idx < len(self.futures):
final_proxies.append(self.futures[future_idx])
return final_proxies
else:
return [
self.futures[i]
for i in self.return_from_workers
if i < len(self.futures)
]
object_refs: list[ObjectRef] = []
has_generator = False
for idx, fut in enumerate(self.futures):
if isinstance(fut, ObjectRefGenerator):
# ray.get cannot be called directly on the generator object – it must be iterated to obtain the individual ObjectRef instances first.
for generated_ref in fut:
object_refs.append(generated_ref)
has_generator = True
else:
object_refs.append(fut)
# Retrieve the concrete results.
all_results = ray.get(object_refs)
# If expanded generator was present we are in streaming mode.
# Every ObjectRef now corresponds to a unique, ordered chunk of data
if has_generator:
return all_results
if self.return_from_workers is not None:
if self.called_workers is not None:
# Create a mapping from global worker indices to local indices in all_results
worker_to_result_idx = {
worker: idx for idx, worker in enumerate(self.called_workers)
}
# # Filter return_from_workers to only include workers that were actually called
valid_return_workers = [
w for w in self.return_from_workers if w in worker_to_result_idx
]
# Map global worker indices to local result indices and get results
return [
all_results[worker_to_result_idx[worker]]
for worker in valid_return_workers
]
else:
return [all_results[worker] for worker in self.return_from_workers]
return all_results
class RayWorkerBuilder:
@ray.remote
class IsolatedWorkerInitializer:
def __init__(self, ray_actor_class_fqn: str, *init_args, **init_kwargs):
self.ray_actor_class_fqn = ray_actor_class_fqn
self.init_args = init_args
self.init_kwargs = init_kwargs
def create_worker(
self,
placement_group: PlacementGroup,
placement_group_bundle_index: int,
num_gpus: int,
bundle_indices: Optional[tuple] = None,
num_gpus_per_node: Optional[int] = None,
**extra_options: Optional[dict[str, Any]],
):
"""Create a Ray worker with the specified configuration.
Order of precedence for worker options configuration (from lowest to highest):
1. Options passed by the user to __call__ (extra_options)
2. Options required by the worker via configure_worker (may override user options with warning)
3. Options set by the RayWorkerBuilder.__call__ (specifically scheduling strategy)
If the worker needs to override user-provided options, it should log a warning
to inform the user about the change and the reason for it.
Args:
placement_group: Ray placement group for resource allocation
placement_group_bundle_index: Index of the bundle in the placement group
num_gpus: Number of GPUs to allocate to this worker
bundle_indices: Tuple of (node_idx, local_bundle_indices) for tensor parallelism (if applicable)
num_gpus_per_node: Number of GPUs per node in the cluster. Passed to
configure_worker so it can map a bundle id to a node-local index,
for example when assigning ports to model-parallel engines that
span more than one node. None when the caller does not supply
cluster topology.
extra_options: Additional options to pass to the Ray actor (may be overridden by actor's configure_worker(...) method)
Returns:
A Ray actor reference to the created worker
"""
# Set up worker arguments and resources
module_name, class_name = self.ray_actor_class_fqn.rsplit(".", 1)
module = importlib.import_module(module_name)
worker_class = getattr(module, class_name)
worker_kwargs = dict(self.init_kwargs)
default_options = getattr(worker_class, "_default_options", {})
options = recursive_merge_options(default_options, extra_options)
# Use the worker's configuration interface if available
if hasattr(worker_class, "configure_worker"):
# Get complete worker configuration from the worker class.
# Returns (resources, env_vars, init_kwargs, runtime_env_overrides).
resources, env_vars, init_kwargs, runtime_env_overrides = (
worker_class.configure_worker(
num_gpus=num_gpus,
bundle_indices=bundle_indices,
num_gpus_per_node=num_gpus_per_node,
)
)
# Apply resource configuration
if resources and "num_gpus" in resources:
num_gpus = resources["num_gpus"]
# Apply environment variables if provided
if env_vars:
if "runtime_env" not in options:
options["runtime_env"] = {"env_vars": {}}
if "env_vars" not in options["runtime_env"]: # type: ignore
options["runtime_env"]["env_vars"] = {} # type: ignore
for k, v in env_vars.items():
options["runtime_env"]["env_vars"][k] = v # type: ignore
# Apply runtime_env overrides (e.g., nsight config)
if runtime_env_overrides:
if "runtime_env" not in options:
options["runtime_env"] = {}
options["runtime_env"] = recursive_merge_options(
options["runtime_env"], runtime_env_overrides
)
# Apply initialization parameters
if init_kwargs:
worker_kwargs.update(init_kwargs)
# Create options for Ray actor
options["scheduling_strategy"] = PlacementGroupSchedulingStrategy(
placement_group=placement_group,
placement_group_bundle_index=placement_group_bundle_index,
placement_group_capture_child_tasks=True,
)
options["num_gpus"] = num_gpus
worker = worker_class.options(**options).remote(
*self.init_args, **worker_kwargs
)
return worker
def __init__(self, ray_actor_class_fqn: str, *args, **kwargs):
self.ray_actor_class_fqn = ray_actor_class_fqn
self.args = args
self.kwargs = kwargs
def create_worker_async(
self,
placement_group: PlacementGroup,
placement_group_bundle_index: int,
num_gpus: float | int,
bundle_indices: Optional[tuple[int, list[int]]] = None,
**extra_options: Any,
) -> tuple[ray.ObjectRef, ray.actor.ActorHandle]:
"""Create a Ray worker asynchronously, returning futures.
This method returns immediately with futures that can be awaited later.
Args:
placement_group: Ray placement group for resource allocation
placement_group_bundle_index: Index of the bundle in the placement group
num_gpus: Number of GPUs to allocate to this worker (can be fractional)
bundle_indices: Tuple of (node_idx, local_bundle_indices) for tensor parallelism (if applicable)
extra_options: Additional options to pass to the Ray actor
Returns:
Tuple of (worker_future, initializer_actor):
- worker_future: A Ray ObjectRef that will resolve to the worker actor
- initializer_actor: The initializer actor (needed to prevent GC)
"""
# Set up worker arguments and resources
options = deepcopy(extra_options)
initializer_options = {"runtime_env": options["runtime_env"]}
isolated_initializer = self.IsolatedWorkerInitializer.options( # type: ignore # @ray.remote call
**initializer_options
).remote(self.ray_actor_class_fqn, *self.args, **self.kwargs)
# Return the future and the initializer actor
worker_future = isolated_initializer.create_worker.remote(
placement_group,
placement_group_bundle_index,
num_gpus,
bundle_indices,
**options,
)
return worker_future, isolated_initializer
def __call__(
self,
placement_group: PlacementGroup,
placement_group_bundle_index: int,
num_gpus: float | int,
bundle_indices: Optional[tuple[int, list[int]]] = None,
**extra_options: Any,
) -> ray.actor.ActorHandle:
"""Create a Ray worker with the specified configuration.
Order of precedence for worker options configuration (from lowest to highest):
1. Options passed by the user to __call__ (extra_options)
2. Options required by the worker via configure_worker (may override user options with warning)
3. Options set by the RayWorkerBuilder.__call__ (specifically scheduling strategy)
If the worker needs to override user-provided options, it should log a warning
to inform the user about the change and the reason for it.
Args:
placement_group: Ray placement group for resource allocation
placement_group_bundle_index: Index of the bundle in the placement group
num_gpus: Number of GPUs to allocate to this worker (can be fractional)
bundle_indices: Tuple of (node_idx, local_bundle_indices) for tensor parallelism (if applicable)
extra_options: Additional options to pass to the Ray actor (may be overridden by actor's configure_worker(...) method)
Returns:
A Ray actor reference to the created worker
"""
# Use the async method and then block on the result
worker_future, isolated_initializer = self.create_worker_async(
placement_group,
placement_group_bundle_index,
num_gpus,
bundle_indices,
**extra_options,
)
# Block to get the worker
worker = ray.get(worker_future)
# We hold onto a reference to the initializer actor to avoid gc (would kill the child, 'real' actor)
worker._RAY_INITIALIZER_ACTOR_REF_TO_AVOID_GC = isolated_initializer
return worker
class RayWorkerGroup:
"""Manages a group of distributed Ray worker/actor processes that execute tasks in parallel.
This class creates and manages Ray actor instances that run on resources
allocated by a RayVirtualCluster. It handles:
- Worker creation and placement on specific GPU resources
- Setting up distributed training environment variables (rank, world size, etc.)
- Executing methods across all workers in parallel
- Collecting and aggregating results
- Support for tied worker groups where multiple workers process the same data
"""
def __init__(
self,
cluster: RayVirtualCluster,
remote_worker_builder: RayWorkerBuilder,
workers_per_node: Optional[Union[int, list[int]]] = None,
name_prefix: str = "",
bundle_indices_list: Optional[list[tuple[int, list[int]]]] = None,
sharding_annotations: Optional[NamedSharding] = None,
env_vars: dict[str, str] = {},
dp_leader_worker_indices: Optional[list[int]] = None,
):
"""Initialize a group of distributed Ray workers.
Args:
cluster: RayVirtualCluster
remote_worker_builder: Callable that launches a ray worker and has updatable options
workers_per_node: Defaults to launch one worker per bundle in the cluster.
Alternatively specify an int or list to launch a different number of workers per node.
name_prefix: Optional prefix for the names of the workers
bundle_indices_list: Explicit list of (node_idx, [local_bundle_indices]) tuples.
Each tuple defines a tied group of workers placed on the same node.
If provided, workers_per_node is ignored.
sharding_annotations: NamedSharding object representing mapping of named axes to ranks (i.e. for TP, PP, etc.)
dp_leader_worker_indices: Global rank of the worker that owns each data
parallel shard. Defaults to the leader of every tied
group, i.e. a tied group *is* a DP shard -- true
whenever one model owner serves a whole rollout shard.
Pass it when the caller's DP layout is coarser than
the tied groups, e.g. TRT-LLM PD disaggregation, where
a replica is several engines (context + generation)
and counting tied groups would overcount DP. Must be a
subset of the tied group leaders, in ascending order.
"""
self._workers: list[ray.actor.ActorHandle] = []
self._worker_metadata: list[dict[str, Any]] = []
# worker_idx -> the arguments its creation call was made with, so a single
# worker can be rebuilt without redoing group-wide setup. Populated during
# creation; see _create_workers_from_bundle_indices.
self._worker_specs: dict[int, dict[str, Any]] = {}
# How many times each worker has been recreated. Only used to keep Ray actor
# names unique, since a dead actor's name can linger in the GCS.
self._worker_incarnations: dict[int, int] = {}
self.cluster = cluster
self.name_prefix = name_prefix
self.sharding_annotations = sharding_annotations
self.dp_leader_worker_indices: list[int] = []
# If explicit bundle indices are provided, use those
if bundle_indices_list is None:
# Create bundle_indices_list from workers_per_node specification
# In this case, each worker is its own group (no tied workers)
bundle_indices_list = []
# Get placement groups
placement_groups = self.cluster.get_placement_groups()
if len(placement_groups) == 1:
# Single unified placement group
pg = placement_groups[0]
workers_per_group = [pg.bundle_count]
else:
# Multiple per-node placement groups
workers_per_group = [pg.bundle_count for pg in placement_groups]
# Determine how many workers per node/placement group
if workers_per_node is None:
workers_per_group = [pg.bundle_count for pg in placement_groups]
elif isinstance(workers_per_node, int):
workers_per_group = [workers_per_node] * len(placement_groups)
elif isinstance(workers_per_node, list):
if len(workers_per_node) == 1 and len(placement_groups) == 1:
workers_per_group = workers_per_node
elif len(workers_per_node) != len(placement_groups):
raise ValueError(
f"workers_per_node list length ({len(workers_per_node)}) must match "
f"number of placement groups ({len(placement_groups)})"
)
else:
workers_per_group = workers_per_node
else:
raise ValueError(
"workers_per_node must be None (for default distribution), an int, or a list"
)
# Validate workers_per_group
for i, (pg, worker_count) in enumerate(
zip(placement_groups, workers_per_group)
):
if worker_count > pg.bundle_count:
raise ValueError(
f"Placement group {i} has {pg.bundle_count} bundles, "
f"but {worker_count} workers were requested"
)
for bundle_idx in range(worker_count):
# Each worker is its own single-element group
# The first element is the PG index (node_idx in the context of tied workers)
bundle_indices_list.append((i, [bundle_idx]))
# Create workers based on the bundle_indices_list
self._create_workers_from_bundle_indices(
remote_worker_builder,
bundle_indices_list,
env_vars=env_vars,
)
if dp_leader_worker_indices is not None:
_check_dp_leader_worker_indices(
dp_leader_worker_indices, self.dp_leader_worker_indices
)
self.dp_leader_worker_indices = dp_leader_worker_indices
def get_dp_leader_worker_idx(self, dp_shard_idx: int) -> int:
"""Returns the index of the primary worker for a given data parallel shard."""
if not 0 <= dp_shard_idx < len(self.dp_leader_worker_indices):
raise IndexError(
f"Data parallel shard index {dp_shard_idx} is out of range. "
f"Valid range is [0, {len(self.dp_leader_worker_indices) - 1}]"
)
return self.dp_leader_worker_indices[dp_shard_idx]
def _create_workers_from_bundle_indices(
self,
remote_worker_builder: RayWorkerBuilder,
bundle_indices_list: list[tuple[int, list[int]]],
env_vars: dict[str, str] = {},
) -> None:
"""Create workers based on explicit bundle indices for tied worker groups.
Args:
remote_worker_builder: Builder function for Ray actors
bundle_indices_list: List of (node_idx, local_bundle_indices) tuples, where each tuple
specifies a tied group with its node and local bundle indices. If the local_bundle_indices
spans multiple nodes, the node_idx will be the first node's index in the tied group.
"""
self.master_address, self.master_port = (
self.cluster.get_master_address_and_port()
)
# Update env_vars with the current environment variables
for k, v in os.environ.items():
if k not in env_vars:
env_vars[k] = v
# Get the python environment for the actor
actor_python_env = get_actor_python_env(
remote_worker_builder.ray_actor_class_fqn
)
if actor_python_env.startswith("uv"):
# If the py_executable begins with uv it signals that we need to create a
# local venv first and then replace the py_executable with the local venv's python.
# The directory the venv will be created in is controlled by the env var
# NEMO_RL_VENV_DIR and defaults to $GIT_ROOT/venvs/.
py_executable = create_local_venv_on_each_node(
py_executable=actor_python_env,
venv_name=remote_worker_builder.ray_actor_class_fqn,
)
else:
py_executable = actor_python_env
# Resolved interpreter for this group's workers, kept so sidecar actors
# that must import the same packages (e.g. the TRT-LLM disagg server)
# can join the venv instead of landing in the driver's environment.
self.py_executable = py_executable
# Count total workers
self.world_size = sum(len(indices) for _, indices in bundle_indices_list)
global_rank = 0
# Collect all async creation calls
worker_futures = []
worker_info = [] # Store metadata for each worker
# Get all placement groups
placement_groups = self.cluster.get_placement_groups()
# Get available address and port for each worker
pg_bundle_pairs = [
(pg_idx, bundle_idx)
for pg_idx, local_bundle_indices in bundle_indices_list
for bundle_idx in local_bundle_indices
]
addr_port_results = self.cluster.get_available_addresses_and_ports_batch(
pg_bundle_pairs
)
available_addresses = [addr for addr, _ in addr_port_results]
available_ports = [port for _, port in addr_port_results]
# Pool one IsolatedWorkerInitializer per unique pg_idx instead of one
# per worker. All workers on a node share the same py_executable, so
# the initializer mostly needs only that in its runtime_env — per-worker
# env_vars are passed through create_worker(). This reduces GCS actor
# registrations from N_workers to N_nodes. The initializer does unpickle
# constructor arguments before it can create the real worker, so the
# import-related variables that trust_remote_code classes need to
# resolve their generated modules have to travel with it.
unique_pg_indices = sorted({pg_idx for pg_idx, _ in bundle_indices_list})
initializer_env_vars = _get_initializer_env_vars(env_vars)
initializer_runtime_env = {}
if py_executable != sys.executable:
initializer_runtime_env["py_executable"] = py_executable
if initializer_env_vars:
initializer_runtime_env["env_vars"] = initializer_env_vars
self._initializer_pool: dict[int, ray.actor.ActorHandle] = {}
for pg_idx in unique_pg_indices:
# num_cpus=0 so the initializer doesn't consume a CPU slot — it
# sits idle after creating workers and only exists for GC lifetime.
self._initializer_pool[pg_idx] = (
RayWorkerBuilder.IsolatedWorkerInitializer.options( # type: ignore
num_cpus=0,
runtime_env=initializer_runtime_env,
).remote(
remote_worker_builder.ray_actor_class_fqn,
*remote_worker_builder.args,
**remote_worker_builder.kwargs,
)
)
for group_idx, (pg_idx, local_bundle_indices) in enumerate(bundle_indices_list):
current_group = []
if len(placement_groups) == 1:
pg = placement_groups[0]
else:
pg = placement_groups[pg_idx]
is_parallel_group = len(local_bundle_indices) > 1
for local_rank, bundle_idx in enumerate(local_bundle_indices):
# Set up basic distributed environment variables
worker_env_vars = deepcopy(env_vars)
worker_env_vars.update(
{
"RANK": str(global_rank),
"LOCAL_RANK": str(bundle_idx),
"WORLD_SIZE": str(self.world_size),
"MASTER_ADDR": self.master_address,
"MASTER_PORT": str(self.master_port),
"NODE_RANK": str(pg_idx),
"AVAILABLE_ADDR_LIST": str(available_addresses),
"AVAILABLE_PORT_LIST": str(available_ports),
# RANK is group-local, so it alone cannot tell a policy
# worker from a generation worker. Observability consumers
# need the group to disambiguate them.
WORKER_GROUP_ENV: self.name_prefix,
}
)
# Remove Ray-specific environment variables, let the worker itself set them.
worker_env_vars.pop("RAY_EXPERIMENTAL_NOSET_CUDA_VISIBLE_DEVICES", None)
worker_env_vars.pop("RAY_CLIENT_MODE", None)
worker_env_vars.pop("RAY_JOB_ID", None)
worker_env_vars.pop("RAY_LD_PRELOAD", None)
worker_env_vars.pop("RAY_RAYLET_PID", None)
worker_env_vars.pop("RAY_USAGE_STATS_ENABLED", None)
# Only the first worker in each group gets bundle_indices
# This ensures only one worker per group is the model owner
worker_bundle_indices = None
if local_rank == 0:
worker_bundle_indices = (pg_idx, local_bundle_indices)
self.dp_leader_worker_indices.append(global_rank)
# Create a descriptive name based on group structure
name = (
f"{self.name_prefix}-grp{group_idx}-{local_rank}"
if is_parallel_group
else f"{self.name_prefix}-{pg_idx}-{bundle_idx}"
)
# Calculate GPU resources
num_gpus = (
1 / self.cluster.max_colocated_worker_groups
if self.cluster.use_gpus
else 0
)
# Build the worker's runtime_env with per-worker env_vars
runtime_env = {
"env_vars": worker_env_vars,
"py_executable": py_executable,
}
py_venv = os.path.dirname(
os.path.dirname(py_executable)
) # to remove the "bin/python" suffix
runtime_env["env_vars"]["VIRTUAL_ENV"] = py_venv
runtime_env["env_vars"]["UV_PROJECT_ENVIRONMENT"] = py_venv
extra_options = {"runtime_env": runtime_env, "name": name}
# Reuse the pooled initializer for this pg_idx
initializer = self._initializer_pool[pg_idx]
worker_future = initializer.create_worker.remote(
pg,
bundle_idx,
num_gpus,
worker_bundle_indices,
num_gpus_per_node=self.cluster.num_gpus_per_node,
**extra_options,
)
# Store the future and metadata
worker_idx = len(worker_futures)
# Everything needed to build this exact worker again. Captured rather
# than re-derived, because re-deriving means reproducing the placement
# group, bundle, venv and rank bookkeeping above -- a second
# implementation that would drift from this one. Replaying the recorded
# call is the only way a restart is guaranteed to reproduce the original.
self._worker_specs[worker_idx] = {
"pg_idx": pg_idx,
"pg": pg,
"bundle_idx": bundle_idx,
"num_gpus": num_gpus,
"worker_bundle_indices": worker_bundle_indices,
"extra_options": extra_options,
}
worker_futures.append(worker_future)
worker_info.append(
{
"group_idx": group_idx,
"worker_idx": worker_idx,
"node_idx": pg_idx,
"local_rank": local_rank,
"global_rank": global_rank,
"name": name,
"bundle_indices": worker_bundle_indices,
"dp_shard_idx": group_idx,
}
)
current_group.append(worker_idx)
global_rank += 1
# Wait for all workers to initialize with timing and progress bar
num_workers = len(worker_futures)
worker_refs = worker_futures
start_time = time.perf_counter()
# Use ray.wait() to track individual worker completion times
remaining_refs = worker_refs.copy()
with tqdm(
total=num_workers,
desc=f"Initializing {self.name_prefix} workers",
unit="worker",
disable=False,
) as pbar:
while remaining_refs:
# Wait for at least one worker to complete
ready_refs, remaining_refs = ray.wait(
remaining_refs, num_returns=1, timeout=None
)
# Update progress bar for each ready worker
for _ in ready_refs:
pbar.update(1)
# Get all worker results
workers = ray.get(worker_refs)
total_init_time = time.perf_counter() - start_time
print(
f" ✓ {num_workers} workers initialized in {total_init_time:.2f}s",
flush=True,
)
for idx, worker in enumerate(workers):
self._workers.append(worker)
# Get the corresponding metadata
info = worker_info[idx]
self._worker_metadata.append(
{
"node_idx": info["node_idx"],
"local_rank": info["local_rank"],
"global_rank": info["global_rank"],
"name": info["name"],
"bundle_indices": info["bundle_indices"],
"dp_shard_idx": info["group_idx"],
}
)
# Here rather than anywhere in telemetry: the host-memory monitor
# scrapes raylets, which know about nodes and nothing about worker
# groups, so the only process that can relate the two is this one.
register_worker_group_placement(
self.name_prefix,
[(spec["pg"], spec["bundle_idx"]) for spec in self._worker_specs.values()],
)
def log_worker_gpu_state(
self, worker_idx: int, *, label: str, timeout_s: float = 30.0
) -> None:
"""Print the state of the GPU a worker's bundle holds, from that node.
For the moment *before* a restart: what is on the device decides whether the
attempt can succeed at all. The same reading is already taken inside the new
worker, at ``_load_model`` -- but that is too late to be a signal (the old actor is
already killed and the replacement is mid-``__init__``) and it does not happen at
all in the case that matters most, a bundle that never gets scheduled, because
``_load_model`` is never reached.
Runs as a short-lived task pinned to the same bundle, because the caller is the
controller and reading NVML there would report the controller's node. Bounded and
non-raising for the same reason the diagnostic itself is: a probe that hangs or
fails the restart path is worse than no probe. Not reaching the node is itself the
finding, so it is printed rather than swallowed.
"""
spec = self._worker_specs.get(worker_idx)
if spec is None:
print(
f" [GPU_DIAG] {label}: no creation spec for worker {worker_idx}; "
"cannot locate its bundle",
flush=True,
)
return
try:
ref = _log_gpu_state_on_bundle.options(
scheduling_strategy=PlacementGroupSchedulingStrategy(
placement_group=spec["pg"],
placement_group_bundle_index=spec["bundle_idx"],
)
).remote(label, spec["bundle_idx"])
ray.get(ref, timeout=timeout_s)
except Exception as e: # noqa: BLE001 - a diagnostic must never fail the caller
print(
f" [GPU_DIAG] {label}: could not read the GPU on worker "
f"{worker_idx}'s bundle within {timeout_s}s "
f"({type(e).__name__}: {e}). The node may be gone, which is itself the "
"answer to whether a restart can succeed.",
flush=True,
)
def recreate_worker(self, worker_idx: int) -> ray.actor.ActorHandle:
"""Rebuild one worker in place, replaying the call that created it.
Used to bring a dead generation shard back without disturbing the rest of the
fleet. The placement-group bundle the old actor held is still reserved -- Ray
does not release a bundle when an actor dies -- so the replacement lands on the
same GPU.
The old actor is killed first with ``no_restart=True``. It is usually already
dead, but a worker can be unresponsive rather than gone, and creating its
replacement while it still holds the GPU would fail on memory rather than on
anything informative.
Returns:
The new actor handle, also installed at ``workers[worker_idx]``.
"""
if worker_idx not in self._worker_specs:
raise KeyError(
f"no creation spec recorded for worker {worker_idx}; "
f"known workers: {sorted(self._worker_specs)}"
)
spec = self._worker_specs[worker_idx]
old = self._workers[worker_idx]
if old is not None:
try:
ray.kill(old, no_restart=True)
except Exception as e: # noqa: BLE001 - already-dead actors raise variously
print(
f" recreate_worker({worker_idx}): old actor not killable: {e}",
flush=True,
)
# A fresh name each incarnation: Ray rejects a duplicate named actor, and the
# dead one's registration can outlive the process.
incarnation = self._worker_incarnations.get(worker_idx, 0) + 1
self._worker_incarnations[worker_idx] = incarnation
extra_options = dict(spec["extra_options"])
extra_options["name"] = f"{extra_options['name']}-r{incarnation}"
initializer = self._initializer_pool[spec["pg_idx"]]
worker = ray.get(
initializer.create_worker.remote(
spec["pg"],
spec["bundle_idx"],
spec["num_gpus"],
spec["worker_bundle_indices"],
num_gpus_per_node=self.cluster.num_gpus_per_node,
**extra_options,
)
)
self._workers[worker_idx] = worker
return worker
@property
def workers(self) -> list[ray.actor.ActorHandle]:
return self._workers
@property
def worker_metadata(self) -> list[dict[str, Any]]:
return self._worker_metadata
@property
def dp_size(self) -> int:
"""Number of data parallel shards."""
return len(self.dp_leader_worker_indices)
def run_single_worker_single_data(
self,
method_name: str,
worker_idx: int,
*args,
**kwargs,
) -> ray.ObjectRef:
"""Run a method on a single, specific worker.
Args:
method_name: Name of the method to call on the worker.
worker_idx: The index of the worker to run the method on.
*args, **kwargs: Arguments to pass to the method.
Returns:
ray.ObjectRef: A Ray future for the result.
"""
assert len(args) == 0, (
"run_single_worker_single_data will fail with args under certain circumstances. "
"Please use kwargs instead. "
"See https://github.com/NVIDIA-NeMo/RL/issues/582 for more details."
)
worker = self.workers[worker_idx]
try:
method = getattr(worker, method_name)
except AttributeError as e:
print(
f"Supported methods: {list(worker._method_shells.keys())}", flush=True
)
raise e
return method.remote(*args, **kwargs)
def run_all_workers_multiple_data(
self,
method_name: str,
*args,
run_rank_0_only_axes: list[str] | None = None,
common_kwargs: Optional[dict[str, Any]] = None,
**kwargs,
) -> list[ray.ObjectRef]:
"""Run a method on all workers in parallel with different data.
Args:
method_name: Name of the method to call on each worker
*args: List of arguments to pass to workers/groups
e.g. [[arg1_for_worker_1, arg1_for_worker_2], [arg2_for_worker_1, arg2_for_worker_2]]
run_rank_0_only_axes: List of named axes for which only rank 0 should run the method.
common_kwargs: Keyword arguments to pass to all workers
**kwargs: Keyword arguments to pass to workers/groups
e.g. {"key1": [value_for_worker_1, value_for_worker_2], "key2": [value_for_worker_1, value_for_worker_2]}
Returns:
list[ray.ObjectRef]: A list of ray futures
"""
assert len(args) == 0, (
"run_all_workers_multiple_data will fail with args under certain circumstances. "
"Please use kwargs instead. "
"See https://github.com/NVIDIA-NeMo/RL/issues/582 for more details."
)
# Check at least one arg or kwarg is provided
assert len(args) > 0 or len(kwargs) > 0, (
"At least one args (positional arguments) or kwargs (keyword arguments) must be provided in run_all_workers_multiple_data. "
"Otherwise, please use run_all_workers_single_data."
)
# Check all args and kwargs have the same length
args_count = [len(arg) for arg in args]
assert all(count == args_count[0] for count in args_count), (
"All args must have the same length"
)
args_count = args_count[0] if len(args_count) > 0 else 0
kwargs_count = [len(value) for value in kwargs.values()]
assert all(count == kwargs_count[0] for count in kwargs_count), (
"All kwargs must have the same length"
)
kwargs_count = kwargs_count[0] if len(kwargs_count) > 0 else 0
if args_count > 0 and kwargs_count > 0:
assert args_count == kwargs_count, (
"The number of args and kwargs must be the same in run_all_workers_multiple_data. "
f"args length = {args_count}, kwargs length = {kwargs_count}"
)
data_count = max(args_count, kwargs_count)
# Check the data length is equal to the number of workers
if run_rank_0_only_axes is None:
assert data_count == len(self.workers), (
"data length should be equal to the number of workers: "
f"data length = {data_count}, number of workers = {len(self.workers)}"
)
futures = []
if run_rank_0_only_axes is None:
run_rank_0_only_axes = []
if common_kwargs is None:
common_kwargs = {}
data_idx = 0
for worker_idx, worker in enumerate(self.workers):
worker_coords = self.sharding_annotations.get_worker_coords(worker_idx)
# Determine if this worker should receive data
should_run = True
for axis in self.sharding_annotations.names: