Skip to content

Commit 72feff2

Browse files
ryan-williamsclaude
andcommitted
Add Modal GPU benchmark (modal/benchmark.py)
Mirrors `gpu-benchmark.yml` but runs on Modal instead of EC2: - Uses `electrai-data` Volume (2,885 samples from dataset_4) - Configurable GPU type, model size, sample count, epochs - WandB logging with same tags/config as EC2 benchmark - Subsample support for quick benchmarks vs full dataset Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 05da4f5 commit 72feff2

2 files changed

Lines changed: 261 additions & 0 deletions

File tree

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
name: GPU Benchmark (Modal)
2+
3+
on:
4+
workflow_dispatch:
5+
inputs:
6+
gpu:
7+
description: 'GPU type (L4, A10G, A100, H100)'
8+
default: 'L4'
9+
type: string
10+
epochs:
11+
description: 'Number of training epochs'
12+
default: '5'
13+
type: string
14+
channels:
15+
description: 'Number of model channels (8=tiny/L4, 32=prod/A100, 64=large)'
16+
default: '8'
17+
type: string
18+
residual_blocks:
19+
description: 'Number of residual blocks (2=tiny/L4, 16=prod/A100)'
20+
default: '2'
21+
type: string
22+
samples:
23+
description: 'Number of samples from Volume (0=all eligible)'
24+
default: '50'
25+
type: string
26+
max_file_size:
27+
description: 'Skip samples larger than N MB (0=no limit, 100=safe for L4)'
28+
default: '100'
29+
type: string
30+
wandb_project:
31+
description: 'WandB project name'
32+
default: 'elf-net-ci'
33+
type: string
34+
35+
jobs:
36+
benchmark:
37+
runs-on: ubuntu-latest
38+
env:
39+
MODAL_TOKEN_ID: ${{ secrets.MODAL_TOKEN_ID }}
40+
MODAL_TOKEN_SECRET: ${{ secrets.MODAL_TOKEN_SECRET }}
41+
steps:
42+
- uses: actions/checkout@v4
43+
- uses: astral-sh/setup-uv@v5
44+
- uses: actions/setup-python@v5
45+
with:
46+
python-version: "3.12"
47+
48+
- name: Install Modal
49+
run: uv pip install --system modal
50+
51+
- name: Run benchmark
52+
env:
53+
GPU: ${{ inputs.gpu || 'L4' }}
54+
EPOCHS: ${{ inputs.epochs || '5' }}
55+
CHANNELS: ${{ inputs.channels || '8' }}
56+
RESIDUAL_BLOCKS: ${{ inputs.residual_blocks || '2' }}
57+
SAMPLES: ${{ inputs.samples || '50' }}
58+
MAX_FILE_SIZE: ${{ inputs.max_file_size || '100' }}
59+
WANDB_PROJECT: ${{ inputs.wandb_project || 'elf-net-ci' }}
60+
run: |
61+
modal run modal/benchmark.py \
62+
--gpu "$GPU" \
63+
--epochs "$EPOCHS" \
64+
--channels "$CHANNELS" \
65+
--residual-blocks "$RESIDUAL_BLOCKS" \
66+
--samples "$SAMPLES" \
67+
--max-file-size "$MAX_FILE_SIZE" \
68+
--wandb-project "$WANDB_PROJECT"

modal/benchmark.py

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
"""Modal GPU benchmark for electrai.
2+
3+
Runs a configurable training benchmark on Modal GPUs with data from the
4+
electrai-data Volume, logs metrics to WandB, and reports wall-clock time.
5+
6+
Usage:
7+
# Default: 50 samples, 5 epochs, 8 channels, 2 blocks, L4
8+
modal run modal/benchmark.py
9+
10+
# Production model on A100 (32ch/16blk needs >=40GB VRAM)
11+
modal run modal/benchmark.py --gpu A100 --channels 32 --residual-blocks 16
12+
13+
# All 2,885 Volume samples
14+
modal run modal/benchmark.py --samples 0
15+
16+
# Quick smoke test
17+
modal run modal/benchmark.py --samples 10 --epochs 2 --channels 8 --residual-blocks 2
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+
data_volume = modal.Volume.from_name("electrai-data")
29+
30+
image = (
31+
modal.Image.debian_slim(python_version="3.12")
32+
.apt_install("git")
33+
.pip_install_from_pyproject(
34+
str(ROOT / "pyproject.toml"), optional_dependencies=["dev"]
35+
)
36+
.add_local_dir(str(ROOT / "src"), remote_path="/root/electrai/src", copy=True)
37+
.add_local_dir(
38+
str(ROOT / "scripts"), remote_path="/root/electrai/scripts", copy=True
39+
)
40+
.add_local_file(
41+
str(ROOT / "pyproject.toml"),
42+
remote_path="/root/electrai/pyproject.toml",
43+
copy=True,
44+
)
45+
.run_commands("cd /root/electrai && pip install --no-deps -e .")
46+
)
47+
48+
app = modal.App("electrai-benchmark", image=image)
49+
50+
DATA_ROOT = "/data/mp/chg_datasets/dataset_4"
51+
52+
53+
@app.function(
54+
gpu="L4",
55+
volumes={"/data": data_volume},
56+
secrets=[modal.Secret.from_name("wandb-credentials")],
57+
timeout=7200,
58+
retries=0,
59+
)
60+
def run_benchmark(
61+
epochs: int = 5,
62+
channels: int = 8,
63+
residual_blocks: int = 2,
64+
samples: int = 50,
65+
max_file_size: float = 100,
66+
seed: int = 42,
67+
wandb_project: str = "elf-net-ci",
68+
gpu_type: str = "L4",
69+
):
70+
"""Run benchmark and return results."""
71+
import logging
72+
import sys
73+
74+
log = logging.getLogger(__name__)
75+
logging.basicConfig(level=logging.INFO)
76+
77+
sys.path.insert(0, "/root/electrai/scripts")
78+
from e2e_train import run_training
79+
80+
# Filter and subsample from volume filelist
81+
filelist = Path(DATA_ROOT) / "mp_filelist.txt"
82+
all_ids = [s for s in filelist.read_text().strip().splitlines() if s]
83+
84+
# Filter by file size first (avoid OOM on large grids)
85+
if max_file_size > 0:
86+
max_bytes = int(max_file_size * 1024 * 1024)
87+
eligible = [
88+
sid
89+
for sid in all_ids
90+
if (Path(DATA_ROOT) / "data" / f"{sid}.CHGCAR").stat().st_size <= max_bytes
91+
]
92+
log.info(
93+
"File size filter: %d/%d eligible (<=%.0fMB)",
94+
len(eligible),
95+
len(all_ids),
96+
max_file_size,
97+
)
98+
else:
99+
eligible = all_ids
100+
101+
# Subsample from eligible
102+
if 0 < samples < len(eligible):
103+
import random
104+
105+
random.seed(seed)
106+
subset = sorted(random.sample(eligible, samples))
107+
log.info("Subsampled %d/%d eligible samples", samples, len(eligible))
108+
else:
109+
subset = eligible
110+
samples = len(subset)
111+
log.info("Using all %d eligible samples", samples)
112+
113+
if not subset:
114+
raise ValueError(
115+
f"No eligible samples (total={len(all_ids)}, max_file_size={max_file_size}MB)"
116+
)
117+
118+
# Create temp data root with symlinks + clean filelist (no trailing newline)
119+
data_root = "/tmp/benchmark_data"
120+
Path(data_root).mkdir(parents=True, exist_ok=True)
121+
for subdir in ["data", "label"]:
122+
link = Path(data_root) / subdir
123+
if not link.exists():
124+
link.symlink_to(Path(DATA_ROOT) / subdir)
125+
Path(data_root, "mp_filelist.txt").write_text("\n".join(subset))
126+
127+
log.info(
128+
"Benchmark: gpu=%s, epochs=%d, channels=%d, blocks=%d, samples=%d",
129+
gpu_type,
130+
epochs,
131+
channels,
132+
residual_blocks,
133+
samples,
134+
)
135+
136+
results = run_training(
137+
channels=channels,
138+
residual_blocks=residual_blocks,
139+
epochs=epochs,
140+
seed=seed,
141+
gpu=True,
142+
gradient_checkpoint=True, # save VRAM for large grids
143+
data_root=data_root,
144+
max_file_size=0, # already filtered above
145+
wandb_project=wandb_project,
146+
verbose=True,
147+
)
148+
149+
log.info("val_loss: %.6f", results["final_val_loss"])
150+
log.info("train_loss: %.6f", results["final_train_loss"])
151+
log.info("Wallclock: %.1fs", results["wallclock_s"])
152+
log.info("GPU: %s", gpu_type)
153+
154+
return results
155+
156+
157+
@app.local_entrypoint()
158+
def main(
159+
gpu: str = "L4",
160+
epochs: int = 5,
161+
channels: int = 8,
162+
residual_blocks: int = 2,
163+
samples: int = 50,
164+
max_file_size: float = 100,
165+
seed: int = 42,
166+
wandb_project: str = "elf-net-ci",
167+
):
168+
import logging
169+
170+
logging.basicConfig(level=logging.INFO)
171+
log = logging.getLogger(__name__)
172+
173+
benchmark_fn = run_benchmark
174+
if gpu != "L4":
175+
benchmark_fn = run_benchmark.with_options(gpu=gpu)
176+
177+
results = benchmark_fn.remote(
178+
epochs=epochs,
179+
channels=channels,
180+
residual_blocks=residual_blocks,
181+
samples=samples,
182+
max_file_size=max_file_size,
183+
seed=seed,
184+
wandb_project=wandb_project,
185+
gpu_type=gpu,
186+
)
187+
188+
log.info(
189+
"Benchmark complete: val_loss=%.6f, wallclock=%.1fs on %s",
190+
results["final_val_loss"],
191+
results["wallclock_s"],
192+
gpu,
193+
)

0 commit comments

Comments
 (0)