Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
14 changes: 14 additions & 0 deletions nemo_curator/backends/experimental/ray_data/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
# See the License for the specific language governing permissions and
# limitations under the License.

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

Expand Down Expand Up @@ -101,6 +103,18 @@ 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 pipeline resolved pip_specs to a venv, prepend to PYTHONPATH so workers use that env.
resolved_path = getattr(self.stage, "_resolved_site_packages_path", None)
if resolved_path is not None:
env_vars = ray_remote_args.setdefault("runtime_env", {}).setdefault("env_vars", {})
existing = env_vars.get("PYTHONPATH") or os.environ.get("PYTHONPATH", "")
env_vars["PYTHONPATH"] = f"{resolved_path}:{existing}" if existing else str(resolved_path)
concurrency_kwargs.update(ray_remote_args)

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

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 @@ -61,6 +61,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.


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

import os

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 Down Expand Up @@ -54,8 +56,16 @@ 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
"""Runtime environment for this stage.

When the pipeline has resolved pip_specs to a venv (_resolved_site_packages_path),
we prepend to PYTHONPATH so workers use that env while keeping any existing path.
"""
resolved_path = getattr(self.processing_stage, "_resolved_site_packages_path", None)
if resolved_path is not None:
existing = os.environ.get("PYTHONPATH", "")
new_path = f"{resolved_path}:{existing}" if existing else str(resolved_path)
Comment thread
suiyoubi marked this conversation as resolved.
Outdated
return pipelines_v1.RuntimeEnv(extra_env_vars={"PYTHONPATH": new_path})
return None

def process_data(self, tasks: list[Task]) -> list[Task] | None:
Expand Down
11 changes: 11 additions & 0 deletions nemo_curator/pipeline/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from nemo_curator.backends.base import BaseExecutor
from nemo_curator.stages.base import CompositeStage, ProcessingStage
from nemo_curator.tasks import Task
from nemo_curator.utils.stage_pip_env import resolve_stage_pip_envs
Comment thread
suiyoubi marked this conversation as resolved.
Outdated


class Pipeline:
Expand Down Expand Up @@ -186,6 +187,16 @@ def run(self, executor: BaseExecutor | None = None, initial_tasks: list[Task] |
"""
self.build()

# Resolve per-stage pip_specs into venvs so executors can use PYTHONPATH (e.g. Ray Data).
# Skip if already resolved (e.g. second run() on same pipeline) to avoid recreating venvs.
needs_resolve = any(
getattr(s, "pip_specs", None)
and not getattr(s, "_resolved_site_packages_path", None)
for s in self.stages
)
if needs_resolve:
resolve_stage_pip_envs(self.stages)
Comment thread
suiyoubi marked this conversation as resolved.
Outdated

if executor is None:
from nemo_curator.backends.xenna import XennaExecutor

Expand Down
3 changes: 2 additions & 1 deletion 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
pip_specs: ClassVar[list[str] | None] = None

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.

Whether we go ahead with this or runtime_env, do you think we need to support with_ as well?


@property
@final
Expand Down
140 changes: 140 additions & 0 deletions nemo_curator/utils/stage_pip_env.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
# 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.

"""Resolve per-stage pip_specs into virtualenvs and set _resolved_site_packages_path on stages.

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.

I'm intrigued about this design choice. Is there a reason to not go with runtime_env directly as Ray supports it? If users want to use uv / conda they can specify that, otherwise we let users specify runtime_env={"pip" : ....}?

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.

I have refactored it so we now use runtime_env instead


Uses the `uv` CLI to create venvs (no Python dependency on uv). When a stage defines
pip_specs (e.g. ["vllm==0.6.0"]), the resolver creates a venv with those packages
and sets the stage's _resolved_site_packages_path so executors can inject PYTHONPATH.
"""

import atexit
import shutil
import sysconfig
from pathlib import Path
from typing import TYPE_CHECKING

from loguru import logger

if TYPE_CHECKING:
from nemo_curator.stages.base import ProcessingStage


def _site_packages_for_venv(venv_dir: Path) -> Path:
Comment thread
suiyoubi marked this conversation as resolved.
Outdated
# Windows uses "Lib" (capital), Unix uses "lib"
for lib_name in ("lib", "Lib"):
lib = venv_dir / lib_name
if not lib.exists():
continue
# On Windows, site-packages may live directly under Lib\ (no python3.x subdir)
site_direct = lib / "site-packages"
if site_direct.exists():
return site_direct.resolve()
for p in lib.iterdir():
if p.is_dir() and p.name.startswith("python"):
site = p / "site-packages"
if site.exists():
return site.resolve()
msg = f"no site-packages found under {venv_dir}"
raise RuntimeError(msg)


def _venv_python_exe(venv_path: Path) -> Path:
Comment thread
suiyoubi marked this conversation as resolved.
Outdated
"""Path to the venv Python interpreter (portable: Windows Scripts/python.exe, Unix bin/python)."""
if sysconfig.get_platform().startswith("win"):
return venv_path / "Scripts" / "python.exe"
return venv_path / "bin" / "python"


def _create_venv_for_specs(
Comment thread
suiyoubi marked this conversation as resolved.
Outdated
specs: list[str], base_dir: Path, subdir_name: str, uv_exe: str
) -> Path:
import subprocess

venv_root = base_dir / subdir_name
venv_root.mkdir(parents=True, exist_ok=True)
venv_path = venv_root / ".venv"
python_path = _venv_python_exe(venv_path)
subprocess.run( # noqa: S603
[uv_exe, "venv", str(venv_path)],
check=True,
capture_output=True,
text=True,
)
# specs come from stage pip_specs (pipeline author configuration)
subprocess.run( # noqa: S603
[uv_exe, "pip", "install", "--python", str(python_path), *specs],
check=True,
capture_output=True,
text=True,
)
Comment thread
suiyoubi marked this conversation as resolved.
Outdated
return _site_packages_for_venv(venv_path)


def resolve_stage_pip_envs(
stages: list["ProcessingStage"],
base_dir: Path | None = None,
) -> None:
"""Create venvs for stages that have pip_specs and set _resolved_site_packages_path.

Stages with the same pip_specs (order-independent) share one venv. Uses the `uv`
CLI; must be on PATH. Modifies each stage instance in place.

Args:
stages: Flat list of execution stages (e.g. after pipeline build).
base_dir: Directory for venv roots. If None, a temp directory is used.
"""
import subprocess
import tempfile

uv_exe = shutil.which("uv")
if not uv_exe:
logger.warning(
"resolve_stage_pip_envs requires `uv` on PATH; skipping.",
)
return
Comment thread
suiyoubi marked this conversation as resolved.
Outdated
try:
subprocess.run( # noqa: S603
[uv_exe, "--version"],
check=True,
capture_output=True,
text=True,
)
Comment thread
suiyoubi marked this conversation as resolved.
Outdated
except subprocess.CalledProcessError as e:
logger.warning(
"resolve_stage_pip_envs: uv check failed; skipping. Error: %s",
e,
)
return

if base_dir is None:
tmp_dir = tempfile.mkdtemp(prefix="curator_pip_envs_")
atexit.register(shutil.rmtree, tmp_dir, ignore_errors=True)
base = Path(tmp_dir)
else:
base = Path(base_dir)
base.mkdir(parents=True, exist_ok=True)
Comment thread
suiyoubi marked this conversation as resolved.
Outdated

# Dedupe: same sorted specs -> one venv
unique_specs: dict[tuple[str, ...], Path] = {}
for stage in stages:
specs = getattr(stage, "pip_specs", None)
if not specs or not isinstance(specs, list):
continue
key = tuple(sorted(specs))
Comment thread
suiyoubi marked this conversation as resolved.
Outdated
if key not in unique_specs:
subdir = f"env_{len(unique_specs)}"
unique_specs[key] = _create_venv_for_specs(list(specs), base, subdir, uv_exe)
logger.info("Created venv for pip_specs %s at %s", list(key), unique_specs[key])
stage._resolved_site_packages_path = unique_specs[key]
Comment thread
suiyoubi marked this conversation as resolved.
Outdated
Comment thread
suiyoubi marked this conversation as resolved.
Outdated
Loading
Loading