Skip to content

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 ThreadPoolExecutor actually 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. Pass simplify=False when every vertex must survive — for instance if the PDF is being post-processed point-by-point.
  • Marker instancing. scatter emits 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 Figure per output is the intended pattern, including for animation, where the render callback returns a new figure per frame.
  • dpi only affects .png. PDF, SVG and HTML are resolution-independent; raising dpi for them costs nothing and does nothing.
  • Buffer-backed input is the fast path. A NumPy array or an array("d") is read directly as an f64 buffer with no intermediate Python list. Lists work fine and are converted in one pass; generators are materialized first.
  • Non-finite handling is free. NaN/inf filtering 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 scatter if 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 .pdf or .svg if 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.