Skip to content

Commit 7a60325

Browse files
ryan-williamsclaude
andcommitted
Add Modal training entrypoint and data volume tooling
- `modal/train.py`: Full training on Modal GPUs with `electrai-data` Volume (dataset_4, 2,885 samples). Supports GPU selection (L4/A100/H100), custom configs, WandB logging, checkpoint persistence. - `modal/populate_volume.py`: Sync S3 → Modal Volume via `boto3`. - Update spec with training docs, data provenance, secrets. Data pipeline: Globus (Della) → S3 → Modal Volume (complete). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d47ace5 commit 7a60325

3 files changed

Lines changed: 341 additions & 3 deletions

File tree

modal/populate_volume.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
"""Populate Modal Volume with training data from S3."""
2+
3+
from __future__ import annotations
4+
5+
import modal
6+
7+
app = modal.App("electrai-populate")
8+
volume = modal.Volume.from_name("electrai-data", create_if_missing=True)
9+
10+
11+
@app.function(
12+
image=modal.Image.debian_slim(python_version="3.12").pip_install("boto3"),
13+
volumes={"/data": volume},
14+
secrets=[modal.Secret.from_name("aws-credentials")],
15+
timeout=7200,
16+
)
17+
def sync_s3(
18+
bucket: str = "openathena",
19+
prefix: str = "electrai/mp/chg_datasets/dataset_4",
20+
dest: str = "/data/mp/chg_datasets/dataset_4",
21+
):
22+
"""Sync dataset from S3 to Modal Volume."""
23+
import logging
24+
from pathlib import Path
25+
26+
import boto3
27+
28+
log = logging.getLogger(__name__)
29+
logging.basicConfig(level=logging.INFO)
30+
31+
s3 = boto3.client("s3")
32+
paginator = s3.get_paginator("list_objects_v2")
33+
34+
# Count and list all objects
35+
objects = [
36+
obj
37+
for page in paginator.paginate(Bucket=bucket, Prefix=prefix)
38+
for obj in page.get("Contents", [])
39+
]
40+
41+
total_bytes = sum(o["Size"] for o in objects)
42+
log.info("Found %d objects, %.1f GiB total", len(objects), total_bytes / (1024**3))
43+
44+
downloaded = 0
45+
skipped = 0
46+
for i, obj in enumerate(objects):
47+
key = obj["Key"]
48+
rel = key[len(prefix) :].lstrip("/")
49+
local_path = Path(dest) / rel
50+
local_path.parent.mkdir(parents=True, exist_ok=True)
51+
52+
# Skip if already exists with same size
53+
if local_path.exists() and local_path.stat().st_size == obj["Size"]:
54+
skipped += 1
55+
continue
56+
57+
s3.download_file(bucket, key, str(local_path))
58+
downloaded += 1
59+
60+
if (i + 1) % 100 == 0:
61+
log.info(
62+
"Progress: %d/%d (downloaded %d, skipped %d)",
63+
i + 1,
64+
len(objects),
65+
downloaded,
66+
skipped,
67+
)
68+
69+
log.info("Done: %d downloaded, %d skipped (already existed)", downloaded, skipped)
70+
volume.commit()
71+
72+
73+
@app.local_entrypoint()
74+
def main(
75+
bucket: str = "openathena",
76+
prefix: str = "electrai/mp/chg_datasets/dataset_4",
77+
dest: str = "/data/mp/chg_datasets/dataset_4",
78+
):
79+
import logging
80+
81+
logging.basicConfig(level=logging.INFO)
82+
sync_s3.remote(bucket=bucket, prefix=prefix, dest=dest)
83+
logging.getLogger(__name__).info("Volume populated.")

modal/train.py

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
"""Modal training entrypoint for electrai.
2+
3+
Run real training experiments on Modal GPUs with data from the
4+
electrai-data Volume and checkpoints persisted to electrai-checkpoints.
5+
6+
Usage:
7+
# Default config (ResUNet, dataset_4, 50 epochs, L4)
8+
modal run modal/train.py
9+
10+
# Custom config file
11+
modal run modal/train.py --config examples/MP/experiments/experiment_0/config.yaml
12+
13+
# Override GPU, epochs, channels
14+
modal run modal/train.py --gpu A100 --epochs 10 --channels 64
15+
16+
# Resume from checkpoint
17+
modal run modal/train.py --resume
18+
"""
19+
20+
from __future__ import annotations
21+
22+
from pathlib import Path
23+
24+
import modal
25+
26+
ROOT = Path(__file__).parent.parent
27+
28+
# Persistent volumes
29+
data_volume = modal.Volume.from_name("electrai-data")
30+
ckpt_volume = modal.Volume.from_name("electrai-checkpoints", create_if_missing=True)
31+
32+
image = (
33+
modal.Image.debian_slim(python_version="3.12")
34+
.apt_install("git")
35+
.pip_install(
36+
"torch~=2.9",
37+
"torchvision>=0.24",
38+
"lightning~=2.5",
39+
"numpy~=2.3",
40+
"scikit-learn>=1.7",
41+
"pymatgen>=2025.10",
42+
"pyyaml>=6.0",
43+
"zarr>=3.1",
44+
"hydra-core>=1.3",
45+
"wandb>=0.12",
46+
)
47+
.add_local_dir(str(ROOT / "src"), remote_path="/root/electrai/src", copy=True)
48+
.add_local_dir(
49+
str(ROOT / "examples"), remote_path="/root/electrai/examples", copy=True
50+
)
51+
.add_local_file(
52+
str(ROOT / "pyproject.toml"),
53+
remote_path="/root/electrai/pyproject.toml",
54+
copy=True,
55+
)
56+
.run_commands("cd /root/electrai && pip install -e .")
57+
)
58+
59+
app = modal.App("electrai-train", image=image)
60+
61+
DATA_ROOT = "/data/mp/chg_datasets/dataset_4"
62+
CKPT_ROOT = "/checkpoints"
63+
64+
65+
@app.function(
66+
gpu="L4",
67+
volumes={"/data": data_volume, CKPT_ROOT: ckpt_volume},
68+
secrets=[modal.Secret.from_name("wandb-credentials")],
69+
timeout=14400, # 4 hours
70+
)
71+
def train(config_json: str, gpu_type: str = "L4"):
72+
"""Run training with the given config (as JSON, converted to YAML remotely)."""
73+
import json
74+
import logging
75+
import subprocess
76+
import sys
77+
78+
import yaml
79+
80+
log = logging.getLogger(__name__)
81+
logging.basicConfig(level=logging.INFO)
82+
83+
cfg = json.loads(config_json)
84+
85+
# Write config as YAML for the training entrypoint
86+
config_path = Path("/tmp/config.yaml")
87+
with config_path.open("w") as f:
88+
yaml.dump(cfg, f, default_flow_style=False)
89+
90+
log.info("GPU: %s", gpu_type)
91+
log.info("Data root: %s", DATA_ROOT)
92+
log.info("Checkpoint dir: %s", CKPT_ROOT)
93+
94+
# Verify data exists
95+
filelist = Path(DATA_ROOT) / "mp_filelist.txt"
96+
if not filelist.exists():
97+
raise FileNotFoundError(f"Filelist not found: {filelist}")
98+
n_samples = len(filelist.read_text().strip().splitlines())
99+
log.info("Dataset: %d samples", n_samples)
100+
101+
# Run training
102+
result = subprocess.run(
103+
[
104+
sys.executable,
105+
"-m",
106+
"electrai.entrypoints.main",
107+
"train",
108+
"--config",
109+
str(config_path),
110+
],
111+
cwd="/root/electrai",
112+
check=False,
113+
)
114+
115+
# Persist checkpoints
116+
ckpt_volume.commit()
117+
118+
if result.returncode != 0:
119+
raise RuntimeError(f"Training failed with exit code {result.returncode}")
120+
121+
log.info("Training complete. Checkpoints saved to electrai-checkpoints volume.")
122+
123+
124+
@app.local_entrypoint()
125+
def main(
126+
config: str = "",
127+
gpu: str = "L4",
128+
epochs: int = 50,
129+
channels: int = 32,
130+
residual_blocks: int = 1,
131+
depth: int = 2,
132+
kernel_size: int = 5,
133+
lr: float = 0.01,
134+
batch_size: int = 1,
135+
val_frac: float = 0.005,
136+
wandb_project: str = "mp-experiment",
137+
):
138+
import json
139+
import logging
140+
141+
logging.basicConfig(level=logging.INFO)
142+
log = logging.getLogger(__name__)
143+
144+
if config:
145+
# Read YAML config locally (pyyaml may not be in Modal's local Python,
146+
# so shell out to parse it)
147+
import subprocess
148+
149+
result = subprocess.run(
150+
[
151+
"python3",
152+
"-c",
153+
f"import yaml, json; print(json.dumps(yaml.safe_load(open('{config}'))))",
154+
],
155+
capture_output=True,
156+
text=True,
157+
check=True,
158+
)
159+
cfg = json.loads(result.stdout)
160+
else:
161+
# Build config from CLI args
162+
cfg = {
163+
"data": {
164+
"_target_": "electrai.dataloader.dataset.RhoRead",
165+
"root": f"{DATA_ROOT}/mp_filelist.txt",
166+
"split_file": None,
167+
"precision": "f32",
168+
"batch_size": batch_size,
169+
"train_workers": 4,
170+
"val_workers": 2,
171+
"pin_memory": False,
172+
"val_frac": val_frac,
173+
"drop_last": False,
174+
"augmentation": False,
175+
"random_seed": 42,
176+
},
177+
"model": {
178+
"_target_": "electrai.model.resunet.ResUNet3D",
179+
"in_channels": 1,
180+
"out_channels": 1,
181+
"n_channels": channels,
182+
"n_residual_blocks": residual_blocks,
183+
"kernel_size": kernel_size,
184+
"depth": depth,
185+
"use_checkpoint": False,
186+
},
187+
"precision": 32,
188+
"epochs": epochs,
189+
"lr": lr,
190+
"weight_decay": 0.0,
191+
"warmup_length": 1,
192+
"beta1": 0.9,
193+
"beta2": 0.99,
194+
"wandb_mode": "online",
195+
"entity": "PrinceOA",
196+
"wb_pname": wandb_project,
197+
"ckpt_path": CKPT_ROOT,
198+
}
199+
200+
# Always override data root and checkpoint path for Modal
201+
if "data" in cfg:
202+
cfg["data"]["root"] = f"{DATA_ROOT}/mp_filelist.txt"
203+
cfg["ckpt_path"] = CKPT_ROOT
204+
205+
config_json = json.dumps(cfg, indent=2)
206+
log.info("Config:\n%s", config_json)
207+
208+
# Override GPU type via with_options
209+
train_fn = train
210+
if gpu != "L4":
211+
train_fn = train.with_options(gpu=gpu)
212+
213+
train_fn.remote(config_json=config_json, gpu_type=gpu)
214+
log.info("Done.")

specs/modal-ci.md

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -114,12 +114,53 @@ jobs:
114114
- **CPU baseline test** — could add a second `@app.function(gpu=None)` call, but CPU tests already run in `gen-expected.yml`
115115
- **`update_expected` mode** — would need to get the file back out of Modal (print to stdout and capture, or use a Modal Volume). Defer for now.
116116
- **Artifact upload** — same challenge; not needed for the basic CI pass/fail
117-
- **WandB logging** — could add by passing `--wandb-project` and setting `WANDB_API_KEY` secret. Straightforward to add later.
118-
- **S3 data sync** — only needed for `gpu-benchmark.yml` (larger dataset). The e2e test uses the small checked-in dataset.
117+
118+
## Training on Modal (`modal/train.py`)
119+
120+
Full training entrypoint for running real experiments on Modal, replacing Lambda Labs.
121+
122+
### Data: `electrai-data` Volume
123+
124+
`dataset_4` (2,885 samples, ~205 GiB) synced from Della (Globus source of truth) → S3 → Modal Volume:
125+
- S3: `s3://openathena/electrai/mp/chg_datasets/dataset_4/`
126+
- Volume mount: `/data/mp/chg_datasets/dataset_4/{data,label}/`
127+
128+
Populate script: `modal/populate_volume.py` (S3 → Volume via `boto3`).
129+
130+
### Checkpoints: `electrai-checkpoints` Volume
131+
132+
Persists across runs. Mounted at `/checkpoints`.
133+
134+
### Usage
135+
136+
```bash
137+
# Default: ResUNet, dataset_4, 50 epochs, L4
138+
modal run modal/train.py
139+
140+
# A100, custom hyperparams
141+
modal run modal/train.py --gpu A100 --channels 64 --epochs 50
142+
143+
# Use existing config file
144+
modal run modal/train.py --config path/to/config.yaml --gpu A100
145+
```
146+
147+
### Data provenance
148+
149+
```
150+
Globus (ROSENGROUP share)
151+
└── /mp/chg_datasets/dataset_4/ (canonical, on Della)
152+
├── Della → S3 (aws s3 sync, one-time)
153+
│ └── s3://openathena/electrai/mp/chg_datasets/dataset_4/
154+
│ └── S3 → Modal Volume (modal/populate_volume.py)
155+
└── Della → Lambda LLFS (Globus transfer, Betsy's prior setup)
156+
└── /home/ubuntu/betsy-electrai-2/dataset2/
157+
```
119158
120159
## Secrets required
121160
122-
- `MODAL_TOKEN_ID` / `MODAL_TOKEN_SECRET` — need to be set on the electrai repo (or at org level). helico has these set already; check whether they're org-wide or repo-level.
161+
- `MODAL_TOKEN_ID` / `MODAL_TOKEN_SECRET` — repo secrets for GHA workflow
162+
- `wandb-credentials` — Modal secret with `WANDB_API_KEY` (for training)
163+
- `aws-credentials` — Modal secret with AWS creds (for `populate_volume.py`)
123164
124165
No `GH_SA_TOKEN` needed (no runner registration).
125166

0 commit comments

Comments
 (0)