logging hot-path harness
logging hot-path harness
A digest-pinned, stdlib-only benchmark for what Python’s logging costs when it
sits on a hot path. Three experiments, no third-party deps, run inside a pinned
python:3.12-slim container.
Three experiments:
- 1. disabled-debug-cost — a
DEBUGline that never emits (logger atINFO), where the argument is expensive to build (a structured order payload rendered viajson.dumps). Three ways to write the same discarded line:eagerf-string (arg built every call, then thrown away),guarded(if logger.isEnabledFor(...)), andlazy(%-style deferred formatting). Measures ns/call and the ratios. - 2. sync-vs-async — real
INFOlines to a durable file sink (flush +os.fsyncper record).sync= theFileHandlerruns on the calling thread;async=QueueHandleron the caller +QueueListenerdraining to the same handler on a background thread. Per-call latency is timed on the calling thread; reports p50/p99/p999/max in microseconds and wall time. - 3. sampling — log an
INFOline for every event vs ~1 in 100 (a counter). Reports lines written, bytes written, and workload throughput (ops/sec).
These are laptop measurements demonstrating the mechanism, not capacity planning. Absolute numbers depend on the machine, the storage, and the Docker VM; the ratios are the point.
Why experiment 2 uses an fsync sink
A plain buffered FileHandler is absorbed by the OS page cache and does not
actually block the caller, so on fast storage moving it to a background thread
made tail latency worse, not better (queue hop + a contending thread). That
first run is preserved under results/attempts/ with a note. To isolate the
variable the experiment is about — the same I/O cost paid on the caller thread
vs a background thread — the main run makes the sink genuinely block by flushing
and fsyncing every record (a durable / audit log). Now sync pays the fsync on
the hot path and async pays it off the hot path.
Run it
Docker with Compose v2. No Python needed on the host (it runs in the container).
cd benchmarks/logging-hot-path
docker compose run --rm bench | tee results/summary.txt
The container mounts the repo dir and writes CSVs + summary.txt to results/.
It runs with network_mode: none (no server, no ports, loopback-only). Iteration
counts and seed are env vars in docker-compose.yml; override per-run, e.g.:
docker compose run --rm -e EXP2_CALLS=100000 -e EXP1_ITERS=2000000 bench
There are no containers left running (run --rm removes it on exit).
Results (captured run, python 3.12.13, arm64)
Image: python@sha256:57cd7c3a7a273101a6485ba99423ee568157882804b1124b4dd04266317710de
Experiment 1 — disabled DEBUG line, 1,000,000 iters/variant (median of 5):
| variant | ns/call | ops/sec |
|---|---|---|
| eager | 5260.39 | 190,099 |
| guarded | 58.55 | 17,078,033 |
| lazy | 1419.64 | 704,404 |
eager is 3.7x slower than lazy and 89.8x slower than guarded, for a
line that produces zero output. The eager cost is almost entirely json.dumps +
f-string building an argument that logging immediately discards.
Experiment 2 — per-call latency on the caller, durable fsync sink, 50,000 calls/mode:
| mode | p50 (us) | p99 (us) | p999 (us) | max (us) | wall (s) |
|---|---|---|---|---|---|
| sync | 276.375 | 645.084 | 5752.167 | 31203.04 | 14.9447 |
| async | 4.167 | 10.417 | 24.625 | 5976.00 | 0.2347 |
sync blocks the caller on every fsync (p99 645us, p999 5.75ms). async turns
the hot-path call into a queue enqueue — p99 10.4us, p999 24.6us, ~62x
lower at p99. The fsync cost doesn’t vanish; it moves to the background thread
(the caller loop finishes in 0.23s while the listener keeps draining).
Experiment 3 — log-everything vs sample 1-in-100, 1,000,000 events/mode:
| mode | lines | bytes | ops/sec |
|---|---|---|---|
| full | 1,000,000 | 75,667,678 | 217,325 |
| sampled | 10,000 | 756,666 | 10,561,711 |
Sampling wrote 100x fewer lines and bytes, and ran ~49x the throughput, because most events skip the logging call entirely.
Files
benchmark.py— the harness (stdlib only; env-configurable).docker-compose.yml— digest-pinnedpython:3.12-slim,network_mode: none.requirements.txt— none; comment-only, kept for convention.results/summary.txt— captured console output of the run above.results/exp1_disabled_debug.csv— variant, iterations, total_ns, ns_per_call, ops_per_sec.results/exp2_sync_vs_async.csv— mode, calls, p50/p99/p999/max_us, wall_s.results/exp2_latency_samples.csv— mode, latency_us; downsampled to ~2000/mode (every Nth call) so a distribution can be charted without a giant file.results/exp3_sampling.csv— mode, events, lines_written, bytes_written, ops_per_sec.results/run_metadata.csv— python version, image digest, seed, iteration counts, headline numbers.results/attempts/— the buffered (no-fsync) exp2 run that didn’t show the async win, plus a note explaining why.
Reproducibility notes
Warm-up runs precede every timed section; timing uses time.perf_counter_ns();
exp1 keeps the median of 5 repeats; any randomness is seeded (SEED=1234).
Experiments 1 and 3 reproduce cleanly (ratios stable across runs; exp1 absolute
ns/call drifts a few percent with machine noise). Experiment 2’s absolute
microseconds depend heavily on how the Docker VM backs fsync, so treat its
numbers as a shape (async p99/p999 an order of magnitude below sync), not a
fixed figure.