Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
3ca93df
Honor device kwarg over sim_cfg.device in build_simulation_context
hujc7 May 30, 2026
a396a07
Add ISAACLAB_PIN_KIT_GPU to pin Kit renderer to one GPU
hujc7 Jun 5, 2026
f1c0d18
[Tests] Add test_devices helper for device-parametrized unit tests
hujc7 Jun 5, 2026
878990a
[Newton] Fix Newton/Warp init on non-default CUDA devices
hujc7 Jun 5, 2026
b6cf4cf
[CI] Add multi-GPU work-queue sharding and per-file reporting to conf…
hujc7 Jun 5, 2026
6cef107
[CI] Forward extra env vars through run-tests / run-package-tests act…
hujc7 Jun 5, 2026
71658df
[CI] Add multi-GPU pytest workflow
hujc7 Jun 5, 2026
cef4569
[Tests] Parametrize unit tests over multi-GPU device scope
hujc7 Jun 5, 2026
5715dcb
[App] Honor ISAACLAB_SIM_DEVICE as implicit default device in AppLaun…
hujc7 Jun 5, 2026
c514199
TEMP: skip docker/docs/install-ci CI while iterating #5823
hujc7 Jun 5, 2026
72128e7
Fix multi-GPU test job to target the multi-GPU runner pool
hujc7 Jun 6, 2026
9d159f3
Extract multi-GPU workflow scripts to sibling files
hujc7 Jun 6, 2026
437a6d2
Build the multi-GPU CI image on the multi-GPU pool
hujc7 Jun 6, 2026
b17679c
Replace root device-skip conftest with a lane plugin
hujc7 Jun 8, 2026
49aadc5
Document multi-GPU scripts; drop py-spy remnants
hujc7 Jun 8, 2026
1e710b1
Revert "[CI] Forward extra env vars through run-tests / run-package-t…
hujc7 Jun 8, 2026
4441f68
Revert "TEMP: skip docker/docs/install-ci CI while iterating #5823"
hujc7 Jun 8, 2026
3694e7f
Merge remote-tracking branch 'origin/develop' into jichuanh/multi-gpu-ci
hujc7 Jun 8, 2026
641068b
Merge remote-tracking branch 'origin/develop' into jichuanh/multi-gpu-ci
hujc7 Jun 11, 2026
2a18923
Merge remote-tracking branch 'origin/develop' into jichuanh/multi-gpu-ci
hujc7 Jun 29, 2026
c799821
Refine multi-GPU test device selection
hujc7 Jun 29, 2026
52a23ac
Unify multi-GPU test device selection
hujc7 Jun 30, 2026
14593ea
Fix OVPhysX test merge resolution
hujc7 Jun 30, 2026
6a2d55a
Switch multi-GPU CI Kit override to ISAACLAB_FABRIC_USE_GPU_INTEROP
hujc7 Jul 7, 2026
9e3c87a
Merge remote-tracking branch 'origin/develop' into jichuanh/multi-gpu-ci
hujc7 Jul 7, 2026
bb416b8
Merge remote-tracking branch 'origin/develop' into jichuanh/multi-gpu-ci
hujc7 Jul 8, 2026
998400a
Scope multi-GPU CI newton changelog to test-only
hujc7 Jul 8, 2026
5670aab
Merge remote-tracking branch 'origin/develop' into jichuanh/multi-gpu-ci
hujc7 Jul 10, 2026
435d74f
Merge remote-tracking branch 'origin/develop' into jichuanh/multi-gpu-ci
hujc7 Jul 12, 2026
a1538e5
Merge remote-tracking branch 'origin/develop' into jichuanh/multi-gpu-ci
hujc7 Jul 13, 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
176 changes: 176 additions & 0 deletions .github/actions/multi-gpu/aggregate_test_summary.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
#!/usr/bin/env python3
# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md).
# All rights reserved.
#
# SPDX-License-Identifier: BSD-3-Clause

"""Aggregate per-shard JUnit XML reports from the multi-GPU pytest lane.

Reads ``RUNTIME_DIR`` (the host launcher's work-queue root, holding
``queue/done/<shard>/<slug>``) and the ``tests/test-reports-*.xml`` JUnit files,
then prints a per-file + per-shard table to stdout and, when
``GITHUB_STEP_SUMMARY`` is set, writes the same content as markdown so it
renders at the top of the run page.
"""

import glob
import os
import pathlib
import xml.etree.ElementTree as ET

runtime_dir = os.environ.get("RUNTIME_DIR", "")
done_root = pathlib.Path(runtime_dir) / "queue" / "done" if runtime_dir else None

# Map slug -> shard from done/<shard>/<slug>; slug uses "__" for "/".
slug_to_shard = {}
if done_root and done_root.is_dir():
for shard_dir in done_root.iterdir():
if not shard_dir.is_dir():
continue
shard = shard_dir.name # e.g. "cuda-1"
for entry in shard_dir.iterdir():
slug_to_shard[entry.name] = shard


def unslug(name: str) -> str:
# report file pattern: test-reports-<slug>.xml
if name.startswith("test-reports-"):
name = name[len("test-reports-") :]
if name.endswith(".xml"):
name = name[:-4]
return name # this is the slug; the file path is slug.replace("__", "/")


# Per-file rows from JUnit XMLs.
rows = []
for path in sorted(glob.glob("tests/test-reports-*.xml")):
slug = unslug(os.path.basename(path))
file_path = slug.replace("__", "/")
shard = slug_to_shard.get(slug, "?")
try:
tree = ET.parse(path)
root = tree.getroot()
except ET.ParseError:
rows.append((file_path, shard, "PARSE-ERROR", 0, 0, 0, 0, 0.0))
continue
tests = failures = errors = skipped = 0
total_time = 0.0
for ts in root.iter("testsuite"):
tests += int(ts.get("tests", 0))
failures += int(ts.get("failures", 0))
errors += int(ts.get("errors", 0))
skipped += int(ts.get("skipped", 0))
total_time += float(ts.get("time", 0) or 0)
passed = tests - failures - errors - skipped
status = "PASS"
if failures + errors > 0:
status = "FAIL"
elif tests == 0:
status = "EMPTY"
rows.append((file_path, shard, status, passed, tests, skipped, failures + errors, total_time))

# Per-shard aggregate.
shards = {}
for fp, sh, st, p, t, sk, f, tm in rows:
if sh not in shards:
shards[sh] = {"files": 0, "passed": 0, "tests": 0, "skipped": 0, "failed": 0, "time": 0.0}
s = shards[sh]
s["files"] += 1
s["passed"] += p
s["tests"] += t
s["skipped"] += sk
s["failed"] += f
s["time"] += tm


# ---------- Output: plain text to stdout ----------
def fmt_row(cols, widths):
return " | ".join(c.ljust(w) for c, w in zip(cols, widths))


out = []
out.append("PER-FILE RESULTS")
out.append("=" * 110)
hdr = ["File", "Shard", "Status", "Pass/Total", "Skip", "Fail", "Time(s)"]
widths = [60, 7, 8, 12, 5, 5, 8]
out.append(fmt_row(hdr, widths))
out.append("-" * 110)
for fp, sh, st, p, t, sk, f, tm in sorted(rows, key=lambda r: (r[1], r[0])):
out.append(fmt_row([fp[-60:], sh, st, f"{p}/{t}", str(sk), str(f), f"{tm:.1f}"], widths))
out.append("")
out.append("PER-SHARD AGGREGATE")
out.append("=" * 80)
shard_hdr = ["Shard", "Files", "Pass/Total", "Skipped", "Failed", "Test time (s)"]
shard_widths = [10, 7, 12, 9, 8, 14]
out.append(fmt_row(shard_hdr, shard_widths))
out.append("-" * 80)
grand = {"files": 0, "passed": 0, "tests": 0, "skipped": 0, "failed": 0, "time": 0.0}
for sh in sorted(shards):
s = shards[sh]
for k in grand:
grand[k] += s[k]
out.append(
fmt_row(
[
sh,
str(s["files"]),
f"{s['passed']}/{s['tests']}",
str(s["skipped"]),
str(s["failed"]),
f"{s['time']:.1f}",
],
shard_widths,
)
)
out.append("-" * 80)
out.append(
fmt_row(
[
"TOTAL",
str(grand["files"]),
f"{grand['passed']}/{grand['tests']}",
str(grand["skipped"]),
str(grand["failed"]),
f"{grand['time']:.1f}",
],
shard_widths,
)
)
print("\n".join(out))

# ---------- Output: markdown to $GITHUB_STEP_SUMMARY ----------
summary_path = os.environ.get("GITHUB_STEP_SUMMARY")
if summary_path:
md = [
"## Multi-GPU CI: Aggregated Test Summary",
"",
"### Per-shard totals",
"",
"| Shard | Files | Pass/Total | Skipped | Failed | Test time (s) |",
"|---|---:|---:|---:|---:|---:|",
]
for sh in sorted(shards):
s = shards[sh]
md.append(
f"| `{sh}` | {s['files']} | {s['passed']}/{s['tests']} | {s['skipped']} | {s['failed']} | {s['time']:.1f} |"
)
md.append(
f"| **TOTAL** | **{grand['files']}** | **{grand['passed']}/{grand['tests']}** | "
f"**{grand['skipped']}** | **{grand['failed']}** | **{grand['time']:.1f}** |"
)
md.append("")
md.append("### Per-file results")
md.append("")
md.append("| File | Shard | Status | Pass/Total | Skipped | Failed | Test time (s) |")
md.append("|---|---|---|---:|---:|---:|---:|")
for fp, sh, st, p, t, sk, f, tm in sorted(rows, key=lambda r: (r[1], r[0])):
status_md = {
"PASS": ":white_check_mark: PASS",
"FAIL": ":x: FAIL",
"EMPTY": ":white_circle: EMPTY",
"PARSE-ERROR": ":warning: PARSE-ERROR",
}.get(st, st)
md.append(f"| `{fp}` | `{sh}` | {status_md} | {p}/{t} | {sk} | {f} | {tm:.1f} |")
md.append("")
with open(summary_path, "a") as fh:
fh.write("\n".join(md) + "\n")
92 changes: 92 additions & 0 deletions .github/actions/multi-gpu/mgpu_shard_select.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md).
# All rights reserved.
#
# SPDX-License-Identifier: BSD-3-Clause

"""pytest plugin: select which tests a non-default GPU shard runs.

Loaded only by the multi-GPU lane: ``tools/conftest.py`` injects it via
``-p mgpu_shard_select`` into each per-file pytest subprocess (and prepends this
directory to ``PYTHONPATH`` so it is importable). It lives next to the lane
scripts rather than as a repo-root ``conftest.py`` so it only affects the lane.

Signal: ``ISAACLAB_TEST_DEVICES`` -- the runtime device mask. This is the SAME
env var ``isaaclab.test.utils.devices.test_devices()`` reads to decide a test's
device parametrization, so this plugin's keep/drop decision agrees with what was
parametrized. Kit-backed tests derive their AppLauncher device from the same mask.
The plugin reads the mask string directly -- no isaaclab import -- matching the
lib-free convention of every other conftest in the repo. The only shared knowledge
is the mask grammar below, mirrored from ``devices.py`` (position 0 = cpu,
position k = cuda:(k-1), trailing ``X`` = include all remaining); keep the two in sync.

Selection rule, on a shard whose mask excludes cpu + cuda:0:

keep a test iff it is parametrized over ``device`` AND that variant's device
is active in the mask; deselect everything else.

That single rule is both criteria at once: candidate = device-parametrized (a
test with no ``device`` param is device-agnostic, covered by single-GPU CI on
cuda:0); scope = only this shard's device variant runs (cpu / cuda:0 / other-index
variants are dropped). Out-of-scope items are deselected (not skipped) so the
shard report shows only what ran.
"""

import os

import pytest

_RUNTIME_ENV = "ISAACLAB_TEST_DEVICES"


def _position(device):
"""Mask position for a device value: cpu -> 0, cuda:k -> k + 1 (else None)."""
if device == "cpu":
return 0
if isinstance(device, str) and device.startswith("cuda:") and device[len("cuda:") :].isdigit():
return int(device[len("cuda:") :]) + 1
return None # not device-parametrized, or an unrecognized device value


def _active(position, mask):
"""Whether a mask position is included. Trailing ``X`` includes all remaining."""
if position is None:
return False
body, fill = (mask[:-1], True) if mask.endswith("X") else (mask, False)
return body[position] == "1" if 0 <= position < len(body) else fill


def _shard_mask():
"""Return the runtime mask iff this is a multi-GPU shard run, else None.

A shard targets non-default GPUs only, so cpu (position 0) and cuda:0
(position 1) are excluded. Anything else -- unset, or single-GPU CI's default
``"110"`` -- is not a shard and the plugin is a no-op.
"""
mask = os.environ.get(_RUNTIME_ENV, "")
return mask if mask and not _active(0, mask) and not _active(1, mask) else None


def pytest_collection_modifyitems(config, items):
mask = _shard_mask()
if mask is None:
return
keep, drop = [], []
for item in items:
callspec = getattr(item, "callspec", None)
device = callspec.params.get("device") if callspec is not None else None
if _active(_position(device), mask):
keep.append(item) # device-parametrized AND in this shard's scope
else:
drop.append(item) # non-device, or cpu / cuda:0 / other-index variant
if drop:
items[:] = keep
config.hook.pytest_deselected(items=drop)


def pytest_sessionfinish(session, exitstatus):
# A file whose tests are all out of scope deselects to zero, so pytest exits
# NO_TESTS_COLLECTED (5). The lane orchestrator treats any non-zero per-file
# exit as a failure (tools/conftest.py), so report "nothing in scope for this
# file" as success rather than a false failure.
if _shard_mask() is not None and exitstatus == pytest.ExitCode.NO_TESTS_COLLECTED:
session.exitstatus = pytest.ExitCode.OK
Loading
Loading