Simulate the spot gang
Simulate a modelNeeds Python with NumPy
Problem
Spot (preemptible) machines are cheap because the provider can take them back at any moment. For an independent task, an interruption costs a retry. For a gang, a job that needs all of its nodes at once (a training run, a tightly coupled MPI job, a stage with a barrier), one interruption anywhere stops everything. The chance that n nodes all survive is (1 − p)^n, which collapses quickly as n grows. Checkpointing limits the damage: an interruption then loses only the work since the last checkpoint, at the cost of writing checkpoints.
Simulate 100 nodes, each interrupted with probability 5% per hour, and a job that needs all of them. Extend it with a checkpoint interval and count the expected lost work per interruption. Then change the model so that only the workers are spot and the coordinator is not, and compare the expected completion time.
The script simulates the one-hour survival, and uses the closed form for the completion time of a 10-hour job (a segment restarts until it runs clean, which takes (e^(λτ) − 1)(1/λ + R) hours on average).
The question. What is the chance that a 100-node spot gang survives an hour, and how does the checkpoint interval set how long a 10-hour job really takes?
Predict
Expected result: survival per hour near 0.006 (0.95¹⁰⁰).
Run it
1Install uv once. It fetches Python and the lab's packages for you.
Terminal curl -LsSf https://astral.sh/uv/install.sh | sh2Get the script. It is the whole lab: read it, then download it.
View the codeHide the code
simulate-the-spot-gang.py # /// script # requires-python = ">=3.10" # dependencies = ["numpy"] # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """Simulate the spot gang. A job that needs all of its 100 spot nodes at once survives an hour only if none of them is interrupted (simulated). Then checkpoints: the expected work lost per interruption, and the expected completion time of a 10-hour job, with all nodes on spot and with only the workers on spot (closed form). """ import argparse, json import numpy as np p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--nodes", type=int, default=100, help="Nodes the job needs at once.") p.add_argument("--p", type=float, default=0.05, help="Probability that one node is interrupted in an hour.") p.add_argument("--job-hours", type=float, default=10, help="Work in the job, in hours on all nodes.") p.add_argument("--checkpoints", type=lambda s: [float(v) for v in s.split(",")], default=[0.02, 0.05, 0.1, 0.2, 0.5, 1], help="Checkpoint intervals in hours, comma-separated.") p.add_argument("--restart-hours", type=float, default=0.1, help="Time to replace a node and restore from the checkpoint.") p.add_argument("--seed", type=int, default=0, help="Random seed.") p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() rng = np.random.default_rng(args.seed) survive = np.mean(np.all(rng.random((100_000, args.nodes)) > args.p, axis=1)) print(f"survive one hour with {args.nodes} spot nodes: {survive:.4f} (0.95^{args.nodes} = {(1 - args.p) ** args.nodes:.4f})") def completion(interval, spot_nodes): """Expected hours to finish job_hours of work, checkpointing every `interval` hours. Interruptions arrive at rate lam across the gang; a segment of length tau restarts until it runs clean, which takes (e^(lam*tau) - 1) * (1/lam + restart) hours on average. Lost work per interruption is what ran before it.""" lam = -np.log(1 - args.p) * spot_nodes # interruptions per hour across the gang segments = args.job_hours / interval per_segment = (np.exp(lam * interval) - 1) * (1 / lam + args.restart_hours) lost = 1 / lam - interval / (np.exp(lam * interval) - 1) # mean work lost per interruption return float(segments * per_segment), float(lost) rows = [] for interval in args.checkpoints: for label, spot in (("all nodes on spot", args.nodes), ("workers on spot, coordinator on demand", args.nodes - 1)): t, l = completion(interval, spot) rows.append({"checkpoint_h": interval, "setup": label, "completion_h": t, "lost_h": l}) print(f"checkpoint every {interval:>4g} h: completion {rows[-2]['completion_h']:6.1f} h (all spot) " f"{rows[-1]['completion_h']:6.1f} h (coordinator on demand); {args.job_hours:g} h of work") interrupt_rate = -np.log(1 - args.p) * args.nodes print(f"interruptions per hour across the gang: {interrupt_rate:.2f}; lost work per interruption: " + ", ".join(f"{r['lost_h'] * 60:.1f} min at {r['checkpoint_h'] * 60:g} min" for r in rows[::2])) if args.json: with open(args.json, "w") as f: json.dump({"tables": {"checkpoints": rows, "summary": [{"survive": float(survive), "formula": (1 - args.p) ** args.nodes, "interrupts_per_h": interrupt_rate}]}}, f)3Run it in the folder you saved it to, with the defaults first. Then change a knob from the table below and run it again.
Terminal uv run simulate-the-spot-gang.py uv run simulate-the-spot-gang.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --nodes100 | Nodes the job needs at once. |
| --p0.05 | Probability that one node is interrupted in an hour. |
| --job-hours10 | Work in the job, in hours on all nodes. |
| --checkpoints0.02, 0.05, 0.1, 0.2, 0.5, 1 | Checkpoint intervals in hours, comma-separated. |
| --restart-hours0.1 | Time to replace a node and restore from the checkpoint. |
| --seed0 | Random seed. |
Our run
Data table
| Checkpoint interval (hours) | series | Hours to finish |
|---|---|---|
| 0.02 | all nodes on spot | 15.9 |
| 0.02 | workers on spot, coordinator on demand | 15.9 |
| 0.05 | all nodes on spot | 17.2 |
| 0.05 | workers on spot, coordinator on demand | 17.2 |
| 0.1 | all nodes on spot | 19.8 |
| 0.1 | workers on spot, coordinator on demand | 19.6 |
| 0.2 | all nodes on spot | 26.4 |
| 0.2 | workers on spot, coordinator on demand | 26.1 |
| 0.5 | all nodes on spot | 70.8 |
| 0.5 | workers on spot, coordinator on demand | 69.3 |
| 1 | all nodes on spot | 495 |
| 1 | workers on spot, coordinator on demand | 473 |
survive one hour with 100 spot nodes: 0.0057 (0.95^100 = 0.0059)
checkpoint every 0.02 h: completion 15.9 h (all spot) 15.9 h (coordinator on demand); 10 h of work
checkpoint every 0.05 h: completion 17.2 h (all spot) 17.2 h (coordinator on demand); 10 h of work
checkpoint every 0.1 h: completion 19.8 h (all spot) 19.6 h (coordinator on demand); 10 h of work
checkpoint every 0.2 h: completion 26.4 h (all spot) 26.1 h (coordinator on demand); 10 h of work
checkpoint every 0.5 h: completion 70.8 h (all spot) 69.3 h (coordinator on demand); 10 h of work
checkpoint every 1 h: completion 495.2 h (all spot) 473.5 h (coordinator on demand); 10 h of work
interruptions per hour across the gang: 5.13; lost work per interruption: 0.6 min at 1.2 min, 1.4 min at 3 min, 2.7 min at 6 min, 5.0 min at 12 min, 9.2 min at 30 min, 11.3 min at 60 minWhat we found
The gang survived an hour 0.006 of the time, against 0.95¹⁰⁰ = 0.006, as expected. Across 100 nodes that is 5.13 interruptions per hour, one every 12 minutes or so, so the checkpoint interval decides everything: with a checkpoint every 1.2 minutes the 10-hour job took 15.9 hours, with one every 30 minutes 70.8 hours, and with hourly checkpoints 495 hours. Taking the coordinator off spot barely helped (473 hours at hourly checkpoints): it is one node of 100, and the gang fails just as often through its workers.
Read it
A gang turns a per-node risk into a per-job certainty: (1 − p)^n shrinks exponentially in n. The cost of a segment grows like e^(λτ), so checkpoints must come much more often than interruptions do; past that point the job spends its time redoing work and the spot discount is gone.
Spot pays for work that can lose a node without losing the job: independent tasks, materialized stage outputs, frequent cheap checkpoints. A gang of hundreds of nodes on spot is a bet against arithmetic.