Skip to content

Commit 82603dd

Browse files
Add per-node worker sizing for download stages
Signed-off-by: Praateek <praateekm@gmail.com>
1 parent 2c9786c commit 82603dd

21 files changed

Lines changed: 422 additions & 60 deletions

File tree

nemo_curator/backends/ray_actor_pool/utils.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@
1818
import ray
1919
from loguru import logger
2020

21-
from nemo_curator.backends.utils import get_available_cpu_gpu_resources
21+
from nemo_curator.backends.utils import get_available_cpu_gpu_resources, get_stage_num_workers_per_node
22+
from nemo_curator.utils.ray_utils import get_alive_ray_node_count
2223

2324
if TYPE_CHECKING:
2425
from ray.actor import ActorClass
@@ -59,6 +60,14 @@ def calculate_optimal_actors_for_stage(
5960
raise ValueError(msg)
6061

6162
num_workers = stage.num_workers()
63+
num_workers_per_node = get_stage_num_workers_per_node(stage)
64+
if num_workers is not None and num_workers_per_node is not None:
65+
msg = (
66+
f"Stage {stage.name} defines both num_workers()={num_workers} and "
67+
f"num_workers_per_node()={num_workers_per_node}. Use only one worker sizing option."
68+
)
69+
raise ValueError(msg)
70+
6271
if num_workers is not None and num_workers > 0:
6372
if num_workers > max_actors_resources:
6473
msg = (
@@ -70,6 +79,19 @@ def calculate_optimal_actors_for_stage(
7079
return max_actors_resources
7180
return num_workers
7281

82+
if num_workers_per_node is not None:
83+
num_nodes = get_alive_ray_node_count(ignore_head_node=ignore_head_node)
84+
requested_actors = max(1, math.ceil(num_workers_per_node * num_nodes))
85+
if requested_actors > max_actors_resources:
86+
msg = (
87+
f"Stage {stage.name} requires {requested_actors} actors from num_workers_per_node(), "
88+
f"but only {max_actors_resources} fit with available resources. "
89+
f"Capping actor count to {max_actors_resources}."
90+
)
91+
logger.warning(msg)
92+
return max_actors_resources
93+
return requested_actors
94+
7395
number_of_batches = (
7496
math.ceil(num_tasks / stage.batch_size) if stage.batch_size is not None and stage.batch_size > 0 else num_tasks
7597
)

nemo_curator/backends/ray_data/adapter.py

Lines changed: 78 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,21 @@
1313
# limitations under the License.
1414

1515
import copy
16+
import math
1617
from collections.abc import Callable
1718
from typing import Any
1819

1920
from loguru import logger
20-
from ray.data import Dataset, TaskPoolStrategy
21+
from ray.data import ActorPoolStrategy, Dataset, TaskPoolStrategy
2122

2223
from nemo_curator.backends.base import BaseStageAdapter
23-
from nemo_curator.backends.utils import RayStageSpecKeys, get_worker_metadata_and_node_id
24+
from nemo_curator.backends.utils import (
25+
RayStageSpecKeys,
26+
get_stage_num_workers_per_node,
27+
get_worker_metadata_and_node_id,
28+
)
2429
from nemo_curator.stages.base import ProcessingStage
30+
from nemo_curator.utils.ray_utils import get_alive_ray_node_count
2531

2632
from .utils import get_actor_compute_strategy_for_stage, get_configured_actor_pool_sizing_keys, is_actor_stage
2733

@@ -39,8 +45,9 @@ class RayDataStageAdapter(BaseStageAdapter):
3945
c. Else we use tasks
4046
"""
4147

42-
def __init__(self, stage: ProcessingStage):
48+
def __init__(self, stage: ProcessingStage, ignore_head_node: bool = False):
4349
super().__init__(stage)
50+
self.ignore_head_node = ignore_head_node
4451

4552
self._batch_size = self.stage.batch_size
4653
if self._batch_size is None and self.stage.resources.gpus > 0:
@@ -90,6 +97,59 @@ def _build_resource_kwargs(self, ray_stage_spec: dict) -> dict[str, float]:
9097
kwargs["num_gpus"] = self.stage.resources.gpus # type: ignore[reportArgumentType]
9198
return kwargs
9299

100+
def _get_per_node_pool_size(self, ray_stage_spec: dict, num_workers: int | None) -> int | None:
101+
num_workers_per_node = get_stage_num_workers_per_node(self.stage)
102+
if num_workers_per_node is None:
103+
return None
104+
105+
if num_workers is not None:
106+
msg = (
107+
f"Stage {self.stage.name} defines both num_workers()={num_workers} and "
108+
f"num_workers_per_node()={num_workers_per_node}. "
109+
"Use only one worker sizing option."
110+
)
111+
raise ValueError(msg)
112+
113+
actor_pool_sizing_keys = get_configured_actor_pool_sizing_keys(ray_stage_spec)
114+
if actor_pool_sizing_keys:
115+
msg = (
116+
f"Stage {self.stage.name} defines num_workers_per_node() "
117+
f"and actor-pool sizing keys {actor_pool_sizing_keys}. Use only one worker sizing option."
118+
)
119+
raise ValueError(msg)
120+
121+
node_count = get_alive_ray_node_count(ignore_head_node=self.ignore_head_node)
122+
if node_count <= 0:
123+
msg = f"No alive Ray nodes available for num_workers_per_node sizing on stage {self.stage.name}."
124+
raise ValueError(msg)
125+
return max(1, math.ceil(num_workers_per_node * node_count))
126+
127+
def _build_actor_compute_kwargs(self, per_node_pool_size: int | None) -> dict[str, object]:
128+
if per_node_pool_size is not None:
129+
return {"compute": ActorPoolStrategy(size=per_node_pool_size)}
130+
return {"compute": get_actor_compute_strategy_for_stage(self.stage)}
131+
132+
def _build_task_compute_kwargs(
133+
self, ray_stage_spec: dict, per_node_pool_size: int | None, num_workers: int | None
134+
) -> dict[str, object]:
135+
actor_pool_sizing_keys = get_configured_actor_pool_sizing_keys(ray_stage_spec)
136+
if actor_pool_sizing_keys:
137+
logger.warning(
138+
f"Ignoring ray_stage_spec worker sizing keys {actor_pool_sizing_keys} "
139+
f"for Ray Data task stage {self.stage.name}; these keys only apply to actor stages."
140+
)
141+
142+
map_batches_kwargs: dict[str, object] = {}
143+
if per_node_pool_size is not None:
144+
map_batches_kwargs["compute"] = TaskPoolStrategy(size=per_node_pool_size)
145+
elif num_workers is not None and num_workers > 0:
146+
map_batches_kwargs["compute"] = TaskPoolStrategy(size=num_workers)
147+
148+
max_calls = ray_stage_spec.get(RayStageSpecKeys.MAX_CALLS_PER_WORKER)
149+
if max_calls is not None:
150+
map_batches_kwargs["max_calls"] = max_calls
151+
return map_batches_kwargs
152+
93153
def process_dataset(self, dataset: Dataset) -> Dataset:
94154
"""Process a Ray Data dataset through this stage.
95155
@@ -101,28 +161,15 @@ def process_dataset(self, dataset: Dataset) -> Dataset:
101161
"""
102162
ray_stage_spec = self.stage.ray_stage_spec()
103163
stage_is_actor = ray_stage_spec.get(RayStageSpecKeys.IS_ACTOR_STAGE, is_actor_stage(self.stage))
164+
num_workers = self.stage.num_workers()
165+
per_node_pool_size = self._get_per_node_pool_size(ray_stage_spec, num_workers)
104166

105167
if stage_is_actor:
106-
map_batches_fn = create_actor_from_stage(self.stage)
107-
map_batches_kwargs = {"compute": get_actor_compute_strategy_for_stage(self.stage)}
168+
map_batches_fn = create_actor_from_stage(self.stage, ignore_head_node=self.ignore_head_node)
169+
map_batches_kwargs = self._build_actor_compute_kwargs(per_node_pool_size)
108170
else:
109-
map_batches_fn = create_task_from_stage(self.stage)
110-
map_batches_kwargs = {}
111-
112-
actor_pool_sizing_keys = get_configured_actor_pool_sizing_keys(ray_stage_spec)
113-
if actor_pool_sizing_keys:
114-
logger.warning(
115-
f"Ignoring ray_stage_spec worker sizing keys {actor_pool_sizing_keys} "
116-
f"for Ray Data task stage {self.stage.name}; these keys only apply to actor stages."
117-
)
118-
119-
num_workers = self.stage.num_workers()
120-
if num_workers is not None and num_workers > 0:
121-
map_batches_kwargs["compute"] = TaskPoolStrategy(size=num_workers)
122-
123-
max_calls = ray_stage_spec.get(RayStageSpecKeys.MAX_CALLS_PER_WORKER)
124-
if max_calls is not None:
125-
map_batches_kwargs["max_calls"] = max_calls
171+
map_batches_fn = create_task_from_stage(self.stage, ignore_head_node=self.ignore_head_node)
172+
map_batches_kwargs = self._build_task_compute_kwargs(ray_stage_spec, per_node_pool_size, num_workers)
126173

127174
map_batches_kwargs.update(self._build_resource_kwargs(ray_stage_spec))
128175

@@ -141,6 +188,9 @@ def process_dataset(self, dataset: Dataset) -> Dataset:
141188
)
142189
raise ValueError(msg)
143190

191+
if per_node_pool_size is not None:
192+
map_batches_kwargs.setdefault("scheduling_strategy", "SPREAD")
193+
144194
map_batches_kwargs.update(ray_remote_args)
145195

146196
# Let Ray Data apply the selected compute strategy and resource requirements.
@@ -154,15 +204,15 @@ def process_dataset(self, dataset: Dataset) -> Dataset:
154204
return processed_dataset
155205

156206

157-
def create_actor_from_stage(stage: ProcessingStage) -> type[RayDataStageAdapter]:
207+
def create_actor_from_stage(stage: ProcessingStage, ignore_head_node: bool = False) -> type[RayDataStageAdapter]:
158208
"""Create a StageProcessor class with the proper stage name for display."""
159209

160210
class RayDataStageActorAdapter(RayDataStageAdapter):
161211
"""Simplified stateful processor that wraps a ProcessingStage for Ray Data."""
162212

163213
def __init__(self):
164214
"""Initialize the stage processor."""
165-
super().__init__(stage)
215+
super().__init__(stage, ignore_head_node=ignore_head_node)
166216
self.setup_done = False
167217
node_info, worker_metadata = get_worker_metadata_and_node_id()
168218
self.setup_on_node(node_info, worker_metadata)
@@ -179,7 +229,9 @@ def __call__(self, batch: dict[str, Any]) -> dict[str, Any]:
179229
return RayDataStageActorAdapter
180230

181231

182-
def create_task_from_stage(stage: ProcessingStage) -> Callable[[dict[str, Any]], dict[str, Any]]:
232+
def create_task_from_stage(
233+
stage: ProcessingStage, ignore_head_node: bool = False
234+
) -> Callable[[dict[str, Any]], dict[str, Any]]:
183235
"""Create a named Ray Data stage adapter function.
184236
185237
This creates a standalone function that wraps the stage processing logic
@@ -192,7 +244,7 @@ def create_task_from_stage(stage: ProcessingStage) -> Callable[[dict[str, Any]],
192244
Callable: A function that can be used directly with Ray Data's map_batches
193245
"""
194246
# Create the adapter instance
195-
adapter = RayDataStageAdapter(stage)
247+
adapter = RayDataStageAdapter(stage, ignore_head_node=ignore_head_node)
196248

197249
# Create a standalone function that wraps the adapter's processing logic
198250
def stage_map_fn(batch: dict[str, Any]) -> dict[str, Any]:

nemo_curator/backends/ray_data/executor.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ def execute(self, stages: list["ProcessingStage"], initial_tasks: list[Task] | N
9393
logger.info(f" CPU cores: {stage.resources.cpus}, GPU ratio: {stage.resources.gpus}")
9494

9595
# Create adapter for this stage
96-
adapter = RayDataStageAdapter(stage)
96+
adapter = RayDataStageAdapter(stage, ignore_head_node=self.ignore_head_node)
9797

9898
# Apply stage transformation
9999
current_dataset = adapter.process_dataset(current_dataset)

nemo_curator/backends/utils.py

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,14 @@
1616
import time
1717
from copy import deepcopy
1818
from enum import Enum
19-
from typing import TYPE_CHECKING
19+
from typing import TYPE_CHECKING, Any
2020

2121
import ray
2222
from loguru import logger
2323

2424
from nemo_curator.backends.base import NodeInfo, WorkerMetadata
2525
from nemo_curator.stages.base import ProcessingStage
26-
from nemo_curator.utils.ray_utils import get_head_node_id, submit_on_each_node
26+
from nemo_curator.utils.ray_utils import get_alive_ray_nodes, get_head_node_id, submit_on_each_node
2727

2828
if TYPE_CHECKING:
2929
import loguru
@@ -137,6 +137,26 @@ class RayStageSpecKeys(str, Enum):
137137
RAY_NUM_CPUS = "ray_num_cpus"
138138

139139

140+
def get_stage_num_workers_per_node(stage: ProcessingStage) -> int | float | None:
141+
"""Return a stage's generic per-node worker request.
142+
143+
Prefer ``ProcessingStage.num_workers_per_node()``. For older stages that still expose
144+
a plain ``num_workers_per_node`` field, accept the field value so backend sizing stays
145+
compatible while stages migrate to the method form.
146+
"""
147+
value_or_method: Any = getattr(stage, "num_workers_per_node", None)
148+
value = value_or_method() if callable(value_or_method) else value_or_method
149+
if value is None:
150+
return None
151+
if isinstance(value, bool) or not isinstance(value, (int, float)):
152+
msg = f"num_workers_per_node() for stage {stage.name} must be a positive number."
153+
raise TypeError(msg)
154+
if value <= 0:
155+
msg = f"num_workers_per_node() for stage {stage.name} must be > 0."
156+
raise ValueError(msg)
157+
return value
158+
159+
140160
def get_worker_metadata_and_node_id() -> tuple[NodeInfo, WorkerMetadata]:
141161
"""Get the worker metadata and node id from the runtime context."""
142162
ray_context = ray.get_runtime_context()
@@ -212,13 +232,8 @@ def execute_setup_on_node(stages: list[ProcessingStage], ignore_head_node: bool
212232
the sum of per-stage times — important when setup is heavy (model downloads, weight
213233
loads) and stages don't contend for the same resources.
214234
"""
215-
head_node_id = get_head_node_id() if ignore_head_node else None
216-
for node in ray.nodes():
217-
if not node.get("Alive"):
218-
continue
235+
for node in get_alive_ray_nodes(ignore_head_node=ignore_head_node):
219236
node_id = node["NodeID"]
220-
if ignore_head_node and node_id == head_node_id:
221-
continue
222237
logger.info(f"Executing setup on node {node_id} for {len(stages)} stages")
223238

224239
refs: list = []

nemo_curator/backends/xenna/executor.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
from loguru import logger
2121

2222
from nemo_curator.backends.base import BaseExecutor
23-
from nemo_curator.backends.utils import register_loguru_serializer
23+
from nemo_curator.backends.utils import get_stage_num_workers_per_node, register_loguru_serializer
2424
from nemo_curator.backends.xenna.adapter import create_named_xenna_stage_adapter
2525
from nemo_curator.stages.base import ProcessingStage
2626
from nemo_curator.tasks import EmptyTask, Task
@@ -83,11 +83,21 @@ def execute(self, stages: list[ProcessingStage], initial_tasks: list[Task] | Non
8383
raise ValueError(msg)
8484

8585
num_workers = stage.num_workers()
86-
num_workers_per_node = stage_config.get("num_workers_per_node")
86+
stage_num_workers_per_node = get_stage_num_workers_per_node(stage)
87+
spec_num_workers_per_node = stage_config.get("num_workers_per_node")
88+
if stage_num_workers_per_node is not None and spec_num_workers_per_node is not None:
89+
msg = (
90+
f"Stage {stage.name} sets both num_workers_per_node() and "
91+
"xenna_stage_spec()['num_workers_per_node']. Use only one worker sizing option."
92+
)
93+
raise ValueError(msg)
94+
num_workers_per_node = (
95+
stage_num_workers_per_node if stage_num_workers_per_node is not None else spec_num_workers_per_node
96+
)
8797
if num_workers is not None and num_workers_per_node is not None:
8898
msg = (
8999
f"Stage {stage.name} sets both num_workers() and "
90-
"xenna_stage_spec()['num_workers_per_node']. Use only one worker sizing option."
100+
"num_workers_per_node(). Use only one worker sizing option."
91101
)
92102
raise ValueError(msg)
93103

nemo_curator/stages/base.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,36 @@ def get_num_workers() -> int | None:
5858
return get_num_workers
5959

6060

61+
def _num_workers_per_node_method(num_workers_per_node: float | None) -> Callable[[], float | None]:
62+
def get_num_workers_per_node() -> float | None:
63+
return num_workers_per_node
64+
65+
return get_num_workers_per_node
66+
67+
68+
def _get_num_workers_per_node_value(stage: ProcessingStage) -> float | None:
69+
value_or_method = stage.num_workers_per_node
70+
return value_or_method() if callable(value_or_method) else value_or_method
71+
72+
73+
def _set_num_workers_per_node_override(stage: ProcessingStage, num_workers_per_node: float | None) -> None:
74+
if callable(stage.num_workers_per_node):
75+
stage.num_workers_per_node = _num_workers_per_node_method(num_workers_per_node)
76+
else:
77+
stage.num_workers_per_node = num_workers_per_node
78+
79+
80+
def _check_worker_sizing_options(stage: ProcessingStage) -> None:
81+
effective_num_workers = stage.num_workers()
82+
effective_num_workers_per_node = _get_num_workers_per_node_value(stage)
83+
if effective_num_workers is not None and effective_num_workers_per_node is not None:
84+
msg = (
85+
"Use only one worker sizing option: num_workers or num_workers_per_node. "
86+
"Set one of them to None before configuring the other."
87+
)
88+
raise ValueError(msg)
89+
90+
6191
class StageMeta(ABCMeta):
6292
"""Metaclass that automatically registers concrete Stage subclasses.
6393
A class is considered *concrete* if it directly inherits from
@@ -173,6 +203,10 @@ def num_workers(self) -> int | None:
173203
"""Number of workers required. If None, then executor will determine the number of workers."""
174204
return None
175205

206+
def num_workers_per_node(self) -> float | None:
207+
"""Number of workers required per Ray node. If None, executor default sizing is used."""
208+
return None
209+
176210
def validate_input(self, task: Task) -> bool:
177211
"""Validate input task meets requirements.
178212
Args:
@@ -321,6 +355,7 @@ def with_( # noqa: PLR0913
321355
ray_stage_spec: dict[str, Any] | None = None,
322356
xenna_stage_spec: dict[str, Any] | None = None,
323357
num_workers: int | None | _UnsetType = _UNSET,
358+
num_workers_per_node: float | None | _UnsetType = _UNSET,
324359
) -> ProcessingStage:
325360
"""Apply configuration changes to this stage with overridden properties.
326361
@@ -335,6 +370,8 @@ def with_( # noqa: PLR0913
335370
xenna_stage_spec: Merge overrides into the Xenna stage spec. User-provided keys win.
336371
Use num_workers instead of setting num_workers in xenna_stage_spec.
337372
num_workers: Override the num_workers() result. Passing None explicitly resets to executor default behavior.
373+
num_workers_per_node: Override the num_workers_per_node() result. Passing None explicitly resets to
374+
executor default behavior.
338375
"""
339376
new_instance = copy.deepcopy(self)
340377

@@ -370,6 +407,10 @@ def with_( # noqa: PLR0913
370407

371408
if num_workers is not _UNSET:
372409
new_instance.num_workers = _num_workers_method(cast("int | None", num_workers))
410+
if num_workers_per_node is not _UNSET:
411+
_set_num_workers_per_node_override(new_instance, cast("float | None", num_workers_per_node))
412+
413+
_check_worker_sizing_options(new_instance)
373414

374415
return new_instance
375416

0 commit comments

Comments
 (0)