Distributed Workflows (HPC)
Run a parameter sweep across a cluster by emitting a self-contained kit for a workflow engine (Slurm, Snakemake, or Nextflow). The kit is declared in the experiment itself, so the same YAML reproduces the run on any machine.
Two tiers of parallelism
A sweep is distributed on two independent tiers. Keeping them apart is the key to using CPU and GPU nodes well.
| Tier | Knob | What it does |
|---|---|---|
| Inter-node | workflow.distribute.chunk |
Splits the sweep into N independent scheduler tasks (a Slurm array). Each task runs on its own node. |
| Intra-node | explorations.<name>.n_parallel and parallel_mode |
Batches cells inside one task with vmap, and across the local GPUs of that task with pmap. |
pmap spans only the devices visible to one task. It never crosses nodes: inter-node parallelism is always the scheduler fan-out (distribute.chunk), not pmap. On a one-GPU or CPU task, pmap degenerates to vmap.
The workflow block
Declare distribution on the study (defaults for every experiment) or on a single experiment (refines the study block by field). A Kuramoto grid on Slurm:
workflow:
distribute:
chunk: 40 # 40 array shards; task i runs the Space[i::40] slice
slurm:
partition: medium
cpus_per_task: 2
mem: 32G
time: "24:00:00"
env:
- {name: XLA_PYTHON_CLIENT_PREALLOCATE, value: "false"}
- {name: OMP_NUM_THREADS, value: "1"}
options:
- {name: qos, value: normal} # any further #SBATCH directiveWorkflowEngineConfig names the directives the three engines read (Slurm: partition, account, gres, array_chunk, mail_*; Snakemake: cores; Nextflow: executor, queue) plus the shared resource fields cpus_per_task, mem, and time. Anything else goes through options and is emitted verbatim, so a new scheduler flag needs no schema change.
env and options are name-keyed lists. A --set slurm.env.X=v at emit time overrides one entry and keeps the rest, rather than replacing the whole list. Values in env are shell-quoted automatically.
Both render in every engine’s native form: env becomes shell export lines ahead of the task command, while options becomes Slurm #SBATCH --<name>=<value> directives, Snakemake resources: entries, or Nextflow process.<name> assignments. Name each option for the target engine (e.g. slurm_partition for Snakemake, clusterOptions for Nextflow).
The kit lifecycle: emit → submit → finalize
A kit passes through three tvbo workflow subcommands. Each stands alone, so you can emit on a laptop, ship the kit, and submit on a login node.
# 1. EMIT — write a self-contained kit (spec + network + backend script + run.sbatch)
tvbo workflow slurm study.yaml --experiment <id> -o kit/
# 2. SUBMIT — run an already-emitted kit as a unit (no recipe, no re-emit)
tvbo workflow submit kit/
# 3. FINALIZE — only for a sharded (multi-task) run: reassemble the shards
tvbo workflow finalize kit/results --compressEmit
tvbo workflow <engine> (slurm, snakemake, nextflow) freezes the run. Override any field at emit time with --set, e.g. --set slurm.mem=64G or --set distribute.chunk=8. The kit is a self-contained provenance record: spec/<name>.yaml is the effective config after merging study, experiment, and every --set, next to the connectome (spec/network.yaml + spec/network.h5) and any custom callable/builder modules the recipe references, bundled into code/. Re-emitting from the frozen spec reproduces the run with no flags:
tvbo workflow slurm kit/spec/<name>.yaml # same partition, chunk, env, …Submit
tvbo workflow submit kit/ runs a bundled kit as a unit. It auto-detects the engine from the kit’s contents (run.sbatch → Slurm, Snakefile → Snakemake, main.nf → Nextflow) and shells out the scheduler command. Because the kit is self-contained, this is the whole story: the generated run.sbatch already exports PYTHONPATH="code:…" so the bundled modules resolve on the compute node — you never set it yourself. This is why a kit is bundled: emit once, ship it, and run it anywhere with one command.
submit also accepts a packaged archive of a kit (.tar.gz / .tgz / .tar / .zip). It extracts next to the archive and submits the kit inside, so a shipped kit needs no manual unzip; a re-submit reuses the already-extracted kit rather than clobbering its results/.
tvbo workflow submit kit/ # a kit directory
tvbo workflow submit koller2024_exp40_gpu.tar.gz # …or its packaged archive (auto-extracts)
tvbo workflow submit kit/ --array 0 # one shard as a smoke test
tvbo workflow submit kit/ --array 0-3 --array-throttle 1 # 4 tasks, 1 at a time
tvbo workflow submit kit/ --engine slurm # force the engine if ambiguousThe submitting host needs only the scheduler command (sbatch / snakemake / nextflow) on $PATH; emission and submission themselves run in-process. The compute node that executes each task needs tvbo installed — provision it from the kit’s requirements.txt / environment.yml.
Two ways to reach the cluster
The kit lifecycle supports two deployment styles; pick by where tvbo and the connectome data already live.
Ground truth on the cluster. Put the full
SimulationStudy/Simulation‑ ExperimentYAML (plus the networks it resolves, or db access) on the cluster and emit there:tvbo workflow run slurm study.yaml --experiment <id> <--set …>. Best when the cluster already has tvbo and the data, and you want to sweep straight from the recipe.Package a kit, then ship it. Emit locally, package the kit dir, and copy the single archive over. The compute nodes never see the recipe or the db — the kit carries the frozen spec, the baked
network.h5, and the code. Submit the archive directly, no unzip:# locally tvbo workflow slurm study.yaml --experiment <id> <--set …> -o kit/ tar czf kit.tar.gz -C kit/.. kit scp kit.tar.gz <cluster>:~ # on the cluster tvbo workflow submit ~/kit.tar.gzBest when the cluster lacks the recipe or the connectome database, or when you want a frozen, shippable provenance artefact.
Emit and submit together in one call when you are already on the login node:
tvbo workflow run slurm study.yaml --experiment <id> # emit + submit
tvbo run study.yaml --engine slurm --experiment <id> # equivalent via `tvbo run`Finalize
A multi-task run (distribute.chunk > 1) writes one shard per array task. tvbo workflow finalize <results-dir> merges them into a single result keyed by the sweep coordinates (--compress gzip-deflates the HDF5). A single-task run (chunk: 1, the GPU profile below) writes the whole result directly, so it needs no finalize step.
Per-subject dataset fan-out
When an experiment fits against per-subject empirical data, it declares a dataset: block and an observation whose source is dataset.subject.<measure> (the target selected by a BIDS query under dataset.bids_root). The planner lowers this to one array task per subject: $SLURM_ARRAY_TASK_ID indexes the subject list, and each task resolves that subject’s target before its fit.
dataset:
dataset_id: hcpya
bids_root: /data/hcp/functional_connectomes # per-subject FC tree
subjects: [100206, 100307, 100408] # optional: a curated subset (else: all)
observations:
empirical_fc:
source: [dataset.subject.fc]
query: {atlas: HCPMMP1, desc: FCavg, suffix: relmat}
reconcile: by_labelAn explicit dataset.subjects list scopes the fan-out — one entry for a single-subject kit, a handful for a pilot, omitted to sweep the whole cohort.
The per-subject data itself reaches the compute node one of two ways:
Reference it (large cohorts). By default the kit records the absolute dataset.bids_root. That tree is machine-specific, so stage it on the cluster and point each task at it with $TVBO_BIDS_ROOT — the run.sbatch reads it with no hand-edit (unset → keeps the baked value):
export TVBO_BIDS_ROOT=/cephfs/hcp/functional_connectomes
tvbo workflow submit cohort_kit.tar.gzBundle it (self-contained kit). --bundle-dataset copies each enumerated subject’s target (sidecar + its payload) into spec/dataset/ and rewrites dataset.bids_root to a relative path that resolves against the frozen spec. The kit then carries exactly the files its fan-out consumes — no upload, no $TVBO_BIDS_ROOT. It resolves from the spec itself: the observation’s own query ({atlas: HCPMMP1, desc: FCavg, suffix: relmat} above) already names the file, so no extra flags are needed:
tvbo workflow slurm study.yaml --experiment 30 --bundle-dataset --pack -o cohort_kit
# → cohort_kit.tar.gz carries spec/dataset/sub-<id>/… for the scoped subjects only--bundle-select KEY=VALUE is only an override for when a subject directory holds several files matching the query — the bundler otherwise raises a clear “ambiguous … add entities to disambiguate” error naming the candidates, so you add just the entity that separates them:
tvbo workflow slurm study.yaml --experiment 30 \
--bundle-dataset --bundle-select acquisition=run1 --pack -o cohort_kitA requested bundle that cannot be resolved (a missing file, an over-tight or mismatched --bundle-select) fails loudly at emit time — a self-contained kit is never shipped silently pointing at data it does not carry.
CPU and GPU: pick the tier that fits the resource
The two tiers trade off against node availability.
CPU nodes are plentiful, so fan the grid across many of them. Pin JAX to a single host device so each task batches with vmap:
workflow:
distribute: {chunk: 40}
slurm:
partition: medium
cpus_per_task: 2
mem: 32G
env:
- {name: XLA_FLAGS, value: "--xla_force_host_platform_device_count=1"}GPU nodes are scarce, so keep the whole grid on one node and batch it on-device with vmap (chunk: 1). Splitting into 40 array tasks here would claim 40 GPUs. Let JAX grow GPU memory on demand:
tvbo workflow slurm study.yaml --experiment <id> \
--set distribute.chunk=1 \
--set slurm.partition=gpu --set slurm.gres=gpu:1 \
--set slurm.env.XLA_PYTHON_CLIENT_PREALLOCATE=falseIf a single vmap batch exceeds VRAM, lower explorations.<name>.n_parallel (the batch size) or set parallel_mode: lax_map, which runs the batch sequentially with memory bounded by one cell. A multi-GPU node uses all its GPUs through pmap automatically (--set slurm.gres=gpu:2).