Skip to content

Commit 05da4f5

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 dc01374 commit 05da4f5

4 files changed

Lines changed: 348 additions & 18 deletions

File tree

modal/ci.py

Lines changed: 5 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,23 +8,13 @@
88

99
ROOT = Path(__file__).parent.parent
1010

11+
# Dependencies read from pyproject.toml (shared with train.py, populate_volume.py)
1112
image = (
1213
modal.Image.debian_slim(python_version="3.12")
1314
.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",
15+
.pip_install_from_pyproject(
16+
str(ROOT / "pyproject.toml"), optional_dependencies=["dev"]
2617
)
27-
# Source code + test data (copy=True so pip install -e works after)
2818
.add_local_dir(str(ROOT / "src"), remote_path="/root/electrai/src", copy=True)
2919
.add_local_dir(
3020
str(ROOT / "scripts"), remote_path="/root/electrai/scripts", copy=True
@@ -36,13 +26,13 @@
3626
remote_path="/root/electrai/pyproject.toml",
3727
copy=True,
3828
)
39-
.run_commands("cd /root/electrai && pip install -e .")
29+
.run_commands("cd /root/electrai && pip install --no-deps -e .")
4030
)
4131

4232
app = modal.App("electrai-ci", image=image)
4333

4434

45-
@app.function(gpu="L4", timeout=600)
35+
@app.function(gpu="L4", timeout=600, retries=0)
4636
def run_e2e_test(epochs: int = 5, check: bool = True):
4737
"""Run e2e training test on GPU."""
4838
import json

modal/populate_volume.py

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

modal/train.py

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
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 last checkpoint
17+
modal run modal/train.py --gpu A100 --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+
# Dependencies read from pyproject.toml (shared with ci.py, populate_volume.py)
33+
image = (
34+
modal.Image.debian_slim(python_version="3.12")
35+
.apt_install("git")
36+
.pip_install_from_pyproject(
37+
str(ROOT / "pyproject.toml"), optional_dependencies=["dev"]
38+
)
39+
.add_local_dir(str(ROOT / "src"), remote_path="/root/electrai/src", copy=True)
40+
.add_local_dir(
41+
str(ROOT / "examples"), remote_path="/root/electrai/examples", copy=True
42+
)
43+
.add_local_file(
44+
str(ROOT / "pyproject.toml"),
45+
remote_path="/root/electrai/pyproject.toml",
46+
copy=True,
47+
)
48+
.run_commands("cd /root/electrai && pip install --no-deps -e .")
49+
)
50+
51+
app = modal.App("electrai-train", image=image)
52+
53+
DATA_ROOT = "/data/mp/chg_datasets/dataset_4"
54+
CKPT_ROOT = "/checkpoints"
55+
56+
57+
@app.function(
58+
gpu="L4",
59+
volumes={"/data": data_volume, CKPT_ROOT: ckpt_volume},
60+
secrets=[modal.Secret.from_name("wandb-credentials")],
61+
timeout=14400, # 4 hours
62+
retries=0,
63+
)
64+
def train(config_json: str, gpu_type: str = "L4"):
65+
"""Run training with the given config (as JSON, converted to YAML remotely)."""
66+
import json
67+
import logging
68+
import subprocess
69+
import sys
70+
71+
import yaml
72+
73+
log = logging.getLogger(__name__)
74+
logging.basicConfig(level=logging.INFO)
75+
76+
cfg = json.loads(config_json)
77+
78+
# Write config as YAML for the training entrypoint
79+
config_path = Path("/tmp/config.yaml")
80+
with config_path.open("w") as f:
81+
yaml.dump(cfg, f, default_flow_style=False)
82+
83+
log.info("GPU: %s", gpu_type)
84+
log.info("Data root: %s", DATA_ROOT)
85+
log.info("Checkpoint dir: %s", CKPT_ROOT)
86+
87+
# Verify data exists
88+
filelist = Path(DATA_ROOT) / "mp_filelist.txt"
89+
if not filelist.exists():
90+
raise FileNotFoundError(f"Filelist not found: {filelist}")
91+
n_samples = len(filelist.read_text().strip().splitlines())
92+
log.info("Dataset: %d samples", n_samples)
93+
94+
# Check for existing checkpoint to resume from
95+
ckpt_path = Path(cfg.get("ckpt_path", CKPT_ROOT))
96+
last_ckpt = ckpt_path / "last.ckpt"
97+
if last_ckpt.exists():
98+
log.info("Found checkpoint: %s", last_ckpt)
99+
else:
100+
log.info("No checkpoint found, starting from scratch")
101+
102+
# Run training
103+
result = subprocess.run(
104+
[
105+
sys.executable,
106+
"-m",
107+
"electrai.entrypoints.main",
108+
"train",
109+
"--config",
110+
str(config_path),
111+
],
112+
cwd="/root/electrai",
113+
check=False,
114+
)
115+
116+
# Persist checkpoints
117+
ckpt_volume.commit()
118+
119+
if result.returncode != 0:
120+
raise RuntimeError(f"Training failed with exit code {result.returncode}")
121+
122+
log.info("Training complete. Checkpoints saved to electrai-checkpoints volume.")
123+
124+
125+
@app.local_entrypoint()
126+
def main(
127+
config: str = "",
128+
gpu: str = "L4",
129+
epochs: int = 50,
130+
channels: int = 32,
131+
residual_blocks: int = 1,
132+
depth: int = 2,
133+
kernel_size: int = 5,
134+
lr: float = 0.01,
135+
batch_size: int = 1,
136+
val_frac: float = 0.005,
137+
wandb_project: str = "mp-experiment",
138+
resume: bool = False,
139+
):
140+
import json
141+
import logging
142+
143+
logging.basicConfig(level=logging.INFO)
144+
log = logging.getLogger(__name__)
145+
146+
if config:
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+
cfg = {
162+
"data": {
163+
"_target_": "electrai.dataloader.dataset.RhoRead",
164+
"root": f"{DATA_ROOT}/mp_filelist.txt",
165+
"split_file": None,
166+
"precision": "f32",
167+
"batch_size": batch_size,
168+
"train_workers": 4,
169+
"val_workers": 2,
170+
"pin_memory": False,
171+
"val_frac": val_frac,
172+
"drop_last": False,
173+
"augmentation": False,
174+
"random_seed": 42,
175+
},
176+
"model": {
177+
"_target_": "electrai.model.resunet.ResUNet3D",
178+
"in_channels": 1,
179+
"out_channels": 1,
180+
"n_channels": channels,
181+
"n_residual_blocks": residual_blocks,
182+
"kernel_size": kernel_size,
183+
"depth": depth,
184+
"use_checkpoint": False,
185+
},
186+
"precision": 32,
187+
"epochs": epochs,
188+
"lr": lr,
189+
"weight_decay": 0.0,
190+
"warmup_length": 1,
191+
"beta1": 0.9,
192+
"beta2": 0.99,
193+
"wandb_mode": "online",
194+
"entity": "PrinceOA",
195+
"wb_pname": wandb_project,
196+
"ckpt_path": CKPT_ROOT,
197+
}
198+
199+
# Always override data root and checkpoint path for Modal
200+
if "data" in cfg:
201+
cfg["data"]["root"] = f"{DATA_ROOT}/mp_filelist.txt"
202+
cfg["ckpt_path"] = CKPT_ROOT
203+
204+
if not resume:
205+
cfg.pop("resume_from_checkpoint", None)
206+
207+
config_json = json.dumps(cfg, indent=2)
208+
log.info("Config:\n%s", config_json)
209+
210+
train_fn = train
211+
if gpu != "L4":
212+
train_fn = train.with_options(gpu=gpu)
213+
214+
train_fn.remote(config_json=config_json, gpu_type=gpu)
215+
log.info("Done.")

0 commit comments

Comments
 (0)