Skip to content
Merged
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
5271c7c
Add support for per-stage pip specifications and virtual environments
suiyoubi Mar 17, 2026
9d1fd4e
add test
suiyoubi Mar 18, 2026
b3624fc
ruff
suiyoubi Mar 18, 2026
e2ae9a2
comments resolved
suiyoubi Mar 18, 2026
b25df9f
comments resolve
suiyoubi Mar 18, 2026
eaaf1d4
Merge branch 'main' into aot/runtime_env
suiyoubi Mar 18, 2026
e243755
comments resolve
suiyoubi Mar 18, 2026
98470ca
normalize pip specifications before creating virtual environments.
suiyoubi Mar 19, 2026
b7b2351
Merge branch 'main' of github.com:NVIDIA-NeMo/Curator into aot/runtim…
suiyoubi Mar 19, 2026
fc8f510
Add Path import and update ProcessingStage class
suiyoubi Mar 19, 2026
2666b24
ruff check
suiyoubi Mar 19, 2026
f4ef209
ruff
suiyoubi Mar 19, 2026
e971625
ruff check
suiyoubi Mar 19, 2026
fab1b90
Add runtime environment conflict check in RayDataStageAdapter and cle…
suiyoubi Mar 19, 2026
e89badb
fix
suiyoubi Mar 19, 2026
8062427
Refactor pip_specs to use ClassVar for type hinting in VersionStage1 …
suiyoubi Mar 20, 2026
a60e271
Merge branch 'main' into aot/runtime_env
ayushdg Mar 23, 2026
297b8e4
refactor to use runtime_env
suiyoubi Apr 6, 2026
d88d678
add more tests
suiyoubi Apr 6, 2026
dcf7143
remove ensurepip and fix it with --seed in dockerfile
suiyoubi Apr 6, 2026
7164dd0
add extra_env_vars
suiyoubi Apr 6, 2026
beb11ac
Refactor extra_env_vars to be a settable attribute in CuratorRuntimeEnv
suiyoubi Apr 6, 2026
952e77f
Update CI workflow to include 'uv venv --seed' command for improved t…
suiyoubi Apr 6, 2026
a882ad4
limit to two test case
suiyoubi Apr 6, 2026
6792826
use _with for unit test
suiyoubi Apr 6, 2026
00dd3b9
Merge branch 'main' into aot/runtime_env
suiyoubi Apr 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docker/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ ENV UV_PROJECT_ENVIRONMENT=/opt/venv
ENV UV_CACHE_DIR=/opt/uv_cache
ENV PATH="$UV_PROJECT_ENVIRONMENT/bin:$PATH"
ENV UV_LINK_MODE=copy
RUN uv venv ${UV_PROJECT_ENVIRONMENT} --system-site-packages
RUN uv venv ${UV_PROJECT_ENVIRONMENT} --system-site-packages --seed

FROM build AS nemo_curator_dep

Expand Down
12 changes: 7 additions & 5 deletions nemo_curator/backends/experimental/ray_actor_pool/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,14 +168,16 @@ def execute(self, stages: list["ProcessingStage"], initial_tasks: list[Task] | N
def _create_actor_pool(self, stage: "ProcessingStage", num_actors: int) -> ActorPool:
"""Create an ActorPool for a specific stage."""
actors = []
actor_options: dict = {
"num_cpus": stage.resources.cpus,
"num_gpus": stage.resources.gpus,
}
if stage.runtime_env:
actor_options["runtime_env"] = stage.runtime_env
for i in range(num_actors):
actor = (
create_named_ray_actor_pool_stage_adapter(stage, RayActorPoolStageAdapter)
.options(
num_cpus=stage.resources.cpus,
num_gpus=stage.resources.gpus,
name=f"{stage.name}-{i}",
)
.options(**actor_options, name=f"{stage.name}-{i}")
.remote(stage)
)
actors.append(actor)
Expand Down
1 change: 1 addition & 0 deletions nemo_curator/backends/experimental/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ class RayStageSpecKeys(str, Enum):
IS_LSH_STAGE = "is_lsh_stage"
IS_SHUFFLE_STAGE = "is_shuffle_stage"
MAX_CALLS_PER_WORKER = "max_calls_per_worker"
RAY_REMOTE_ARGS = "ray_remote_args"
Comment thread
praateekmahajan marked this conversation as resolved.
Resolved


def get_worker_metadata_and_node_id() -> tuple[NodeInfo, WorkerMetadata]:
Expand Down
10 changes: 10 additions & 0 deletions nemo_curator/backends/ray_data/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.

import copy
from collections.abc import Callable
from typing import Any

Expand Down Expand Up @@ -101,6 +102,15 @@ def process_dataset(self, dataset: Dataset, ignore_head_node: bool = False) -> D
if self.stage.resources.gpus > 0:
concurrency_kwargs["num_gpus"] = self.stage.resources.gpus # type: ignore[reportArgumentType]

# Per-stage ray_remote_args (e.g. runtime_env with different pip versions per stage).
ray_remote_args = copy.deepcopy(self.stage.ray_stage_spec().get(RayStageSpecKeys.RAY_REMOTE_ARGS) or {})
# If the stage declares runtime_env, forward it directly to Ray so Ray creates and
# caches an isolated virtualenv for this stage's workers.
if self.stage.runtime_env:
ray_remote_args["runtime_env"] = self.stage.runtime_env

concurrency_kwargs.update(ray_remote_args)
Comment thread
suiyoubi marked this conversation as resolved.
Resolved
Comment thread
suiyoubi marked this conversation as resolved.
Resolved

# Calculate concurrency based on available resources
logger.info(f"{self.stage.__class__.__name__} {is_actor_stage_=} with {concurrency_kwargs=}")

Expand Down
3 changes: 3 additions & 0 deletions nemo_curator/backends/ray_data/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ def execute(self, stages: list["ProcessingStage"], initial_tasks: list[Task] | N
# Initialize with initial tasks if provided, otherwise start with EmptyTask
tasks: list[Task] = initial_tasks if initial_tasks else [EmptyTask]
output_tasks: list[Task] = []
# When runtime_env with pip is used, Ray's pip plugin sets up per-stage virtualenvs
# lazily on first task dispatch by cloning the current virtualenv. The NeMo Curator
# container's /opt/venv is created with `uv venv --seed` so pip is available in clones.
try:
# Initialize ray and explicitly set NOSET to empty
# This ensures if Xenna was used before which was setting NOSET, we end up overriding it.
Expand Down
35 changes: 32 additions & 3 deletions nemo_curator/backends/xenna/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@
# See the License for the specific language governing permissions and
# limitations under the License.

from typing import Any

import ray.runtime_env
from cosmos_xenna.pipelines import v1 as pipelines_v1
from cosmos_xenna.pipelines.private.resources import NodeInfo as XennaNodeInfo
from cosmos_xenna.pipelines.private.resources import Resources as XennaResources
Expand All @@ -23,6 +26,27 @@
from nemo_curator.tasks import Task


class CuratorRuntimeEnv:
"""Duck-typed replacement for Xenna's RuntimeEnv that supports the full Ray runtime_env dict.

Xenna's RuntimeEnv only supports conda + env_vars. This class accepts a raw
Ray-format runtime_env dict and implements the interface Xenna calls:
``to_ray_runtime_env()``, ``format()``, and ``extra_env_vars``.
"""

def __init__(self, runtime_env: dict[str, Any]) -> None:
self._runtime_env = runtime_env
# Xenna's actor pool both reads and writes extra_env_vars on the runtime env object,
# so this must be a plain settable attribute, not a read-only property.
self.extra_env_vars: dict[str, str] = dict(runtime_env.get("env_vars", {}))

def to_ray_runtime_env(self) -> ray.runtime_env.RuntimeEnv:
return ray.runtime_env.RuntimeEnv(**self._runtime_env)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 to_ray_runtime_env() silently drops Xenna's extra_env_vars mutations

__init__ copies runtime_env["env_vars"] into self.extra_env_vars as a separate dict. The comment confirms Xenna both reads and writes that attribute at actor startup (e.g., injecting per-actor CUDA device IDs). But to_ray_runtime_env() reconstructs RuntimeEnv from the original self._runtime_env snapshot and never consults self.extra_env_vars, so any env vars Xenna injects are silently discarded before Ray ever receives the runtime environment.

Suggested change
def __init__(self, runtime_env: dict[str, Any]) -> None:
self._runtime_env = runtime_env
# Xenna's actor pool both reads and writes extra_env_vars on the runtime env object,
# so this must be a plain settable attribute, not a read-only property.
self.extra_env_vars: dict[str, str] = dict(runtime_env.get("env_vars", {}))
def to_ray_runtime_env(self) -> ray.runtime_env.RuntimeEnv:
return ray.runtime_env.RuntimeEnv(**self._runtime_env)
def to_ray_runtime_env(self) -> ray.runtime_env.RuntimeEnv:
merged = {**self._runtime_env, "env_vars": self.extra_env_vars}
return ray.runtime_env.RuntimeEnv(**merged)


def format(self) -> str:
return f"runtime_env_keys: {', '.join(self._runtime_env.keys())}"


class XennaStageAdapter(BaseStageAdapter, pipelines_v1.Stage):
"""Adapts ProcessingStage to Xenna.
Args:
Expand Down Expand Up @@ -54,9 +78,14 @@ def stage_batch_size(self) -> int:

@property
def env_info(self) -> pipelines_v1.RuntimeEnv | None:
"""Runtime environment for this stage."""
# Can be customized per stage if needed
return None
"""Runtime environment for this stage.

Converts the ProcessingStage.runtime_env dict (Ray-format) to a
CuratorRuntimeEnv that Xenna can forward to Ray actors.
"""
if not self.processing_stage.runtime_env:
return None
return CuratorRuntimeEnv(self.processing_stage.runtime_env)

def process_data(self, tasks: list[Task]) -> list[Task] | None:
"""Process batch of tasks with automatic performance tracking.
Expand Down
14 changes: 11 additions & 3 deletions nemo_curator/stages/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
import time
from abc import ABC, ABCMeta, abstractmethod
from inspect import isabstract
from typing import TYPE_CHECKING, Any, Generic, TypeVar, final
from typing import TYPE_CHECKING, Any, ClassVar, Generic, TypeVar, final

from loguru import logger

Expand Down Expand Up @@ -85,6 +85,7 @@ class ProcessingStage(ABC, Generic[X, Y], metaclass=StageMeta):
name = "ProcessingStage"
resources = Resources(cpus=1.0)
batch_size = 1
runtime_env: ClassVar[dict[str, Any] | None] = None

@property
@final
Expand Down Expand Up @@ -114,7 +115,7 @@ def __init_subclass__(cls, **kwargs):
msg = f"{cls.__name__} must not override '_batch_size'"
raise TypeError(msg)

for attr in ("name", "resources", "batch_size"):
for attr in ("name", "resources", "batch_size", "runtime_env"):
if isinstance(cls.__dict__.get(attr), property):
msg = (
f"{cls.__name__} must not define '{attr}' as a @property. "
Expand Down Expand Up @@ -259,7 +260,11 @@ def xenna_stage_spec(self) -> dict[str, Any]:
return {}

def with_(
self, name: str | None = None, resources: Resources | None = None, batch_size: int | None = None
self,
name: str | None = None,
resources: Resources | None = None,
batch_size: int | None = None,
runtime_env: dict[str, Any] | None = None,
) -> ProcessingStage:
"""Apply configuration changes to this stage with overridden properties.

Expand All @@ -269,6 +274,7 @@ def with_(
name: Override the name property
resources: Override the resources property
batch_size: Override the batch_size property
runtime_env: Override the runtime_env (Ray runtime environment dict)
"""
new_instance = copy.deepcopy(self)

Expand All @@ -279,6 +285,8 @@ def with_(
new_instance.resources = resources
if batch_size is not None:
new_instance.batch_size = batch_size
if runtime_env is not None:
new_instance.runtime_env = runtime_env

return new_instance

Expand Down
198 changes: 198 additions & 0 deletions tests/pipelines/test_per_stage_runtime_env.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
# 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.

"""Tests for per-stage runtime environment: different Python package versions per stage.

Each stage declares runtime_env and Ray creates an isolated virtualenv per unique spec set.
RayData, Xenna, and RayActorPool backends are all tested via parametrization, for both
pip and uv spec types.
"""

Comment thread
suiyoubi marked this conversation as resolved.
Resolved
from typing import Any, ClassVar

import pandas as pd
import pytest

from nemo_curator.backends.base import BaseExecutor
from nemo_curator.backends.experimental.ray_actor_pool import RayActorPoolExecutor
from nemo_curator.backends.ray_data import RayDataExecutor
from nemo_curator.backends.xenna import XennaExecutor
Comment thread
suiyoubi marked this conversation as resolved.
Resolved
from nemo_curator.pipeline.pipeline import Pipeline
from nemo_curator.stages.base import ProcessingStage
from nemo_curator.stages.resources import Resources
from nemo_curator.tasks import DocumentBatch


def _record_packaging_and_loguru(task: DocumentBatch, version_col: str, loguru_col: str) -> DocumentBatch:
"""Helper: record packaging.__version__ and whether loguru is importable."""
import packaging

try:
from loguru import logger

loguru_available = logger is not None
except ImportError:
loguru_available = False

batch = task.to_pandas().copy()
batch[version_col] = packaging.__version__
batch[loguru_col] = loguru_available
return DocumentBatch(
task_id=task.task_id,
dataset_name=task.dataset_name,
data=batch,
_metadata=task._metadata,
_stage_perf=task._stage_perf,
)


class BaseEnvStage(ProcessingStage[DocumentBatch, DocumentBatch]):
"""Stage with no runtime_env — runs in the base environment."""

name = "base_env"
resources = Resources(cpus=0.5)
batch_size = 1

def inputs(self) -> tuple[list[str], list[str]]:
return ["data"], []

Comment thread
suiyoubi marked this conversation as resolved.
Resolved
def outputs(self) -> tuple[list[str], list[str]]:
return ["data"], ["base_packaging_version"]

def process(self, task: DocumentBatch) -> DocumentBatch:
import packaging

batch = task.to_pandas().copy()
batch["base_packaging_version"] = packaging.__version__
return DocumentBatch(
task_id=task.task_id,
dataset_name=task.dataset_name,
data=batch,
_metadata=task._metadata,
_stage_perf=task._stage_perf,
)


class VersionStage1(ProcessingStage[DocumentBatch, DocumentBatch]):
"""Stage 1: packaging==23.2 (default pip; overridable via with_())."""

name = "version_stage_1"
resources = Resources(cpus=0.5)
batch_size = 1
runtime_env: ClassVar[dict] = {"pip": ["packaging==23.2"]}

def inputs(self) -> tuple[list[str], list[str]]:
return ["data"], ["base_packaging_version"]

def outputs(self) -> tuple[list[str], list[str]]:
return ["data"], ["stage1_packaging_version", "stage1_loguru_available"]

def process(self, task: DocumentBatch) -> DocumentBatch:
return _record_packaging_and_loguru(task, "stage1_packaging_version", "stage1_loguru_available")


class VersionStage2(ProcessingStage[DocumentBatch, DocumentBatch]):
"""Stage 2: packaging==24.0 (default pip; overridable via with_())."""

Comment thread
suiyoubi marked this conversation as resolved.
Resolved
name = "version_stage_2"
resources = Resources(cpus=0.5)
batch_size = 1
runtime_env: ClassVar[dict] = {"pip": ["packaging==24.0"]}

def inputs(self) -> tuple[list[str], list[str]]:
return ["data"], ["stage1_packaging_version", "stage1_loguru_available"]

def outputs(self) -> tuple[list[str], list[str]]:
return ["data"], ["stage2_packaging_version", "stage2_loguru_available"]

def process(self, task: DocumentBatch) -> DocumentBatch:
return _record_packaging_and_loguru(task, "stage2_packaging_version", "stage2_loguru_available")


@pytest.mark.parametrize(
"backend_config",
[
pytest.param((RayDataExecutor, {}, "pip"), id="ray_data-pip"),
pytest.param((RayDataExecutor, {}, "uv"), id="ray_data-uv"),
pytest.param((XennaExecutor, {"execution_mode": "streaming"}, "pip"), id="xenna_streaming-pip"),
pytest.param((XennaExecutor, {"execution_mode": "streaming"}, "uv"), id="xenna_streaming-uv"),
pytest.param((XennaExecutor, {"execution_mode": "batch"}, "pip"), id="xenna_batch-pip"),
pytest.param((XennaExecutor, {"execution_mode": "batch"}, "uv"), id="xenna_batch-uv"),
pytest.param((RayActorPoolExecutor, {}, "pip"), id="ray_actor_pool-pip"),
pytest.param((RayActorPoolExecutor, {}, "uv"), id="ray_actor_pool-uv"),
],
indirect=True,
)
class TestPerStageRuntimeEnv:
"""Stages with different runtime_env see different package versions across all backends and spec types."""

backend_cls: type[BaseExecutor] | None = None
config: dict[str, Any] | None = None
results: list[DocumentBatch] | None = None

@pytest.fixture(scope="class", autouse=True)
def backend_config(self, request: pytest.FixtureRequest, shared_ray_cluster: str):
"""Run the three-stage pipeline once per backend/spec_type and store results."""
backend_cls, config, spec_type = request.param
request.cls.backend_cls = backend_cls
request.cls.config = config

initial = DocumentBatch(
task_id="test",
dataset_name="test",
data=pd.DataFrame({"text": ["hello"]}),
)
# Use with_() to override runtime_env spec type (pip or uv) per parametrize run.
stage1 = VersionStage1().with_(runtime_env={spec_type: ["packaging==23.2"]})
stage2 = VersionStage2().with_(runtime_env={spec_type: ["packaging==24.0"]})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need these two stages too, and not just do BaseEnvStage().with_(...)?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So I want to have both stages need to write to different columns (stage1_packaging_version vs stage2_packaging_version) to preserve both versions in the final result for comparison.

Unless we drop the "both versions in one result" design and run two separate pipelines instead:

  stage = BaseEnvStage()  # one reusable class

  result1 = Pipeline(..., stages=[stage.with_(runtime_env={spec_type: ["packaging==23.2"]})]).run(...)
  result2 = Pipeline(..., stages=[stage.with_(runtime_env={spec_type: ["packaging==24.0"]})]).run(...)

  assert result1[0].to_pandas()["base_packaging_version"].iloc[0] == "23.2"
  assert result2[0].to_pandas()["base_packaging_version"].iloc[0] == "24.0"

Is this what you mean ?

pipeline = Pipeline(
name="per_stage_runtime_env_test",
stages=[BaseEnvStage(), stage1, stage2],
)
request.cls.results = pipeline.run(executor=backend_cls(config), initial_tasks=[initial])

def test_stage1_packaging_version(self):
assert self.results is not None
result = self.results[0].to_pandas()
assert result["stage1_packaging_version"].iloc[0] == "23.2"

def test_stage2_packaging_version(self):
assert self.results is not None
result = self.results[0].to_pandas()
assert result["stage2_packaging_version"].iloc[0] == "24.0"

def test_base_env_uses_installed_version(self):
"""Stage with no runtime_env sees the base environment's packaging version."""
assert self.results is not None
result = self.results[0].to_pandas()
assert "base_packaging_version" in result.columns
assert result["base_packaging_version"].iloc[0] # non-empty

def test_all_three_versions_differ(self):
"""Base env, 23.2, and 24.0 must all be distinct."""
assert self.results is not None
result = self.results[0].to_pandas()
versions = {
result["base_packaging_version"].iloc[0],
result["stage1_packaging_version"].iloc[0],
result["stage2_packaging_version"].iloc[0],
}
assert len(versions) == 3, f"Expected 3 distinct versions, got {versions}"

def test_runtime_env_is_additive(self):
"""Stages with runtime_env can still import base-env packages (loguru is a Curator dep, not a Ray dep)."""
assert self.results is not None
result = self.results[0].to_pandas()
assert bool(result["stage1_loguru_available"].iloc[0]), "loguru not importable in stage1 runtime_env"
assert bool(result["stage2_loguru_available"].iloc[0]), "loguru not importable in stage2 runtime_env"
Loading