Workflows/Embarassinly-Parallel: Difference between revisions

From HPCwiki
Jump to navigation Jump to search
Created page with "This tutorial is a gentle, hands-on introduction to '''parallel computing on Anunna'''. Instead of a heavy scientific code, it uses one friendly problem — estimating the number π by throwing random darts — and runs the ''same'' calculation across every kind of parallelism the cluster offers: a single core, many threads, many processes, multiple nodes, and a GPU. Because the problem stays the same, you can see exactly what each step up the ladder buys you. You do no..."
 
No edit summary
Line 1: Line 1:
This tutorial is a gentle, hands-on introduction to '''parallel computing on Anunna'''. Instead of a heavy scientific code, it uses one friendly problem — estimating the number π by throwing random darts and runs the ''same'' calculation across every kind of parallelism the cluster offers: a single core, many threads, many processes, multiple nodes, and a GPU. Because the problem stays the same, you can see exactly what each step up the ladder buys you.
[[Workflows/Serial-Workflows|Serial]] ran one program, once, on one core. This page runs that same program '''many times over''' — two hundred samples, a parameter sweep, a hundred simulations that differ only by their random seed with the runs never needing to speak to each other.


You do not need to write any C code. The whole exercise comes down to filling in '''two''' blanks in two job scripts, launching a batch of jobs, and reading three plots.
That last part is what makes this the easiest kind of parallelism there is, and by far the most common one in biology. It is also the rung where you most often change '''nothing whatsoever''' about your program. The tool stays exactly as it was. What changes is the job script around it, which stops describing one run and starts describing a whole batch of them. Slurm has a purpose-built mechanism for this: the '''job array'''.


== What you will learn ==
== When this is you ==


* How a single program can be run serially, with threads (OpenMP), with processes (MPI), across several nodes, and on a GPU.
You are looking at an embarrassingly parallel workflow when all three of these are true:
* How Slurm ''allocates'' resources and why your program still has to be ''told'' how many to use.
* The difference between '''strong scaling''' (same work, more workers, less time) and '''weak scaling''' (more workers, more work, same time).
* Why more workers make a calculation '''faster, not more accurate'''.
* How to read scaling and speedup plots — and where their limits are.


== Before you begin ==
* You run the '''same''' analysis many times over.
* Each run has its '''own''' input — a different sample, a different parameter value, a different seed.
* No run needs anything another run produces. You could run them in any order, or all at once, and the answers would be identical.


You should already be comfortable logging into Anunna and submitting a basic Slurm job. If you are not sure, work through the [[Linux Basic/Linux Self Assessment|Linux Self Assessment]] first.
That covers a great deal of everyday work: one pipeline over two hundred sequencing samples, one model fitted per chromosome, one simulation per point on a parameter grid.


You also need the '''course environment loaded''', because it sets the <code>$myScratch</code> variable that the tutorial relies on. If your instructor has not told you otherwise, it is loaded for you when you log in. You can check with:
The tell that you are '''not''' here is a dependency between runs — if run 7 needs a number that run 6 computed, the runs are not independent and an array will not save you. The other tell is subtler: if you have '''one''' slow analysis over '''one''' dataset, this page cannot help either. An array multiplies runs; it does not split a single run. For that, go to [[Workflows/Multi-Threaded-Workflows|multi-threaded]] or [[Workflows/Multi-Process-Workflows|multi-process]].


<syntaxhighlight lang="bash">
If you have ever written a <code>for</code> loop that calls <code>sbatch</code>, or submitted the same script by hand with the filename edited each time, this is the page you were looking for.
echo "$myScratch"
 
</syntaxhighlight>
== What it looks like on Anunna ==


If that prints a path under <code>/lustre/scratch/...</code>, you are ready. If it prints nothing, load the course environment before continuing.
Everything from the serial page still holds: you never run the analysis on the login node, your work lives under <code>$myScratch</code> on Lustre rather than in your home directory, and you hand the job to Slurm with <code>sbatch</code>.


Everything you run lives in two places:
Take the serial script and add '''one''' <code>#SBATCH</code> line. The smallest array worth looking at does nothing but show you what the mechanism does:


* '''The shared tutorial''' at <code>/lustre/shared/hpcCourses/pi-tutorial</code> — read-only, the same for everyone.
<syntaxhighlight lang="bash">
* '''Your personal copy''' at <code>$myScratch/hpcCourse/pi-tutorial</code> — created the first time you launch, and where you make your edits.
#!/bin/bash
#SBATCH --job-name=fruit_salad
#SBATCH --array=0-6            # seven tasks, numbered 0 to 6
#SBATCH --ntasks=1            # each task is still ONE process ...
#SBATCH --cpus-per-task=1      # ... on one core
#SBATCH --mem=100M            # memory PER TASK
#SBATCH --time=00:10:00        # wall-clock limit PER TASK
#SBATCH --output=%x-%A_%a.out  # one log per task


== The big idea: estimating π with random darts ==
cd "$myScratch/my_analysis"


Imagine a square dartboard with a quarter-circle drawn inside it. If you throw darts at random and they land anywhere in the square with equal chance, the fraction that land ''inside'' the quarter-circle is exactly the ratio of the two areas — which works out to π/4. So if you throw '''N''' darts and '''H''' of them land inside:
fruits=(banana apple orange grape berry tomato watermelon)


<syntaxhighlight lang="text">
echo "task ${SLURM_ARRAY_TASK_ID} got: ${fruits[${SLURM_ARRAY_TASK_ID}]}"
π ≈ 4 × H / N
</syntaxhighlight>
</syntaxhighlight>


Throw more darts, and the estimate gets better. This is a '''Monte Carlo''' method: a calculation built out of huge numbers of independent random trials. It has one property that makes it perfect for teaching parallelism — every dart is independent, so the work splits cleanly across as many workers as you like, with almost no coordination between them.
Submit it exactly as before, '''once''':
 
The tutorial implements this ''same'' dart-throwing kernel six ways:
 
{| class="wikitable"
! Rung !! Program !! What it uses
|-
| serial || <code>pi_serial</code> || one core (the reference)
|-
| OpenMP || <code>pi_omp</code> || many threads on one node
|-
| MPI || <code>pi_mpi</code> || many processes on one node
|-
| multi-node || <code>pi_mpi</code> || processes spread across several nodes (same program)
|-
| GPU || <code>pi_gpu</code> || thousands of device threads on one GPU
|-
| hybrid || <code>pi_hybrid</code> || MPI processes, each running OpenMP threads
|}
 
Because the random-number generator is identical everywhere and each worker draws its own independent stream, the ''answer'' for a given number of darts and seed is the same no matter how the work is divided. Only the ''time'' changes. That is the whole point.
 
== Step 1: Make the tutorial modules available ==
 
The tutorial ships as a set of environment modules. Point Lmod at them once per session:


<syntaxhighlight lang="bash">
<syntaxhighlight lang="bash">
module use /lustre/shared/hpcCourses/pi-tutorial/modules
sbatch run.slurm
module avail pi-tutorial
</syntaxhighlight>
</syntaxhighlight>


You will see three modules. They are mutually exclusive loading one unloads the others:
Slurm expands that single submission into seven independent jobs. You get seven log files, each naming one fruit. Nothing here is parallel programming there is no library, no communication, no special compiler. There is a list, and a number that picks one item out of it.


* <code>pi-tutorial</code> — the CPU rungs (serial, OpenMP, MPI, hybrid).
Against the serial script, exactly two things changed:
* <code>pi-tutorial-cuda</code> — the NVIDIA GPU rung.
* <code>pi-tutorial-rocm</code> — the AMD GPU rung.


You do not need to load these by hand for the exercise — each job script loads the right one for you. The <code>module use</code> line above is enough.
* '''<code>--array=0-6</code> was added.''' This is the whole feature. Everything else on this page is a consequence of it.
* '''The output name gained <code>%A_%a</code>''' in place of <code>%j</code>, so each task writes its own log rather than seven tasks fighting over one file.


== Step 2: Get your personal copy of the scripts ==
Everything else — <code>--ntasks</code>, <code>--cpus-per-task</code>, the scratch directory — is the serial script untouched. Each array task '''is''' a serial job; there are simply many of them.


Run the launcher once:
Turning the demonstration into real work is a substitution, not a redesign. The list becomes your data, and the <code>echo</code> becomes your program:


<syntaxhighlight lang="bash">
<syntaxhighlight lang="bash">
bash /lustre/shared/hpcCourses/pi-tutorial/exercise/launch.sh
samples=(sampleA sampleB sampleC sampleD)
 
module load 2024 Python/3.12.3    # replace with your actual module
python myScript.py --input "${samples[${SLURM_ARRAY_TASK_ID}]}.csv" \
                  --output "results/${samples[${SLURM_ARRAY_TASK_ID}]}.out"
</syntaxhighlight>
</syntaxhighlight>


On this first run it does three things: it copies the job scripts into your personal space at <code>$myScratch/hpcCourse/pi-tutorial/slurm</code>, clears any old results, and submits the whole batch of jobs. Watch them with:
<code>myScript.py</code> never learns it is part of an array. It is handed one input and produces one output, exactly as it did serially. That is why this rung is so cheap to climb.


<syntaxhighlight lang="bash">
Two numbers behave differently from the [[Workflows/Multi-Threaded-Workflows|multi-threaded]] page, and both catch people out. <code>--mem</code> and <code>--time</code> are '''per task''': each job gets its own allocation. Threads share memory; array tasks do not, because they are separate jobs on possibly separate nodes. Ask for 4 GB across a 200-task array and you have asked the cluster for 800 GB in total — size the request to '''one''' task, then remember to multiply before you are surprised.
squeue --me
</syntaxhighlight>


Most of the jobs (serial, GPU, multi-node, hybrid) are complete and will run correctly. '''Two of them — the OpenMP and the MPI jobs — contain a deliberate blank''' and will not give a useful result yet. Fixing those blanks is your job, and it is the next step.
The array-specific patterns in the filenames are worth knowing:


== Step 3: Fill in the two blanks ==
{| class="wikitable"
! Pattern !! Expands to
|-
| <code>%A</code> || the array job id — the same for every task
|-
| <code>%a</code> || the task id — 0, 1, 2 ... the thing that differs
|-
| <code>%j</code> || the individual job id of that one task
|-
| <code>%x</code> || the job name
|}


When you ask Slurm for resources, Slurm sets aside the cores or tasks you requested — but it does not automatically tell your ''program'' about them. You have to pass the number along. Slurm makes that easy by exporting the counts as environment variables. Your task is to connect the two.
== Choosing what each task works on ==


Open the OpenMP script in your personal copy (use <code>nano</code> if you are not comfortable with <code>vi</code>):
The array gives every task a number and '''nothing else'''. Turning that number into an input is your job. The list above is the clearest way when the list is short; for longer ones there are two more idioms:


<syntaxhighlight lang="bash">
<syntaxhighlight lang="bash">
nano $myScratch/hpcCourse/pi-tutorial/slurm/run_multithread.slurm
# One line of a file per task — for lists too long to write in the script
</syntaxhighlight>
SAMPLE=$(sed -n "${SLURM_ARRAY_TASK_ID}p" samples.txt)
 
You will find this line:


<syntaxhighlight lang="bash">
# One computed parameter value per task — for sweeps
export OMP_NUM_THREADS=FIXME
THRESHOLD=$(awk "BEGIN{print 0.05 * ${SLURM_ARRAY_TASK_ID}}")
</syntaxhighlight>
</syntaxhighlight>


The script asked Slurm for cores with <code>--cpus-per-task</code>, and Slurm exported that count as <code>SLURM_CPUS_PER_TASK</code>. Tell OpenMP to use exactly that many threads:
Mind the counting, because this is where array jobs go quietly wrong. Bash lists are numbered from '''0''', so seven fruits are <code>0-6</code>. <code>sed</code> numbers lines from '''1''', so two hundred samples are <code>1-200</code>. Write <code>--array=0-4</code> against those seven fruits and the job succeeds, reports no error, and simply never processes tomato or watermelon. Nothing fails. A sample is just missing from your final table, and you find out much later, if at all.


<syntaxhighlight lang="bash">
Task ids must also be '''non-negative whole numbers'''. You cannot pass filenames to <code>--array</code>; you pass positions, and the script looks up the name.
export OMP_NUM_THREADS=${SLURM_CPUS_PER_TASK}
</syntaxhighlight>


Now the MPI script:
=== Building the array from the data ===


<syntaxhighlight lang="bash">
Slurm fixes an array's size when the job is submitted, and the script cannot resize its own array from the inside. So when the number of inputs is whatever happens to be in a directory today, compute the range '''outside''' the job script and pass it on the command line, where it overrides the <code>#SBATCH</code> directive:
nano $myScratch/hpcCourse/pi-tutorial/slurm/run_multiprocess.slurm
</syntaxhighlight>
 
Here the blank is the number of processes to launch:


<syntaxhighlight lang="bash">
<syntaxhighlight lang="bash">
mpirun -np FIXME pi_mpi --total 1e10 --seed 12345
#!/bin/bash
</syntaxhighlight>
# launcher.sh — count the data, then submit an array to match
 
This script asked Slurm for tasks with <code>--ntasks</code>, and Slurm exported that as <code>SLURM_NTASKS</code>. Launch one process per task:


<syntaxhighlight lang="bash">
samples=(data/*.csv)
mpirun -np ${SLURM_NTASKS} pi_mpi --total 1e10 --seed 12345
sbatch --array=0-$(( ${#samples[@]} - 1 )) run.slurm
</syntaxhighlight>
</syntaxhighlight>


That is the entire editable surface — two lines. Save both files.
The job script rebuilds the same list with the same glob and indexes into it as before. Note the parentheses in <code>samples=(data/*.csv)</code> — they are what makes this a list. Without them you get a single string, its length is 1 no matter how many files exist, and you submit a one-task array that runs cleanly and does a fraction of the work.
 
== Step 4: Launch the full sweep ==
 
Run the launcher again:
 
<syntaxhighlight lang="bash">
bash /lustre/shared/hpcCourses/pi-tutorial/exercise/launch.sh
</syntaxhighlight>


This time it '''keeps your edited scripts''' (it never overwrites your copy), clears the old results, and resubmits everything. The launcher sweeps a range of sizes for you automatically — OpenMP from 1 up to 128 threads, MPI from 1 up to 128 processes — plus the GPU, multi-node, and hybrid runs.
== Being a good neighbour ==


Keep an eye on the queue:
A <code>%</code> at the end of the range caps how many tasks run at once:


<syntaxhighlight lang="bash">
<syntaxhighlight lang="bash">
squeue --me
#SBATCH --array=1-200%20      # 200 tasks, at most 20 running concurrently
</syntaxhighlight>
</syntaxhighlight>


Each finished job writes one result line into <code>$myScratch/hpcCourse/pi-tutorial/results</code>. Wait until the queue is empty before moving on.
It is optional, and it is a courtesy worth extending — two hundred tasks all hammering Lustre at the same moment is unpleasant for everyone, including you.


== Step 5: Analyze your results ==
Throttling costs less than you would think, because array tasks queue '''individually'''. Each asks for a single core, and single cores come free constantly. A two-hundred-task array typically starts trickling through within minutes, while a job demanding two hundred cores in one block waits for them to line up. This is the same effect described on the [[Workflows/Parallel-Computing#Why not just ask for a thousand cores|overview]], working in your favour for once.


The analysis script reads every result file, prints a summary table, and writes three plots. It needs matplotlib, which lives in the SciPy bundle:
The other quiet advantage: when eleven of your two hundred tasks fail, you rerun exactly those eleven.


<syntaxhighlight lang="bash">
<syntaxhighlight lang="bash">
module load 2024 SciPy-bundle/2024a
sbatch --array=17,42,88-95 run.slurm
python /lustre/shared/hpcCourses/pi-tutorial/analyze.py $myScratch/hpcCourse/pi-tutorial/results
</syntaxhighlight>
 
When it finishes you will have three PNG files in your results directory:
 
* <code>walltime_vs_cores.png</code> — how long each run took, against the number of cores.
* <code>speedup_vs_cores.png</code> — how much faster than the serial run, against the ideal straight line.
* <code>best_per_approach.png</code> — the single fastest run of each approach, side by side.
 
Copy them to your laptop (for example with <code>scp</code>) to view them, or open them through the OnDemand file browser.
 
== Results ==
 
[[File:Pi-tutorial-walltime-vs-cores.png|thumb|right|360px|Wall time falls as cores are added. Note both axes are logarithmic; a straight line here means near-ideal scaling.]]
[[File:Pi-tutorial-speedup-vs-cores.png|thumb|right|360px|Speedup against the serial baseline. The dashed line is perfect (linear) speedup; real runs sit just below it.]]
[[File:Pi-tutorial-best-per-approach.png|thumb|right|360px|The fastest run of each approach. The GPU finishes the same 10-billion-dart job in a fraction of the time the CPU rungs need.]]
 
A correct run produces a table like the one below (trimmed; your exact times will vary by a few percent). Every run threw the same '''10 billion''' darts.
 
<syntaxhighlight lang="text">
variant  nodes cores              device      wall_s            pi  pct_err
serial        1    1                cpu      26.008    3.1416052  0.00040
omp          1    1                cpu      26.499    3.1416052  0.00040
omp          1    16                cpu        1.663    3.1415960  0.00011
omp          1  128                cpu        0.235    3.1416075  0.00047
mpi          1    1                cpu      69.676    3.1416052  0.00040
mpi          1  128                cpu        0.555    3.1416075  0.00047
mpi          4    64                cpu        1.090    3.1416039  0.00036
hybrid        4  128                cpu        0.217    3.1416075  0.00047
gpu          1    1  AMD_Instinct_MI210        0.080    3.1415897  0.00009
gpu          1    1  NVIDIA_A100_80GB_PCIe    0.045    3.1415897  0.00009
</syntaxhighlight>
</syntaxhighlight>


Two things are worth noticing straight away:
== What it buys you ==
 
* The serial run took about '''26 seconds'''; the 128-thread OpenMP run took about '''0.24 seconds'''; the A100 GPU finished in about '''0.045 seconds'''. The work was identical — only the time changed.
* Look at the <code>pi</code> column. For any given total work and seed, the estimate is '''identical''' across serial, OpenMP, MPI, multi-node, and hybrid — for example every 128-core CPU run lands on 3.1416075. Splitting the darts across more workers did not change the answer, because each worker draws its own independent random stream. (The GPU differs in the last few digits because it uses far more streams; it still lands well within the expected error.)
 
== Discussion ==
 
The plots tell a tidy story: add workers, get proportionally faster, almost along the ideal line. That is real — but it is important to understand ''why'' it looks so clean, and where the limits are.
 
'''This problem is unusually friendly.''' Throwing darts is "embarrassingly parallel": the workers never need to talk to each other until the very end, when their hit-counts are summed. Most real scientific codes are not like this. A fluid-dynamics solver, a molecular-dynamics simulation, or a genome assembler has data dependencies and communication between workers, and that communication eventually caps how much faster you can go (this ceiling is known as '''Amdahl's law'''). Do not expect your own code to scale as cleanly as π does — this tutorial shows you the ''best'' case, not the typical one.
 
'''More workers buy speed, not accuracy.''' The error of a Monte Carlo estimate shrinks with the number of darts as roughly 1/√N — so to halve the error you must throw '''four times''' as many darts. Adding cores lets you throw a fixed number of darts ''sooner'', or throw ''more'' darts in the same time, but it never makes a fixed number of darts more accurate. Speed and accuracy are separate knobs.
 
'''Compare equal core counts.''' In the table, the 4-node MPI run (1.09 s) looks ''slower'' than the 1-node MPI run (0.56 s). It is not a penalty for crossing nodes — the 4-node run used '''64''' cores while the 1-node run used '''128'''. When you compare like with like (64 cores on 1 node ≈ 1.11 s versus 64 cores across 4 nodes ≈ 1.09 s) the times are essentially equal: for this workload the network between nodes is not a bottleneck. Always check the core count before reading a comparison as good or bad news.


'''The measurements are deliberately rough.''' Each point is a single run with a single seed, and the fastest runs finish in well under a second far too short to time precisely on a shared machine. That noise is why a step in the curve can occasionally look ''better'' than perfect ("super-linear"); it is timing jitter, not magic. A proper benchmark repeats each run several times and reports the median. This tutorial trades that rigour for clarity and a short queue.
Plain arithmetic, and it is the friendliest arithmetic in this section. Two hundred samples at thirty minutes each is one hundred hours — over four days run one after another. As an array with twenty tasks running at once, it is about five hours. With fifty, about two.


'''A real-world wrinkle.''' You may notice the single-process MPI run (about 70 s) is markedly slower than the single-thread OpenMP run (about 26 s) for the same work — and that gap persists across the sweep. That is a property of how this particular MPI program starts up and is being investigated; it is not a general rule that MPI is slower than OpenMP. It is a good reminder that ''how'' you measure matters as much as ''what'' you measure.
Notice what sets the ceiling. It is not your program and it is not the size of a node: it is how many tasks the cluster is willing to run for you concurrently. Nothing here grows more expensive as you add workers, because the workers never communicate — no message passing, no shared memory, no coordination to pay for. Every other page in this section pays some version of that cost. This one does not, which is why it scales further than any of them.


== Conclusion ==
The [[Tutorials/Parallelism-Estimating-Pi|π tutorial]] has no job-array rung — its ladder runs serial, threads, processes, multi-node, GPU — but its [[Tutorials/Parallelism-Estimating-Pi#Discussion|discussion]] makes the general point this page rests on: work that divides into genuinely independent pieces parallelises far better than work that does not.


You ran one calculation on a single core and on hundreds, on CPUs and on GPUs, on one node and across four — and the answer never changed, only the time. That is the core lesson of parallel computing on a cluster: '''you choose a parallel model to match the structure of your problem''', and the reward is speed, not correctness.
== The honest limitation ==


A few takeaways to carry forward:
It only works if the runs truly do not need each other.


* If your work is made of independent pieces (parameter sweeps, many input files, Monte Carlo trials), it will parallelise well threads, processes, or a GPU all help.
That sounds obvious and is easy to violate by accident. '''Array tasks do not run in a guaranteed order''', and they do not run one at a time task 5 may finish before task 2 starts. Any assumption about sequence is already broken. So: two tasks writing to the same output file will interleave and corrupt it; two tasks writing scratch files with the same name will clobber each other; a task that reads a result another task is still producing gets whatever happened to be on disk at that instant. None of these fail loudly — they fail by producing plausible, wrong numbers. Give every task its own output path, keyed on <code>$SLURM_ARRAY_TASK_ID</code>, and the whole class of problem disappears.
* If your work has steps that depend on each other, expect communication to limit your speedup, and measure before assuming more cores will help.
* More cores make things faster; only more samples (or a better method) make a Monte Carlo result more accurate.


To go further, try editing a script to use <code>--per-worker</code> instead of <code>--total</code> and watch the difference between weak and strong scaling, or move on to packaging your own software with the [[Tutorials/Apptainer-Introduction|Apptainer tutorials]].
Real dependencies between runs need a real answer, not an array: [[Workflows/Multi-Process-Workflows|MPI]] if the pieces must exchange data as they compute, or a workflow manager such as Nextflow or Snakemake if the pipeline has stages that must follow one another.


== Troubleshooting ==
There is one dependency you will almost certainly have, and it is benign: the array leaves you with two hundred output files, and something has to combine them. That collection step is a separate job, usually serial, told to wait for the array to finish:


* '''<code>myScratch is not set</code>''' — the course environment is not loaded. Load it and try again.
* '''A GPU job sits in the queue (pending)''' — the NVIDIA cards may be busy; the job will start when one frees up. The AMD GPU run uses a reservation and is the reliable one for the course.
* '''You edited a script badly and want a clean copy''' — delete your script folder and re-run the launcher to get fresh copies:
<syntaxhighlight lang="bash">
<syntaxhighlight lang="bash">
rm -rf "$myScratch/hpcCourse/pi-tutorial/slurm"
ARRAY_ID=$(sbatch --parsable run.slurm)
bash /lustre/shared/hpcCourses/pi-tutorial/exercise/launch.sh
sbatch --dependency=afterok:${ARRAY_ID} collect.slurm
</syntaxhighlight>
</syntaxhighlight>


== See also ==
== See also ==


* [[Linux Basic/Linux Self Assessment|Linux Self Assessment]] — check your command-line readiness.
* [[Workflows/Parallel-Computing|Parallel Computing]] — the overview, and the map to the other workflow types.
* [[Tutorials/Apptainer-Introduction|Apptainer: Introduction]] — packaging software for reproducible runs.
* [[Workflows/Serial-Workflows|Serial Workflows]] — the baseline this page starts from.
* [[Tutorials|All tutorials]] · [[Workshops]] · [[Training Materials]]
* [[Array Jobs]] — the full <code>--array</code> syntax, including multi-dimensional index tricks.
* [[Workflows/Multi-Threaded-Workflows|Multi-Threaded Workflows]] — for when it is one run that needs many cores, not many runs.
* [[Tutorials/Parallelism-Estimating-Pi|Parallelism: Estimating π]] — the hands-on companion to this section.

Revision as of 07:39, 5 August 2026

Serial ran one program, once, on one core. This page runs that same program many times over — two hundred samples, a parameter sweep, a hundred simulations that differ only by their random seed — with the runs never needing to speak to each other.

That last part is what makes this the easiest kind of parallelism there is, and by far the most common one in biology. It is also the rung where you most often change nothing whatsoever about your program. The tool stays exactly as it was. What changes is the job script around it, which stops describing one run and starts describing a whole batch of them. Slurm has a purpose-built mechanism for this: the job array.

When this is you

You are looking at an embarrassingly parallel workflow when all three of these are true:

  • You run the same analysis many times over.
  • Each run has its own input — a different sample, a different parameter value, a different seed.
  • No run needs anything another run produces. You could run them in any order, or all at once, and the answers would be identical.

That covers a great deal of everyday work: one pipeline over two hundred sequencing samples, one model fitted per chromosome, one simulation per point on a parameter grid.

The tell that you are not here is a dependency between runs — if run 7 needs a number that run 6 computed, the runs are not independent and an array will not save you. The other tell is subtler: if you have one slow analysis over one dataset, this page cannot help either. An array multiplies runs; it does not split a single run. For that, go to multi-threaded or multi-process.

If you have ever written a for loop that calls sbatch, or submitted the same script by hand with the filename edited each time, this is the page you were looking for.

What it looks like on Anunna

Everything from the serial page still holds: you never run the analysis on the login node, your work lives under $myScratch on Lustre rather than in your home directory, and you hand the job to Slurm with sbatch.

Take the serial script and add one #SBATCH line. The smallest array worth looking at does nothing but show you what the mechanism does:

#!/bin/bash
#SBATCH --job-name=fruit_salad
#SBATCH --array=0-6            # seven tasks, numbered 0 to 6
#SBATCH --ntasks=1             # each task is still ONE process ...
#SBATCH --cpus-per-task=1      # ... on one core
#SBATCH --mem=100M             # memory PER TASK
#SBATCH --time=00:10:00        # wall-clock limit PER TASK
#SBATCH --output=%x-%A_%a.out  # one log per task

cd "$myScratch/my_analysis"

fruits=(banana apple orange grape berry tomato watermelon)

echo "task ${SLURM_ARRAY_TASK_ID} got: ${fruits[${SLURM_ARRAY_TASK_ID}]}"

Submit it exactly as before, once:

sbatch run.slurm

Slurm expands that single submission into seven independent jobs. You get seven log files, each naming one fruit. Nothing here is parallel programming — there is no library, no communication, no special compiler. There is a list, and a number that picks one item out of it.

Against the serial script, exactly two things changed:

  • --array=0-6 was added. This is the whole feature. Everything else on this page is a consequence of it.
  • The output name gained %A_%a in place of %j, so each task writes its own log rather than seven tasks fighting over one file.

Everything else — --ntasks, --cpus-per-task, the scratch directory — is the serial script untouched. Each array task is a serial job; there are simply many of them.

Turning the demonstration into real work is a substitution, not a redesign. The list becomes your data, and the echo becomes your program:

samples=(sampleA sampleB sampleC sampleD)

module load 2024 Python/3.12.3     # replace with your actual module
python myScript.py --input "${samples[${SLURM_ARRAY_TASK_ID}]}.csv" \
                   --output "results/${samples[${SLURM_ARRAY_TASK_ID}]}.out"

myScript.py never learns it is part of an array. It is handed one input and produces one output, exactly as it did serially. That is why this rung is so cheap to climb.

Two numbers behave differently from the multi-threaded page, and both catch people out. --mem and --time are per task: each job gets its own allocation. Threads share memory; array tasks do not, because they are separate jobs on possibly separate nodes. Ask for 4 GB across a 200-task array and you have asked the cluster for 800 GB in total — size the request to one task, then remember to multiply before you are surprised.

The array-specific patterns in the filenames are worth knowing:

Pattern Expands to
%A the array job id — the same for every task
%a the task id — 0, 1, 2 ... the thing that differs
%j the individual job id of that one task
%x the job name

Choosing what each task works on

The array gives every task a number and nothing else. Turning that number into an input is your job. The list above is the clearest way when the list is short; for longer ones there are two more idioms:

# One line of a file per task — for lists too long to write in the script
SAMPLE=$(sed -n "${SLURM_ARRAY_TASK_ID}p" samples.txt)

# One computed parameter value per task — for sweeps
THRESHOLD=$(awk "BEGIN{print 0.05 * ${SLURM_ARRAY_TASK_ID}}")

Mind the counting, because this is where array jobs go quietly wrong. Bash lists are numbered from 0, so seven fruits are 0-6. sed numbers lines from 1, so two hundred samples are 1-200. Write --array=0-4 against those seven fruits and the job succeeds, reports no error, and simply never processes tomato or watermelon. Nothing fails. A sample is just missing from your final table, and you find out much later, if at all.

Task ids must also be non-negative whole numbers. You cannot pass filenames to --array; you pass positions, and the script looks up the name.

Building the array from the data

Slurm fixes an array's size when the job is submitted, and the script cannot resize its own array from the inside. So when the number of inputs is whatever happens to be in a directory today, compute the range outside the job script and pass it on the command line, where it overrides the #SBATCH directive:

#!/bin/bash
# launcher.sh — count the data, then submit an array to match

samples=(data/*.csv)
sbatch --array=0-$(( ${#samples[@]} - 1 )) run.slurm

The job script rebuilds the same list with the same glob and indexes into it as before. Note the parentheses in samples=(data/*.csv) — they are what makes this a list. Without them you get a single string, its length is 1 no matter how many files exist, and you submit a one-task array that runs cleanly and does a fraction of the work.

Being a good neighbour

A % at the end of the range caps how many tasks run at once:

#SBATCH --array=1-200%20       # 200 tasks, at most 20 running concurrently

It is optional, and it is a courtesy worth extending — two hundred tasks all hammering Lustre at the same moment is unpleasant for everyone, including you.

Throttling costs less than you would think, because array tasks queue individually. Each asks for a single core, and single cores come free constantly. A two-hundred-task array typically starts trickling through within minutes, while a job demanding two hundred cores in one block waits for them to line up. This is the same effect described on the overview, working in your favour for once.

The other quiet advantage: when eleven of your two hundred tasks fail, you rerun exactly those eleven.

sbatch --array=17,42,88-95 run.slurm

What it buys you

Plain arithmetic, and it is the friendliest arithmetic in this section. Two hundred samples at thirty minutes each is one hundred hours — over four days — run one after another. As an array with twenty tasks running at once, it is about five hours. With fifty, about two.

Notice what sets the ceiling. It is not your program and it is not the size of a node: it is how many tasks the cluster is willing to run for you concurrently. Nothing here grows more expensive as you add workers, because the workers never communicate — no message passing, no shared memory, no coordination to pay for. Every other page in this section pays some version of that cost. This one does not, which is why it scales further than any of them.

The π tutorial has no job-array rung — its ladder runs serial, threads, processes, multi-node, GPU — but its discussion makes the general point this page rests on: work that divides into genuinely independent pieces parallelises far better than work that does not.

The honest limitation

It only works if the runs truly do not need each other.

That sounds obvious and is easy to violate by accident. Array tasks do not run in a guaranteed order, and they do not run one at a time — task 5 may finish before task 2 starts. Any assumption about sequence is already broken. So: two tasks writing to the same output file will interleave and corrupt it; two tasks writing scratch files with the same name will clobber each other; a task that reads a result another task is still producing gets whatever happened to be on disk at that instant. None of these fail loudly — they fail by producing plausible, wrong numbers. Give every task its own output path, keyed on $SLURM_ARRAY_TASK_ID, and the whole class of problem disappears.

Real dependencies between runs need a real answer, not an array: MPI if the pieces must exchange data as they compute, or a workflow manager such as Nextflow or Snakemake if the pipeline has stages that must follow one another.

There is one dependency you will almost certainly have, and it is benign: the array leaves you with two hundred output files, and something has to combine them. That collection step is a separate job, usually serial, told to wait for the array to finish:

ARRAY_ID=$(sbatch --parsable run.slurm)
sbatch --dependency=afterok:${ARRAY_ID} collect.slurm

See also