pulse2percept benchmarks
A small suite that measures the library’s main job: predicting a percept from a
stimulus, an implant and a phosphene model. It tracks execution time and
peak memory for the reference pipelines in scenarios.py, broken down by
pipeline stage.
These are benchmarks, not tests. They assert nothing and cannot fail on a regression; they produce numbers you compare against numbers from another run.
Running
Install the extra once:
pip install -e ".[benchmark]"
Then, from the repository root:
pytest benchmarks/ --benchmark-only
Without --benchmark-only every benchmark is skipped, so a bare pytest at
the repository root stays a test run and never pays for them. They also live
outside the pulse2percept package, so pytest --pyargs pulse2percept –
what CI and make tests run – does not collect them at all. make bench
wraps the whole thing.
Useful invocations:
# one stage, or one scenario
pytest benchmarks/ --benchmark-only -k predict_percept
pytest benchmarks/ --benchmark-only -k argus2_axonmap_logobvl
# include the scenarios that are too slow for the default run
pytest benchmarks/ --benchmark-only --runslow
# see how the models scale with threads (see the caveat below)
pytest benchmarks/ --benchmark-only --n-threads=8
# save a run, then compare a later one against it
pytest benchmarks/ --benchmark-only --benchmark-save=baseline
pytest benchmarks/ --benchmark-only --benchmark-compare=0001
Saved runs land in .benchmarks/ as JSON, including the memory numbers.
What is measured
Every scenario is measured at each stage, rather than only end to end. That decomposition is the whole point: an end-to-end number cannot tell you whether a regression moved into stimulus construction, the model build, or the percept computation.
Group |
What it covers |
|---|---|
|
Building the stimulus, before any implant exists. |
|
Assigning the stimulus to the implant, including the downsampling of an image or video onto the electrode grid. |
|
|
|
The headline number. |
|
The whole one-liner. |
|
Drawing the percept. Mostly a matplotlib measurement, kept in its own group so it is never read as model cost. |
Reading the numbers
Compare min, not mean. Noise only ever makes a run slower, so
the minimum is the most stable estimate. Watch stddev as a signal of how
quiet the machine was, not as a property of the code.
Threads are pinned to 1 by default. The library defaults n_threads to
one per CPU, which makes results incomparable between machines, and between runs
on a loaded machine. Pinning gives a number that means something in isolation.
Use --n-threads deliberately, and never compare a run against a baseline
taken at a different thread count.
Memory is measured separately from time. tracemalloc inflates run time
several-fold, so a timing that included it would be measuring the profiler. Each
benchmark therefore runs its payload one extra time under tracemalloc and
records peak_mem_mb in extra_info.
Memory numbers are a floor, not a total. tracemalloc tracks NumPy data
buffers, which is where essentially all of the memory in these workloads goes,
but it does not see raw malloc inside the Cython/OpenMP kernels. It was
chosen over RSS sampling because it is deterministic, needs no extra dependency,
and works on Windows – which rules out pytest-memray.
Run on a quiet machine. Shared CI runners are too noisy for these numbers to mean much, which is why nothing here gates a pull request.
Adding a scenario
Add a Scenario to scenarios.py. The benchmark functions are
parametrized over that list, so a new entry is picked up by every stage
automatically and no other file changes. For example, a temporal model:
Scenario(
id='argus2_axonmap_fading',
stimulus=lambda: p2p.stimuli.LogoBVL(),
implant=lambda stim: p2p.implants.ArgusII(stim=stim),
model=lambda **kwargs: p2p.models.Model(
spatial=p2p.models.AxonMapSpatial,
temporal=p2p.models.FadingTemporal, **kwargs),
)
Two things to know. Model(...) routes unknown keywords to its sub-models, so
check that whatever you pass is accepted: Parametrized freezes attributes,
so a keyword the model does not know raises FreezeError instead of being
quietly ignored. And if the scenario takes more than a few seconds per
predict_percept call – video stimuli in particular, which predict one frame
per time point – mark it slow=True so it stays out of the default run:
Scenario(
id='argus2_axonmap_bostontrain',
...
slow=True,
)
Slow scenarios only run with --runslow, mirroring the convention the test
suite already uses. Keeping the default run to roughly a minute is what makes it
something people actually run before opening a pull request.
Scope
Deliberately not included: no CI job, no regression gate, no historical tracking. If per-commit history over time becomes the goal, that is the point to consider asv, which is built for it. It was not chosen here because it builds an isolated environment per commit, which is heavy for a Cython/OpenMP project and awkward on Windows.