Skip to content

Commit dc01374

Browse files
ryan-williamsclaude
andcommitted
Add Modal GPU CI workflow and app
- `modal/ci.py`: Modal app that runs `run_training()` on an L4 GPU, checks val_loss against expected values. Tested locally — produces identical results to EC2 L4 (val_loss=0.364269). - `.github/workflows/gpu-e2e-modal.yml`: GHA workflow that calls `modal run` from `ubuntu-latest`. Requires `MODAL_TOKEN_ID` and `MODAL_TOKEN_SECRET` secrets. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 70ba3f1 commit dc01374

3 files changed

Lines changed: 145 additions & 4 deletions

File tree

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
name: GPU E2E (Modal)
2+
3+
on:
4+
pull_request:
5+
branches: [main]
6+
workflow_dispatch:
7+
inputs:
8+
epochs:
9+
description: 'Number of training epochs'
10+
default: '5'
11+
type: string
12+
13+
jobs:
14+
test:
15+
runs-on: ubuntu-latest
16+
env:
17+
MODAL_TOKEN_ID: ${{ secrets.MODAL_TOKEN_ID }}
18+
MODAL_TOKEN_SECRET: ${{ secrets.MODAL_TOKEN_SECRET }}
19+
steps:
20+
- uses: actions/checkout@v4
21+
- uses: astral-sh/setup-uv@v5
22+
- uses: actions/setup-python@v5
23+
with:
24+
python-version: "3.12"
25+
26+
- name: Install Modal
27+
run: uv pip install --system modal
28+
29+
- name: Run e2e test on Modal GPU
30+
env:
31+
EPOCHS: ${{ inputs.epochs || '5' }}
32+
run: modal run modal/ci.py --epochs "$EPOCHS"

modal/ci.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
"""Modal GPU CI for electrai e2e training test."""
2+
3+
from __future__ import annotations
4+
5+
from pathlib import Path
6+
7+
import modal
8+
9+
ROOT = Path(__file__).parent.parent
10+
11+
image = (
12+
modal.Image.debian_slim(python_version="3.12")
13+
.apt_install("git")
14+
.pip_install(
15+
"torch~=2.9",
16+
"torchvision>=0.24",
17+
"lightning~=2.5",
18+
"numpy~=2.3",
19+
"scikit-learn>=1.7",
20+
"pymatgen>=2025.10",
21+
"pyyaml>=6.0",
22+
"zarr>=3.1",
23+
"hydra-core>=1.3",
24+
"wandb>=0.12",
25+
"click",
26+
)
27+
# Source code + test data (copy=True so pip install -e works after)
28+
.add_local_dir(str(ROOT / "src"), remote_path="/root/electrai/src", copy=True)
29+
.add_local_dir(
30+
str(ROOT / "scripts"), remote_path="/root/electrai/scripts", copy=True
31+
)
32+
.add_local_dir(str(ROOT / "tests"), remote_path="/root/electrai/tests", copy=True)
33+
.add_local_dir(str(ROOT / "data"), remote_path="/root/electrai/data", copy=True)
34+
.add_local_file(
35+
str(ROOT / "pyproject.toml"),
36+
remote_path="/root/electrai/pyproject.toml",
37+
copy=True,
38+
)
39+
.run_commands("cd /root/electrai && pip install -e .")
40+
)
41+
42+
app = modal.App("electrai-ci", image=image)
43+
44+
45+
@app.function(gpu="L4", timeout=600)
46+
def run_e2e_test(epochs: int = 5, check: bool = True):
47+
"""Run e2e training test on GPU."""
48+
import json
49+
import logging
50+
import sys
51+
52+
log = logging.getLogger(__name__)
53+
54+
sys.path.insert(0, "/root/electrai/scripts")
55+
from e2e_train import run_training
56+
57+
results = run_training(epochs=epochs, gpu=True, verbose=True)
58+
log.info("Platform: %s", results["platform"])
59+
log.info("Final val_loss: %.6f", results["final_val_loss"])
60+
if results["final_train_loss"] is not None:
61+
log.info("Final train_loss: %.6f", results["final_train_loss"])
62+
log.info("Wallclock: %.1fs", results["wallclock_s"])
63+
64+
if check:
65+
expected_file = Path("/root/electrai/tests/expected_values.json")
66+
expected_values = json.loads(expected_file.read_text())
67+
platform = results["platform"]
68+
if platform not in expected_values:
69+
raise ValueError(
70+
f"No expected values for platform {platform!r}, "
71+
f"available: {list(expected_values.keys())}"
72+
)
73+
expected = expected_values[platform]
74+
if expected.get("final_val_loss") is None:
75+
raise ValueError(f"Expected values for {platform!r} are null")
76+
expected_val_loss = expected["final_val_loss"]
77+
diff = abs(results["final_val_loss"] - expected_val_loss)
78+
tolerance = 0.001
79+
if diff > tolerance:
80+
raise AssertionError(
81+
f"val_loss {results['final_val_loss']:.6f} differs from expected "
82+
f"{expected_val_loss:.6f} by {diff:.6f} (tolerance: {tolerance})"
83+
)
84+
log.info(
85+
"PASS: val_loss matches expected within tolerance (%.6f <= %f)",
86+
diff,
87+
tolerance,
88+
)
89+
90+
return results
91+
92+
93+
@app.local_entrypoint()
94+
def main(epochs: int = 5, check: bool = True):
95+
import logging
96+
97+
logging.basicConfig(level=logging.INFO)
98+
results = run_e2e_test.remote(epochs=epochs, check=check)
99+
logging.getLogger(__name__).info(
100+
"Results: val_loss=%.6f in %.1fs",
101+
results["final_val_loss"],
102+
results["wallclock_s"],
103+
)

specs/modal-ci.md

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,9 @@ image = (
3939
.add_local_dir(str(ROOT / "tests"), remote_path="/root/electrai/tests")
4040
.add_local_dir(str(ROOT / "scripts"), remote_path="/root/electrai/scripts")
4141
.add_local_dir(str(ROOT / "data"), remote_path="/root/electrai/data")
42-
.add_local_file(str(ROOT / "pyproject.toml"), remote_path="/root/electrai/pyproject.toml")
42+
.add_local_file(
43+
str(ROOT / "pyproject.toml"), remote_path="/root/electrai/pyproject.toml"
44+
)
4345
)
4446

4547
app = modal.App("electrai-ci", image=image)
@@ -49,10 +51,14 @@ app = modal.App("electrai-ci", image=image)
4951
def run_e2e_test(epochs: int = 5, check: bool = True):
5052
"""Run e2e training test on GPU."""
5153
import subprocess
54+
5255
cmd = [
53-
"python", "scripts/e2e_train.py",
54-
"--gpu", "--verbose",
55-
"--epochs", str(epochs),
56+
"python",
57+
"scripts/e2e_train.py",
58+
"--gpu",
59+
"--verbose",
60+
"--epochs",
61+
str(epochs),
5662
]
5763
if not check:
5864
cmd.append("--no-check")

0 commit comments

Comments
 (0)