Skip to content

Commit ceb5c80

Browse files
Make num_workers_per_node method-only and centralize worker-sizing validation
- num_workers_per_node is now strictly a method (like num_workers); drop the field/method duality and hard-code via with_(num_workers_per_node=...). - Enforce at-most-one worker-sizing option (num_workers / num_workers_per_node / actor-pool min/max/initial_workers) once in ProcessingStage._validate_worker_sizing, run at construction (StageMeta.__call__) and in with_(). Remove the duplicated checks from the ray_actor_pool, ray_data, and xenna backends. - Simplify get_stage_num_workers_per_node to call num_workers_per_node() directly. - Simplify RayDataStageAdapter compute-strategy construction. - Migrate ImageDuplicatesRemovalStage and CallHomeReaderStage off the legacy xenna_stage_spec num_workers_per_node key; drop legacy spec-key support. Signed-off-by: Praateek <praateekm@gmail.com>
1 parent 82603dd commit ceb5c80

12 files changed

Lines changed: 122 additions & 197 deletions

File tree

nemo_curator/backends/ray_actor_pool/utils.py

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -61,12 +61,6 @@ def calculate_optimal_actors_for_stage(
6161

6262
num_workers = stage.num_workers()
6363
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)
7064

7165
if num_workers is not None and num_workers > 0:
7266
if num_workers > max_actors_resources:

nemo_curator/backends/ray_data/adapter.py

Lines changed: 25 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -97,49 +97,41 @@ def _build_resource_kwargs(self, ray_stage_spec: dict) -> dict[str, float]:
9797
kwargs["num_gpus"] = self.stage.resources.gpus # type: ignore[reportArgumentType]
9898
return kwargs
9999

100-
def _get_per_node_pool_size(self, ray_stage_spec: dict, num_workers: int | None) -> int | None:
100+
def _per_node_pool_size(self) -> int | None:
101+
"""Actor/task pool size derived from ``num_workers_per_node()``, or None if unset.
102+
103+
Mutual exclusion with ``num_workers()`` and actor-pool sizing keys is enforced at
104+
stage construction (``ProcessingStage._validate_worker_sizing``).
105+
"""
101106
num_workers_per_node = get_stage_num_workers_per_node(self.stage)
102107
if num_workers_per_node is None:
103108
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-
121109
node_count = get_alive_ray_node_count(ignore_head_node=self.ignore_head_node)
122110
if node_count <= 0:
123111
msg = f"No alive Ray nodes available for num_workers_per_node sizing on stage {self.stage.name}."
124112
raise ValueError(msg)
125113
return max(1, math.ceil(num_workers_per_node * node_count))
126114

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:
115+
def _compute_kwargs(self, ray_stage_spec: dict, stage_is_actor: bool, per_node_pool_size: int | None) -> dict:
116+
"""Select the Ray Data compute strategy (actor pool vs task pool) and its sizing."""
117+
if stage_is_actor:
118+
compute = (
119+
ActorPoolStrategy(size=per_node_pool_size)
120+
if per_node_pool_size is not None
121+
else get_actor_compute_strategy_for_stage(self.stage)
122+
)
123+
return {"compute": compute}
124+
125+
# Task stages: actor-pool sizing keys don't apply, so surface them if present.
126+
sizing_keys = get_configured_actor_pool_sizing_keys(ray_stage_spec)
127+
if sizing_keys:
137128
logger.warning(
138-
f"Ignoring ray_stage_spec worker sizing keys {actor_pool_sizing_keys} "
129+
f"Ignoring ray_stage_spec worker sizing keys {sizing_keys} "
139130
f"for Ray Data task stage {self.stage.name}; these keys only apply to actor stages."
140131
)
141132

142133
map_batches_kwargs: dict[str, object] = {}
134+
num_workers = self.stage.num_workers()
143135
if per_node_pool_size is not None:
144136
map_batches_kwargs["compute"] = TaskPoolStrategy(size=per_node_pool_size)
145137
elif num_workers is not None and num_workers > 0:
@@ -161,15 +153,11 @@ def process_dataset(self, dataset: Dataset) -> Dataset:
161153
"""
162154
ray_stage_spec = self.stage.ray_stage_spec()
163155
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)
156+
per_node_pool_size = self._per_node_pool_size()
166157

167-
if stage_is_actor:
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)
170-
else:
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)
158+
stage_factory = create_actor_from_stage if stage_is_actor else create_task_from_stage
159+
map_batches_fn = stage_factory(self.stage, ignore_head_node=self.ignore_head_node)
160+
map_batches_kwargs = self._compute_kwargs(ray_stage_spec, stage_is_actor, per_node_pool_size)
173161

174162
map_batches_kwargs.update(self._build_resource_kwargs(ray_stage_spec))
175163

nemo_curator/backends/ray_data/utils.py

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414

1515
from collections.abc import Mapping
1616

17-
from loguru import logger
1817
from ray.data import ActorPoolStrategy
1918

2019
from nemo_curator.backends.utils import RayStageSpecKeys
@@ -42,12 +41,6 @@ def get_actor_compute_strategy_for_stage(stage: ProcessingStage) -> ActorPoolStr
4241
"""
4342
num_workers = stage.num_workers()
4443
if num_workers is not None and num_workers > 0:
45-
actor_pool_sizing_keys = get_configured_actor_pool_sizing_keys(stage.ray_stage_spec())
46-
if actor_pool_sizing_keys:
47-
logger.warning(
48-
f"Stage {stage.name} uses num_workers={num_workers}; ignoring ray_stage_spec "
49-
f"actor-pool sizing keys {actor_pool_sizing_keys}."
50-
)
5144
return ActorPoolStrategy(size=num_workers)
5245

5346
ray_stage_spec = stage.ray_stage_spec()

nemo_curator/backends/utils.py

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

2121
import ray
2222
from loguru import logger
@@ -138,14 +138,8 @@ class RayStageSpecKeys(str, Enum):
138138

139139

140140
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
141+
"""Return a stage's validated per-node worker request from ``num_workers_per_node()``."""
142+
value = stage.num_workers_per_node()
149143
if value is None:
150144
return None
151145
if isinstance(value, bool) or not isinstance(value, (int, float)):

nemo_curator/backends/xenna/executor.py

Lines changed: 7 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -78,28 +78,15 @@ def execute(self, stages: list[ProcessingStage], initial_tasks: list[Task] | Non
7878
for stage in stages:
7979
# Get stage configuration
8080
stage_config = stage.xenna_stage_spec()
81-
if "num_workers" in stage_config:
82-
msg = f"Stage {stage.name} sets num_workers in xenna_stage_spec(). Use num_workers() instead."
83-
raise ValueError(msg)
81+
for reserved in ("num_workers", "num_workers_per_node"):
82+
if reserved in stage_config:
83+
msg = f"Stage {stage.name} sets {reserved} in xenna_stage_spec(). Use {reserved}() instead."
84+
raise ValueError(msg)
8485

86+
# Mutual exclusion of num_workers() vs num_workers_per_node() is enforced at
87+
# stage construction (ProcessingStage._validate_worker_sizing).
8588
num_workers = stage.num_workers()
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-
)
97-
if num_workers is not None and num_workers_per_node is not None:
98-
msg = (
99-
f"Stage {stage.name} sets both num_workers() and "
100-
"num_workers_per_node(). Use only one worker sizing option."
101-
)
102-
raise ValueError(msg)
89+
num_workers_per_node = get_stage_num_workers_per_node(stage)
10390

10491
# Create Xenna stage adapter with the original stage's name
10592
xenna_stage = create_named_xenna_stage_adapter(

nemo_curator/stages/base.py

Lines changed: 53 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -65,29 +65,6 @@ def get_num_workers_per_node() -> float | None:
6565
return get_num_workers_per_node
6666

6767

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-
9168
class StageMeta(ABCMeta):
9269
"""Metaclass that automatically registers concrete Stage subclasses.
9370
A class is considered *concrete* if it directly inherits from
@@ -111,6 +88,12 @@ def __new__(mcls, name, bases, namespace, **kwargs): # noqa: ANN001
11188

11289
return cls
11390

91+
def __call__(cls, *args, **kwargs):
92+
"""Validate worker sizing once every stage instance is fully constructed."""
93+
instance = super().__call__(*args, **kwargs)
94+
instance._validate_worker_sizing()
95+
return instance
96+
11497

11598
def get_stage_class(name: str) -> type[ProcessingStage]:
11699
"""Retrieve a registered stage class by its *class name*.
@@ -179,16 +162,17 @@ def __init_subclass__(cls, **kwargs):
179162
msg = f"{cls.__name__} must not override '{attr}'"
180163
raise TypeError(msg)
181164

182-
num_workers = cls.__dict__.get("num_workers")
183-
if (num_workers is not None and not callable(num_workers)) or (
184-
num_workers is None and "num_workers" in cls.__dict__.get("__annotations__", {})
185-
):
186-
msg = (
187-
f"{cls.__name__} must not define 'num_workers' as a stage attribute. "
188-
"Override num_workers() for backend worker sizing, or use a different field name "
189-
"for stage-specific worker counts."
190-
)
191-
raise TypeError(msg)
165+
for worker_attr in ("num_workers", "num_workers_per_node"):
166+
value = cls.__dict__.get(worker_attr)
167+
if (value is not None and not callable(value)) or (
168+
value is None and worker_attr in cls.__dict__.get("__annotations__", {})
169+
):
170+
msg = (
171+
f"{cls.__name__} must not define '{worker_attr}' as a stage attribute. "
172+
f"Override {worker_attr}() for backend worker sizing, or use a different field name "
173+
"for stage-specific worker counts."
174+
)
175+
raise TypeError(msg)
192176

193177
for attr in ("name", "resources", "batch_size", "runtime_env"):
194178
if isinstance(cls.__dict__.get(attr), property):
@@ -207,6 +191,38 @@ def num_workers_per_node(self) -> float | None:
207191
"""Number of workers required per Ray node. If None, executor default sizing is used."""
208192
return None
209193

194+
def _validate_worker_sizing(self) -> None:
195+
"""Enforce that at most one worker-sizing mechanism is configured.
196+
197+
A stage may size its worker pool with exactly one of ``num_workers()``,
198+
``num_workers_per_node()``, or the Ray Data actor-pool sizing keys
199+
(``min_workers``/``max_workers``/``initial_workers``) in ``ray_stage_spec()``.
200+
Combining them is ambiguous, so we reject it once here — at construction and in
201+
``with_()`` — rather than defensively re-checking in every backend.
202+
"""
203+
configured = [
204+
name
205+
for name, value in (
206+
("num_workers()", self.num_workers()),
207+
("num_workers_per_node()", self.num_workers_per_node()),
208+
)
209+
if value is not None
210+
]
211+
ray_stage_spec = self.ray_stage_spec()
212+
if ray_stage_spec:
213+
# Lazy import: only stages that populate ray_stage_spec touch the Ray Data backend.
214+
from nemo_curator.backends.ray_data.utils import get_configured_actor_pool_sizing_keys
215+
216+
if get_configured_actor_pool_sizing_keys(ray_stage_spec):
217+
configured.append("actor-pool sizing keys (min/max/initial_workers)")
218+
219+
if len(configured) > 1:
220+
msg = (
221+
f"Stage {self.name} sets multiple worker-sizing options ({', '.join(configured)}); "
222+
"use only one of num_workers, num_workers_per_node, or actor-pool sizing keys."
223+
)
224+
raise ValueError(msg)
225+
210226
def validate_input(self, task: Task) -> bool:
211227
"""Validate input task meets requirements.
212228
Args:
@@ -408,9 +424,11 @@ def with_( # noqa: PLR0913
408424
if num_workers is not _UNSET:
409425
new_instance.num_workers = _num_workers_method(cast("int | None", num_workers))
410426
if num_workers_per_node is not _UNSET:
411-
_set_num_workers_per_node_override(new_instance, cast("float | None", num_workers_per_node))
427+
new_instance.num_workers_per_node = _num_workers_per_node_method(
428+
cast("float | None", num_workers_per_node)
429+
)
412430

413-
_check_worker_sizing_options(new_instance)
431+
new_instance._validate_worker_sizing()
414432

415433
return new_instance
416434

nemo_curator/stages/image/deduplication/removal.py

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414

1515
import os
1616
from dataclasses import dataclass, field
17-
from typing import Any
1817

1918
import pyarrow as pa
2019
import pyarrow.dataset as ds
@@ -36,15 +35,14 @@ class ImageDuplicatesRemovalStage(ProcessingStage[ImageBatch, ImageBatch]):
3635
removal_parquets_dir: Directory containing Parquet files with image IDs to remove
3736
duplicate_id_field: Name of the column containing image IDs to remove
3837
verbose: Whether to log verbose output
39-
num_workers_per_node: Number of workers per node for the stage. This is sometimes needed
40-
to avoid OOM when concurrently running actors on one node loading the same removal
41-
parquet files into memory.
38+
39+
To cap concurrent actors per node (e.g. to avoid OOM when each actor loads the same
40+
removal parquet files into memory), configure ``.with_(num_workers_per_node=...)``.
4241
"""
4342

4443
removal_parquets_dir: str
4544
duplicate_id_field: str = "id"
4645
verbose: bool = False
47-
num_workers_per_node: int | None = None
4846

4947
name: str = "image_dedup_filter"
5048

@@ -96,9 +94,3 @@ def process(self, task: ImageBatch) -> ImageBatch:
9694
_metadata=task._metadata,
9795
_stage_perf=task._stage_perf,
9896
)
99-
100-
def xenna_stage_spec(self) -> dict[str, Any]:
101-
spec: dict[str, Any] = {}
102-
if self.num_workers_per_node is not None:
103-
spec["num_workers_per_node"] = self.num_workers_per_node
104-
return spec

tests/backends/ray_actor_pool/test_executor.py

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -95,20 +95,9 @@ def test_calculate_optimal_actors_respects_num_workers_per_node(
9595
mock_warning.assert_called_once()
9696
assert expected_warning in mock_warning.call_args.args[0]
9797

98-
@pytest.mark.parametrize("num_workers", [0, 4])
99-
def test_calculate_optimal_actors_rejects_num_workers_with_num_workers_per_node(self, num_workers: int) -> None:
100-
stage = _stage_with_worker_sizing(num_workers=num_workers, num_workers_per_node=2, cpus=1.0, batch_size=10)
101-
102-
with (
103-
mock.patch(
104-
"nemo_curator.backends.ray_actor_pool.utils.get_available_cpu_gpu_resources",
105-
return_value=(8.0, 0.0),
106-
),
107-
pytest.raises(ValueError, match=r"num_workers\(\).*num_workers_per_node\(\)"),
108-
):
109-
calculate_optimal_actors_for_stage(stage, num_tasks=1)
110-
11198

99+
# Mutual exclusion of num_workers() and num_workers_per_node() is enforced at stage
100+
# construction (ProcessingStage._validate_worker_sizing); see tests/stages/common/test_base.py.
112101
def _stage_with_worker_sizing(
113102
*, num_workers: int | None, num_workers_per_node: float | None, cpus: float, batch_size: int
114103
) -> mock.Mock:

0 commit comments

Comments
 (0)