Skip to content

Replace the pytorch example with a distributed ImageNet training example - #164

Draft
lebrice wants to merge 27 commits into
masterfrom
imagenet-example
Draft

Replace the pytorch example with a distributed ImageNet training example#164
lebrice wants to merge 27 commits into
masterfrom
imagenet-example

Conversation

@lebrice

@lebrice lebrice commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Replaces the CIFAR-10 pytorch-example stub with a port of the mila-docs ImageNet
example
, adapted
to run on any configured cluster using one job script per cluster (job_script_path).

The old example never trained anything and never used more than one GPU (it had a TODO: Make this a distributed example, so that it can also run on Tamia and others), so it didn't exercise the parts of
cluv that matter for real work: multi-node jobs, per-cluster resource differences, dataset staging into
$SLURM_TMPDIR, checkpoint/requeue.

What the example does

examples/pytorch-example/examples/imagenet/, with the Mila-specific parts replaced by cluv's
runtime API:

  • checkpoints and wandb files go to current_run_info().results_path instead of a hand-rolled
    $SCRATCH/checkpoints symlink
  • prepare_data.py reads the ILSVRC2012 archives from the cluster's resolved datasets_path instead
    of a hardcoded /network/datasets/imagenet
  • upstream's safe_sbatch / code_checkpointing.sh are dropped: cluv submit already enforces a
    clean tree and exports $GIT_COMMIT

Each cluster gets its own scripts/job_<cluster>.sh holding nothing but #SBATCH directives, which
execs a shared scripts/train.sh. --use_fake_data also skips the dataset staging step, so a cheap
smoke job can run on a cluster that has no copy of ImageNet.

Runs on the clusters

Every row is a real job submitted with cluv submit from this branch, with no job script named on
the command line - the per-cluster job_script_path picks it. Runtimes are sacct Elapsed, which
includes the per-node venv warm-up and (for real-data rows) the ImageNet extraction.

Cluster GPUs requested Job ID Runtime Dataset W&B
mila 2× L40S 10286668 13m24s real ImageNet mila_10286668
mila 2× L40S 10286635 38 s fake --no_wandb
tamia 4× H100 (whole node) 395938 42 s fake offline
fir 4× H100 53007584 7m03s fake offline
rorqual 4× H100 18361699 55 s fake offline
narval 4× A100-40GB 147008 queued fake offline
nibi 4 of 8× H100 19105826 queued fake offline
killarney 4× L40S 4605084 running fake offline
vulcan 4× L40S 345697 queued fake offline
trillium-gpu 4× H100 (whole node) 715096 queued fake offline

Every completed run trained, validated, wrote epoch_0.pt/epoch_1.pt, and produced one profiler
trace per rank. The real-ImageNet run on mila spent 11m37s of its 13m24s extracting the archives into
$SLURM_TMPDIR, then trained at ~2500 images/second on 2 L40S.

The remaining rows are queued on the DRAC/Vector schedulers; the grid will be updated with their
runtimes. A cluv sync is separately replicating the ~150GB of ImageNet archives from mila to every
cluster (the local pull is done; the pushes take hours), after which real-data runs can follow
elsewhere.

Bugs this shook out

Getting one example to run on nine clusters turned up seven real defects. Each is a separate commit;
the cluv-side ones carry regression tests.

In cluv:

  1. current_run_info() returned None for the first task of every jobnot SLURM_PROCID
    treats 0 as unset, so rank 0 disagreed with the other ranks about the run id and results path.
  2. --no-sync-datasets didn't skip the push. sync_datasets was only consulted for the
    data_source pull; sync_task_function decided whether to push from config.data_source alone,
    so the flag silently rsynced the whole dataset to every cluster anyway.
  3. A per-cluster project_dir couldn't relocate a subproject. clone_project derived the repo
    path from $HOME whenever the local repo was under $HOME, ignoring project_dir. Killarney
    refuses jobs submitted from /home, and Trillium doesn't mount /home on compute nodes, so
    neither could work at all.
  4. cluv submit had no --no-sync-datasets, although cluv sync does — so you couldn't submit
    a job while a cluv sync was replicating a dataset.
  5. cluv submit now exports $CLUV_CLUSTER, and current_cluster() prefers it. A cluster
    doesn't always call itself by the name used to reach it: jobs submitted to trillium-gpu report
    CC_CLUSTER=trillium and Slurm's ClusterName there is grillium, so the job resolved the wrong
    [tool.cluv.clusters.<name>] section. Killarney and Vulcan only set CC_CLUSTER in a login shell.

In the example / job scripts:

  1. $SLURM_SUBMIT_DIR is the wrong variable for locating a helper script — it is where sbatch
    was invoked ($HOME over SSH), not cluv's --chdir target, so every job died with
    bash: /home/<user>/scripts/train.sh: No such file or directory.
  2. --gpus-per-task=1 breaks DDP — cgroups expose one GPU per task, so
    torch.cuda.set_device(LOCAL_RANK) fails with invalid device ordinal in every rank but the
    first. --gres-flags=allow-task-sharing (which upstream relies on) had no effect on tamia, so the
    scripts allocate GPUs with --gpus-per-node.

Plus a performance trap worth knowing about: on Lustre-backed $HOME, four ranks faulting in the
same ~2GB of torch shared libraries at once left fir stuck in cl_sync_io_wait for over five
minutes. scripts/train.sh now reads the venv once per node first.

Known cluv issues this PR only works around

Two problems that are cluv-wide, not example-specific. Both are worked around in this example's
config rather than fixed in cluv, because the fixes reach much further than this PR.

1. Slurm on the Vector clusters (Killarney, Vulcan) does not create the parent directory of
--output.
cluv's default is {results_path}/{cluster}_%j/slurm-%j.out, so the job is killed
before it starts — FAILED, exit 0:53, no output file at all. Verified by A/B test: the identical
job with a flat output path runs fine.

2. $SCRATCH in a cluv-computed path can expand to nothing. cluv submit runs
bash --login -c '<env vars> sbatch ... <args>', but the shlex-quoted arguments are concatenated
into that single-quoted string and close it, so the paths are expanded by the non-login shell ssh
starts — not by the login shell. On most clusters $SCRATCH is set in both and nothing looks wrong;
on Killarney and Vulcan it is login-shell-only, and Slurm recorded
StdOut=/logs/imagenet/<jobid>.out. Anything else relying on $SCRATCH in a cluv-computed path
fails the same way there. The real fix is to quote the inner command once
(bash --login -c {shlex.quote(inner)}), which changes every submission cluv makes and deserves its
own PR.

Both are dodged here with an output in sbatch_args that is relative to the job's working
directory
, resolved through the logs symlink cluv already creates in the project folder.

Dataset handling

data_source = "mila:/network/datasets/imagenet" with datasets_path = "$SCRATCH/datasets/imagenet"
uniformly on every cluster. datasets_path is deliberately not pointed at the shared read-only
copies that some clusters have (/network/datasets on mila, the rrg-bengioy-ad curated copy on
fir, /datashare/imagenet on nibi), because cluv sync rsyncs into that path — aiming it at a
shared dataset folder would try to write there. The README lists the shared copies for anyone who
would rather read them directly and skip the sync.

One caveat worth knowing: since the data_source cluster is also a sync target, cluv sync pushes a
redundant copy back to mila's own $SCRATCH. Jobs on mila don't use it — current_cluster_config()
rewrites datasets_path to the data_source path when running on the source cluster.

Tests

  • New test_imagenet_example (slow, per-cluster) submits the fake-data smoke job with no job script
    argument, so per-cluster job_script_path resolution is what's under test.
  • New fast test_example_job_scripts_exist asserts every configured job_script_path exists locally —
    cluv reads the script header before submitting, so a typo would otherwise only surface at submit time.
  • uv run pytest -m "not integration": 162 passed. mkdocs build --strict clean.

🤖 Generated with Claude Code

lebrice and others added 27 commits August 4, 2026 19:21
`if SLURM_JOB_ID and not SLURM_PROCID` treated `SLURM_PROCID=0` as unset, so
rank 0 got `None` back and fell out of sync with the other tasks of the job
(different run id, different results path). Check for `None` explicitly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ports the mila-docs ImageNet example (DDP, AMP, checkpoint/resume, wandb,
profiler) into cluv, replacing the CIFAR-10 stub that never trained anything
and never used more than one GPU.

The Mila-specific parts are replaced by cluv's runtime API:

- checkpoints and wandb files go to `current_run_info().results_path`, instead
  of a hand-rolled `$SCRATCH/checkpoints` symlink
- `prepare_data.py` reads the ILSVRC2012 archives from the cluster's resolved
  `datasets_path` instead of a hardcoded `/network/datasets/imagenet`
- upstream's `safe_sbatch` / `code_checkpointing.sh` are dropped, since
  `cluv submit` already enforces a clean tree and exports `$GIT_COMMIT`

Each cluster gets its own job script via `job_script_path`, since the node
layouts differ a lot (Tamia mandates whole nodes, only Mila honours `--tmp`,
...). The wrappers hold nothing but `#SBATCH` directives and `exec` a shared
`scripts/train.sh`.

`--use_fake_data` also skips the dataset staging step, so a cheap smoke job can
be submitted to a cluster before the ~150GB of archives have been synced there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Also note that a per-cluster job script has to exist locally, since cluv reads
its header before submitting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Read from `sinfo` on each cluster: cpus-per-task now matches the cores-per-GPU
ratio of the GPU nodes (12 on mila/tamia/fir, 16 on rorqual, 14 on nibi).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…g it

`cluv sync` copies a `data_source` dataset through the machine you submit from.
That is fine for CIFAR-10 but not for ~150GB of ImageNet: it fills up a laptop
disk before reaching any cluster.

Drop `data_source` from the example and override `datasets_path` per cluster
for the two that already have a shared copy (mila's /network/datasets, and the
rrg-bengioy-ad curated copy on fir). Everywhere else it defaults to
$SCRATCH/datasets/imagenet, which you fill yourself - or you use
`--use_fake_data`, which needs no dataset at all.

`test_cluv_sync_with_data_path` moves to the hydra example, which still syncs
CIFAR-10.

Also cap the configured job time at 1h and document a subset run that fits in
it, since extracting ImageNet alone takes 10-15 minutes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
train.sh hardcoded `python main.py --dataset_path=...`, so the usual cluv
invocation (`cluv submit <cluster> -- python main.py ...`) ended up appending a
second copy of the command. Run "$@" instead, like the other examples do, and
default to `python main.py` when no command is given.

The explicit `--dataset_path` / RANK / LOCAL_RANK plumbing is dropped too:
main.py already defaults dataset_path to $SLURM_TMPDIR/data and derives
RANK/LOCAL_RANK from $SLURM_PROCID/$SLURM_LOCALID.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
$SLURM_SUBMIT_DIR is the directory sbatch was invoked from, which for
`cluv submit` is the home directory of the SSH session - not the `--chdir`
target. Every job died instantly with
`bash: /home/<user>/scripts/train.sh: No such file or directory`.

cluv passes `--chdir=<project dir>`, so a plain relative `scripts/train.sh`
is both correct and simpler.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
With `--gpus-per-task=1`, Slurm's cgroups expose only one GPU to each task, so
`torch.cuda.set_device(LOCAL_RANK)` failed with "invalid device ordinal" in
every task except rank 0. `--gres-flags=allow-task-sharing` is meant to lift
that isolation but had no effect on tamia, so allocate the GPUs per node
instead and let each task pick its device by local rank.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ified

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
On fir, all four ranks sat in Lustre's `cl_sync_io_wait` for over five minutes:
each task was faulting in the same ~2GB of torch shared libraries from $HOME
over the network at the same time. Reading them once per node first puts them
in the node's page cache, so the tasks start promptly.

Also refuse to stage ImageNet when $SLURM_TMPDIR is unset, instead of silently
extracting ~150GB into the node's shared /tmp (which is what the earlier mila
run started doing), and print the value so it's visible in the job log. The
check runs inside an `srun` step because $SLURM_TMPDIR is set by a Slurm plugin
at step launch, not in the batch script.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Some sites (Mila among them) give each job a private /tmp on the node's local
disk, so `$SLURM_TMPDIR=/tmp` is legitimate there. The guard is only about
$SLURM_TMPDIR being unset, i.e. not knowing where node-local scratch lives.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The salloc / `mila code` snippets still used --gpus-per-task, which is exactly
the flag the rest of the example avoids.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Restores `data_source = "mila:/network/datasets/imagenet"`: `cluv sync` stages
the archives through the local `datasets_path` on their way to each cluster,
which is what the feature is for. `datasets_path` is uniformly
`$SCRATCH/datasets/imagenet` on every cluster, because sync rsyncs *into* it -
pointing it at a read-only shared dataset folder would try to write there.

Adds job scripts and config for narval, killarney, vulcan and trillium-gpu,
matched to their node layouts and accounts (aip-bengioy on killarney/vulcan).
Killarney and Vulcan turn out to have both $SCRATCH and $CC_CLUSTER, so their
$HOME path overrides are gone.

`cluv submit` now exports $CLUV_CLUSTER, and `current_cluster()` prefers it.
A cluster doesn't always call itself by the name used to reach it: jobs
submitted to `trillium-gpu` report CC_CLUSTER=trillium and Slurm's ClusterName
there is "grillium", so without this the job resolves the wrong
[tool.cluv.clusters.<name>] section.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`cluv sync` already has the flag; `cluv submit` runs a sync of its own and had
no way to skip the dataset replication. That makes it impossible to submit a job
while a separate `cluv sync` is replicating the dataset, or to submit at all when
the data is already in place and you don't want to re-check ~150GB of it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The `_step{SLURM_STEP_ID}` suffix made the run id depend on how many `srun`
steps precede training in the job script - adding the venv warm-up step silently
renamed runs to `<cluster>_<jobid>_step2`. cluv's run id is already unique per
job and handles job arrays and packing, so the suffix is redundant.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`sync_datasets` was only consulted for the `data_source` pull step in `sync()`.
`sync_task_function` decided whether to push purely from `config.data_source`,
so `cluv sync --no-sync-datasets` still rsynced the whole dataset to every
cluster - the flag silently did almost nothing.

Found by using the new `cluv submit --no-sync-datasets` to submit a job while a
`cluv sync` was replicating ImageNet: the submit spent 30 minutes pushing a
half-downloaded copy of the dataset instead of skipping it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`clone_project` derived the repo location from $HOME whenever the local repo was
under $HOME, ignoring any `project_dir` configured for the cluster. So a
subproject (like these examples) could not be placed anywhere else, and
Killarney rejects every job submitted from a directory under /home:

    Submitting jobs from directories residing in /home is not permitted.
    Transfer your files to a directory in /scratch or /project.

Now a configured `project_dir` wins, with the repo cloned at the path that
contains the subproject.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Trillium rejects --mem outright, and neither Trillium nor Killarney will run a
job submitted from $HOME, so both now keep the project on $SCRATCH via
project_dir.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Slurm on Killarney and Vulcan does not create the parent directory of the
`--output` path, and cluv's default is
`{results_path}/{cluster}_%j/slurm-%j.out`. The job was killed before it started
(FAILED, no output file at all). Setting `output` in `sbatch_args` makes cluv
leave the path alone, so it points at an existing directory.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Slurm recorded StdOut=/logs/imagenet/killarney_<jobid>.out: $SCRATCH had
expanded to nothing. cluv assembles its sbatch command inside
`bash --login -c '...'`, but the shlex-quoted pieces close that string, so paths
are actually expanded by the non-login ssh shell - and Killarney and Vulcan only
define $SCRATCH in a login shell.

A path relative to the job's working directory avoids the expansion entirely,
and `logs` is the symlink to results_path that cluv already creates in the
project folder.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
On Killarney, srun refused to start any step:

    srun: fatal: SLURM_MEM_PER_CPU, SLURM_MEM_PER_GPU, and SLURM_MEM_PER_NODE
    are mutually exclusive

A site-wide default memory setting ends up in the job environment alongside the
--mem-per-gpu the job asked for. Keep the most specific one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Mila's /network/datasets folders are datalad datasets whose annex objects live
in `.git.bak`. cluv excluded `.git` and `.datalad` but not that, so syncing
ImageNet pulled a second 145GB copy of the same archives - and would then have
pushed it to every cluster.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@codecov-commenter

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 73.68421% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 65.80%. Comparing base (aa2ba35) to head (2d2741c).

Files with missing lines Patch % Lines
cluv/cli/sync.py 66.66% 4 Missing ⚠️
cluv/cli/submit.py 66.66% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master     #164      +/-   ##
==========================================
+ Coverage   63.78%   65.80%   +2.02%     
==========================================
  Files          19       19              
  Lines        2079     2091      +12     
==========================================
+ Hits         1326     1376      +50     
+ Misses        753      715      -38     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@lebrice lebrice changed the title Replace the pytorch example with a multi-node ImageNet example Replace the pytorch example with a distributed ImageNet training example Aug 5, 2026
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