Replace the pytorch example with a distributed ImageNet training example - #164
Draft
lebrice wants to merge 27 commits into
Draft
Replace the pytorch example with a distributed ImageNet training example#164lebrice wants to merge 27 commits into
lebrice wants to merge 27 commits into
Conversation
`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 Report❌ Patch coverage is
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. 🚀 New features to boost your workflow:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Replaces the CIFAR-10
pytorch-examplestub with a port of the mila-docs ImageNetexample, 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 ofcluv 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'sruntime API:
current_run_info().results_pathinstead of a hand-rolled$SCRATCH/checkpointssymlinkprepare_data.pyreads the ILSVRC2012 archives from the cluster's resolveddatasets_pathinsteadof a hardcoded
/network/datasets/imagenetsafe_sbatch/code_checkpointing.share dropped:cluv submitalready enforces aclean tree and exports
$GIT_COMMITEach cluster gets its own
scripts/job_<cluster>.shholding nothing but#SBATCHdirectives, whichexecs a sharedscripts/train.sh.--use_fake_dataalso skips the dataset staging step, so a cheapsmoke 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 submitfrom this branch, with no job script named onthe command line - the per-cluster
job_script_pathpicks it. Runtimes aresacctElapsed, whichincludes the per-node venv warm-up and (for real-data rows) the ImageNet extraction.
1028666810286635--no_wandb3959385300758418361699147008191058264605084345697715096Every completed run trained, validated, wrote
epoch_0.pt/epoch_1.pt, and produced one profilertrace 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 syncis separately replicating the ~150GB of ImageNet archives from mila to everycluster (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:
current_run_info()returnedNonefor the first task of every job —not SLURM_PROCIDtreats
0as unset, so rank 0 disagreed with the other ranks about the run id and results path.--no-sync-datasetsdidn't skip the push.sync_datasetswas only consulted for thedata_sourcepull;sync_task_functiondecided whether to push fromconfig.data_sourcealone,so the flag silently rsynced the whole dataset to every cluster anyway.
project_dircouldn't relocate a subproject.clone_projectderived the repopath from
$HOMEwhenever the local repo was under$HOME, ignoringproject_dir. Killarneyrefuses jobs submitted from
/home, and Trillium doesn't mount/homeon compute nodes, soneither could work at all.
cluv submithad no--no-sync-datasets, althoughcluv syncdoes — so you couldn't submita job while a
cluv syncwas replicating a dataset.cluv submitnow exports$CLUV_CLUSTER, andcurrent_cluster()prefers it. A clusterdoesn't always call itself by the name used to reach it: jobs submitted to
trillium-gpureportCC_CLUSTER=trilliumand Slurm'sClusterNamethere isgrillium, so the job resolved the wrong[tool.cluv.clusters.<name>]section. Killarney and Vulcan only setCC_CLUSTERin a login shell.In the example / job scripts:
$SLURM_SUBMIT_DIRis the wrong variable for locating a helper script — it is wheresbatchwas invoked (
$HOMEover SSH), not cluv's--chdirtarget, so every job died withbash: /home/<user>/scripts/train.sh: No such file or directory.--gpus-per-task=1breaks DDP — cgroups expose one GPU per task, sotorch.cuda.set_device(LOCAL_RANK)fails withinvalid device ordinalin every rank but thefirst.
--gres-flags=allow-task-sharing(which upstream relies on) had no effect on tamia, so thescripts allocate GPUs with
--gpus-per-node.Plus a performance trap worth knowing about: on Lustre-backed
$HOME, four ranks faulting in thesame ~2GB of torch shared libraries at once left fir stuck in
cl_sync_io_waitfor over fiveminutes.
scripts/train.shnow 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 killedbefore it starts —
FAILED, exit0:53, no output file at all. Verified by A/B test: the identicaljob with a flat output path runs fine.
2.
$SCRATCHin a cluv-computed path can expand to nothing.cluv submitrunsbash --login -c '<env vars> sbatch ... <args>', but theshlex-quoted arguments are concatenatedinto 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
$SCRATCHis 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$SCRATCHin a cluv-computed pathfails 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 itsown PR.
Both are dodged here with an
outputinsbatch_argsthat is relative to the job's workingdirectory, resolved through the
logssymlink cluv already creates in the project folder.Dataset handling
data_source = "mila:/network/datasets/imagenet"withdatasets_path = "$SCRATCH/datasets/imagenet"uniformly on every cluster.
datasets_pathis deliberately not pointed at the shared read-onlycopies that some clusters have (
/network/datasetson mila, therrg-bengioy-adcurated copy onfir,
/datashare/imageneton nibi), becausecluv syncrsyncs into that path — aiming it at ashared 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_sourcecluster is also a sync target,cluv syncpushes aredundant copy back to mila's own
$SCRATCH. Jobs on mila don't use it —current_cluster_config()rewrites
datasets_pathto thedata_sourcepath when running on the source cluster.Tests
test_imagenet_example(slow, per-cluster) submits the fake-data smoke job with no job scriptargument, so per-cluster
job_script_pathresolution is what's under test.test_example_job_scripts_existasserts every configuredjob_script_pathexists 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 --strictclean.🤖 Generated with Claude Code