Repository navigation
[Feat] Per Stage Runtime Env #1623
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 22 commits
5271c7c
9d1fd4e
b3624fc
e2ae9a2
b25df9f
eaaf1d4
e243755
98470ca
b7b2351
fc8f510
2666b24
f4ef209
e971625
fab1b90
e89badb
8062427
a60e271
297b8e4
d88d678
dcf7143
7164dd0
beb11ac
952e77f
a882ad4
6792826
00dd3b9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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 | ||||||||||||||||||||||||
|
|
@@ -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) | ||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| 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: | ||||||||||||||||||||||||
|
|
@@ -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. | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| 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. | ||
| """ | ||
|
|
||
|
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 | ||
|
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"], [] | ||
|
|
||
|
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_()).""" | ||
|
|
||
|
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"]}) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why do we need these two stages too, and not just do
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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: 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" | ||
Uh oh!
There was an error while loading. Please reload this page.