Skip to content

More bug fixes and performance improvements - #92

Open
ndryden wants to merge 53 commits into
mainfrom
round2-fixes
Open

More bug fixes and performance improvements#92
ndryden wants to merge 53 commits into
mainfrom
round2-fixes

Conversation

@ndryden

@ndryden ndryden commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

Another round of fixes from Claude Fable 5.

Note that I expect very modest, at best, performance improvements; the headline improvement (groupnorm) isn't really applicable yet and may get superseded.

ndryden added 30 commits July 31, 2026 19:21
The benchmark driver no longer expands list-valued config parameters into a
cross product of runs: each `scaffold benchmark` invocation now runs exactly
one worker in the resolved run directory, so the per-combination `param_set_i`
subdirectories are gone and restart/checkpoint paths stay in one place. Config
validation now always rejects list values for scalar keys, naming each
offending key and its value ("problem_scale: parameter sweeps are no longer
supported; got list [6, 7]") instead of the previous TypeErrors from
`math.floor(list)` / `int - list` deep inside Config; load_config's config type
"sweep" is renamed "benchmark". This supersedes findings R11, R12 and R16 of
the round-2 review, which all stem from the half-finished sweep path.
A resume whose checkpoint already covers config.epochs leaves the epoch
loop before any epoch body runs, so val_loss_avg was never bound and the
final-save block crashed with UnboundLocalError on every rank. train()
now logs that there was nothing to resume and returns cleanly, leaving
the existing checkpoint (which already covers those epochs) alone.

Round-2 review: R01.
…rged

The training loop runs while the validation dice is below target_dice but
that score was never checkpointed, so every restart of a converged
epochs:-1 run re-trained one full epoch before rediscovering it had
converged, inflating the epoch count and the FOM's total train time. The
score now rides along in the checkpoint extras and seeds the loop
variable on resume.

Round-2 review: R06.
cleanup(train_from_scratch=True) deleted the checkpoint files but kept
best_val_loss and last_saved_epoch, so the fresh run's is_best decisions
stayed gated by the deleted run's best and it wrote no best checkpoint
until it beat a score nothing backed any more.

Round-2 review: R02.
…model

The existing NaN check tests the hard-argmax dice, which stays finite even
for an all-NaN model, so it could never fire; a diverged run kept looping
below target while NaN weights overwrote checkpoint_last.pth. The reduced
train and validation losses are now checked right after the data-parallel
reductions -- identical on every rank, so all ranks raise together.

Round-2 review: R03.
wait_for_save now re-raises the writer's exception instead of logging and
dropping it, train() consumes the run's final save so a failed async write
can no longer exit 0 with no checkpoint, and save_checkpoint broadcasts
rank 0's outcome (success or error sentinel, including a deferred async
failure) before anyone raises -- previously rank 0 raised ahead of its own
broadcast and left the peers in an unmatched collective reporting a
transport error instead of the disk error.

Round-2 review: R04, R05.
A killed write strands a full-checkpoint-sized checkpoint_*.pth.tmp.<pid>
and a quarantined checkpoint keeps its .corrupt copy forever; nothing ever
removed either. The from-scratch cleanup now deletes both, and manager
construction sweeps orphaned temp files (skipping this pid's) since the
kill/restart cycle that creates them always takes the resume path.

Round-2 review: R07.
The module had no import sites anywhere in the package, tests or scripts,
its usage comments reference a train.py flow that no longer exists, and
compare_state_dicts2 crashes on any real optimizer state dict (it
truth-tests an elementwise tensor comparison).

Round-2 review: R10.
The nothing-to-resume warning suggested lowering target_dice, which is
exactly backwards for the converged case, and read oddly for a fresh run
that never entered the loop. Report the actual inputs (start epoch, epochs,
starting val dice vs target) instead.

Round-2 review: R01, R06 follow-up.
Any failure in the rank-0 reuse/generate decision or the final meta-write and rename is now broadcast as an error sentinel, so peers raise the same error instead of hanging in bcast/Barrier. The reuse scan also skips .tmp_* staging dirs and tolerates unreadable metadata, and staging dir names carry pid+uuid so same-second jobs cannot collide. R27
meta.yaml is now written to a temp file, fsynced, and renamed into place, so a killed job cannot leave a truncated document. The loader treats only a missing meta.yaml as legacy v1; a present-but-unreadable or version-less one raises instead of silently reinterpreting a modern dataset. R28
Killed generations left their .tmp_* staging trees under the config_id base forever, so every retry stacked another copy. Rank 0 now removes staging dirs that have sat untouched past an age threshold, which keeps a concurrent job's (far younger) staging dir safe. R37
Categories and instances are derived from config.seed but resume is a pure file-existence check, so a run under a new seed silently adopted another seed's library and published a dataset stamped with the wrong seed. Library paths now carry seed<seed>, so cross-seed reuse is impossible; old-layout libraries are not found and are regenerated. R29
category_search derived its loop-gating remaining count from a per-rank filesystem scan, so ranks with divergent views could post mismatched collectives (bcast against reduce) and hang. Rank 0 now scans and broadcasts the existing-index list, mirroring the instance.py work-list fix. R30
A category CSV truncated by a killed job kept its six-digit name, so the resume scan counted it as done forever while instance generation and the search's own resume both died parsing it. Categories are now staged under a temp name that no scan matches, fsynced, and renamed into place. R31
The centering offset subtracted an extra voxel, biasing every cloud toward the origin: the first half-voxel of each filled axis floored to -1 and was clipped onto plane 0 (1.5x the interior density, with the far plane at 0.5x). DATASET_FORMAT_VERSION is bumped so misregistered datasets are regenerated rather than reused. R33
The guard compared the class count against the int16 limit, but v2 masks ship raw category ids and a sparse split lists only the categories it contains, so a two-entry table holding id 40000 passed and then wrapped negative. The bound is now the largest id the carrier will hold, still the remapped count for legacy datasets. R34
_git_commit_short ran git in the process working directory, so meta.yaml, the published directory name, and the commit-based reuse gate carried whatever repo the job was launched from. It now runs git in the package directory; a non-checkout install still degrades to no-commit-id. R35
Nothing validates vol_size against dc_num_shards, so 16 over 3 shards is accepted as 6/6/4 and per-shard pooling silently diverges from the global result. The fix belongs in DistConv, so this records the hazard as a strict xfail rather than working around it. R32
cli.py decides run dirs and restart state on MPI rank 0 while training uses the launcher's rank environment; a plain torchrun makes mpi4py a singleton in every process, so each claims its own run dir and the job hangs. Cross-check the two world sizes at the CLI entry and abort with an explanation of the launcher wiring. R13
A run launched with --config=PATH emitted a literal --config=__CFG__ into restart.sh, because placeholder substitution only matched whole tokens; the restart then died with "Config file '__CFG__' not found". Substitute the value half of any --flag=PLACEHOLDER token as well. R14
The CLI holds MPI.COMM_WORLD but did not pass its size, and the generator's environment sniffing missed launcher variables the rank side honors (MV2, PALS, and bare SLURM/FLUX task counts), so e.g. a Cray PALS job got a single-process restart.sh. Pass the communicator size and share one world-size variable list with get_world_size. R17
Every rank stat-ed the shared filesystem for a checkpoint and raised on its own verdict, so a divergent view (stale attribute cache) either strands the peers in benchmark.py's timeout-less barrier or lets them run on after rank 0 aborted. Make it a rank-0 decision broadcast to all ranks, matching the other CLI decisions. R18
generate_fractals ran the CLI's rank-0 block too, littering base_run_dir with a timestamped benchmark directory whose restart.sh replayed generate_fractals with --restart/--run-dir, flags that subparser rejects (exit 2). Create the run dir, config dumps and restart script only for benchmark. R19
datagen_batch_size and verbose are accepted config keys, but Config never stores them, so the merge silently replaced them with argparse defaults. Carry the file's values into the merged config and let only options actually given on the command line override them: CLI flag > config file > argparse default. R20
mem_stats called torch.cuda.current_device() unguarded, so gather_and_print_mem -- which BaseTrainer.__init__ invokes unconditionally -- killed any CPU/gloo run launched with -v. Report the missing device and log a fallback instead; the early return is uniform across ranks, so no collective is skipped on one rank only. R21
Copying the base config into the run dir kept its original filename, so a base config named config.yaml overwrote the merged config.yaml the CLI had just written -- and restart.sh points -c at that file. Always copy it to base_config.yaml. R22
Config accepted any unet_bottleneck_dim, so a value outside 0..problem_scale-1 built a U-Net with zero or too many pooling levels and failed much later with an opaque max_pool3d size error naming no config key. Reject it at config time, in Config and again after the CLI applies overrides. R25
export_chrome_trace runs before the barrier that precedes rank-0 post-processing and raises when the run had zero profiled steps (or the write fails), killing the profiling rank and blocking every other rank in that barrier until timeout. Move it into a helper that logs the failure and returns. R15
get_local_size ignored LOCAL_WORLD_SIZE/PMI_LOCAL_SIZE/PALS_LOCAL_SIZE that get_local_rank honors, so it returned 1 and the per-node profiler gate selected every rank while the trace name claimed one node per rank. Add the missing variables, round the node count up, and write the trace into config.run_dir instead of the working directory. R23
ndryden added 23 commits July 31, 2026 19:22
The gate enabled profiling for any value except the literal "off", so PROFILE_TORCH=0, =false, =no and ="" all turned the profiler ON -- the opposite of what they say, and inconsistent with the sub-option flags in the same module. BEHAVIOR CHANGE: profiling is now enabled only by 1/true/on/yes (any case); every other value, including previously-enabling ones such as "enabled", leaves it off. R24
The profiler context wraps checkpoint cleanup and every warmup batch while prof.step() advances only per training batch, so PROFILE_TORCH_WAIT=0 buffered that entire prologue in host memory as one unbounded step. Clamp wait to 1 and say so, rather than reordering the context or stepping it from warmup. R26
Regression guard for the merge order: a restart driven by the run dir's config.yaml (what restart.sh emits) must reproduce the first run's CLI overrides and auxiliary config keys and still take the resume path. R20 R22
Rank 0 is the only rank that touches the run directory, and it does so while
its peers are already committed to the next collective, so every one of those
windows has to report failure *through* that collective. Three were left open.

cleanup() ran _remove_checkpoint_files between the drain and the broadcast:
individual unlinks were tolerated, but the glob and stat around them can raise
on a shared filesystem (ESTALE, EACCES), re-creating the R05 hazard in a narrow
window. load_from_checkpoint() folded only the drain error into its decision
broadcast, leaving _select_and_load -- which stats, deserializes and renames --
able to kill rank 0 before the broadcast the peers were waiting in. Both now
travel as decisions; cleanup's payload carries the phase so the peers name the
operation that actually failed rather than reporting a "save".

__init__'s orphan sweep is rank-0-only outside any collective, so a directory
it cannot list would abort rank 0 alone and strand the peers at cleanup's first
collective. It only reclaims space, so it is now best-effort with a warning.

VA-1, VA-2, VA-3
The category search broadcasts its scan of the existing category files, but
rank 0 then loaded those files' parameters -- for its duplicate guard -- just
*after* that broadcast, unfenced. A CSV that will not parse (ragged, or
hand-edited) killed rank 0 while every peer had already taken the broadcast and
entered the work loop, stranding them in its next collective: the same window
class the broadcast was introduced to close. Scan and load are now one guarded
decision reported through one broadcast, as get_dataset reports its selection.

The datagen consensus guards caught (Exception, SystemExit), which is not the
same as "everything": a KeyboardInterrupt delivered to rank 0 alone (Ctrl-C on
the launching terminal, a watchdog SIGINT) unwound straight past the broadcast
and hung the peers. They now catch BaseException. SystemExit keeps being
converted -- it must never escape get_dataset, since a peer would read the
silent unwind as success -- while the rank that was interrupted re-raises the
interrupt after posting the sentinel, so it keeps the operator's exit status
and its peers still learn to stop. volumegen's guard, the same pattern feeding
the same allreduce, is fixed with it.

VB-2, VB-3
The orphan cleanup judged a staging directory by the newest mtime among the
directory and its immediate children. Volume writing lands at depth >= 2
(volumes/<split>/N.npy), and generation is not bounded by the staleness
threshold -- at the larger scales it runs for days -- so a perfectly healthy
generation stopped touching anything the probe could see and read as
"untouched for 48 hours". A concurrent same-config start then rmtree'd it out
from under its peers, which died on FileNotFoundError.

Liveness is now reported rather than inferred: every rank writing volumes
refreshes <staging>/.heartbeat every five minutes, and cleanup keeps any
directory whose heartbeat is younger than the threshold. The marker is removed
before the staging dir is published, so a dataset does not carry it.

Backing the heartbeat up (for staging dirs written before it existed, or killed
before the first beat) the mtime probe now walks a bounded number of directory
levels instead of one. It stats directories, whose mtimes change when entries
are created in them, so it notices a writer without a stat storm over the
volume files, and it stops at the first recent entry -- the live case, the one
that must not be misjudged, is the cheap one. A tree too wide to walk within
the bound is called live: failing to reclaim disk is recoverable, deleting a
running job's dataset is not.

VB-1
Both atomic writers in the category search name their temp file after the
writing pid and unlink it when the write raises -- but a SIGKILL (walltime,
OOM, node failure) skips that Python-level cleanup. Nothing ever looked at the
strays again, so .NNNNNN.csv.tmp<pid> and .rng_attempt_rank*.tmp<pid> piled up
one per killed process. Rank 0 now sweeps them at startup, best-effort, the way
instance.py already sweeps its equivalents.

The seed-keyed relayout is silent about libraries in the old location: the
existence check simply does not find them. From outside that looks like a
library that was there yesterday being regenerated for no reason -- hours of
work at scale. One warning naming the old directory and the new one explains
it.

VB-4, VB-6
restart and run_dir can come from the config file as well as the command line
-- the generated restart.sh replays a dumped config.yaml, which carries both --
and an absent --restart cannot outrank a file that sets it, since an unset
store_true flag is indistinguishable from its default. resolve_run_dir read
only the command line while the pre-check read the merged config, so the two
disagreed in both directions.

A config-file `restart: true` with no run directory therefore created a fresh
timestamped directory, wrote its config dumps and restart script into it, and
only then died for want of a run dir; it now fails before anything is claimed.
And a run_dir inherited from a reused config.yaml stayed in the config while a
fresh directory was created to train in, so the pre-check passed on another
run's checkpoints and the job died hours later with its dataset generated.

resolve_run_dir now reads the merged values, and writes its answer back to
benchmark_run_dir, restart and run_dir (cleared for a fresh run), so nothing
downstream can re-derive a different one; missing_checkpoint_error keys off the
resolved benchmark run dir. The pre-check is also gated on the benchmark
subcommand, which is the only one that has run directories at all.

VC-1
Rank 0 builds the whole job's config while every peer waits in a barrier, so
anything raising in there stranded them: the recomputed bottleneck check and
the n_categories check added with R25, the Config() validation that has always
been there, and now the run-dir resolution. The user saw a hang where an error
message belonged.

The rank-0 block moves into build_run_config and runs inside a guard whose
outcome is broadcast, exactly like the restart pre-check below it. Rank 0
re-raises the original exception, keeping its traceback; the peers rebuild it
from the type name and message that crossed the wire, so a builtin type comes
back as itself and anything else degrades to a RuntimeError naming the
original. The exception object itself is deliberately not pickled across: a
failure to unpickle on the receiving side would turn the error being reported
back into the hang it was reported to avoid.

VC-2
The launcher-environment helpers called int() on whatever a variable held.
A site wrapper that exports WORLD_SIZE= (an unset shell variable) or a
placeholder like "auto" therefore killed every scaffold invocation with a bare
ValueError naming neither the variable nor a remedy -- and it fired from the
top of the entry point, so even `scaffold --help` died. Each lookup now goes
through a helper that treats an unusable value as absent and consults the next
source in the priority order, warning when the value looked deliberate (as
_sniff_launch_shape already did with `if val:`). The Slurm and Flux
tasks-per-node divisions no longer trust the node count either: zero is as
unusable as a word.

The world-size cross-check also moved after parse_args. Asking what the flags
are is not a job launch, and answering it with a launcher mismatch -- exactly
the environment someone debugging one is sitting in -- helps nobody. It still
runs before any run directory is created, which is the property R13 needs.

VC-3, VC-4
Flux and Slurm state their node count, so the restart script reproduced their
shape correctly. Everything else -- Cray PALS, plain torchrun -- fell back to
NODES=1, which relaunched an 8-rank job spread over 2 nodes as NODES=1
TASKS_PER_NODE=8: an oversubscribed node, or a job the scheduler rejects.

get_local_size already reads those launchers' per-node variables (PALS_LOCAL_
SIZE, LOCAL_WORLD_SIZE, ...), so the node count is derived from it, ceil-ing
the division as the rank side does. required=True keeps "one rank per node"
distinct from "nothing reported a per-node count"; only the latter falls back
to the historical single-node assumption.

VC-5
The "no new epoch was trained" warning covers two ways of entering train()
with nothing to do, and described one of them as the other: a fresh epochs:0
run was told "there was nothing to resume", sending its user to look for a
checkpoint that was never part of the story. It now says the run had no epoch
left to run, which is true of a completed resume and a zero-epoch fresh start
alike. (The deeper problem in that scenario -- worker.py's rank-0-only
genfromtxt raising after destroy_process_group, so rank 0 exits non-zero while
its peers exit 0 -- is pre-existing structure, unchanged here.)

explicit_cli_keys' docstring claimed that a flag passed with exactly its
default value costs nothing because "both spellings then agree on the default".
They do not: --datagen-batch-size 10000 next to datagen_batch_size: 500 in the
config yields 500. The rationale is corrected to name the real trade-off rather
than deny it; the defaults stay where they are, which is what the R20 tests
pin.

perf_measure now has a logger instead of printing. Its messages are all "your
profiling request was not honored as written", which belongs on a channel a
caller can filter or capture, not in the middle of the run's stdout. The R26
test follows it to caplog. And the R21 tests' function-body `import pytest`
moves to module level as a skipif marker. The config-error re-raise added a
commit ago is spelled as a statement rather than a conditional expression.

VA-4, VC-6, VC-cosmetics
test_non_repo_install_reports_no_commit_id builds a "not a checkout" directory
under tmp_path, but git walks upwards until it finds a repository: run with
--basetemp inside a ScaFFold checkout, that directory inherits the checkout's
HEAD and the test fails on where it was run rather than on what it tests.
GIT_CEILING_DIRECTORIES stops the walk at tmp_path.

VB-7
An improving epoch pickled and fsynced the identical state dict twice, once
for checkpoint_last.pth and again for checkpoint_best.pth. The best file is
now copied (tmp + fsync + os.replace) from the last file the same writer just
committed, halving the serialization CPU and checkpoint bytes. R08
snapshot_training_state cloned model and optimizer state device-to-device,
holding ~3x parameter bytes of accelerator memory across the whole warmup
phase (and warmup's own memory peak). It now copies to CPU; load_state_dict
puts the state back on each parameter's device on restore. R09
instance.main re-ran np.genfromtxt on the same category CSV for every
(category, instance) work item -- 145 identical shared-filesystem reads per
category. Work items for a category are contiguous in the block partition, so
a one-entry cache gives one parse per category per rank. R36
Warmup ran only leading, full-size batches, so with local_batch_size>1 and an
indivisible shard the epoch's partial batch met cuDNN/MIOpen for the first
time inside the first timed epoch -- a measured 95 s stall in epoch_duration,
the FOM denominator. Warmup now runs one extra iteration per distinct ragged
size (agreed across ranks, since validation shards are unpadded) inside the
existing snapshot/restore envelope. R41
pyplot retained every figure the run-summary plots created, and a sweep calls
standard_viz.main in-process once per combination, so figures (and their
canvases) piled up for the whole sweep. Each is now closed after its savefig,
and unconditionally in a finally since plotting errors are swallowed. R43
The U-Net's use_checkpointing (made correct by F46) had no config key and no
production caller, so the memory/compute trade it offers could not be taken.
Per the user's decision to wire the feature rather than delete it, an
activation_checkpointing 0/1 flag now enables it in worker.py, before the DDP
wrap hides the method behind .module. R44
ATen's GroupNorm computes its per-group statistics with a kernel that launches
one workgroup per (batch, group) row. At the benchmark defaults
(local_batch_size=1, group_norm_groups=8) that is 8 of an MI300A's 228 CUs, and
GroupNorm was 86.98 ms of the 186.97 ms scale-7 training step -- 46.5% of wall
time, the single largest cost in the step (R38).

FastGroupNorm subclasses nn.GroupNorm and routes its forward through a lazily
built, process-wide torch.compile(F.group_norm, dynamic=False, fullgraph=True),
which hands the reduction to Inductor to tile across the whole device. The
module holds the same weight/bias under the same names with no new buffers, so
checkpoints round-trip in both directions against a plain nn.GroupNorm build.
The compiled path is used only where it is safe, and every rejection falls back
to the stock kernel: CPU tensors (the CPU suite pays no compile latency),
tensor subclasses such as DistConv's DCTensor (Dynamo cannot trace
__torch_dispatch__ wrappers), an already-compiled enclosing region, an explicit
SCAFFOLD_GROUPNORM_COMPILE=0, and -- permanently, with one warning -- any
exception out of torch.compile. Dynamo's per-function recompile limit is raised
from its stock 8: one UNet needs 10 cache entries (5 distinct GroupNorm shapes,
each again under no_grad for evaluation), and past the limit Dynamo gives up and
silently reverts every GroupNorm to the slow kernel.

Measured on one MI300A with review/round2/repros/perf/step_bench.py --layout cl
(1x3x128^3, layers=4, bf16 autocast, GradScaler disabled, warm MIOpen db),
median of 20 steps, eager numbers from the same build with
SCAFFOLD_GROUPNORM_COMPILE=0:

  step        184.69 ms -> 100.71 ms  (1.83x)
  forward     104.04 ms ->  30.64 ms
  backward     75.59 ms ->  64.66 ms
  peak alloc    9.80 GiB -> 8.22 GiB
  --batch 2   279.58 ms -> 187.61 ms  (1.49x; no regression at B>1)

torch.profiler over the same step: GroupNorm 86.98 ms/step (46.5% of wall) ->
6.56 ms/step (6.7% of a 97.91 ms step). Compilation is one-time: the first
compiled step takes 15.15 s with a cold Inductor cache and produces 5 graphs,
after which 12 steps at 97.4 ms produce none; the first no_grad forward adds the
other 5. The default 64 warmup batches absorb it outside every timed epoch.

Determinism: no gate needed. Two separate processes running three
fwd+bwd+Adam steps of the scale-7 UNet under the more_determinism settings
(use_deterministic_algorithms(True, warn_only=True), cudnn.benchmark=False,
fixed seeds) hash bitwise identically with the compiled path, exactly as they
do with the eager one, so no config flag is plumbed through.

Not yet visible in the default configuration: worker.py wraps activations in
DCTensor even at dc_num_shards=[1,1,1], and GroupNorm then keeps the eager
kernel. Verified through a worker.py-shaped DistConvDDP harness that all three
paths (DCTensor, plain tensors, plain + use_checkpointing) train without error
and agree on their losses; the speedup lands as soon as the unsharded wrap is
skipped (R39).
A rank whose warmup fetched no batch returned before the ragged-size
all_gather, skipping a collective its peers post — the divergence class
this round closes elsewhere. Latent today (padded training shards make
loader lengths rank-invariant), but the collective pattern must not
depend on local loader state. Found by the final verification pass.
The R08 comment claimed the copy halves the bytes pushed at the shared
filesystem; measured on Lustre the copy is ~1.02x — the real saving is
the serialization CPU. Note the redundancy trade-off (best is now a byte
copy of last), use a 16 MiB copy buffer to cut syscall count on parallel
filesystems, and document that the Dynamo recompile-limit raise clobbers
a deliberately smaller limit. Found by the final verification pass.
The seed-keyed relayout (R29) made the shipped seed-unknown CSVs at
ScaFFold/fractals/var0.15/3DIFS_param unreachable under any
configuration. Remove them, their package-data glob, the dead
Config.library_root (zero readers), and the README claim; libraries
regenerate deterministically from the configured seed. Resolves
verification item VB-5 per user decision.

@michaelmckinsey1 michaelmckinsey1 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A lot of these changes appear to be targeted at bugs in checkpoint restarts and checkpointing, with some optimizations, the most notable being the compiled group norm.

I find it interesting that ~50% of added lines are comments/docstrings. A lot of these are quite useful.

I'm concerned that some fixes seem to fix issues that we do not realistically encounter. I understand that it improves the maturity of ScaFFold to check edge cases, and many checks here are valid, there are some that are scenarios that are either rare or a user would have to intentionally misuse the benchmark to encounter.

Comment thread ScaFFold/utils/trainer.py
Comment on lines +779 to +783
# Whether this invocation completed at least one NEW epoch. A resume
# whose checkpoint already covers config.epochs (or an --epochs lowered
# below the checkpointed epoch) leaves the loop at the max-epoch check
# before any epoch body runs, so none of the per-epoch metrics exist.
completed_new_epoch = False

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Update: I see validity in this one, so I'm fine keeping it as-is. I think it's a good example of a rare edge case though.


283c00bab57434d0126ea020794a9c14ec58a596 Is checking an edge case where you train to E epochs, your checkpoint_interval is exactly E, and then your restart happens to restart at E+1. and things break.

Where this may actually happen more realistically is if you set --epochs and checkpointing, but this is also something I have never ran into.

Comment on lines +248 to +250
# ---------------------------------------------------------------------------
# R02 -- a from-scratch cleanup drops the deleted run's best, not just its files
# ---------------------------------------------------------------------------

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I notice at the start of these comments, R02 and the next one F71. Do these ID's have any meaning outside of the review document? i.e. should we keep them actually in the codebase, or just the description.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re 3d26b7b3f83952359ec17c8aebb517e2be45c621 and 9b028fa856d8f27b85d67c8b448b83b636f6ac02:

I think we take this opportunity to deprecate legacy datasets.There's V1, V2, and V3, now this is V4.

@PatrickRMiles thoughts?

Comment on lines +178 to +197
def _max_class_id(self):
"""Return the largest class id ``_to_mask_carrier`` will have to carry.

The bound differs by format, and using the wrong one is unsafe in one
direction and needlessly strict in the other. v2+ masks ship *raw*
``category + 1`` ids, and the per-split table lists only the categories
present in that split -- so a sparse split can declare two classes while
holding an id in the tens of thousands, which the class *count* check
happily waved through. Legacy masks, by contrast, are remapped to
``0..len(mask_values)-1``, so the count is exactly right there and their
(arbitrarily large) raw values are irrelevant.
"""
if self.dataset_format_version < DATASET_FORMAT_VERSION:
return len(self.mask_values) - 1

ids = np.asarray(self.mask_values)
if ids.size == 0:
return 0
return int(ids.max())

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

d52dd07a9a976ba11cfd20d1610df712cb4376ee specific V2 dataset change that would not be necessary if we deprecate old datasets. I say we deprecate and remove corresponding support for old datasets.

It's no different than when we change the convergence metric from softmax to argmax, the previous convergence data becomes incomparable.

Comment thread ScaFFold/cli.py
Comment on lines +31 to +69
def check_launcher_world_size(mpi_world_size):
"""Verify that the MPI world spans the whole job.

ScaFFold uses two different sources of truth for the job shape: the CLI and
the benchmark driver make their job-wide decisions on ``MPI.COMM_WORLD``
rank 0, while the training path takes its rank and world size from the
launcher's environment (``get_world_rank`` / ``get_world_size``). Those
agree only when the job was started by an MPI-aware launcher.

Under a plain ``torchrun`` (or a bare ``python`` invocation of several
processes) mpi4py initializes as an independent singleton in every process,
so every process believes it is MPI rank 0: each one runs the rank-0 block,
atomically claims its *own* timestamped run directory, and the job then
diverges -- non-zero launcher ranks crash on a broadcast that never
happened while rank 0 blocks in the first collective until it times out.

Fail loudly here, before any run directory is created, instead of leaving
that mess behind. ``get_world_size`` falls back to the MPI communicator
when the environment reports nothing, so an unlauncher-ed single process
trivially agrees with itself.
"""
env_world_size = get_world_size()
if env_world_size != mpi_world_size:
raise RuntimeError(
f"Launcher/MPI world size mismatch: MPI.COMM_WORLD reports "
f"{mpi_world_size} rank(s) but the launcher environment reports "
f"{env_world_size}. ScaFFold decides run directories and restart "
"state on MPI rank 0 and broadcasts them, so an MPI world that "
"does not span the job is unrecoverable: every process acts as "
"rank 0, each claims a separate run directory, and the job hangs "
"in the first collective. This is what a plain 'torchrun' (or "
"launching the processes directly) produces, because mpi4py then "
"initializes as a singleton in every process. Launch ScaFFold "
"with an MPI-aware launcher (torchrun-hpc, flux run, srun, "
"mpirun) so that MPI spans all ranks, or run a single process "
"with no launcher environment set."
)


Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2d77083a64d3f1ec8eaa1f203329ddb76b524ac9 FWIW we don't test the benchmark for any launcher outside of torchrun-hpc, so checking an edge case of plain torchrun seems irrelevant.

Do we foresee external use leveraging torchrun instead of torchrun-hpc? I suppose it's possible, but then why not check edge cases in flux and slurm? We have experienced issues with those when running scaffold outside of torchrun-hpc.

Maybe the appropriate solution is to affirm that we only support running with torchrun-hpc, otherwise the user needs to appropriately configure their run at their own risk.

Comment thread ScaFFold/worker.py
barrier that precedes rank-0 post-processing, so an exception here does not
just lose a trace: it kills this rank and leaves every other rank blocked
in that barrier until the collective times out. Failures are real (a run
with zero training batches never starts the profiler, and export_chrome_trace

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2ac96437f1d0b6e6289635ff7b56d018b9bdcf48 A "run with zero training batches" is not a real edge case. This function realistically allows the run to finish when the file can not be written for whatever reason, but since we are benchmarking if we are profiling with pytorch and didn't get our pytorch profile we probably won't care about the result of the run anyway right?

# One-entry cache of the most recently parsed category CSV. The work list
# is built category-major and block-sliced, so every rank's items for a
# given category are contiguous: a single entry is enough to turn the
# per-item re-parse (145 identical reads of the same small file off the

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

instead of 145 this is n_instances_used_per_fractal reads

return nn.GroupNorm(num_groups, num_channels)
# FastGroupNorm is nn.GroupNorm plus a compiled GPU kernel; it holds the
# same parameters under the same names, so checkpoints are unaffected.
return FastGroupNorm(num_groups, num_channels)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is where I'm seeing most of the 1-11% better performance over #88

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants