7 Practical Considerations
The earlier chapters gave you a working formula. You write a worker function, hand it
to sim_template, pick a number of cores, and collect the results. For a great deal of
real work that is genuinely all you need. This chapter is about the gap between code that
works once on your machine and code you can trust in the long run, hand to a collaborator,
or come back to in six months and still understand.
None of what follows is deep; but, most of it is the kind of thing you will only discover after the fact:
- How many cores do you actually have, as opposed to how many the operating system claims?
- Why does adding cores sometimes make a simulation slower instead of faster?
- What do you do when you stop a run and your laptop’s fans keep roaring an hour later?
- How do you time code in a way that means something, name things so the next person can follow them, and seed a study so that “reproducible” is a fact rather than a hope?
The sections that follow tackle these questions one at a time. Section 7.1 is about counting cores honestly. Section 7.2 covers the bottleneck that is not speed at all but memory. Section 7.3 is about recovery when a parallel run goes wrong, including the orphaned processes that can outlive the program that spawned them. Section 7.4 replaces eyeballing a clock with proper benchmarking. Section 7.5 is about naming, comments, and project layout, the unglamorous habits that make parallel code survivable. Section 7.6 looks at load balancing, why the work does not always divide as evenly as the arithmetic suggests. And Section 7.7 closes with reproducibility, which is the whole point of fixing a seed in the first place. You can peruse this now to get an overview and come back to it when you need it.
7.1 Cores, Threads, and How to Count Them
Every parallel call in this book asks you for the same number, n_cores. The quality of
that choice does more for your wall-clock time than almost anything else. To choose well
you have to know what you are selecting from, and in this instance the machine can be less than forthcoming.
The obvious move is to ask the computer how many cores it has. In R that is
library(parallel)
detectCores()and in Python it is
The trouble is that the number these commands return is not the number of cores you want to use,
for two separate reasons. The first is hyperthreading. Many Intel and AMD chips present
each physical core to the operating system as two logical processors. A genuine
four-core chip reports eight. Hyperthreads share the underlying arithmetic hardware. Consequently,
for the compute-bound numerical work in this book they deliver far less than what would be obtained from the addition of another physical core. Counting them as cores and setting n_cores to the reported number that includes hyperthreads is a
common way to be disappointed.
The second reason is the one introduced back in Chapter 1: modern machines
mix performance and efficiency cores. The two iMac and ASUS machines used for timing in
Chapters 3 and 4 make the point. The iMac is an eight-core Apple
M4 with four performance and four efficiency cores; the ASUS is a twenty-core Windows
laptop with eight performance and twelve efficiency cores. Efficiency cores exist to save
power on light tasks, not to crunch numbers, and detectCores cheerfully counts them
alongside the rest.
There is also a subtler point that Chapter 1 raised that bears repeating
here, because it changes how you read every one of these numbers. When you call
makeCluster(n_cores) in R or open a Pool(n_cores) in Python, you are not seizing
hardware cores directly. You are asking the operating system to start n_cores worker
processes, and the OS then time-shares those processes across whatever physical cores it
sees fit, alongside your browser, your editor, and everything else running. The mapping
from process to core is the operating system’s to make, not yours. This is why asking for
more workers than you have performance cores rarely helps and often hurts: you have simply
handed the scheduler more processes to juggle on the same hardware.
So how should you choose n_cores in practice? A reliable rule of thumb is to count the
performance cores, not the logical-processor total, and start there, leaving one core free
for the operating system and your own interactive work. On the iMac that means around four;
on the ASUS, around eight. You do not have to guess these values. R’s parallelly
package, a companion to the popular future framework, exposes
parallelly::availableCores(), which is more conservative than detectCores() and
respects environment limits set by, for example, a cluster scheduler. But the most honest
answer comes from the machine itself: run the short speed-up experiment of Section
7.4, watch where the curve flattens, and let that be your n_cores. The
number the hardware reports is a starting hypothesis; the number that stops getting faster
is the truth.
7.2 Memory: The Other Bottleneck
It is natural to assume that the constraint on a simulation is always speed, and that more cores is therefore always the lever to pull. For a whole class of problems that assumption is wrong, and pulling the lever makes things worse. The other bottleneck is memory.
The reason is built into how the parallel tools in this book work. R’s default PSOCK cluster, and Python’s default process-spawning behavior on Windows and macOS, both start each worker as a fresh, independent process. A fresh process does not share your data; it gets its own copy. If your simulation reads a one-gigabyte dataset and you run it on eight workers, you can be holding eight gigabytes of it in RAM at once, plus the original, plus the operating system’s own needs. The arithmetic is unforgiving, and it is easy to walk into. A reviewer of an early draft of this book raised exactly this case: a residual bootstrap, of the kind we built in Chapter 5, runs not on a teaching dataset but on a ten-gigabyte production one. Two or three workers and you are out of memory before the processors have broken a sweat.
The symptom is distinctive once you have seen it, and it is the mirror image of the compute-bound case. When you are short on cores, adding cores makes things faster. When you are short on memory, adding cores makes things slower. Memory use climbs as you add workers, the machine begins swapping to disk to fake the RAM it does not have, throughput collapses because disk is thousands of times slower than memory, and in the worst case the operating system kills your run outright to save itself. If you ever see a study get slower as you grant it more cores, suspect memory before anything else.
A little estimation goes a long way here. Before launching a large run, find out how big
the per-worker footprint actually is. In R, object.size on the data you are about to
ship to each worker gives you the base figure; multiply by n_cores and compare against the
RAM you have. In Python, sys.getsizeof is a rough first look and the memory_profiler
package a more careful one. While a run is going, keep a system monitor open (Activity
Monitor on macOS, Task Manager on Windows, top or htop on Linux) and watch the memory
line rather than the CPU line. If it is the memory line that is pinned, more cores is the
wrong fix.
The right fixes are the opposite of more parallelism. The simplest is to use fewer
workers: three workers that fit in memory will finish a memory-bound job that eight workers
will thrash to death. Beyond that, you can avoid the copying altogether. On macOS and
Linux, R can build a FORK cluster with makeForkCluster() (and mclapply() uses the same
mechanism), and Python’s multiprocessing can use the fork start method. A forked worker
inherits the parent’s memory through the operating system’s copy-on-write machinery, which
means a large dataset the workers only read is shared rather than duplicated, and stays
shared until someone writes to it. That can turn an impossible eight-copy job into a
comfortable one. The catch, and the reason this book defaults to PSOCK and spawn despite
the cost, is portability: forking is not available on Windows and interacts badly with some
multithreaded libraries, so it is a deliberate choice for a specific machine rather than a
default to reach for blindly. When even forking is not enough, the honest conclusion is that
the data has outgrown the laptop, which is the memory-bound signal that Chapter
8 addresses under when to move beyond a personal machine.
7.3 When Things Go Wrong: Debugging Parallel Code
Parallel code fails in ways serial code does not, and the failures are harder to see because the action is happening inside worker processes you are not watching. Two habits make the difference between a frustrating afternoon and a quick fix.
The first is to do all of your debugging in serial. Every sim_template in this book takes
n_cores as an argument, and the single most useful thing you can do when something breaks
is to set n_cores = 1. With one core, the worker runs in a context you can actually inspect:
error messages come straight back to you instead of being swallowed and possibly re-reported by the
cluster machinery, you can drop a print or a browser into the worker and see its output.
The whole confusing layer of inter-process communication is simply absent. Get the
worker correct and fast on a small problem with n_cores = 1, confirm it gives the answer
you expect, and only then scale up the cores and the replications. A bug that survives to
the parallel run is then almost always in the parallel plumbing, which is a much smaller
place to look.
The second habit is to understand what happens when one worker experiences errors partway through a
run. The frameworks differ in their responses. Python’s Pool.starmap will propagate an
exception from any worker back to your main program, which stops the run. So a single bad
replication takes the whole job down and you see the traceback. R’s parallel functions are
quieter; depending on how you call them, an error in one task may surface as a cryptic
message buried in the returned list rather than a clean stop. Either way the lesson is the
same: validate the inputs and guard the edges of your worker before you launch ten replications. The cost of a crash on replication 9,998 is the whole run.
Then there is the failure the reviewers were most emphatic about, because it is the one that
quietly costs you money and machine time: orphaned processes. When you build a cluster you
are starting real, independent processes, and they do not automatically die when your script
does. If you interrupt a run, close R or Python without a clean shutdown, or the controlling
process crashes, those workers can keep running, holding CPU and memory, for hours or days,
until you notice the computer’s fans whirring or the next run mysteriously has no cores to use. The discipline
that prevents this is to make shutdown automatic rather than something you remember to do.
In R, pair every makeCluster() with a stopCluster(), and put it where it will run even
on failure:
cl <- makeCluster(n_cores)
on.exit(stopCluster(cl)) # runs even if the body errors out
# ... do the parallel work ...The sim_template listings in Chapters 3 and 4 shut their clusters
and pools down for exactly this reason; Python’s with Pool(...) as pool: form is the same
idea, guaranteeing the pool is closed when the block exits no matter how that happens. When an orphan
does still manage to get loose, you find it with the same system monitor from the previous section.
Look for processes named R or python chewing CPU with no terminal attached, and end it from there:
Activity Monitor or Task Manager has a button for it, and on macOS or Linux pkill -f sim_template or a plain kill on the process id will do it from the command line. Checking
for strays after an interrupted run is a five-second habit that saves a great deal of
confusion.
7.4 Benchmarking Properly
The simplest timing tool in R is system.time(). It’s
the honest tool for the job: built in, needing no explanation, and for a run that takes
seconds, good enough. For deciding things, though, a single system.time() reading is shaky
ground. Run it twice and you will get two different numbers, because a real machine is never
doing only your work: a background update kicks in, or the clock speed drifts under thermal
limits. One stopwatch reading cannot tell signal from noise.
Proper benchmarking tools exist to resolve precisely this issue. In R the two standard choices are
the bench and microbenchmark packages and we have used bench to obtain the median times, etc., that were reported for our R code. Both libraries run your expression many times and report a
distribution, a median, and a spread, rather than a single number. They handle the details
a hand-rolled timer gets wrong, such as warming up so the first slow run does not skew the
result and accounting for garbage collection. A typical use looks like
library(bench)
bench::mark(
serial = sim_template(seed, n, n_reps, n_cores = 1),
parallel = sim_template(seed, n, n_reps, n_cores = 4),
# the two return equivalent results, not identical objects
check = FALSE
)Python’s equivalent ships with the language, the timeit module. This likewise runs a
snippet repeatedly and reports the best of several timings, deliberately filtering out the
slow outliers that come from the system doing something else. For longer simulation runs,
where you care about wall-clock time rather than the microsecond precision timeit is built
for, you can wrap time.perf_counter() around the call and that is perfectly
adequate as long as you average a few runs.
What you do with these tools matters as much as the tools. The quantity worth measuring is
speed-up, defined in Section 3.2 as the ratio of the serial time to the time on
n_cores cores. Measure it not at one core count but across a range, one, two, four, eight,
and look at the shape of the curve. Early on it climbs, perhaps not at the ideal rate but
climbing. Then it bends over and flattens, and eventually, as the iMac showed in Section
3.2, it can turn back down. The core count where the curve stops climbing is your
answer: it is the most parallelism this problem and this machine can usefully absorb, and
paying for more, in cores, in cloud dollars, or in complexity, buys nothing. Benchmarking is
not bookkeeping you do after the fact; it is how you find the right n_cores in the first
place, and it costs a few minutes against runs that may take hours.
7.5 Naming, Code Style, and Project Organization
Simulation code has a way of outliving the afternoon it was written. The script that produced a figure gets reopened when a referee asks a question, handed to a student, or adapted for the next project, and at that point its readability stops being a matter of taste and becomes a matter of whether the work can be trusted and reused. A few habits here pay for themselves many times over.
Start with names. A reviewer of this book pushed back on calling everything simple_, on the
grounds that “simple is in the eye of the beholder,” and the point generalizes. A name should
describe an object’s purpose. We kept sim_template as our running example precisely because it
describes its job, a template you fill in, rather than passing judgment on how hard that job
is. We have tried to consistently name our worker functions as worker because they perform the same basic task: generate data and compute estimators. When you adapt
this code, resist the temptation to call your worker f or your result x. The five seconds
you save typing are borrowed against the hour someone later spends working out what x might contain.
Comment the seams, not the obvious. Ordinary serial code often needs little commentary; but, parallel code has joints that are genuinely not self-explanatory, and those are where a sentence earns its place: a note on why each worker gets its own random-number stream and what would break if they don’t, a reminder that the worker function must be self-contained because it runs in a process that has never seen your global environment, a line explaining how the replications are divided across cores. A collaborator reading your code can see what the parallel call does; the comment should tell them why it is safe.
Keep workers pure. The single most useful structural rule for simulation code is that the worker function should take its inputs, compute its result, and return it, with no side effects, no writing to files, no changing global state, no installing packages. An early version of this book’s own code installed a package from inside the worker function, and a reviewer rightly flagged it: a function that quietly alters your system while it runs is a function you cannot reason about, reproduce, or run safely on a shared machine. Installation belongs in your setup notes, run once, as the Preface describes; the worker should do only one thing.
Finally, give a study a shape on disk. A simulation project is naturally made of a few
distinct kinds of file, and keeping them separate keeps the project legible: the worker
function that defines the statistics, the sim_template driver that runs it in parallel, an
analysis script that turns raw output into tables and figures, and a place for the results
themselves. A layout as plain as
project/
R/ worker functions and the sim_template driver
analysis/ scripts that summarize and plot
results/ saved output, named by the run that produced it
data/ inputs, treated as read-only
is enough. The value is not tidiness for its own sake; it is that anyone, including you in six months, can see at a glance where the simulation is defined, how it was run, and where its output went. That is the difference between code that produced a result once and code that can produce it again.
7.6 Load Balancing
When sim_template divides n_reps replications across n_cores workers, it splits them as
evenly as the arithmetic allows, giving each core a base share and parceling out any remainder
one at a time, as the program-details sections of Chapters 3 and 4
described. If every replication took the same time, that would be the end of the matter and
all cores would finish together. Real work is rarely so tidy, and the gap between an even
division of tasks and an even division of time is what load balancing is about.
Two things spoil the tidy picture. The first is that the replications themselves may not be equal. In the segmented-regression study of Chapter 5, each replication runs a random search and an optimization whose running time depends on the data it happens to draw; some replications converge quickly and others grind. Hand a process a job that happens to contain several slow tasks and it will still be working long after its neighbors have gone idle, and the whole run is only as fast as that unluckiest process. The second is the hardware we have returned to throughout this chapter: on a machine that mixes performance and efficiency cores, an equal share of work handed to an efficiency core takes longer than the same share on a performance core, so equal division in this case guarantees uneven finishing times.
The lever that helps is chunk size, the trade between overhead and balance. Splitting the work
into a few big chunks, one per process, keeps overhead low. This is because each worker is set up and
torn down only once. But it balances badly, since one heavy chunk delays everything. Splitting
into many small chunks, more tasks than processes, balances well, because a process that finishes
early simply picks up the next waiting piece. But, each handoff carries communication cost, and
too many tiny tasks drown the real work in dispatch overhead. There are tools that expose this directly.
R’s parallel package offers load-balancing variants such as parLapplyLB and clusterApplyLB
that hand out work dynamically rather than in one fixed split, and Python’s
Pool accepts a chunksize argument and offers imap for the same purpose. The practical
advice is unfussy: for the homogeneous problems that make up most of this book, the even split
sim_template already performs is the right default and you need not think about it. When you
notice, in the system monitor, that some cores go idle while others labor on, that is the signal
to make the chunks smaller and let the fast cores help with the slow work.
7.7 Reproducibility
Every chapter of this book has handed sim_template a seed, and it is worth ending the
practical material by being precise about what that seed does and does not buy you. Because
reproducibility is the entire scientific justification for the care we have taken with random
numbers. The promise from Chapter 2 is real: the same seed drives the same
deterministic stream out of the generator, so a simulation run with a fixed seed gives
bit-for-bit identical results every time it is run. That is what makes a simulated result a
result at all, something a colleague can check rather than take on faith, and it is the reason
the seed is the first argument to every template in the book rather than an afterthought.
There is one wrinkle that parallelism introduces and that you should know about before it
surprises you. Because sim_template builds a separate, independent random-number stream for
each process, as Chapter 2 and the program details of Chapter 3 lay out,
the exact set of random draws depends on how many cores the work was split across. Run the same
study with the same seed on four processes and then on eight, and you will get answers that are each
perfectly reproducible but not identical to each other, because the second run divided the
replications differently and therefore drew from the streams in a different pattern. This is not
a bug and it does not threaten your conclusions, which are statements about a distribution that
both runs estimate consistently. But it does mean the honest reproducibility recipe records
n_cores alongside the seed. The full quartet, seed, n, n_reps, and n_cores,
reproduces a run exactly; the seed alone reproduces it only on the same number of cores.
Reproducibility also extends past your own code to the software underneath it. A generator’s
output, a package’s default, even a numerical routine can change between versions, so a study
that ran identically last year may drift this year through no fault of your own. The defenses
are simple and worth the small effort. Record the environment that produced a result:
sessionInfo() in R and pip freeze or conda list in Python capture the package versions in
a form you can save next to the output. Save the seed, the core count, and the parameters in or
beside the results file itself, so a figure always carries the information needed to regenerate
it. And where exact long-term reproducibility matters, pin your package versions rather than
letting them float. A run you cannot
reproduce is, scientifically, a run that did not happen.
7.8 Chapter Summary
In this chapter we stepped away from new simulations and toward the operational knowledge that
makes the ones you have reliable. We saw that the core count a machine reports is a starting
hypothesis rather than an answer, inflated by hyperthreads and efficiency cores, and that the
performance-core count, confirmed by a speed-up experiment, is the number to trust. We met
memory as the bottleneck that is not speed, the one where adding cores makes things slower, and
the copying behavior of PSOCK and spawned workers that causes it, along with fewer workers and
copy-on-write forking as the fixes. We covered recovery when a parallel run goes wrong:
debugging in serial with n_cores = 1, knowing how each framework reports a worker’s error, and
the orphaned processes that outlive an interrupted run unless you make shutdown automatic. We
replaced a single stopwatch reading with proper benchmarking through bench, microbenchmark,
and timeit, and with reading the speed-up curve to find where more cores stop helping. We
argued for names that describe, comments on the parallel seams, pure side-effect-free workers,
and a project laid out so it can be understood and rerun. We saw why an even split of tasks is
not always an even split of time, and how chunk size trades overhead against balance. And we
ended on reproducibility, the payoff for all the care with seeds, including the wrinkle that a
parallel run reproduces exactly only when the core count is recorded alongside the seed.
These are the habits that separate code that worked once from code you can stand behind. With them in place, the natural next question is what to do when even a well-tuned laptop is no longer enough. That is where Chapter 8 turns next, to the cloud, the cluster, and the GPU.