74012fe049
Inference has no backward graph or optimizer state, so it has a much lower per-sample memory footprint than training. estimate_batch_size now takes a training flag selecting between two calibration points; predict uses the inference one (hidden_dim=1024, n_blocks=8, batch_size=65536 measured at ~2037 MiB). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
222 lines
7.0 KiB
Python
222 lines
7.0 KiB
Python
import random
|
|
import subprocess
|
|
import sys
|
|
import tomllib
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
import torch
|
|
|
|
DEFAULT_CONFIG: dict = {
|
|
"train": {
|
|
"mode": "flow",
|
|
"epochs": 100,
|
|
"batch_size": 4096,
|
|
"lr": 3e-4,
|
|
"val_fraction": 0.1,
|
|
"num_workers": 4,
|
|
"seed": 0,
|
|
"validate_every": 10,
|
|
"validate_steps": 10,
|
|
"warmup_epochs": 5,
|
|
},
|
|
"model": {
|
|
"hidden_dim": 256,
|
|
"n_blocks": 6,
|
|
"emb_dim": 16,
|
|
"dropout": 0.1,
|
|
},
|
|
}
|
|
|
|
|
|
def git_hash() -> str:
|
|
try:
|
|
return (
|
|
subprocess.check_output(
|
|
["git", "rev-parse", "HEAD"], stderr=subprocess.DEVNULL
|
|
)
|
|
.decode()
|
|
.strip()
|
|
)
|
|
except Exception:
|
|
return "unknown"
|
|
|
|
|
|
def auto_device() -> torch.device:
|
|
if torch.cuda.is_available():
|
|
return torch.device("cuda")
|
|
if torch.backends.mps.is_available():
|
|
return torch.device("mps")
|
|
return torch.device("cpu")
|
|
|
|
|
|
# Calibration point for estimate_batch_size(training=True): hidden_dim=512,
|
|
# n_blocks=6, batch_size=131072 measured at ~8 GiB VRAM. Activation memory is
|
|
# assumed to scale linearly with batch_size * hidden_dim * n_blocks (the
|
|
# ResBlock stack dominates), so this is a rough estimate rather than a
|
|
# guaranteed bound.
|
|
_REF_BYTES = 8 * 1024**3
|
|
_REF_BATCH_SIZE = 131072
|
|
_REF_HIDDEN_DIM = 512
|
|
_REF_N_BLOCKS = 6
|
|
|
|
# Calibration point for estimate_batch_size(training=False): inference has no
|
|
# backward graph or optimizer state, so its memory footprint is much smaller
|
|
# per sample. hidden_dim=1024, n_blocks=8, batch_size=65536 measured at ~2037
|
|
# MiB VRAM.
|
|
_REF_BYTES_PREDICT = 2037 * 1024**2
|
|
_REF_BATCH_SIZE_PREDICT = 65536
|
|
_REF_HIDDEN_DIM_PREDICT = 1024
|
|
_REF_N_BLOCKS_PREDICT = 8
|
|
|
|
|
|
def estimate_batch_size(
|
|
hidden_dim: int,
|
|
n_blocks: int,
|
|
device: torch.device,
|
|
safety_factor: float = 0.8,
|
|
min_batch_size: int = 1024,
|
|
training: bool = True,
|
|
) -> int:
|
|
"""Estimate a batch size that fits in the free memory on `device`.
|
|
|
|
Only supported on CUDA devices, which expose a free/total memory query;
|
|
other backends (cpu, mps) raise ValueError. Pass `training=False` for
|
|
inference (e.g. `predict`), which uses a much lower per-sample memory
|
|
calibration since there's no backward graph or optimizer state.
|
|
"""
|
|
if device.type != "cuda":
|
|
raise ValueError(
|
|
f"--batch-size auto is only supported on cuda devices, got {device.type!r}"
|
|
)
|
|
device_index = (
|
|
device.index if device.index is not None else torch.cuda.current_device()
|
|
)
|
|
free_bytes, _total_bytes = torch.cuda.mem_get_info(device_index)
|
|
if training:
|
|
ref_bytes, ref_batch_size, ref_hidden_dim, ref_n_blocks = (
|
|
_REF_BYTES,
|
|
_REF_BATCH_SIZE,
|
|
_REF_HIDDEN_DIM,
|
|
_REF_N_BLOCKS,
|
|
)
|
|
else:
|
|
ref_bytes, ref_batch_size, ref_hidden_dim, ref_n_blocks = (
|
|
_REF_BYTES_PREDICT,
|
|
_REF_BATCH_SIZE_PREDICT,
|
|
_REF_HIDDEN_DIM_PREDICT,
|
|
_REF_N_BLOCKS_PREDICT,
|
|
)
|
|
bytes_per_unit = ref_bytes / (ref_batch_size * ref_hidden_dim * ref_n_blocks)
|
|
bytes_per_sample = bytes_per_unit * hidden_dim * n_blocks
|
|
batch_size = int(free_bytes * safety_factor / bytes_per_sample)
|
|
batch_size = max(min_batch_size, (batch_size // 1024) * 1024)
|
|
return batch_size
|
|
|
|
|
|
def load_toml(path: Path) -> dict:
|
|
with open(path, "rb") as f:
|
|
return tomllib.load(f)
|
|
|
|
|
|
def warn_if_git_hash_mismatch(file_cfg: dict, config_path: Path) -> None:
|
|
"""Warn (don't fail) if a config.toml's [meta].git_hash predates the current checkout.
|
|
|
|
A config saved by a previous run may have been produced by code that has
|
|
since changed, so its hyperparameters might not mean what they used to —
|
|
surface that as a heads-up rather than blocking the rerun.
|
|
"""
|
|
file_hash = file_cfg.get("meta", {}).get("git_hash")
|
|
current_hash = git_hash()
|
|
if not file_hash or file_hash == "unknown" or current_hash == "unknown":
|
|
return
|
|
if file_hash != current_hash:
|
|
print(
|
|
f"warning: {config_path} was generated at git commit {file_hash}, "
|
|
f"but the current checkout is at {current_hash} — hyperparameters "
|
|
"may not match the code that originally produced this config",
|
|
file=sys.stderr,
|
|
)
|
|
|
|
|
|
def warn_if_checkpoint_config_mismatch(ckpt_path: str | Path) -> None:
|
|
"""Look for a config.toml next to a checkpoint and warn on a git_hash mismatch.
|
|
|
|
Training writes config.toml into the same out_dir as its checkpoints, so a
|
|
checkpoint loaded later (for `predict` or `giant.analysis`) can be
|
|
cross-checked the same way `--config` loading is, without the caller having
|
|
to pass the toml path explicitly. Silently does nothing if no config.toml
|
|
is found alongside the checkpoint.
|
|
"""
|
|
config_path = Path(ckpt_path).parent / "config.toml"
|
|
if not config_path.exists():
|
|
return
|
|
warn_if_git_hash_mismatch(load_toml(config_path), config_path)
|
|
|
|
|
|
def merge_cli_overrides(
|
|
defaults: dict,
|
|
config_path: Path | None,
|
|
train_overrides: dict,
|
|
model_overrides: dict,
|
|
) -> dict:
|
|
"""Resolve config as defaults -> TOML file -> explicit CLI flags."""
|
|
cfg = {"train": dict(defaults["train"]), "model": dict(defaults["model"])}
|
|
if config_path is not None:
|
|
file_cfg = load_toml(config_path)
|
|
for section in ("train", "model"):
|
|
cfg[section].update(file_cfg.get(section, {}))
|
|
warn_if_git_hash_mismatch(file_cfg, config_path)
|
|
cfg["train"].update(train_overrides)
|
|
cfg["model"].update(model_overrides)
|
|
return cfg
|
|
|
|
|
|
def seed_everything(seed: int) -> None:
|
|
random.seed(seed)
|
|
np.random.seed(seed)
|
|
torch.manual_seed(seed)
|
|
if torch.cuda.is_available():
|
|
torch.cuda.manual_seed_all(seed)
|
|
|
|
|
|
def save_config(cfg: dict, out_dir: Path, meta: dict) -> None:
|
|
lines = []
|
|
for section, values in cfg.items():
|
|
lines.append(f"[{section}]")
|
|
for k, v in values.items():
|
|
lines.append(f"{k:<14} = {repr(v) if isinstance(v, str) else v}")
|
|
lines.append("")
|
|
|
|
lines.append("[meta]")
|
|
for k, v in meta.items():
|
|
lines.append(f"{k:<14} = {repr(v) if isinstance(v, str) else v}")
|
|
|
|
(out_dir / "config.toml").write_text("\n".join(lines))
|
|
|
|
|
|
def build_run_meta(
|
|
data: Path,
|
|
seed: int,
|
|
n_pdg_codes: int,
|
|
n_materials: int,
|
|
n_train_events: int,
|
|
n_val_events: int,
|
|
n_train_steps: int,
|
|
) -> dict:
|
|
return {
|
|
"git_hash": git_hash(),
|
|
"seed": seed,
|
|
"timestamp_utc": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
|
"python_version": sys.version.split()[0],
|
|
"torch_version": torch.__version__,
|
|
"command": " ".join(sys.argv),
|
|
"data_path": str(data),
|
|
"n_pdg_codes": n_pdg_codes,
|
|
"n_materials": n_materials,
|
|
"n_train_events": n_train_events,
|
|
"n_val_events": n_val_events,
|
|
"n_train_steps": n_train_steps,
|
|
}
|