dwehr's picture
Migrate action viewer to local Cosmos generation
9f818c5
Raw
History Blame Contribute Delete
10.1 kB
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: OpenMDW-1.1
from cosmos_framework.utils.lazy_config import lazy_call
lazy_call._CONVERT_TARGET_TO_STRING = True
import gc
import os
from functools import cache
from pathlib import Path
import pytest
from cosmos_framework.inference.fixtures.args import ALL_LEVELS, ALL_NUM_GPUS, ALLOWED_GPUS_BY_LEVEL, Args, get_args, init_args
@pytest.fixture(scope="module")
def original_datadir(request: pytest.FixtureRequest) -> Path:
root_dir = request.config.rootpath
relative_path = request.path.with_suffix("").relative_to(root_dir)
return root_dir / "tests/data" / relative_path
@cache
def _get_available_gpus() -> int:
import pynvml
try:
pynvml.nvmlInit()
device_count = pynvml.nvmlDeviceGetCount()
pynvml.nvmlShutdown()
return device_count
except pynvml.NVMLError as e:
print(f"WARNING: Failed to get available GPUs: {e}")
return 0
def pytest_addoption(parser: pytest.Parser):
parser.addoption("--manual", action="store_true", default=False, help="Run manual tests")
parser.addoption(
"--num-gpus",
default=None,
type=int,
choices=ALL_NUM_GPUS,
help="Run tests with the specified number of GPUs",
)
parser.addoption("--levels", default=None, help="Run tests with the specified levels (comma-separated list)")
def pytest_xdist_auto_num_workers(config: pytest.Config) -> int | None:
num_gpus: int | None = config.option.num_gpus
if num_gpus is None:
return 1
if num_gpus == 0:
return None
available_gpus = _get_available_gpus()
if available_gpus < num_gpus:
raise ValueError(f"Not enough GPUs available. Required: {num_gpus}, Available: {available_gpus}")
return available_gpus // num_gpus
def pytest_configure(config: pytest.Config):
args = Args.from_config(config)
init_args(args)
if (
args.num_gpus is not None
and args.levels is not None
and all(args.num_gpus not in ALLOWED_GPUS_BY_LEVEL[level] for level in args.levels)
):
pytest.exit(f"No tests for {args.num_gpus} GPUs and levels {args.levels}.", returncode=0)
if args.worker_id == "master":
return
if args.worker_index > 1:
if args.num_gpus is None:
raise NotImplementedError(f"Running parallel tests requires --num-gpus to be set.")
# Check if there are enough GPUs available.
if args.num_gpus is not None and args.num_gpus > 0:
required_gpus = args.num_gpus * (args.worker_index + 1)
else:
required_gpus = 1
available_gpus = _get_available_gpus()
if available_gpus < required_gpus:
raise ValueError(f"Not enough GPUs available. Required: {required_gpus}, Available: {available_gpus}")
# Limit threading to reduce contention
import torch
torch.set_num_threads(1)
torch.set_num_interop_threads(1)
def _get_marker(item: pytest.Item, name: str) -> pytest.Mark | None:
markers = list(item.iter_markers(name=name))
if not markers:
return None
marker = markers[0]
for other_marker in markers[1:]:
if other_marker != marker:
raise ValueError(f"Multiple different markers found for {name}: {markers}")
return marker
def _parse_level_marker(mark: pytest.Mark) -> int:
if len(mark.args) != 1:
raise ValueError(f"Invalid arguments: {mark.args}")
if mark.kwargs:
raise ValueError(f"Invalid keyword arguments: {mark.kwargs}")
level = mark.args[0]
if level not in ALL_LEVELS:
raise ValueError(f"Invalid level {level} not in {ALL_LEVELS}")
return level
def _parse_gpus_marker(mark: pytest.Mark) -> int:
if len(mark.args) != 1:
raise ValueError(f"Invalid arguments: {mark.args}")
if mark.kwargs:
raise ValueError(f"Invalid keyword arguments: {mark.kwargs}")
required_gpus = int(mark.args[0])
if required_gpus not in ALL_NUM_GPUS:
raise ValueError(f"Invalid number of GPUs {required_gpus} not in {ALL_NUM_GPUS}")
return required_gpus
def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]):
args = get_args()
for item in items:
manual_mark = _get_marker(item, "manual")
level_mark = _get_marker(item, "level")
gpus_mark = _get_marker(item, "gpus")
try:
level = _parse_level_marker(level_mark) if level_mark else 0
gpus = _parse_gpus_marker(gpus_mark) if gpus_mark else 0
except ValueError as e:
pytest.fail(f"Invalid marker on test {item.name}: {e}")
assert False, "unreachable"
allowed_gpus = ALLOWED_GPUS_BY_LEVEL[level]
if gpus not in allowed_gpus:
pytest.fail(f"Level {level} tests must have {allowed_gpus} GPUs, but {item.name} has {gpus} GPUs")
# Check if the test should be skipped
if not args.enable_manual and manual_mark is not None:
item.add_marker(pytest.mark.skip(reason="test requires --manual"))
if args.levels is not None and level not in args.levels:
item.add_marker(pytest.mark.skip(reason=f"test requires --levels={level}"))
if args.num_gpus is not None and gpus != args.num_gpus:
item.add_marker(pytest.mark.skip(reason=f"test requires --num-gpus={gpus}"))
available_gpus = _get_available_gpus()
if gpus > available_gpus:
item.add_marker(
pytest.mark.skip(reason=f"test requires {gpus} GPUs, but only {available_gpus} are available")
)
# Exclude skipped tests
selected_items = []
deselected_items = []
for item in items:
if item.get_closest_marker("skip"):
deselected_items.append(item)
continue
selected_items.append(item)
items[:] = selected_items
config.hook.pytest_deselected(items=deselected_items)
def pytest_runtest_setup(item: pytest.Item):
import torch
args = get_args()
# Distributed tests launched via torchrun manage their own per-rank device
# (each rank calls torch.cuda.set_device(rank/LOCAL_RANK)). We must NOT pin
# CUDA_VISIBLE_DEVICES here or every rank would see only GPU 0, and rank>0's
# set_device(rank) crashes with "invalid device ordinal". torchrun sets RANK
# in the environment, so use it to detect the distributed launch and leave
# device selection to the test.
if "RANK" in os.environ:
return
gpus_mark = item.get_closest_marker(name="gpus")
try:
gpus = _parse_gpus_marker(gpus_mark) if gpus_mark else 0
except ValueError as e:
pytest.fail(f"Invalid marker on test {item.name}: {e}")
assert False, "unreachable"
# Limit the number of GPUs used by the test
if gpus > 0:
device_start = args.worker_index * gpus
device_end = device_start + gpus
os.environ["CUDA_VISIBLE_DEVICES"] = ",".join(map(str, range(device_start, device_end)))
os.environ["NUM_GPUS"] = str(gpus)
else:
device = 0
os.environ["CUDA_VISIBLE_DEVICES"] = str(device)
os.environ["NUM_GPUS"] = "1"
test_max_processes = int(os.environ.get("TEST_MAX_PROCESSES", "8"))
device_memory_fraction = 1 / max(args.worker_count, test_max_processes)
os.environ["DEVICE_MEMORY_FRACTION"] = str(device_memory_fraction)
# Guard the GPU-only call so CPU-only test runs (e.g. the cpu-tests CI
# job on a GPU-less runner) don't crash in setup; a no-op when a GPU is
# present, so GPU CI behavior is unchanged.
if torch.cuda.is_available():
torch.cuda.set_per_process_memory_fraction(device_memory_fraction)
@pytest.fixture(autouse=True)
def init_cosmos_test(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
from cosmos_framework.inference.common.init import _init_log_console, _init_log_files
monkeypatch.setenv("IMAGINAIRE_OUTPUT_ROOT", str(tmp_path / "imaginaire4-output"))
_init_log_console()
_init_log_files(tmp_path)
yield
@pytest.fixture(autouse=True)
def init_torch_test():
import torch
from cosmos_framework.inference.common.init import set_seed
# Reproducibility
set_seed(0)
# Restore autograd in case a previously-imported/-run module left it
# globally disabled (e.g. inference/ray/serve.py calls
# torch.set_grad_enabled(False) at import time), which would otherwise break
# later tests that need backward().
torch.set_grad_enabled(True)
yield
# Cleanup memory
gc.collect()
if torch.cuda.is_available():
torch.cuda.empty_cache()
_WHITELIST_ENV_VARS = {
"LD_LIBRARY_PATH",
"QT_QPA_FONTDIR",
"QT_QPA_PLATFORM_PLUGIN_PATH",
"TORCHINDUCTOR_CACHE_DIR",
"TRITON_CACHE_DIR", # set by Triton during flex-attention compilation
"NVTE_CUDA_INCLUDE_DIR", # set by Transformer Engine during its CUDA extension setup
}
@pytest.fixture(autouse=True)
def detect_env_modifications():
original_env = dict(os.environ)
yield
new_env = dict(os.environ)
for env in [original_env, new_env]:
for k in list(env.keys()):
if k.startswith("PYTEST_") or k in _WHITELIST_ENV_VARS:
del env[k]
if new_env != original_env:
added, removed, modified = _compare_dict(new_env, original_env)
os.environ.clear()
os.environ.update(original_env)
raise ValueError(
f"Environment variables modified by test! Use 'monkeypatch.setenv' to temporarily modify environment variables. \n"
f"Added: {added}\n"
f"Removed: {removed}\n"
f"Modified: {modified}"
)
def _compare_dict(actual: dict[str, str], expected: dict[str, str]) -> tuple[set[str], set[str], set[str]]:
added = set(actual) - set(expected)
removed = set(expected) - set(actual)
modified = {k for k in expected if k in actual and expected[k] != actual[k]}
return added, removed, modified