Profiling overhead#
Install for this page:
pip install "scope-profiler[pproc]"; the benchmark uses maxplotlib to save its result as a figure.
scope-profiler is designed for production HPC workloads where instrumentation must not distort the measurements. This page documents the per-call overhead of each profiling mode.
Benchmark#
The benchmark script (examples/benchmark_overhead.py) times a small
workload function through each profiling mode and subtracts the bare
function-call baseline to isolate the overhead.
python examples/benchmark_overhead.py # save figure
python examples/benchmark_overhead.py --show # display interactively

Results summary#
Region type |
Overhead / call |
|---|---|
Disabled |
~0.1 µs |
TimeOnly |
~0.33 µs |
LineProfiler |
~50 µs |
(Numbers measured on an Apple M-series CPU; absolute values will vary, but the relative ordering is stable.)
What this means for HPC#
The TimeOnly mode — the default and most commonly used — adds roughly 0.33 µs per instrumented call. In practice:
A 64×64 matrix multiply takes ~36 µs, so the overhead is < 2 %.
A 256×256 matrix multiply takes ~780 µs, giving < 0.1 % overhead.
Typical simulation time steps run for milliseconds or longer, making the overhead unmeasurable.
The profiler can also be fully deactivated at startup
(deactivate_profiling=True) without removing any instrumentation from
the source code. In this mode the overhead drops to ~0.1 µs — barely
above the cost of a bare function call.
LineProfiler#
The line_profiler mode is intentionally heavier (~50 µs per call)
because it instruments every source line in the profiled function. It is
meant for targeted debugging of individual functions, not for
always-on use in hot loops.
Regression guards#
The numbers above are benchmarks. What keeps them from drifting is a
separate set of budgets in the test suite, run with the overhead
marker:
pytest -m overhead # per-call, write, and read budgets
pytest -s -m overhead # ...printing every measurement
pytest src/scope_profiler/tests/test_storage_size.py -s # file size
module |
covers |
|---|---|
|
per-call cost of each region type, region lookup, source capture, buffer growth, nesting depth |
|
|
|
bytes per event, the fixed floor, scaling in events/ranks/regions, compression ratios |
The budgets sit roughly an order of magnitude above what an idle laptop measures, so a loaded CI machine still passes while a structural regression — an allocation or a lock on the hot path, a per-event Python loop in the writer, a quadratic index rebuild — does not. Each measurement takes the minimum over several repeats, which is the robust estimator for a cost: noise can only ever add time.
The scaling assertions matter more than the absolute ones, and are the reason the suite catches things a benchmark would not. An absolute budget can only catch a change large enough to blow it; a ratio between the same measurement at two sizes catches a change in the shape of the cost. For example, a summary-only read of a ten-times-larger profile must cost about the same (measured: 1.0x), which is what proves it never touches the event columns.
Writing and reading a profile#
The per-call numbers above are what instrumentation adds to the program.
What finalize() then spends turning the recorded buffers into a file
is separate, and matters for its own reasons: it happens at the end of a
run that may have been queued for hours, and under MPI it is collective,
so a write that scales badly stalls every rank.
Measured on an idle laptop, jittered synthetic data:
cost |
|
|---|---|
write, 100,000 events |
~93 ns/event |
write, 10,000 events |
~510 ns/event |
write with |
~2x the plain write |
publication packing, small profile |
~2.5 ms (fixed) |
read, uncompressed |
~18 ns/event |
read, gzip-compressed |
~66 ns/event |
summary-only read |
independent of event count |
timestamp encode / decode |
~0.4 / ~2.1 ns/event |
examples/benchmark_io.py measures all of these, and --scaling sweeps
the rank count so the per-rank cost can be checked for flatness:
python examples/benchmark_io.py --scaling 8,32,128
python examples/benchmark_io.py --json # to diff between revisions
A small profile is dominated by the fixed ~2.5 ms publication pass, which is why the per-event figure is so much higher at 10,000 events than at 100,000. That pass is what removes the per-dataset chunk overhead; see Output file size.
Reading through gzip costs about 3.5x an uncompressed read. That is the other side of the ~5x space saving, and it is why compression is opt-in rather than the default.
Where the time goes#
A recorded call is two perf_counter_ns() reads (~33 ns each from
Python) plus a slot reservation; the rest is the cost of the with
statement or the decorator wrapper itself. Those two together are about
half the total and are out of the library’s hands: an empty with on a
Python object already costs ~109 ns, and on a C-implemented one ~76 ns.
Nothing touches the filesystem: the timestamps accumulate in a numpy
buffer that doubles when it fills, and the whole buffer is written once,
at finalize(). Writing is therefore not part of the per-call cost at
all — deactivate_file_output changes what happens at the end of the
run, not what happens in the loop.