Python: tprof 1.3.0: now with less overhead

Artist’s depiction of developers targeting what to profile.

Back in January, I introduced tprof, a targeting profiler for Python 3.12+ that measures the time spent in specific functions, rather than your whole program. As a reminder, here’s the basic usage, specifying a target function with -t and a script to run:

$ tprof -t lib:maths ./example.py
...
🎯 tprof results:
 function    calls total  median ± σ     min … max
 lib:maths()     2 610ms 305ms ± 2ms 304ms … 307ms

Today, it’s my pleasure to announce tprof 1.3.0, a big release with the following changes.

Way less overhead

tprof previously stored each recorded call as a Python int inside a dict of lists, then used the statistics module to crunch the numbers at report time. These data structures were easy to write in an initial version and get the project going, but it added meaningful overhead to every monitored call.

The new release replaces all of that with per-thread C structures: a stack of entry times and a flat array of durations per target, storing raw nanosecond int64_t values instead of boxed Python ints. On Python 3.13+, it also reads timestamps with the new PyTime_PerfCounterRaw() instead of calling time.perf_counter_ns().

Most importantly, sys.monitoring callbacks are now disabled entirely for code that isn’t a target, so non-target functions run at full speed.

The results, benchmarking a trivial function on Python 3.13 (overhead versus an unprofiled call in parentheses):

 BeforeAfter
Target call469ns (+439)170ns (+141)
Non-target call440ns (+410)28ns (+0)
Memory per 1M calls34.8 MiB8.0 MiB
Report per 1M calls~430ms~3ms

In short:

Comparing against a saved baseline

tprof’s existing comparison mode (-x/--compare) is great for comparing two functions side-by-side in one run, such as “before” and “after” versions while you’re mid-refactor. But sometimes the “before” version only exists on another Git branch. For that, this release adds --json and --baseline.

Run with --json <path> to write the statistics to a file (or - for stdout) as well as printing the usual report:

$ git checkout main
$ tprof -t lib:maths --json before.json ./example.py

Then, after checking out your change, pass that file to --baseline to get a “delta” column comparing each function’s median against the saved run:

$ git checkout my-optimization
$ tprof -t lib:maths --baseline before.json ./example.py
...
🎯 tprof results:
 function    calls total  median ± σ     min … max     delta
 lib:maths()     2 592ms 296ms ± 2ms  294ms … 297ms -3.11%

Median, not mean

The headline statistic in tprof’s report is now the median rather than the mean, since it’s more robust to the outliers that profiling data tends to contain, like garbage collection pauses and cold caches. Comparison mode deltas, and the new baseline deltas above, are computed from medians too. The median is computed exactly, in C, via quickselect over a scratch buffer at report time, so this change doesn’t add any recording overhead—only a small amount of extra work when the report is generated.

Programmatic access to results

The tprof() context manager / decorator now yields a list of FunctionStats objects, populated once the profiled block ends, so you can use the results in code rather than only reading the printed report:

from lib import maths

from tprof import tprof

with tprof(maths) as results:
    maths()

(function_stats,) = results  # unpack the single result for maths()
print(f"{function_stats.name} took {function_stats.median_ns}ns")

Each FunctionStats has name, calls, total_ns, min_ns, max_ns, median_ns, and stdev_ns, matching the fields in the --json output.

Fin

If you’re already using tprof, this release should make it easier to get correct results. And if you haven’t tried it yet, install it from PyPI and give it a go.

More tea, prof?

—Adam


Check out my new book Boost Your GitHub DX.


Subscribe via RSS, Twitter, Mastodon, or email:

One summary email a week, no spam, I pinky promise.

Related posts:

Tags: