Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
3debef9
Add test infrastructure: pytest config, shared fixtures, MPI/torchrun…
ndryden Jul 22, 2026
10915fa
Fix reporting and visualization bugs
ndryden Jul 22, 2026
cb037cb
Fix U-Net construction validation and activation checkpointing
ndryden Jul 22, 2026
7a9ba6f
Generate restart scripts from the run's actual environment and launch…
ndryden Jul 22, 2026
6fbc8e0
Fix IFS point-generation kernel bugs
ndryden Jul 22, 2026
7e93cf5
Make dataset id ordering deterministic and v1 label mapping split-con…
ndryden Jul 22, 2026
3c99abe
Harden checkpointing: atomic writes, corruption fallback, race-free b…
ndryden Jul 22, 2026
7a95c57
Compute validation Dice on hard predictions and sample-weighted val loss
ndryden Jul 22, 2026
ebeabd2
Make datagen RNG deterministic and category-search resume safe
ndryden Jul 22, 2026
be2b110
Validate generated instances, write them atomically, and fix rasteriz…
ndryden Jul 22, 2026
ec5a1e9
Validate config keys, wire --config overrides, implement benchmark sw…
ndryden Jul 22, 2026
9983add
Make distributed datagen failure paths collective-safe
ndryden Jul 22, 2026
9590601
Fix DDP device pinning, launcher detection, and worker teardown order
ndryden Jul 22, 2026
5469a21
Reduce training/validation metrics globally across data-parallel repl…
ndryden Jul 22, 2026
a93fe5f
Fix resume flag handling, stats-file headers, and step-counter persis…
ndryden Jul 22, 2026
0802d96
Handle a missing parallel strategy and CPU-only devices in training
ndryden Jul 22, 2026
2d4eb0e
Only treat .csv files as fractal categories when indexing instances
ndryden Jul 22, 2026
245d98f
Clean up unused imports in test_reporting
ndryden Jul 22, 2026
3846534
Apply ruff formatting to checkpointing
ndryden Jul 22, 2026
2a0254d
Rasterize by scattering point indices and store point clouds as float32
ndryden Jul 23, 2026
0eaafab
Resolve mask paths once and size category search rounds adaptively
ndryden Jul 23, 2026
2c99f6f
Skip the upsample pad when no padding is needed
ndryden Jul 23, 2026
7ce9a5c
Trim per-step overhead in the training and validation compute path
ndryden Jul 23, 2026
03a3530
Cut redundant dataset I/O per sample and per startup scan
ndryden Jul 23, 2026
ed1edd3
Load the resume checkpoint on rank 0 and broadcast it
ndryden Jul 23, 2026
cca7e3a
Adapt tests and helpers to the always-distributed design
ndryden Jul 23, 2026
07c2393
Persist the cumulative optimizer-step total across resumes
ndryden Jul 23, 2026
9d8b787
Gather the dataset id digest on the backend's device
ndryden Jul 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 74 additions & 16 deletions ScaFFold/benchmark.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,45 @@
#
# SPDX-License-Identifier: (Apache-2.0)

import itertools
import shutil
from argparse import Namespace
from pathlib import Path, PosixPath

import yaml
from mpi4py import MPI

from ScaFFold import worker
from ScaFFold.utils.config_utils import Config
from ScaFFold.utils.distributed import get_world_rank
from ScaFFold.utils.perf_measure import adiak_init, adiak_value
from ScaFFold.utils.utils import setup_mpi_logger


def expand_sweep_combinations(config_dict):
"""
Expand list-valued sweep parameters into per-run scalar configs.

Scalar-typed config fields holding lists are treated as sweep dimensions;
the cross product of their values yields one config dict per combination.
An all-scalar config yields exactly one combination. Legitimately
list-typed fields (e.g. dc_num_shards) are never treated as sweeps.
"""
sweep_keys = sorted(
k for k in Config._SCALAR_KEYS if isinstance(config_dict.get(k), list)
)
if not sweep_keys:
return [dict(config_dict)]

combinations = []
value_lists = [config_dict[k] for k in sweep_keys]
for values in itertools.product(*value_lists):
combo = dict(config_dict)
combo.update(dict(zip(sweep_keys, values)))
combinations.append(combo)
return combinations


def main(kwargs_dict: dict = {}):
args = Namespace(**kwargs_dict)
log = setup_mpi_logger(__file__, args.verbose)
Expand All @@ -33,10 +60,18 @@ def main(kwargs_dict: dict = {}):
rank = get_world_rank(required=True)
log.debug("args found: %s", args)

kdict = None
run_dicts = None
# Now set up and start benchmark run(s)
if args.restart:
# Resume the run in the directory the CLI resolved. The worker path
# reads config.run_dir/run_iter directly (a missing run_dir would crash
# BaseTrainer), so fill both from the resolved benchmark_run_dir here
# just as the fresh, single-combination path does below.
benchmark_run_dir = args.benchmark_run_dir
kdict = {k: v for k, v in vars(args).items() if k not in ["command"]}
kdict["run_dir"] = str(benchmark_run_dir)
kdict["run_iter"] = Path(f"{benchmark_run_dir}/run")
run_dicts = [kdict]
elif rank == 0:
# Get run dir
benchmark_run_dir = args.benchmark_run_dir
Expand All @@ -45,22 +80,45 @@ def main(kwargs_dict: dict = {}):
bench_config_path = Path(args.config)
shutil.copy(bench_config_path, benchmark_run_dir)

run_dir_with_iter = Path(f"{benchmark_run_dir}/run")
kdict = {k: v for k, v in vars(args).items() if k not in ["command"]}
kdict["run_dir"] = str(benchmark_run_dir)
kdict["run_iter"] = run_dir_with_iter
base_dict = {k: v for k, v in vars(args).items() if k not in ["command"]}
combinations = expand_sweep_combinations(base_dict)

# One run (and run directory) per parameter combination. The single
# all-scalar combination keeps the original flat layout.
run_dicts = []
if len(combinations) == 1:
kdict = combinations[0]
kdict["run_dir"] = str(benchmark_run_dir)
kdict["run_iter"] = Path(f"{benchmark_run_dir}/run")
run_dicts.append(kdict)
else:
for i, kdict in enumerate(combinations):
run_dir = Path(benchmark_run_dir) / f"param_set_{i}"
run_dir.mkdir(parents=True, exist_ok=True)
kdict["run_dir"] = str(run_dir)
kdict["run_iter"] = run_dir / "run"
with open(run_dir / "run_config.yaml", "w") as file:
yaml.dump(
{
k: str(v) if isinstance(v, PosixPath) else v
for k, v in kdict.items()
},
file,
)
run_dicts.append(kdict)

comm.Barrier()
kdict = comm.bcast(kdict, root=0)
run_dicts = comm.bcast(run_dicts, root=0)

# Add all config params as metadata
adiak_init(comm)
for key, value in kdict.items():
if isinstance(value, dict):
log.debug("Adiak: skipping key with dict value '%s'", key)
continue
if isinstance(value, PosixPath):
value = str(value)
adiak_value(key, value)

worker.main(kwargs_dict=kdict)
for kdict in run_dicts:
# Add all config params as metadata
for key, value in kdict.items():
if isinstance(value, dict):
log.debug("Adiak: skipping key with dict value '%s'", key)
continue
if isinstance(value, PosixPath):
value = str(value)
adiak_value(key, value)

worker.main(kwargs_dict=kdict)
145 changes: 114 additions & 31 deletions ScaFFold/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,80 @@
from ScaFFold.utils.utils import setup_mpi_logger


def _make_fresh_run_dir(base_run_dir, timestamp):
"""Create a fresh timestamped run directory without clobbering an existing one.

The bare timestamped name is attempted first. If it already exists -- two
jobs launched under the same base directory within the same wall-clock
second name their directories identically -- a numeric suffix is appended
and incremented until an unused name is found. ``mkdir(exist_ok=False)`` is
atomic, so each name is claimed by exactly one creator and neither job
overwrites the other's config or stats.

Returns the created directory as a ``Path``.
"""
candidate = base_run_dir / timestamp
suffix = 0
while True:
try:
candidate.mkdir(parents=True, exist_ok=False)
return candidate
except FileExistsError:
suffix += 1
candidate = base_run_dir / f"{timestamp}-{suffix}"


def resolve_run_dir(args_dict, combined_config):
"""Decide the benchmark run directory and whether this launch resumes a run.

The semantics are fixed and unambiguous:

* ``--run-dir DIR`` (with or without ``--restart``): resume in that exact
directory. ``train_from_scratch`` is forced off and ``restart`` on so the
downstream benchmark driver takes its restart path.
* ``--restart`` without ``--run-dir``: rejected with a clear error. The run
directory to resume must be named explicitly; the most recent directory
is never guessed.
* neither flag: create a fresh timestamped directory under ``base_run_dir``,
retrying with a numeric suffix on a same-second name collision.

``combined_config['benchmark_run_dir']`` is set in every path so the driver
can always read it. Returns ``(benchmark_run_dir: Path, restarting: bool)``.
"""
restart_flag = bool(args_dict.get("restart"))
run_dir_arg = args_dict.get("run_dir")

if run_dir_arg is not None:
benchmark_run_dir = Path(run_dir_arg)
# An explicit run dir is expected to already exist; tolerate a missing
# one but never treat a pre-existing dir as an error here.
benchmark_run_dir.mkdir(parents=True, exist_ok=True)
restarting = True
elif restart_flag:
raise ValueError(
"--restart requires --run-dir: pass the directory of the run to "
"resume (e.g. '--restart --run-dir <path>'). The most recent run "
"directory is not resolved automatically."
)
else:
base_run_dir = Path(combined_config["base_run_dir"])
timestamp = datetime.now().strftime(
f"{combined_config.get('job_name')}_%Y%m%d-%H%M%S"
)
benchmark_run_dir = _make_fresh_run_dir(base_run_dir, timestamp)
log = setup_mpi_logger(__file__, args_dict.get("verbose", 0))
log.info(
"benchmark_run_dir created at path %s", Path.resolve(benchmark_run_dir)
)
restarting = False

combined_config["benchmark_run_dir"] = str(benchmark_run_dir)
if restarting:
combined_config["train_from_scratch"] = False
combined_config["restart"] = True
return benchmark_run_dir, restarting


def main():
"""
Command line interface for ScaFFold.
Expand Down Expand Up @@ -101,13 +175,19 @@ def main():
"Requires path to config file."
),
)
# Specify config file
# Specify config file(s): the first is the complete base config, any
# additional -c/--config files are partial overrides applied in order.
benchmark_parser.add_argument(
"-c",
"--config",
type=str,
action="append",
default=None,
help="Path to config file for running benchmark",
help=(
"Path to config file for running benchmark. May be given more "
"than once: the first file is the base config and later files "
"are partial overrides."
),
required=True,
)

Expand Down Expand Up @@ -224,14 +304,36 @@ def main():
log = setup_mpi_logger(__file__, args.verbose)
combined_config = None

# Reject an incoherent resume request on every rank, before the rank-0
# config work and its collective barrier, so the job fails fast and
# uniformly instead of leaving the non-zero ranks blocked in a barrier
# while rank 0 aborts.
if (
args.command == "benchmark"
and getattr(args, "restart", False)
and getattr(args, "run_dir", None) is None
):
parser.error(
"--restart requires --run-dir: pass the directory of the run to "
"resume (e.g. '--restart --run-dir <path>')."
)

if rank == 0:
log.debug("args = %s", args)

bench_config = config_utils.load_config(Path(args.config), "sweep")
bench_config_dict = (
vars(bench_config) if not isinstance(bench_config, dict) else bench_config
)
# --config may be a single path (generate_fractals) or a list of
# paths (benchmark, action="append"): base config plus overrides.
config_paths = args.config if isinstance(args.config, list) else [args.config]
merged_dict = config_utils.load_config_files(config_paths)
# Validate the merged result and derive dependent settings
# (list-valued sweep params are allowed here; the benchmark driver
# expands them per run).
bench_config = config_utils.Config(merged_dict, allow_sweeps=True)
bench_config_dict = vars(bench_config)
cli_args = vars(args)
# Downstream consumers expect a single config path (e.g. to copy it
# into the run dir); keep the base config there.
cli_args["config"] = config_paths[0]

# Combine configs: CLI args override config file values
combined_config = bench_config_dict.copy()
Expand Down Expand Up @@ -276,31 +378,12 @@ def main():
combined_config["vol_size"] = pow(2, combined_config["problem_scale"])
combined_config["point_num"] = int(combined_config["vol_size"] ** 3 / 256)

# Handle Restart / Resume logic
if hasattr(args, "restart") and args.restart:
log.info("Restart flag detected: forcing train_from_scratch = False")
combined_config["train_from_scratch"] = False
combined_config["restart"] = True

# If user manually supplied --run-dir (via restart script), use it.
if hasattr(args, "run_dir") and args.run_dir is not None:
log.info("Resuming in existing directory: %s", args.run_dir)
benchmark_run_dir = Path(args.run_dir)
# Ensure we don't accidentally wipe checkpoints even if --restart wasn't explicitly passed
combined_config["train_from_scratch"] = False
else:
base_run_dir = Path(combined_config["base_run_dir"])
timestamp = datetime.now().strftime(
f"{combined_config.get('job_name')}_%Y%m%d-%H%M%S"
)
benchmark_run_dir = base_run_dir / timestamp
log.info(
"benchmark_run_dir created at path %s",
Path.resolve(benchmark_run_dir),
)

combined_config["benchmark_run_dir"] = str(benchmark_run_dir)
benchmark_run_dir.mkdir(parents=True, exist_ok=True)
# Resolve the run directory and whether this launch resumes a run.
# This sets combined_config["benchmark_run_dir"] on every path and,
# when resuming, forces train_from_scratch off / restart on.
benchmark_run_dir, restarting = resolve_run_dir(vars(args), combined_config)
if restarting:
log.info("Resuming in existing directory: %s", benchmark_run_dir)

# Add scheduler metadata and machine name to config.yaml
combined_config["scheduler_metadata"] = collect_scheduler_metadata()
Expand Down
Loading
Loading