Linux & Bash · Part II · Module 31 · Batch Execution, Test Lists and Seeds
Bounded Parallelism: wait, xargs -P and Job Slots
A regression of 240 runs on a machine with 8 cores needs a bound on how many run at once. This chapter builds that bound three ways, measures the speedup curve on real simulations, and shows the two measurements that gave contradictory answers — one because the page cache was cold, one because the noise was larger than the effect.
Advanced26 min readLinuxPart IIParallelxargsMeasurement
Part II · Module 31 · Chapter 31.5 · Bounded Parallelism
Chapter 31.4 produced a list of 240 runs and priced it at 76 seconds sequential. This box has 8 cores. The obvious move is to run several at once, and the two questions that matter are how many and how do you know.
1. Unbounded Is Not an Option
The shortest way to run everything at once:
# DO NOT do this with a real matrix
while read -r spec; do
run_one.sh "$spec" &
done < specs.txt
waitWith 240 specs that forks 240 simulators. Each one of this fixture's heavy runs holds about 9 MB resident and wants a core; 240 of them want 2 GB and 240 cores, and the machine spends its time context-switching and paging rather than simulating. On a real block — where one simulation is hundreds of megabytes — the same loop is a machine that stops responding.
So the pattern is slots: a fixed number of runs in flight, a new one starting as one finishes.
2. Three Ways to Bound It
xargs -P
$ xargs -P 8 -I{} run_one.sh {} < specs.txtEight at a time, a ninth starting the moment one exits. It is one line, it is in POSIX-adjacent daily use, and it is what the rest of this chapter measures.
A hand-rolled slot counter
Useful when each job needs more setup than a single command:
slots=8
running=0
while IFS= read -r spec; do
run_one.sh "$spec" &
running=$((running + 1))
if [ "$running" -ge "$slots" ]; then
wait # <-- waits for ALL of them, not one
running=0
fi
done < specs.txt
waitThis works and it is worse than it looks. Plain wait blocks until every background job finishes, so the loop runs in batches of eight and the whole batch waits for its slowest member. With one 30-second run among seven 1-second runs, seven slots sit idle for 29 seconds.
Why not wait -n
The fix in a modern bash is wait -n, which returns when any one job finishes, so a new job can start immediately:
$ /bin/bash -c 'sleep 0.1 & wait -n; echo "rc=$?"'
/bin/bash: line 0: wait: -n: invalid option
wait: usage: wait [n]
rc=23. Measuring the Curve — the Light Jobs
A warm single run of this fixture, three times:
$ bash bin/probe_speed.sh
=== a warm single run, three times ===
run 1: 405 ms
run 2: 370 ms
run 3: 402 msSo 16 of them is about 6.2 seconds sequential. The sweep, three repetitions at each slot count:
=== the -P sweep, three repetitions each (16 jobs, 8 cores) ===
-P rep1 rep2 rep3 median vs -P 8
1 5915ms 10588ms 6205ms 6205ms -
2 3246ms 3389ms 4753ms 3389ms -
4 2175ms 2216ms 3264ms 2216ms -
8 8527ms 5636ms 3195ms 5636ms 1.00x
16 2259ms 2360ms 1650ms 2259ms .40x
32 1780ms 1367ms 1228ms 1367ms .24xRead the -P 8 row before reading anything else. Its median is 5636 ms, which is slower than -P 4's 2216 ms, on a machine with 8 cores, for work that is not memory-bound. That cannot be true. Its three repetitions were 8527, 5636 and 3195 — a spread of 2.7× within one configuration.
The honest conclusion is not "-P 8 is slow". It is the run-to-run noise on this measurement is larger than the effect being measured, so the table cannot support a claim about the shape of the curve.
Which is worth stating plainly, because the table is otherwise very persuasive. It has repetitions, it has a median, it has a normalised column, and the normalised column is nonsense.
What the table can support
Two things survive the noise, because they are much larger than it.
-P 1 at 6205 ms and -P 32 at 1367 ms differ by 4.5×, far beyond the spread. Parallelism helps a great deal.
And one clean repetition is monotone. Reading rep3 alone — a single sweep, in order, with nothing else contending:
| -P | rep3 |
|---|---|
| 1 | 6205ms |
| 2 | 4753ms |
| 4 | 3264ms |
| 8 | 3195ms |
| 16 | 1650ms |
| 32 | 1228ms |
That is a sensible curve, and it says something surprising: it is still improving at 32 slots on 8 cores. Which brings us to the measurement that matters.
4. The Heavy Jobs Say the Opposite
The light jobs above are about 370 ms each, and most of that is process startup, dynamic linking and reading the compiled image — not simulation. A job that is 80% waiting is a job where oversubscription fills the gaps, so more slots than cores keeps helping.
Real regression jobs are not like that. This fixture also has a deliberately heavy simulation, around 5.5 seconds of actual compute each:
=== 8 memory-heavy runs, one rep per -P ===
-P elapsed per-job avg vs -P 8
1 43847ms 5480ms -
4 12620ms 1577ms -
8 9796ms 1224ms 1.00x
16 10561ms 1320ms 1.07xHere the curve does what the textbook says: it falls to a minimum at the core count and then turns back up. -P 16 is 7% slower than -P 8, on 8 cores, from nothing but oversubscription.
Three numbers to take from this table.
The speedup at the core count is 4.48×, not 8×. 43847 / 9796. Eight slots on eight cores bought four and a half times, because the cores are shared with the operating system, the slots contend for memory bandwidth, and the eight jobs do not finish together.
Beyond the core count it gets worse, not flat. The penalty here is small — 7% — and it grows with the per-job memory footprint, because that is where swapping starts.
And the two tables disagree. The light jobs improve past 8 slots; the heavy jobs degrade past 8 slots. Both measurements are correct. They are measuring different things: one measures process startup, the other measures simulation.
5. The First Measurement I Published Was Backwards
The first version of the sweep in §3 was run once, cold, on a freshly created fixture. It produced this:
-P 8 1839ms
-P 32 3052ms 1.66x— which is the clean, expected, textbook result: oversubscription hurts. It agreed with everything I expected to write, and I nearly wrote it.
Repeating the same sweep on a warm page cache reversed the ordering: -P 32 came in at 1413 ms, 0.82× of -P 8.
The mechanism is Chapter 31.1's cold-start cost. The first sweep ran -P 1 first, which paid the cost of faulting in the simulator binary, the compiled image and every shared library. By the time the sweep reached -P 32, the cache was warm — except that the fixture had been rebuilt between configurations, so the high slot counts were paying it again. The number being compared was not parallelism, it was which configuration happened to run first on a cold filesystem.
6. Exit Status From Many Children
A parallel launcher has to report whether the regression passed, and xargs has one answer for a wide range of outcomes. Every case, measured:
=== xargs exit status, every combination ===
CASE rc
1 child, exit 0 0
1 child, exit 7 (no -P) 1
3 children, all exit 0 0
3 children, all exit 7 (no -P) 1
3 children exit 7, -P 1 1
3 children exit 7, -P 2 1
3 children exit 7, -P 4 1
mixed 0,7,0 with -P 2 1
mixed 0,0,7 with -P 2 1
1 child exit 255, -P 2 1
missing command, -P 2 127And the manual says why:
EXIT STATUS
The xargs utility exits with a value of 0 if no error occurs. If utility
cannot be found, xargs exits with a value of 127, otherwise if utility
cannot be executed, xargs exits with a value of 126. If any other error
occurs, xargs exits with a value of 1.So the whole vocabulary is three values:
| xargs rc | means |
|---|---|
| 0 | every child exited 0 |
| 1 | at least one child did not — count and reason unavailable |
| 126 | the utility exists but could not be executed |
| 127 | the utility was not found |
One failing run and two hundred failing runs are both 1. So is a run that exited 3 because its log had no verdict, which Chapter 31.1 deliberately made a distinct status. All of that distinction is discarded at the xargs boundary.
This is not a flaw to work around in xargs; it is a consequence of there being one exit status and many children. The answer is that the per-run status has to be recorded by the run, on disk, where it survives the collapse — which is Chapter 31.7.
What xargs's own status is still good for is the two values that are not about the children at all:
xargs -P 8 -I{} "$BIN/run_one.sh" {} < specs.txt
rc=$?
case $rc in
126|127) echo "launcher problem: run_one.sh not found or not executable" >&2; exit 2;;
esac
# rc 0 or 1 tells you almost nothing; read the status files instead127 and 126 mean the regression never ran, which is a completely different report from "some tests failed" — and the one that a script treating any non-zero as "failures" will mislabel. Chapter 30.6 made the same distinction for a submitted job.
7. Sizing the Slot Count
Cores are the first bound and rarely the binding one. The others:
$ sysctl -n hw.ncpu
8Memory. Slots times peak per-run RSS has to fit, and the per-run figure needs measuring, not guessing:
the wrapper pid=30293 (/bin/bash)
its children: 30297
the vvp pid=30297
peak RSS of the WRAPPER: 1056 KB (1 MB)
peak RSS of VVP itself: 8896 KB (8 MB)Those two lines are a measurement error of 8.4×, and it is the one I made. The sampler watched $! — which is the wrapper script, not the simulator. A wrapper is a few hundred kilobytes of bash forever; the process that actually holds the design is its child. Sizing 8 slots from the wrapper's 1 MB gives a memory budget of 8 MB for something that needs 72.
Two caveats on even the corrected number. It is sampled at 0.1 s intervals, so it is a lower bound on the true peak — a short spike between samples is invisible. And it is this fixture; a real design is three orders of magnitude larger, which is the whole reason the slot count usually comes from memory rather than cores.
Licences. Chapter 25.4 covered diagnosing a licence failure; the scheduling consequence is that if the site has 4 simulator licences, 8 slots means 4 runs and 4 failures, and the failures look like tool errors rather than a scheduling mistake.
And disk. Chapter 28.2 measured what one run leaves behind; eight at once produce it eight times as fast, and a waveform-enabled regression can fill a filesystem in the middle of the night.
So:
# slots: the smallest of the four bounds, overridable
cores=$(sysctl -n hw.ncpu 2>/dev/null || echo 4)
slots=${SLOTS:-$cores}
[ "$slots" -lt 1 ] && slots=1
echo "regression: $slots slots on $cores cores" >&2
xargs -P "$slots" -I{} "$BIN/run_one.sh" {} < specs.txtsysctl -n hw.ncpu is the macOS spelling. nproc is not installed here, and getconf _NPROCESSORS_ONLN is the more portable fallback — hence the || echo 4, which keeps the script running on a machine where neither answers.
8. The Whole Launcher
#!/bin/bash
# regress SPECS -- run a matrix with a bounded number of slots.
set -u
specs=${1:-}
[ -r "$specs" ] || { echo "regress: cannot read '$specs'" >&2; exit 2; }
[ -n "${ROOT:-}" ] || { echo "regress: set ROOT" >&2; exit 2; }
[ -n "${RUNS:-}" ] || { echo "regress: set RUNS" >&2; exit 2; }
cores=$(sysctl -n hw.ncpu 2>/dev/null || getconf _NPROCESSORS_ONLN 2>/dev/null || echo 4)
slots=${SLOTS:-$cores}
n=$(grep -cv '^[[:space:]]*\(#.*\)\{0,1\}$' "$specs")
[ "$n" -gt 0 ] || { echo "regress: '$specs' has no runs" >&2; exit 2; }
echo "regress: $n runs, $slots slots on $cores cores" >&2
"$ROOT/bin/run_one.sh" smoke:0 >/dev/null 2>&1 # warm-up, status ignored
s=$(date +%s)
xargs -P "$slots" -I{} "$ROOT/bin/run_one.sh" {} < "$specs"
rc=$?
e=$(date +%s)
echo "regress: finished in $((e-s))s, xargs rc=$rc" >&2
case $rc in
126|127) echo "regress: run_one.sh not found or not executable -- nothing ran" >&2; exit 2;;
esac
exit 0 # the verdict comes from the status files, not from hereThe last line is deliberate and it is the hinge into the next chapter. This launcher does not report whether the regression passed, because xargs cannot tell it — and a launcher that exits 1 for "something failed" invites a caller to treat 1 as the whole answer.
Practice
- Fork 240 background jobs on a toy command and watch the load average.
- Write the batching
waitloop and time it with one deliberately slow job among fast ones. - Run
wait -non this machine and read the exit status. - Measure a warm single run three times and compute the sequential cost of 16.
- Sweep
-Pwith three repetitions and print the raw values next to the median. - Find a row whose spread exceeds the difference between rows and say what the table can still support.
- Sweep a heavy job and find where the curve turns up.
- Compute the speedup at the core count and explain why it is not the core count.
- Run
timeon one job and compute the compute fraction. - Sweep once cold and once warm and see whether your conclusion changes.
- Tabulate
xargs's exit status for all-pass, one-fail, all-fail, missing and non-executable. - Handle 126 and 127 specifically and say what report they deserve.
- Sample the RSS of
$!and then of its child, and compare. - Compute your memory bound from the corrected figure.
- Write the launcher, with a warm-up, and have it exit 0 whatever the children did.
- Explain to someone else why that exit 0 is right.
Check Your Understanding
- What does an unbounded fork of 240 runs cost?
- Why is the batching
waitloop worse than it looks? - What does
wait -ndo on this machine, and what is the danger? - In §3's table, which row is impossible and how do you know?
- What is the honest conclusion from that table?
- What two claims does it still support?
- Why do the light jobs keep improving past 8 slots?
- What do the heavy jobs do past 8 slots, and by how much here?
- Why is the speedup at the core count 4.48× and not 8×?
- How do you tell which table describes your regression?
- Why is the cost of too many slots worse than too few?
- What made the first, cold sweep wrong?
- Give the three rules for a timing claim.
- Why is a confirming measurement suspicious?
- What are the only four values
xargscan exit with, and what does 1 cover? - Which two of them are worth branching on, and why?
- What was the 8.4× error in the RSS measurement?
- Name the four bounds on the slot count.
- Why does the launcher exit 0 even when runs failed?
9. Remember
- Bound the parallelism. An unbounded fork of a real matrix is a machine that stops responding.
- Plain
waitbatches, so the loop waits for each batch's slowest member.wait -ndoes not exist in bash 3.2 — it exits 2 and the loop runs unbounded. xargs -Pmaintains the slot count itself, in one process, with no version dependency.- Look at the spread before the median. A 2.7× spread inside one configuration makes the whole normalised column meaningless, repetitions and all.
- Light jobs and heavy jobs give opposite answers. Startup-dominated work keeps improving past the core count; real simulation turns up at it.
- Measure the compute fraction with
timeto know which regime you are in. Default to bounding at the core count: the cost of too many slots is asymmetric. - 8 slots on 8 cores bought 4.48×, not 8×.
- Warm up before the first measured configuration, or the table measures the page cache.
- Distrust a measurement that confirms what you were about to write. The cold sweep gave the textbook answer and was wrong.
xargsexits 0, 1, 126 or 127.1means "at least one child failed" and nothing more; branch on 126 and 127, which mean nothing ran.- Size slots from the simulator's RSS, not the wrapper's — that mistake was 8.4× here — and from licences and disk as well as cores.
- A parallel launcher should not report the verdict. It cannot know it.
10. What Is Next
Eight runs are now in flight at once, writing into whatever directories and files they were told to. Chapter 31.6 measures what happens when two of them are told the same name — including four runs that produced one log, and two passing tests recorded as failures.
Where this fits
Part of the Linux & Bash curriculum.
