Skip to content

Commit 7f970b0

Browse files
committed
ci: parallelize ng_test_all in-process (gym env test / ng_test_all)
Ports #1596 onto the unified gym CLI (#1434). Adds +max_concurrency to ng_test_all: each module still runs in its own isolated subprocess/venv, but up to N run concurrently via a ThreadPoolExecutor, so local ng_test_all and CI both speed up (no matrix sharding). run_command gains an additive capture= param so concurrent module output is collected and printed atomically. Workflow runs on ${{ vars.TEST_RUNNER || 'ubuntu-latest' }} with TEST_CONCURRENCY (default 8). Targets martas/1434. Signed-off-by: Wojciech Prazuch <wprazuch@nvidia.com>
1 parent 4b97e8a commit 7f970b0

5 files changed

Lines changed: 190 additions & 31 deletions

File tree

.github/workflows/unit-tests.yml

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@ concurrency:
2626
jobs:
2727
test:
2828
name: Test
29-
runs-on: ubuntu-latest
29+
# Runs on a standard runner by default. Set the `TEST_RUNNER` repo/org variable to a larger
30+
# runner label (e.g. a multi-core runner) to give the concurrent server suite more cores.
31+
runs-on: ${{ vars.TEST_RUNNER || 'ubuntu-latest' }}
3032
steps:
3133
- name: Checkout repository
3234
uses: actions/checkout@v6
@@ -139,14 +141,19 @@ jobs:
139141
140142
- name: Test
141143
if: steps.changes.outputs.run_full == 'true' || steps.changes.outputs.run_servers == 'true'
144+
env:
145+
# How many module test suites to run concurrently. Each module still runs in its own
146+
# isolated subprocess/venv. Defaults to 8; override via the `TEST_CONCURRENCY` repo/org
147+
# variable (e.g. lower it if a runner hits memory pressure, raise it on a larger runner).
148+
TEST_CONCURRENCY: ${{ vars.TEST_CONCURRENCY || '8' }}
142149
run: |
143150
source .venv/bin/activate
144151
145152
# Full suite: core library tests + all server tests
146153
if [[ "${{ steps.changes.outputs.run_full }}" == "true" ]]; then
147154
echo "Running full test suite"
148155
ng_dev_test
149-
ng_test_all +fail_on_total_and_test_mismatch=true +delete_venvs_after_each_test=true
156+
ng_test_all +fail_on_total_and_test_mismatch=true +delete_venvs_after_each_test=true +max_concurrency=${TEST_CONCURRENCY}
150157
151158
# Server-only: test only the changed servers
152159
elif [[ "${{ steps.changes.outputs.run_servers }}" == "true" ]]; then

nemo_gym/cli/env.py

Lines changed: 92 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
import json
1818
import os
1919
import shlex
20+
from concurrent.futures import ThreadPoolExecutor, as_completed
21+
from dataclasses import dataclass
2022
from glob import glob
2123
from os import makedirs
2224
from os.path import exists
@@ -501,11 +503,13 @@ def _validate_data_single(test_config: TestConfig) -> None: # pragma: no cover
501503
print(f"The data for {test_config.dir_path} has been successfully validated!")
502504

503505

504-
def _test_single(test_config: TestConfig, global_config_dict: DictConfig) -> Popen: # pragma: no cover
506+
def _test_single(
507+
test_config: TestConfig, global_config_dict: DictConfig, capture: bool = False
508+
) -> Popen: # pragma: no cover
505509
# Eventually we may want more sophisticated testing here, but this is sufficient for now.
506510
prefix = test_config.entrypoint.replace("/", "\\/")
507511
command = f"""{setup_env_command(test_config.dir_path, global_config_dict, prefix)} && pytest"""
508-
return run_command(command, test_config.dir_path)
512+
return run_command(command, test_config.dir_path, capture=capture)
509513

510514

511515
def test(): # pragma: no cover
@@ -549,6 +553,71 @@ class TestAllConfig(BaseNeMoGymCLIConfig):
549553
default=False,
550554
description="Delete each server venv after its tests have been run (default: False).",
551555
)
556+
max_concurrency: int = Field(
557+
default=1,
558+
ge=1,
559+
description="How many module test suites to run concurrently (default: 1 = sequential). "
560+
"Each module still runs in its own isolated subprocess/venv; raising this parallelizes the "
561+
"suite on one machine (CI runner or local), bounded by available cores/IO.",
562+
)
563+
564+
565+
@dataclass
566+
class _ModuleTestResult:
567+
dir_path: Path
568+
return_code: int
569+
data_validation_failed: bool
570+
elapsed_s: float
571+
output: Optional[str] # captured combined stdout/stderr when run concurrently, else None
572+
573+
574+
def _run_module_tests(
575+
dir_path: Path, global_config_dict: DictConfig, delete_venv: bool, capture: bool
576+
) -> _ModuleTestResult: # pragma: no cover
577+
"""Run one module's test suite (build venv -> pytest -> data validation -> optional cleanup).
578+
579+
Safe to call from multiple threads concurrently: each module builds its own venv and runs in
580+
its own subprocess, and `_validate_data_single` only reads files. When `capture` is set, the
581+
subprocess output is collected so the caller can print it without interleaving.
582+
"""
583+
start_time = time()
584+
test_config = TestConfig(entrypoint=str(dir_path), should_validate_data=True)
585+
proc = _test_single(test_config, global_config_dict, capture=capture)
586+
587+
if capture:
588+
output, _ = proc.communicate()
589+
return_code = proc.returncode
590+
else:
591+
return_code = proc.wait()
592+
output = None
593+
594+
data_validation_failed = False
595+
try:
596+
_validate_data_single(test_config)
597+
except AssertionError:
598+
data_validation_failed = True
599+
600+
if delete_venv:
601+
rmtree(dir_path / ".venv", ignore_errors=True)
602+
603+
return _ModuleTestResult(dir_path, return_code, data_validation_failed, time() - start_time, output)
604+
605+
606+
def _run_module_tests_all(run_one, dir_paths: List[Path], max_concurrency: int) -> List[_ModuleTestResult]:
607+
"""Apply `run_one(dir_path)` over every module, up to `max_concurrency` at a time.
608+
609+
Returns results in completion order. With max_concurrency == 1 this is a plain sequential loop;
610+
otherwise modules run concurrently in a thread pool (the heavy work happens in subprocesses).
611+
"""
612+
if max_concurrency <= 1:
613+
return [run_one(dir_path) for dir_path in tqdm(dir_paths, desc="Running tests")]
614+
615+
results: List[_ModuleTestResult] = []
616+
with ThreadPoolExecutor(max_workers=max_concurrency) as executor:
617+
futures = [executor.submit(run_one, dir_path) for dir_path in dir_paths]
618+
for future in tqdm(as_completed(futures), total=len(dir_paths), desc="Running tests"):
619+
results.append(future.result())
620+
return results
552621

553622

554623
def test_all(): # pragma: no cover
@@ -566,22 +635,29 @@ def test_all(): # pragma: no cover
566635
dir_paths = [p for p in dir_paths if (p / "README.md").exists()]
567636
print(f"Found {len(dir_paths)} modules to test:{_display_list_of_paths(dir_paths)}\n")
568637

638+
max_concurrency = test_all_config.max_concurrency
639+
capture = max_concurrency > 1
640+
if capture:
641+
print(f"Running up to {max_concurrency} module test suites concurrently.\n")
642+
643+
def run_one(dir_path: Path) -> _ModuleTestResult:
644+
return _run_module_tests(dir_path, global_config_dict, test_all_config.delete_venvs_after_each_test, capture)
645+
646+
results = _run_module_tests_all(run_one, dir_paths, max_concurrency)
647+
569648
tests_passed: List[Path] = []
570649
tests_failed: List[Path] = []
571650
tests_missing: List[Path] = []
572651
data_validation_failed: List[Path] = []
573652
times_taken: List[Tuple[float, Path]] = []
574-
for dir_path in tqdm(dir_paths, desc="Running tests"):
575-
start_time = time()
576-
577-
test_config = TestConfig(
578-
entrypoint=str(dir_path),
579-
should_validate_data=True, # Test all always validates data.
580-
)
581-
proc = _test_single(test_config, global_config_dict)
582-
return_code = proc.wait()
583-
584-
match return_code:
653+
for result in results:
654+
dir_path = result.dir_path
655+
# When run concurrently each module's output was captured; print it atomically here so
656+
# logs stay readable instead of interleaving across modules.
657+
if result.output is not None:
658+
print(f"\n===== {dir_path} =====\n{result.output}")
659+
660+
match result.return_code:
585661
case 0:
586662
tests_passed.append(dir_path)
587663
case 1 | 2:
@@ -590,21 +666,14 @@ def test_all(): # pragma: no cover
590666
tests_missing.append(dir_path)
591667
case _:
592668
raise ValueError(
593-
f"""Hit unrecognized exit code {return_code} while running tests for {dir_path}.
669+
f"""Hit unrecognized exit code {result.return_code} while running tests for {dir_path}.
594670
You can rerun just these tests using `ng_test +entrypoint={dir_path}` or run detailed tests via `cd {dir_path} && source .venv/bin/activate && pytest`."""
595671
)
596672

597-
try:
598-
_validate_data_single(test_config)
599-
except AssertionError:
673+
if result.data_validation_failed:
600674
data_validation_failed.append(dir_path)
601675

602-
if test_all_config.delete_venvs_after_each_test:
603-
venv_path = dir_path / ".venv"
604-
print(f"Deleting {venv_path} since `delete_venvs_after_each_test=true`")
605-
rmtree(venv_path, ignore_errors=True)
606-
607-
times_taken.append((time() - start_time, dir_path))
676+
times_taken.append((result.elapsed_s, dir_path))
608677

609678
times_taken.sort(reverse=True)
610679
table = Table(title="Times taken per test (sorted from highest to lowest)")

nemo_gym/cli_setup_command.py

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
import os
1717
from os import environ
1818
from pathlib import Path
19-
from subprocess import Popen
19+
from subprocess import PIPE, STDOUT, Popen
2020
from sys import stderr, stdout
2121

2222
from omegaconf import DictConfig
@@ -172,7 +172,7 @@ def setup_env_command(dir_path: Path, global_config_dict: DictConfig, prefix: st
172172
return f"cd {dir_path} && {env_setup_cmd}"
173173

174174

175-
def run_command(command: str, working_dir_path: Path, server_name: str = "") -> Popen:
175+
def run_command(command: str, working_dir_path: Path, server_name: str = "", capture: bool = False) -> Popen:
176176
global_config_dict = get_global_config_dict()
177177

178178
work_dir = f"{working_dir_path.absolute()}"
@@ -192,13 +192,24 @@ def run_command(command: str, working_dir_path: Path, server_name: str = "") ->
192192
log_path.parent.mkdir(parents=True, exist_ok=True)
193193
command = f"set -o pipefail; ({command}) 2>&1 | tee -a {log_path}"
194194

195-
redirect_stdout = stdout
196-
redirect_stderr = stderr
195+
# When capturing, pipe stdout+stderr into the process (text mode) so callers (e.g. concurrent
196+
# test runs) can collect each command's output and print it atomically instead of interleaving
197+
# streams. Otherwise inherit the parent's streams exactly as before.
198+
if capture:
199+
return Popen(
200+
command,
201+
executable="/bin/bash",
202+
shell=True,
203+
env=custom_env,
204+
stdout=PIPE,
205+
stderr=STDOUT,
206+
text=True,
207+
)
197208
return Popen(
198209
command,
199210
executable="/bin/bash",
200211
shell=True,
201212
env=custom_env,
202-
stdout=redirect_stdout,
203-
stderr=redirect_stderr,
213+
stdout=stdout,
214+
stderr=stderr,
204215
)

tests/unit_tests/test_cli.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
_GRACEFUL_SHUTDOWN_TIMEOUT_SEC,
3232
RunConfig,
3333
RunHelper,
34+
_run_module_tests_all,
3435
init_resources_server,
3536
)
3637
from nemo_gym.cli.general import display_help_legacy
@@ -211,3 +212,51 @@ def test_graceful_termination_does_not_kill(self) -> None:
211212
assert a.wait.call_count == 1
212213
assert b.wait.call_count == 1
213214
assert runner._processes == {}
215+
216+
217+
class TestRunModuleTestsAll:
218+
def test_sequential_runs_all_in_order(self) -> None:
219+
paths = [Path(f"resources_servers/s{i}") for i in range(5)]
220+
seen: list[Path] = []
221+
222+
def run_one(p: Path) -> Path:
223+
seen.append(p)
224+
return p
225+
226+
results = _run_module_tests_all(run_one, paths, max_concurrency=1)
227+
assert results == paths
228+
assert seen == paths # max_concurrency=1 preserves order
229+
230+
def test_concurrent_runs_every_module_exactly_once(self) -> None:
231+
paths = [Path(f"resources_servers/s{i:02d}") for i in range(20)]
232+
233+
def run_one(p: Path) -> Path:
234+
return p
235+
236+
results = _run_module_tests_all(run_one, paths, max_concurrency=8)
237+
# Completion order is non-deterministic, but every module must run exactly once.
238+
assert sorted(results, key=str) == sorted(paths, key=str)
239+
assert len(results) == len(set(results)) == len(paths)
240+
241+
def test_concurrency_actually_overlaps(self) -> None:
242+
import threading
243+
from time import sleep
244+
245+
paths = [Path(f"resources_servers/s{i}") for i in range(8)]
246+
lock = threading.Lock()
247+
in_flight = 0
248+
max_in_flight = 0
249+
250+
def run_one(p: Path) -> Path:
251+
nonlocal in_flight, max_in_flight
252+
with lock:
253+
in_flight += 1
254+
max_in_flight = max(max_in_flight, in_flight)
255+
sleep(0.05)
256+
with lock:
257+
in_flight -= 1
258+
return p
259+
260+
_run_module_tests_all(run_one, paths, max_concurrency=4)
261+
# With a pool of 4, multiple modules must have been in flight simultaneously.
262+
assert max_in_flight >= 2

tests/unit_tests/test_cli_setup_command.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,29 @@ def test_sanity(self, monkeypatch: MonkeyPatch) -> None:
274274
actual_args = Popen_mock.call_args
275275
assert expected_args == actual_args
276276

277+
def test_capture_pipes_combined_output(self, monkeypatch: MonkeyPatch) -> None:
278+
from subprocess import PIPE, STDOUT
279+
280+
Popen_mock, _ = self._setup(monkeypatch)
281+
282+
run_command(
283+
command="my command",
284+
working_dir_path=Path("/my path"),
285+
capture=True,
286+
)
287+
288+
# capture=True pipes stdout+stderr together in text mode so callers can collect output.
289+
expected_args = call(
290+
"my command",
291+
executable="/bin/bash",
292+
shell=True,
293+
env={"PYTHONPATH": "/my path", "UV_CACHE_DIR": "default uv cache dir"},
294+
stdout=PIPE,
295+
stderr=STDOUT,
296+
text=True,
297+
)
298+
assert Popen_mock.call_args == expected_args
299+
277300
def test_custom_pythonpath(self, monkeypatch: MonkeyPatch) -> None:
278301
Popen_mock, get_global_config_dict_mock = self._setup(monkeypatch)
279302
monkeypatch.setattr(nemo_gym.cli_setup_command, "environ", {"PYTHONPATH": "existing pythonpath"})

0 commit comments

Comments
 (0)