Why benchmarking discipline matters
Benchmarking is not “timing some code.” It is a controlled measurement process designed to answer a specific question with minimal noise and maximal repeatability. Discipline is the set of habits and constraints that keep you from measuring the wrong thing, or measuring the right thing in a way that cannot be trusted. Repeatable measurement means that if you (or a teammate, or CI) rerun the benchmark under the same conditions, you get results that are consistent enough to support decisions.
Across Python, Ruby, Java, and C, the details differ (JIT warmup in Java, interpreter overhead in Python/Ruby, compiler flags and CPU affinity in C), but the core discipline is the same: define the question, control the environment, isolate the unit under test, run enough iterations, summarize with robust statistics, and record everything needed to reproduce the run.
Define the benchmark question and the unit under test
Every benchmark should start with a precise question. “Is A faster than B?” is too vague. Better: “For inputs of size N in distribution D, is implementation A at least 15% faster than B in median latency, without increasing p95 latency by more than 5%?” Even if you do not need percentiles, specifying the metric (throughput, latency, allocations, CPU time) and the input model prevents accidental benchmarking of irrelevant work.
Checklist: what exactly are you measuring?
- Metric: wall-clock time, CPU time, allocations, GC time, peak RSS, throughput (ops/s), latency distribution.
- Scope: microbenchmark (small function), mesobenchmark (component), end-to-end (system).
- Workload: input sizes, distributions, data locality, cache state assumptions.
- Correctness: outputs checked, invariants asserted, dead-code elimination prevented.
A common failure mode is benchmarking setup or I/O instead of the algorithm. Another is measuring a “fast path” that never occurs in production. Discipline means you explicitly model the workload and validate that the benchmark exercises the intended code paths.
Control the environment: reduce noise before you run
Repeatability starts with controlling variability. Some noise is unavoidable, but you can reduce it dramatically by standardizing the runtime environment.
Continue in our app.
You can listen to the audiobook with the screen off, receive a free certificate for this course, and also have access to 5,000 other free online courses.
Or continue reading below...Download the app
Hardware and OS controls
- Pin CPU frequency: disable turbo/boost if possible, or at least record whether it was enabled. Frequency scaling can dwarf small improvements.
- CPU affinity: bind the process to a core (or a fixed set of cores) to reduce scheduler variance.
- Isolation: close background apps, avoid running on a busy laptop, prefer a dedicated machine or a quiet CI runner.
- Thermals: ensure the machine is not thermal-throttling; repeated runs should not heat the CPU into a different regime.
- Power mode: use “performance” mode; record the setting.
Runtime and build controls
- Version pinning: record exact versions of Python/Ruby/Java, compiler versions, and OS kernel.
- Dependencies: lock dependency versions; avoid “latest” in benchmarks.
- Build flags: for C, record optimization level, LTO, target architecture flags; for Java, record JVM flags; for Python/Ruby, record interpreter build if relevant.
- GC settings: for Java, record GC algorithm and heap sizing; for Ruby/Python, record relevant GC toggles if you change them.
When you cannot control something (shared CI, cloud VM), discipline means you record it and increase repetition, then use statistics that tolerate noise.
Warmup, steady state, and measurement phases
Benchmarks often have phases: initialization, warmup, steady state, and teardown. Repeatable measurement requires you to decide which phase you care about and measure that phase consistently.
Java: JIT compilation and warmup
On the JVM, code may start interpreted and later become JIT-compiled. Measuring the first few iterations often measures compilation and profiling rather than steady-state performance. Discipline means you either (a) explicitly benchmark cold-start behavior, or (b) run a warmup phase and only measure after the system stabilizes.
Python/Ruby: caches and allocator behavior
Python and Ruby do not JIT compile in the same way (in typical deployments), but they still have warmup effects: module import caches, method caches, branch prediction, filesystem cache, and allocator/GC state. A disciplined benchmark either resets state between trials (when measuring cold behavior) or runs enough iterations to reach a representative steady state.
C: cache effects and branch prediction
C code is compiled ahead of time, but microbenchmarks are still sensitive to instruction cache, data cache, and branch predictor training. If you run a loop that fits entirely in cache, you may measure an unrealistically favorable scenario. Discipline means you choose input sizes and access patterns that match your real workload, and you randomize or structure inputs to avoid accidental best-case caching unless that is your target.
Step-by-step: a repeatable benchmarking protocol
The following protocol is language-agnostic and can be applied to Python, Ruby, Java, and C. The goal is to produce results you can trust and rerun later.
Step 1: Write a benchmark spec
Create a short spec (even a comment block) that includes:
- Question being answered and success criteria.
- Metric(s) and how they are computed.
- Input generation method and sizes.
- Environment requirements (CPU pinning, versions, flags).
- Number of warmup iterations, measurement iterations, and trials.
Step 2: Build a harness that separates setup from measurement
A harness should ensure that the timed region contains only the work you intend to measure. Setup (data generation, file creation, object construction) should be outside the timed region unless setup is part of the question.
Also ensure you consume results so the compiler/runtime cannot optimize away the work. In C, the compiler can eliminate unused computations; in Java, the JIT can do similar; in Python/Ruby, dead-code elimination is less aggressive but you can still accidentally benchmark nothing (for example, by timing an empty loop due to a bug).
Step 3: Choose a timer and measure correctly
Use a monotonic, high-resolution clock. Prefer CPU time when you want to exclude waiting/scheduling, and wall-clock time when you want end-to-end latency. Be consistent and record which you used.
- Python:
time.perf_counter()for wall-clock;time.process_time()for CPU time. - Ruby:
Process.clock_gettime(Process::CLOCK_MONOTONIC)for wall-clock;Process.clock_gettime(Process::CLOCK_PROCESS_CPUTIME_ID)for CPU time (platform-dependent). - Java:
System.nanoTime()for monotonic wall-clock; use JMH for best practice. - C:
clock_gettime(CLOCK_MONOTONIC)orCLOCK_MONOTONIC_RAWon Linux; considerrdtsconly with expertise.
Step 4: Run multiple trials and summarize robustly
One run is not a benchmark; it is a sample. Run multiple trials (separate process invocations when possible) and report median and dispersion (e.g., interquartile range). Mean is sensitive to outliers (GC pauses, OS interrupts). Percentiles are useful for latency-sensitive code.
As a practical baseline: 10–30 trials for noisy environments, fewer for stable dedicated machines. Within each trial, run enough iterations so that the timed duration is comfortably above timer resolution (e.g., tens of milliseconds or more) unless you use a specialized harness.
Step 5: Validate correctness and invariants
Benchmarks that do not validate outputs can “optimize” into incorrectness. Add assertions or checksums. If correctness checks are expensive, compute a checksum outside the timed region but based on results produced inside it.
Step 6: Record metadata for reproducibility
Store benchmark results with metadata: git commit hash, compiler/JVM/interpreter version, flags, OS version, CPU model, core count, governor/power mode, and date/time. Without this, you cannot explain regressions or reproduce improvements.
Practical examples: disciplined harness patterns
Python: separating setup, warmup, and measurement
import os, platform, statistics, time, hashlib, sys, subprocess, json, random
def workload(data):
# Example: compute a checksum-like reduction
h = 0
for x in data:
h = (h * 1315423911 + x) & 0xFFFFFFFF
return h
def run_trial(n, warmup_iters=5, measure_iters=20):
rng = random.Random(12345)
data = [rng.randrange(0, 1_000_000) for _ in range(n)] # setup outside timing
# warmup
for _ in range(warmup_iters):
workload(data)
# measurement
t0 = time.perf_counter()
acc = 0
for _ in range(measure_iters):
acc ^= workload(data)
t1 = time.perf_counter()
# consume result to avoid accidental elimination
if acc == 0xDEADBEEF:
print("unlikely")
return (t1 - t0) / measure_iters
def benchmark(n, trials=20):
samples = [run_trial(n) for _ in range(trials)]
return {
"n": n,
"trials": trials,
"median_s": statistics.median(samples),
"p90_s": statistics.quantiles(samples, n=10)[8],
"min_s": min(samples),
"max_s": max(samples),
}
if __name__ == "__main__":
result = benchmark(n=200_000)
meta = {
"python": sys.version,
"platform": platform.platform(),
}
print(json.dumps({"meta": meta, "result": result}, indent=2))Discipline points illustrated: fixed RNG seed for repeatable inputs, setup excluded from timing, warmup included, multiple trials, robust summary, metadata capture. If you want to measure cold-start, you would move data generation and imports into the measured region and run separate process invocations.
Ruby: monotonic timing and repeated trials
require "json"
def workload(data)
h = 0
data.each do |x|
h = (h * 1315423911 + x) & 0xFFFFFFFF
end
h
end
def run_trial(n, warmup_iters: 5, measure_iters: 20)
rng = Random.new(12345)
data = Array.new(n) { rng.rand(1_000_000) } # setup
warmup_iters.times { workload(data) }
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
acc = 0
measure_iters.times { acc ^= workload(data) }
t1 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
puts "unlikely" if acc == 0xDEADBEEF
(t1 - t0) / measure_iters
end
def benchmark(n, trials: 20)
samples = Array.new(trials) { run_trial(n) }.sort
median = samples[trials / 2]
{
n: n,
trials: trials,
median_s: median,
min_s: samples.first,
max_s: samples.last
}
end
result = benchmark(200_000)
meta = { ruby: RUBY_DESCRIPTION, platform: RUBY_PLATFORM }
puts JSON.pretty_generate({ meta: meta, result: result })Ruby’s GC and allocator can introduce variance. If you experiment with GC settings, treat them as part of the benchmark configuration and record them. Avoid calling GC.start inside the timed region unless the question is explicitly about GC behavior.
Java: prefer JMH for microbenchmarks
For Java microbenchmarks, disciplined practice usually means using JMH (Java Microbenchmark Harness). It handles warmup, measurement iterations, forks (separate JVM processes), and common pitfalls like dead-code elimination.
import org.openjdk.jmh.annotations.*;
import java.util.concurrent.TimeUnit;
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Warmup(iterations = 5, time = 500, timeUnit = TimeUnit.MILLISECONDS)
@Measurement(iterations = 10, time = 500, timeUnit = TimeUnit.MILLISECONDS)
@Fork(value = 3)
@State(Scope.Thread)
public class HashBench {
int[] data;
@Setup(Level.Trial)
public void setup() {
data = new int[200_000];
long x = 12345;
for (int i = 0; i < data.length; i++) {
x = (x * 1103515245 + 12345) & 0x7fffffff;
data[i] = (int)(x % 1_000_000);
}
}
@Benchmark
public int workload() {
int h = 0;
for (int v : data) {
h = h * 1315423911 + v;
}
return h;
}
}Discipline points: warmup and measurement are explicit, multiple forks reduce cross-trial contamination, and returning a value prevents dead-code elimination. If you need to measure allocation rate, JMH can also report GC and allocation metrics with profilers; treat profiler configuration as part of the recorded metadata.
C: avoid compiler elimination and measure with monotonic clocks
#define _POSIX_C_SOURCE 200809L
#include <stdint.h>
#include <stdio.h>
#include <time.h>
#include <stdlib.h>
static inline uint64_t ns_now(void) {
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return (uint64_t)ts.tv_sec * 1000000000ull + (uint64_t)ts.tv_nsec;
}
uint32_t workload(const uint32_t *data, size_t n) {
uint32_t h = 0;
for (size_t i = 0; i < n; i++) {
h = h * 1315423911u + data[i];
}
return h;
}
int main(void) {
const size_t n = 200000;
const int warmup = 5;
const int iters = 30;
uint32_t *data = (uint32_t*)malloc(n * sizeof(uint32_t));
uint32_t x = 12345;
for (size_t i = 0; i < n; i++) {
x = x * 1103515245u + 12345u;
data[i] = x % 1000000u;
}
for (int i = 0; i < warmup; i++) {
(void)workload(data, n);
}
uint64_t t0 = ns_now();
volatile uint32_t sink = 0;
for (int i = 0; i < iters; i++) {
sink ^= workload(data, n);
}
uint64_t t1 = ns_now();
double avg_ns = (double)(t1 - t0) / (double)iters;
printf("avg_ns=%.1f sink=%u\n", avg_ns, sink);
free(data);
return 0;
}Discipline points: monotonic clock, warmup, enough iterations to reduce timer noise, and a volatile sink to prevent the compiler from removing the computation. For C, also record compiler flags (e.g., -O3 -march=native or a fixed -march) and ensure you are not accidentally benchmarking debug builds.
Common benchmarking traps and how to avoid them
Trap: measuring I/O or logging
Disk, network, and console output dominate timings and are highly variable. If the goal is algorithmic performance, remove I/O from the timed region. If the goal is end-to-end performance including I/O, then keep it, but treat it as a different benchmark class and run more trials.
Trap: too-small timed regions
If each iteration takes microseconds or less, timer resolution and overhead become significant. Solutions include batching (do more work per timed block), increasing iteration counts, or using specialized harnesses (JMH for Java). In Python/Ruby, loop overhead can dominate; batch operations or time a function that performs many operations per call.
Trap: cross-test contamination
Running benchmark A then B in the same process can bias results due to warmed caches, allocator state, or JIT profile state. Use randomized order, separate process invocations, or multiple forks (Java). If you must run in-process, alternate A/B and report paired differences.
Trap: comparing different work
Two implementations may not be equivalent (different output, different error handling, different edge-case behavior). Add correctness checks and ensure both paths do the same work. If one version precomputes something, decide whether precomputation is allowed and include it consistently.
Trap: ignoring variance and over-interpreting small deltas
If run-to-run noise is ±3%, a measured 1% improvement is not actionable. Discipline means you quantify variance and set a minimum effect size threshold. If you need to detect small regressions, reduce noise (dedicated machine, CPU pinning) and increase trials.
Statistical habits for repeatable decisions
You do not need advanced statistics to be disciplined, but you do need consistent summaries and decision rules.
- Use median as the default central tendency for latency-like measurements.
- Report spread: min/max are not enough; include IQR or p90/p95.
- Compare distributions, not single numbers: keep the raw samples and plot them when investigating.
- Define a threshold: e.g., “accept change if median improves by ≥10% and p95 does not regress by more than 2%.”
When benchmarking across languages, also be explicit about what “faster” means: wall-clock latency, CPU usage, or throughput under concurrency. Different runtimes may trade CPU for latency or vice versa.
Making benchmarks reproducible in teams and CI
Versioned benchmark code and pinned inputs
Store benchmarks alongside the code they measure, versioned in the same repository. Pin input datasets or generate them deterministically with fixed seeds. If you use real datasets, store a checksum and a stable download location; avoid “whatever is on disk.”
Standardized command lines
Create a single entry point per benchmark suite with explicit flags for warmup, iterations, and output format (JSON is a good default). A disciplined team can rerun the same command and get comparable results.
Machine-readable outputs and regression gates
Emit results as machine-readable artifacts so you can compare runs over time. In CI, avoid hard pass/fail gates on noisy benchmarks unless you have stable runners. Prefer trend monitoring, or gates with generous thresholds and multiple reruns on failure.
Document the environment contract
Write down the expected environment: CPU governor, affinity settings, JVM flags, compiler flags, and any container settings. If you run in containers, record CPU limits and whether the container is pinned to specific cores.
Benchmarking concurrency and throughput without self-deception
Throughput benchmarks (ops/s) and concurrent latency benchmarks introduce additional pitfalls: coordination overhead, lock contention, and measurement bias from the harness itself.
- Control client load: use a fixed number of worker threads/processes and a fixed request rate or closed-loop model; record which model you used.
- Avoid synchronized timers: starting all threads at once can create artificial contention; use a barrier but measure after a ramp-up.
- Measure per-operation latency: record distributions, not just aggregate throughput.
- Account for runtime scheduling: Python’s GIL and Ruby’s threading model can change what “concurrency” means; be explicit whether you use threads or processes.
Discipline here means the harness is part of the system under test. Keep it minimal, validate it, and measure its overhead separately when possible.