|
| 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