dwehr's picture
Migrate action viewer to local Cosmos generation
9f818c5
Raw
History Blame Contribute Delete
12 kB
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: OpenMDW-1.1
"""Inference/training script test fixtures.
Used by 'tests/scripts_test.py'.
"""
import os
import re
import shutil
import subprocess
import warnings
from dataclasses import dataclass
from functools import cached_property
from pathlib import Path
from typing import Any, Callable
import numpy as np
import pydantic
import pytest
from cosmos_framework.inference.common.args import MEDIA_EXTENSIONS, ResolvedFilePath
from cosmos_framework.inference.common.init import get_free_port
from cosmos_framework.inference.fixtures.args import Level, NumGpus
from cosmos_framework.utils.checkpoint_db import HF_VERSION
from cosmos_framework.utils.easy_io import easy_io
INPUT_DIR = Path("inputs").absolute()
OUTPUT_DIR = Path("outputs").absolute()
class ScriptConfig(pydantic.BaseModel):
model_config = pydantic.ConfigDict(extra="forbid")
name: str = ""
"""Test name."""
script: ResolvedFilePath
"""Script path."""
use_tmp_input_dir: bool = False
"""If set, use a per-test temp directory for INPUT_DIR."""
levels: tuple[Level, ...] = (0,)
"""Test levels."""
gpus: tuple[NumGpus, NumGpus, NumGpus] = (0, 1, 1)
"""Number of GPUs for each level."""
marks: tuple[pytest.MarkDecorator | pytest.Mark, ...] = ()
"""Additional pytest marks."""
golden_psnr: pydantic.PositiveFloat = 14.0
"""Golden comparison PSNR threshold in dB."""
get_env: Callable[["ScriptRunner", "ScriptConfig"], dict[str, str]] | None = None
"""Function to get environment variables."""
before_script: Callable[["ScriptRunner", "ScriptConfig"], None] | None = None
"""Function to run before the script."""
after_script: Callable[["ScriptRunner", "ScriptConfig"], None] | None = None
"""Function to run after the script."""
@pydantic.model_validator(mode="before")
@classmethod
def validate_name(cls, data: Any) -> Any:
if not isinstance(data, dict):
return data
if not data.get("name"):
script_path: Path = data["script"]
data["name"] = script_path.name.replace(".sh", "")
return data
def get_marks(self, level: int) -> list[pytest.MarkDecorator | pytest.Mark]:
marks = list(self.marks)
if level not in self.levels:
marks.append(pytest.mark.manual)
marks.append(pytest.mark.gpus(self.gpus[level]))
return marks
@dataclass(kw_only=True, frozen=True)
class ScriptRunner:
request: pytest.FixtureRequest
tmp_path_factory: pytest.TempPathFactory
tmp_path: Path
level: int = 0
@cached_property
def output_name(self) -> str:
test_name = self.request.node.name
if "[" in test_name and "]" in test_name:
base_part, param_part = test_name.split("[", 1)
param_part = param_part.rstrip("]").replace("/", "_").replace("-", "_")
sanitized_name = f"{base_part}_{param_part}"
else:
sanitized_name = test_name.replace("/", "_").replace("-", "_")
return sanitized_name
@cached_property
def input_dir(self) -> Path:
return INPUT_DIR
@cached_property
def tmp_input_dir(self) -> Path:
return self.tmp_path / "inputs"
@cached_property
def output_dir(self) -> Path:
return OUTPUT_DIR / "pytest" / self.output_name
@cached_property
def golden_dir(self) -> Path:
return INPUT_DIR / "outputs/pytest" / self.output_name
def _get_env(
self,
cfg: ScriptConfig,
*,
torchrun_args: list[str] | None = None,
inference_args: list[str] | None = None,
train_args: list[str] | None = None,
train_overrides: list[str] | None = None,
) -> dict[str, str]:
if torchrun_args is None:
torchrun_args = []
if inference_args is None:
inference_args = []
if train_args is None:
train_args = []
if train_overrides is None:
train_overrides = []
num_gpus = os.environ["NUM_GPUS"]
master_port = get_free_port()
env = dict(os.environ)
# Ensure reproducibility
env = {k: v for k, v in os.environ.items() if not k.startswith("COSMOS_")}
env |= {
"COSMOS_INTERNAL": "0",
# Disable S3 checkpoints
"IMAGINAIRE_CACHE_DIR": "/invalid",
"INPUT_DIR": f"{self.tmp_input_dir if cfg.use_tmp_input_dir else self.input_dir}",
"OUTPUT_DIR": f"{self.output_dir}",
"TMP_DIR": f"{self.tmp_path}/tmp",
"MASTER_PORT": str(master_port),
"HF_VERSION": HF_VERSION,
"TORCHRUN_ARGS": " ".join(
[
f"--nproc_per_node={num_gpus}",
f"--master_port={master_port}",
*torchrun_args,
]
),
"INFERENCE_ARGS": " ".join(
[
"--seed=0",
"--debug",
*inference_args,
]
),
"TRAIN_ARGS": " ".join(
[
*train_args,
]
),
"TRAIN_OVERRIDES": " ".join(
[
"job.wandb_mode=disabled",
f"model.config.parallelism.data_parallel_shard_degree={num_gpus}",
"model.config.parallelism.context_parallel_shard_degree=1",
"model.config.parallelism.cfg_parallel_shard_degree=1",
*train_overrides,
]
),
}
if cfg.get_env is not None:
env |= cfg.get_env(self, cfg)
return env
def get_env(self, cfg: ScriptConfig, level: int) -> dict[str, str]:
match level:
case 0:
return self._get_env(cfg) | {"COSMOS_SMOKE": "1"}
case 1:
return self._get_env(
cfg,
inference_args=[
"--no-guardrails",
],
train_overrides=[
"trainer.max_iter=5",
],
)
case 2:
return self._get_env(
cfg,
inference_args=[
"--guardrails",
],
train_overrides=[
"trainer.max_iter=20",
],
)
case _:
assert False, "unreachable"
def run(self, cfg: ScriptConfig, level: int):
object.__setattr__(self, "level", level) # frozen dataclass, but level is set per call
shutil.rmtree(self.output_dir, ignore_errors=True)
if cfg.before_script is not None:
cfg.before_script(self, cfg)
subprocess.check_call(
["bash", "-euxo", "pipefail", str(cfg.script)],
cwd=self.request.config.rootpath,
env=self.get_env(cfg, level),
)
if cfg.after_script is not None:
cfg.after_script(self, cfg)
if False:
_check_golden_dir(self.output_dir, self.golden_dir, min_psnr=cfg.golden_psnr)
def script_test(configs: list[ScriptConfig]) -> Callable[[type], type]:
names = set()
for cfg in configs:
if cfg.name in names:
raise ValueError(f"Duplicate script name: {cfg.name}")
names.add(cfg.name)
def decorator(cls: type) -> type:
@pytest.fixture
def script_runner(
self, request: pytest.FixtureRequest, tmp_path_factory: pytest.TempPathFactory, tmp_path: Path
) -> ScriptRunner:
return ScriptRunner(request=request, tmp_path_factory=tmp_path_factory, tmp_path=tmp_path)
setattr(cls, "script_runner", script_runner)
@pytest.mark.level(0)
@pytest.mark.parametrize("cfg", [pytest.param(cfg, id=cfg.name, marks=cfg.get_marks(0)) for cfg in configs])
def test_level_0(self, cfg: ScriptConfig, script_runner: ScriptRunner):
script_runner.run(cfg, 0)
setattr(cls, "test_level_0", test_level_0)
@pytest.mark.level(1)
@pytest.mark.parametrize("cfg", [pytest.param(cfg, id=cfg.name, marks=cfg.get_marks(1)) for cfg in configs])
def test_level_1(self, cfg: ScriptConfig, script_runner: ScriptRunner):
script_runner.run(cfg, 1)
setattr(cls, "test_level_1", test_level_1)
@pytest.mark.level(2)
@pytest.mark.parametrize("cfg", [pytest.param(cfg, id=cfg.name, marks=cfg.get_marks(2)) for cfg in configs])
def test_level_2(self, cfg: ScriptConfig, script_runner: ScriptRunner):
script_runner.run(cfg, 2)
setattr(cls, "test_level_2", test_level_2)
return cls
return decorator
def _extract_bash_commands(md_file: Path) -> list[str]:
content = md_file.read_text()
pattern = r"```(bash|shell)([^\n]*)\n(.*?)```"
matches = re.findall(pattern, content, re.DOTALL)
scripts = []
for lang, attrs, block_content in matches:
if "exclude=true" in attrs.lower():
continue
lines = []
for line in block_content.strip().split("\n"):
if line.strip() and not line.strip().startswith("#"):
line = line.split("#")[0].rstrip()
# Replace --nproc_per_node with dynamic NUM_GPUS value
line = re.sub(r"--nproc_per_node=\d+", "--nproc_per_node=$NUM_GPUS", line)
line = re.sub(r"--master_port=\d+", "--master_port=$MASTER_PORT", line)
if line:
lines.append(line)
if lines:
script = "\n".join(lines)
scripts.append(script)
return scripts
def _array_to_float(array: np.ndarray) -> np.ndarray:
if np.issubdtype(array.dtype, np.floating):
assert np.min(array) >= 0.0 and np.max(array) <= 1.0
return array
if array.dtype == np.uint8:
return array / 255.0
raise NotImplementedError(f"Unsupported dtype: {array.dtype}")
def _compute_psnr(array1: np.ndarray, array2: np.ndarray) -> float:
"""Compare PSNR between two arrays."""
array1 = _array_to_float(array1)
array2 = _array_to_float(array2)
overall_mse = ((array1 - array2) ** 2).mean()
return 10 * np.log10(1.0 / overall_mse) if overall_mse > 0 else float("inf")
def _check_golden_file(output_path: Path, golden_path: Path, /, min_psnr: float) -> None:
output_array, _output_meta = easy_io.load(output_path)
assert isinstance(output_array, np.ndarray)
golden_array, _golden_meta = easy_io.load(golden_path)
assert isinstance(golden_array, np.ndarray)
psnr = _compute_psnr(output_array, golden_array)
if psnr < min_psnr:
warnings.warn(
f"FAIL: Golden PSNR {psnr:.2f} dB is less than minimum {min_psnr:.2f} dB for file '{output_path}'"
)
else:
print(f"PASS: Golden PSNR {psnr:.2f} dB is greater than minimum {min_psnr:.2f} dB for file '{output_path}'")
def _check_golden_dir(output_dir: Path, golden_dir: Path, /, min_psnr: float) -> None:
if not golden_dir.exists():
warnings.warn(f"Golden directory '{golden_dir}' does not exist")
return
for dirpath, _dirnames, filenames in os.walk(golden_dir):
for filename in filenames:
golden_path = Path(dirpath) / filename
output_path = output_dir / golden_path.relative_to(golden_dir)
if output_path.suffix not in MEDIA_EXTENSIONS:
continue
if not output_path.exists():
warnings.warn(f"File '{output_path}' missing in output directory")
continue
_check_golden_file(output_path, golden_path, min_psnr=min_psnr)