Skip to content

Commit f0bc92a

Browse files
fix(ci): isolate runtime caches and Dynamo service listeners
Signed-off-by: functionstackx <47992694+functionstackx@users.noreply.github.com>
1 parent 7674e56 commit f0bc92a

10 files changed

Lines changed: 384 additions & 5 deletions

‎scripts/ci/README.md‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@ SPDX-License-Identifier: Apache-2.0
77

88
This lane stages reviewed Dynamo source over SSH and builds/tests it in a one-GPU
99
Slurm allocation using Pyxis/Enroot. The login host only stages files and controls
10-
Slurm. The implementation has offline tests; a successful HIP/Dynamo hardware run,
11-
local artifact parity, and hosted Actions connectivity still require qualification.
10+
Slurm. Offline tests validate the controller. Qualify each source/controller
11+
contract with a successful local build and HIP/Dynamo test run, a fresh local
12+
allocation reusing that image, and an Actions run reusing the same image.
1213

1314
## Prerequisites
1415

@@ -90,6 +91,19 @@ scheduler's GPU assignment. Images are published as
9091
`images/<sha256>/image.sqsh`; model snapshots live under `models/<manifest-hash>`.
9192
No host Docker daemon or login-node compiler is used.
9293

94+
Runtime library caches use per-allocation writable scratch. AITER keeps access to
95+
the image's prebuilt modules through verified symlinks in its private JIT cache;
96+
`aiter-cache.json` records those targets. Dynamo's native model-metadata cache
97+
ignores `XDG_CACHE_HOME`, so only `$HOME/.cache/dynamo/mdc` receives a private
98+
scratch bind mount inside the test container. The account's real home is not
99+
mounted, `HOME` is unchanged, and the image and pinned model snapshot stay
100+
read-only during testing.
101+
102+
This single-node lane binds Dynamo's HTTP, system, request, response and event
103+
interfaces to loopback. Listener evidence covers the five named service ports
104+
and every TCP listener owned by the frontend and worker system-endpoint PIDs.
105+
It does not claim to inventory unrelated engine subprocess sockets.
106+
93107
## Status, reconnect, and cleanup
94108

95109
Keep the original run key and evidence directory. `status` reports durable state;

‎scripts/ci/barite.sbatch‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -236,10 +236,15 @@ PY
236236
)
237237
MODEL_CACHE="$ROOT/models/$MODEL_KEY"
238238
[[ -d "$MODEL_CACHE" ]]
239+
# Native MDC caching ignores XDG_CACHE_HOME. Bind only its cache directory;
240+
# preserve HOME and keep the user's actual home outside the container.
241+
[[ "${HOME:?}" =~ ^/[A-Za-z0-9_-]+(/[A-Za-z0-9_.-]+)*$ ]]
242+
[[ "/$HOME/" != *"/../"* && "/$HOME/" != *"/./"* ]]
243+
mkdir -p "$scratch/mdc-cache"
239244
export HF_HOME=/models HF_HUB_CACHE=/models/hub HF_HUB_OFFLINE=1 TRANSFORMERS_OFFLINE=1
240245
run_step "${COMMON[@]}" --container-image="$TEST_SQSH" \
241246
--container-env=ROCR_VISIBLE_DEVICES,HF_HOME,HF_HUB_CACHE,HF_HUB_OFFLINE,TRANSFORMERS_OFFLINE \
242-
--container-mounts="$JOB_SOURCE:/workspace,$RUN_DIR:/results,$MODEL_CACHE:/models:ro" \
247+
--container-mounts="$JOB_SOURCE:/workspace,$RUN_DIR:/results,$MODEL_CACHE:/models:ro,$scratch/mdc-cache:$HOME/.cache/dynamo/mdc" \
243248
--container-workdir=/workspace \
244249
bash /results/controller/rocm/run-tests.sh --suite aggregate --python /opt/dynamo/venv/bin/python3 --results /results
245250
python3 "$CONTROLLER/barite_verify.py" workload --run-dir "$RUN_DIR" --runtime-dir "$RUN_DIR" --output "$RUN_DIR/completed.json"

‎scripts/ci/barite_client.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,7 @@ def preflight(connection, args):
246246
import io,json,pathlib,sys,tarfile
247247
run=pathlib.Path(sys.argv[1]); cap=64*1024*1024; total=0; omitted=[]
248248
names={'request.json','source-manifest.json','build-manifest.json','image-manifest.json',
249-
'model-manifest.json','allocation.json','completed.json','hip.json','import-origins.json',
249+
'model-manifest.json','aiter-cache.json','allocation.json','completed.json','hip.json','import-origins.json',
250250
'test-manifest.json','test-summary.json','contract.json','provenance.json','receipt.json','terminal.json','wait-result.json',
251251
'controller-result.json','monitor.json','heartbeat.json','cancel.json','scheduler-latest.json',
252252
'submission-intent.json','partition-selection.json','collection.txt','environment.json','wheel-manifest.json',

‎scripts/ci/barite_verify.py‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,53 @@ def verify_listener_evidence(evidence, request, suite, job_id, started_at):
361361
and entry["captured_at"] >= started_at,
362362
"stale or invalid listener process",
363363
)
364+
anchors = [
365+
entry
366+
for entry in evidence["listeners"]
367+
if entry["role"] in ("frontend", "system")
368+
]
369+
expanded = evidence["service_listeners"]
370+
require(
371+
{entry["pid"] for entry in expanded} == {entry["pid"] for entry in anchors},
372+
"Dynamo service listener inventory incomplete",
373+
)
374+
for entry in expanded:
375+
owners = [anchor for anchor in anchors if anchor["pid"] == entry["pid"]]
376+
require(
377+
entry["service_roles"] == sorted({anchor["role"] for anchor in owners}),
378+
"Dynamo service listener owner mismatch",
379+
)
380+
require(
381+
all(
382+
entry["process_created_at"] == anchor["process_created_at"]
383+
and entry["root_pid"] == anchor["root_pid"]
384+
for anchor in owners
385+
),
386+
"Dynamo service listener process identity mismatch",
387+
)
388+
require(
389+
entry["captured_at"] >= started_at
390+
and entry["captured_at"] >= entry["process_created_at"],
391+
"stale Dynamo service listener",
392+
)
393+
require(
394+
isinstance(entry["port"], int) and 0 < entry["port"] < 65536,
395+
"invalid Dynamo service listener port",
396+
)
397+
require(
398+
ipaddress.ip_address(entry["address"]).is_loopback,
399+
f"non-loopback Dynamo service listener: {entry['address']}:{entry['port']}",
400+
)
401+
for anchor in anchors:
402+
require(
403+
any(
404+
entry["pid"] == anchor["pid"]
405+
and entry["port"] == anchor["port"]
406+
and entry["address"] == anchor["address"]
407+
for entry in expanded
408+
),
409+
"named endpoint missing from Dynamo service listener inventory",
410+
)
364411
return evidence
365412

366413

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
#!/usr/bin/env python3
2+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
3+
# SPDX-License-Identifier: Apache-2.0
4+
"""Preserve the pinned image's AITER prebuilts in a writable per-job JIT directory."""
5+
6+
import argparse
7+
import json
8+
import sys
9+
from importlib import metadata
10+
from pathlib import Path
11+
12+
EXPECTED_VERSION = "0.1.19"
13+
14+
15+
def seed_prebuilt_modules(source: Path, destination: Path) -> list[dict]:
16+
source = source.resolve(strict=True)
17+
destination.mkdir(parents=True, exist_ok=True)
18+
if destination.is_symlink() or source == destination.resolve():
19+
raise ValueError("AITER JIT destination must be a separate writable directory")
20+
modules = sorted(source.glob("*.so"))
21+
if not modules:
22+
raise ValueError("Pinned AITER image has no prebuilt JIT modules")
23+
records = []
24+
for module in modules:
25+
target = module.resolve(strict=True)
26+
if not target.is_file() or not target.is_relative_to(source):
27+
raise ValueError(
28+
f"AITER prebuilt target escapes installed JIT directory: {module}"
29+
)
30+
link = destination / module.name
31+
if link.is_symlink():
32+
if link.resolve(strict=True) != target:
33+
raise ValueError(f"Unexpected AITER cache symlink: {link}")
34+
elif link.exists():
35+
raise ValueError(
36+
f"AITER cache entry already exists without source provenance: {link}"
37+
)
38+
else:
39+
link.symlink_to(target)
40+
records.append(
41+
{"name": module.name, "target": str(target), "size": target.stat().st_size}
42+
)
43+
return records
44+
45+
46+
def main():
47+
parser = argparse.ArgumentParser(description=__doc__)
48+
parser.add_argument("--directory", type=Path, required=True)
49+
parser.add_argument("--output", type=Path, required=True)
50+
args = parser.parse_args()
51+
# Metadata lookup does not import aiter: importing it initializes JIT state.
52+
distribution = metadata.distribution("amd-aiter")
53+
if distribution.version != EXPECTED_VERSION:
54+
raise ValueError(
55+
f"Expected amd-aiter {EXPECTED_VERSION}, got {distribution.version}"
56+
)
57+
source = Path(distribution.locate_file("aiter/jit")).resolve(strict=True)
58+
if not source.is_relative_to(Path(sys.base_prefix).resolve()):
59+
raise ValueError(
60+
"AITER prebuilts must come from the installed base interpreter"
61+
)
62+
modules = seed_prebuilt_modules(source, args.directory)
63+
# AITER 0.1.19 unlinks an existing target before copying a rebuilt module.
64+
# Thus a genuine required rebuild replaces our link, never the base .so.
65+
evidence = {
66+
"version": distribution.version,
67+
"source": str(source),
68+
"directory": str(args.directory),
69+
"modules": modules,
70+
}
71+
args.output.parent.mkdir(parents=True, exist_ok=True)
72+
args.output.write_text(json.dumps(evidence, indent=2) + "\n")
73+
print(f"Preserved {len(modules)} AITER {distribution.version} prebuilt modules")
74+
75+
76+
if __name__ == "__main__":
77+
main()

‎scripts/ci/rocm/pytest_driver.py‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,31 @@ def owned_listeners(root_pid, expected_ports):
4848
return records
4949

5050

51+
def service_listeners(services):
52+
"""Inspect every listener on the PIDs owning the two proven service ports."""
53+
records = []
54+
for pid in sorted({entry["pid"] for entry in services}):
55+
anchors = [entry for entry in services if entry["pid"] == pid]
56+
process = psutil.Process(pid)
57+
created = process.create_time()
58+
if any(entry["process_created_at"] != created for entry in anchors):
59+
raise ValueError("Dynamo service PID was reused during listener capture")
60+
for connection in process.net_connections(kind="tcp"):
61+
if connection.status == psutil.CONN_LISTEN:
62+
records.append(
63+
{
64+
"pid": pid,
65+
"process_created_at": created,
66+
"root_pid": anchors[0]["root_pid"],
67+
"captured_at": time.time(),
68+
"service_roles": sorted({entry["role"] for entry in anchors}),
69+
"port": connection.laddr.port,
70+
"address": connection.laddr.ip,
71+
}
72+
)
73+
return records
74+
75+
5176
def main():
5277
suite, directory = sys.argv[1:]
5378
result = Path(directory)
@@ -123,6 +148,7 @@ def capture_services(root_pid):
123148
for entry in evidence["listeners"]
124149
if entry["role"] not in ("frontend", "system")
125150
] + services
151+
evidence["service_listeners"] = service_listeners(services)
126152
evidence["status"] = "passed"
127153
atomic_json(path, evidence)
128154
verify_listener_evidence(

‎scripts/ci/rocm/run-tests.sh‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,13 @@ export XDG_CONFIG_HOME="$cache_root/config"
1515
export VLLM_CONFIG_ROOT="$cache_root/config/vllm"
1616
export TORCHINDUCTOR_CACHE_DIR="$cache_root/torchinductor"
1717
export TRITON_CACHE_DIR="$cache_root/triton"
18+
export CUPY_CACHE_DIR="$cache_root/cupy"
1819
export TORCH_EXTENSIONS_DIR="$cache_root/torch-extensions"
1920
export VLLM_CACHE_ROOT="$cache_root/vllm"
2021
export TMPDIR="$cache_root/tmp"
2122
export HF_HOME=/models HF_HUB_CACHE=/models/hub
2223
export HF_HUB_OFFLINE=1 TRANSFORMERS_OFFLINE=1
24+
export AITER_JIT_DIR="$cache_root/aiter"
25+
/opt/dynamo/venv/bin/python3 -I "$(dirname "$0")/prepare_aiter_cache.py" \
26+
--directory "$AITER_JIT_DIR" --output /results/aiter-cache.json
2327
exec /opt/dynamo/venv/bin/python3 -I "$(dirname "$0")/run_tests.py" "$@"

‎scripts/ci/rocm/run_tests.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ def main():
3737
DYNAMO_CI_SERVICE_HOST="127.0.0.1",
3838
DYN_HTTP_HOST="127.0.0.1",
3939
DYN_SYSTEM_HOST="127.0.0.1",
40+
# All cooperating Dynamo services share this one-node allocation.
41+
DYN_TCP_RPC_HOST="127.0.0.1",
42+
DYN_TCP_RESPONSE_STREAM_HOST="127.0.0.1",
43+
DYN_EVENT_PLANE_HOST="127.0.0.1",
4044
)
4145
contract = json.loads(Path(__file__).with_name("contract.json").read_text())
4246
request = json.loads((result / "request.json").read_text())
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
"""Exercise AITER cache seeding without importing AITER or accessing a GPU."""
4+
5+
import importlib.util
6+
import json
7+
import tempfile
8+
import unittest
9+
from pathlib import Path
10+
from types import SimpleNamespace
11+
from unittest.mock import patch
12+
13+
14+
class AiterCacheTests(unittest.TestCase):
15+
def setUp(self):
16+
temporary = tempfile.TemporaryDirectory()
17+
self.addCleanup(temporary.cleanup)
18+
self.root = Path(temporary.name).resolve()
19+
self.source = self.root / "base/aiter/jit"
20+
self.source.mkdir(parents=True)
21+
self.module = self.source / "module.so"
22+
self.module.write_bytes(b"pinned prebuilt")
23+
self.destination = self.root / "job/cache"
24+
path = Path(__file__).resolve().parents[1] / "rocm/prepare_aiter_cache.py"
25+
spec = importlib.util.spec_from_file_location("barite_aiter_test", path)
26+
self.helper = importlib.util.module_from_spec(spec)
27+
spec.loader.exec_module(self.helper)
28+
29+
def test_seed_is_idempotent_and_rebuild_does_not_modify_source(self):
30+
records = self.helper.seed_prebuilt_modules(self.source, self.destination)
31+
self.assertEqual(
32+
records, self.helper.seed_prebuilt_modules(self.source, self.destination)
33+
)
34+
link = self.destination / "module.so"
35+
self.assertTrue(link.is_symlink())
36+
self.assertEqual(link.resolve(), self.module)
37+
self.assertEqual(
38+
records,
39+
[
40+
{
41+
"name": "module.so",
42+
"target": str(self.module),
43+
"size": len(b"pinned prebuilt"),
44+
}
45+
],
46+
)
47+
# AITER 0.1.19 removes its output before installing a rebuilt extension.
48+
link.unlink()
49+
link.write_bytes(b"rebuilt for this job")
50+
self.assertEqual(self.module.read_bytes(), b"pinned prebuilt")
51+
self.assertEqual(link.read_bytes(), b"rebuilt for this job")
52+
with self.assertRaisesRegex(ValueError, "without source provenance"):
53+
self.helper.seed_prebuilt_modules(self.source, self.destination)
54+
self.assertEqual(link.read_bytes(), b"rebuilt for this job")
55+
56+
def test_escaping_source_symlink_is_rejected(self):
57+
outside = self.root / "outside.so"
58+
outside.write_bytes(b"untrusted")
59+
(self.source / "escape.so").symlink_to(outside)
60+
with self.assertRaisesRegex(ValueError, "escapes installed"):
61+
self.helper.seed_prebuilt_modules(self.source, self.destination)
62+
self.assertFalse((self.destination / "escape.so").exists())
63+
64+
def test_conflicting_destination_and_empty_source_are_rejected(self):
65+
for destination in (self.source, self.root / "alias"):
66+
if destination != self.source:
67+
destination.symlink_to(self.root / "base", target_is_directory=True)
68+
with (
69+
self.subTest(destination=destination),
70+
self.assertRaisesRegex(ValueError, "separate writable"),
71+
):
72+
self.helper.seed_prebuilt_modules(self.source, destination)
73+
self.destination.mkdir(parents=True)
74+
wrong = self.root / "wrong.so"
75+
wrong.write_bytes(b"wrong")
76+
(self.destination / "module.so").symlink_to(wrong)
77+
with self.assertRaisesRegex(ValueError, "Unexpected AITER cache symlink"):
78+
self.helper.seed_prebuilt_modules(self.source, self.destination)
79+
self.module.unlink()
80+
with self.assertRaisesRegex(ValueError, "no prebuilt"):
81+
self.helper.seed_prebuilt_modules(self.source, self.destination)
82+
83+
def test_metadata_gate_and_evidence(self):
84+
output = self.root / "evidence.json"
85+
distribution = SimpleNamespace(
86+
version="0.1.19", locate_file=lambda _: self.source
87+
)
88+
with (
89+
patch.object(
90+
self.helper.sys,
91+
"argv",
92+
[
93+
"prepare",
94+
"--directory",
95+
str(self.destination),
96+
"--output",
97+
str(output),
98+
],
99+
),
100+
patch.object(self.helper.sys, "base_prefix", str(self.root / "base")),
101+
patch.object(
102+
self.helper.metadata, "distribution", return_value=distribution
103+
),
104+
patch("builtins.print"),
105+
):
106+
self.helper.main()
107+
evidence = json.loads(output.read_text())
108+
self.assertEqual(evidence["source"], str(self.source))
109+
self.assertEqual(evidence["version"], "0.1.19")
110+
self.assertEqual(len(evidence["modules"]), 1)
111+
distribution.version = "0.1.20"
112+
with self.assertRaisesRegex(ValueError, "Expected amd-aiter"):
113+
self.helper.main()
114+
distribution.version = "0.1.19"
115+
with (
116+
patch.object(self.helper.sys, "base_prefix", str(self.root / "other")),
117+
self.assertRaisesRegex(ValueError, "installed base interpreter"),
118+
):
119+
self.helper.main()
120+
121+
122+
if __name__ == "__main__":
123+
unittest.main()

0 commit comments

Comments
 (0)