|
| 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.") |
0 commit comments