Performance¶
pyplotrs is fast because the loops that scale with your data — ingestion, autoscaling, transforms, rasterization, encoding — run in Rust, and because the layout is solved once rather than iterated. This page is about what that buys and how to keep it.
Where the time goes¶
A figure is built in Python (each mark call records a dict) and rendered when
you call save. That means save() is the whole pipeline: layout, text
shaping, geometry, and the backend. Nothing is drawn before it, so there is no
"draw twice to get the layout right" step.
The head-to-head numbers live in
benchmarks/RESULTS.md,
which is regenerated by python benchmarks/matrix.py and reports export
wall-time and file size across mark × point-count × panels × format. A few
shapes from that table, at matched size and dpi:
| Case | pyplotrs | matplotlib |
|---|---|---|
| 10k-point line, 1 panel, PDF | 0.001 s | 0.010 s |
| 10k-point line, 9 panels, SVG | 0.001 s | 0.066 s |
| 100k-point scatter, 1 panel, PDF | 0.056 s | 0.651 s |
| 1M-point line, 9 panels, PNG | 0.014 s | 0.114 s |
import the library |
~12 ms | ~237 ms |
Read them with the caveats the file states: for pyplotrs the timed region is close to the whole pipeline, while matplotlib has built its artists beforehand; data ingestion and import time are outside the timer for both; and the PNG rows scale with the core count of the machine that produced them.
Why the lead grows with panel count¶
Layout is a single pass. Every band a figure needs — titles, tick labels, axis labels, colorbar strips, the legend column — is measured and reserved before anything is drawn, in Rust, for the whole grid at once. Per-panel chrome that would otherwise be re-solved per axes is amortized, so a 9-panel figure costs far less than nine times a 1-panel one.
Threads¶
Nothing below the Python layer touches a Python object, so the GIL is
released for the whole of a render and for every compute kernel. Both halves
matter, and only the first used to be true: the renderers (to_pdf, to_svg,
to_png, and the GIF/APNG encoders) detached, while marching squares, the
filled-contour rasterizer, hist2d, hexbin, the violin KDE, the histogram
and the colormap mapping all held it. A figure whose cost was mostly contour
rather than raster therefore did not parallelize at all — four of them on
four threads took as long as four in a row. Three consequences:
- Exporting a batch of figures from a
ThreadPoolExecutoractually runs them concurrently, rather than serializing on the interpreter lock. - A long call stays interruptible. A 2000x2000 contour used to block every
other thread — and
Ctrl-C— for its whole twelve seconds. - Raster export is itself multi-threaded: an expensive canvas is split into horizontal bands rasterized in parallel, and PNG scanline filtering and DEFLATE run in parallel too.
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor() as pool:
pool.map(lambda i: build(i).save(f"panel{i}.png"), range(64))
This is safe because there is no global state to race on: no current figure, no current theme, no rcParams. A figure is an object, and two threads building two figures share nothing.
Banding is a function of the canvas alone, not of the core count, so a figure renders identically on a 4-core laptop and a 64-core server — only the wall time differs. Small figures take the exact serial path and are byte-identical to what they were before banding existed. PDF and SVG export is single-threaded.
Keeping vector files small¶
Vector output has a second cost axis: file size. Two defaults handle the common cases.
- Line simplification.
line(...)collapses runs of near-collinear vertices in device space (simplify=True). The output is visually identical and the file is dramatically smaller on dense data. Passsimplify=Falsewhen every vertex must survive — for instance if the PDF is being post-processed point-by-point. - Marker instancing.
scatteremits one reusable form (a PDF XObject, an SVG<use>) and places it, rather than writing a full path per point.
If a figure is genuinely huge — millions of points, all of them meaningful —
consider whether the reader needs each one. hexbin, hist2d and
pcolormesh summarize a dense cloud into a colormapped field that stays small
in vector output, and imshow embeds a raster image inside an otherwise vector
figure.
Practical notes¶
- Reuse nothing, share nothing. Building a fresh
Figureper output is the intended pattern, including for animation, where the render callback returns a new figure per frame. dpionly affects.png. PDF, SVG and HTML are resolution-independent; raisingdpifor them costs nothing and does nothing.- Buffer-backed input is the fast path. A NumPy array or an
array("d")is read directly as anf64buffer with no intermediate Python list. Lists work fine and are converted in one pass; generators are materialized first. - Non-finite handling is free.
NaN/inffiltering happens inside the same Rust reduction that computes the data range, not as a separate pass.
The one case that is slow: a long polyline¶
Raster (.png) cost for a line scales with the polyline's total length in
device pixels, not with its point count. Each segment is rasterized over its
own bounding box, so a segment that crosses the panel costs far more than one
that advances a pixel — however many points are behind it.
For a sampled signal this never matters, because x advances by a fraction of a pixel per point. It matters when consecutive points are far apart in x:
300 000 points, one panel, .png |
time |
|---|---|
smooth curve (sin, simplification collapses most vertices) |
0.02 s |
| noisy signal, sorted x (nothing collapses) | 0.12 s |
| unsorted x — consecutive points jump across the panel | 26 s |
The third row is almost always a mistake rather than a workload — it draws a scribble covering the whole panel. If you hit it:
- Sort by x if the data is a function of x. This is the usual fix, and it is what makes the figure readable as well as fast.
- Use
scatterif the points are not a path. Scatter draws one stamped marker per point regardless of how far apart they are: a million points rasterize in ~0.15 s. - Export to
.pdfor.svgif you do need the path drawn. Vector output records segments rather than filling pixels, so it stays sub-second at a million points on any shape.
benchmarks/RESULTS.md reports both the smooth (line) and the noisy
(line_dense) shapes so that neither number stands alone.