Loading repository data…
Loading repository data…
subinium / repository
Drop-in matplotlib backend powered by a Rust rendering engine (tiny-skia)
A transparent discovery signal based on current public GitHub metadata.
This score does not audit code, security, maintainers, documentation quality, or suitability. Verify the repository and its current documentation before adoption.
Drop-in matplotlib backend powered by a Rust rendering engine.
One line to switch:
import matplotlib
matplotlib.use('module://mpl_rust_backend')
Replaces matplotlib's C++ Agg rasterizer with a Rust pipeline built on tiny-skia (CPU rasterizer) and FreeType text-as-paths. Supports PNG and SVG output with pixel-accurate fidelity across 26 tested chart types.
All images below are rendered entirely by the Rust backend — no Agg involved.
| Line & Fill | Scatter | Statistics |
|---|---|---|
| Polar | 3D Surface | Advanced (Contour, Quiver, Stream, Heatmap) |
|---|---|---|
# Build (requires Rust toolchain + maturin)
pip install maturin
maturin develop --release
# Use as matplotlib backend
python -c "
import matplotlib
matplotlib.use('module://mpl_rust_backend')
import matplotlib.pyplot as plt
plt.plot([1, 2, 3], [1, 4, 2])
plt.savefig('test.png')
"
Or use the canvas directly:
from matplotlib.figure import Figure
from mpl_rust_backend._canvas import FigureCanvasRust
fig = Figure()
canvas = FigureCanvasRust(fig)
ax = fig.add_subplot(111)
ax.plot([1, 2, 3], [1, 4, 2])
fig.savefig("output.png")
matplotlib draw calls (Python)
|
+-- draw_path() --+
+-- draw_path_collection() | RendererRust (_renderer.py)
+-- draw_markers() | Translates to scene graph + binary blobs
+-- draw_text() | Path simplification + polygon batching
+-- draw_image() --+
|
v
Scene graph (JSON) + binary blobs (PXPK)
|
+---> Rust mpl-rust-core
|
+-- serde_json parse + blob routing
+-- tiny-skia rasterizer (stamp-cached markers)
+-- PNG / SVG encoder
|
v
Output bytes -> Python
| Layer | Language | Role |
|---|---|---|
mpl_rust_backend | Python | matplotlib RendererBase implementation, scene graph builder |
mpl-rust-pybridge | Rust (PyO3) | FFI bridge, accepts JSON + blobs, returns PNG/SVG bytes |
mpl-rust-core | Rust | Scene parser, tiny-skia rasterizer (stamp-cached markers), SVG writer, PNG encoder |
Large data bypasses JSON entirely via the PXPK binary side-channel:
f32 vertex arrays + u8 code arrays as blobsdraw_path_collection batches polygon collections into a single node with per-polygon fill colorsf32 position arrays as blobsThis eliminates the Python dict creation and JSON serialization overhead for the most expensive draw calls.
26/26 chart types MATCH (MSE < 1500). All tested with dpi=100, figsize=(6,4).
| Plot Type | MSE | Status |
|---|---|---|
| Bar chart | 30.3 | MATCH |
| Imshow (heatmap) | 52.5 | MATCH |
| Scatter (5K pts) | 85.6 | MATCH |
| Histogram (10K) | 88.9 | MATCH |
| Step plot | 108.5 | MATCH |
| Subplots (2x2) | 141.9 | MATCH |
| Line (100K pts) | 150.0 | MATCH |
| Fill between | 177.3 | MATCH |
| Plot Type | MSE | Status |
|---|---|---|
| Pie chart | 21.7 | MATCH |
| Contourf (filled) | 67.9 | MATCH |
| Stackplot | 69.9 | MATCH |
| 3D bar | 97.6 | MATCH |
| Quiver | 117.8 | MATCH |
| Annotated heatmap | 119.9 | MATCH |
| Polar bar | 137.6 | MATCH |
| Errorbar | 139.2 | MATCH |
| 3D surface | 140.5 | MATCH |
| 3D scatter | 157.6 | MATCH |
| Polar scatter | 185.9 | MATCH |
| Polar line (rose) | 208.2 | MATCH |
| Log-log scale | 210.2 | MATCH |
| Twin axes | 300.2 | MATCH |
| 3D wireframe | 316.0 | MATCH |
| Stem plot | 383.8 | MATCH |
| Streamplot | 479.5 | MATCH |
The remaining pixel differences are fundamental — Agg and tiny-skia use different anti-aliasing algorithms:
Every shape edge produces slightly different sub-pixel coverage values. This affects a few percent of pixels per figure, but MSE stays well below the MATCH threshold across all chart types.
Measured via isolated subprocesses (fresh Python per test). Median of 5 timed runs after 2 warmups. savefig(format="png"), dpi=100, figsize=(6,4).
| Case | Agg (ms) | Rust (ms) | Ratio |
|---|---|---|---|
| imshow_500 | 25 | 18 | 1.36x |
| scatter_50k | 23 | 18 | 1.28x |
| scatter_10k | 23 | 20 | 1.15x |
| scatter_1k | 20 | 18 | 1.11x |
| line_1k | 16 | 15 | 1.06x |
| imshow_100 | 17 | 16 | 1.06x |
| fill_between | 19 | 18 | 1.05x |
| step_50 | 17 | 17 | 0.98x |
| subplots_2x2 | 36 | 38 | 0.94x |
| bar_20 | 23 | 24 | 0.93x |
| errorbar_20 | 17 | 19 | 0.91x |
| hist_10k | 18 | 20 | 0.89x |
| multiline_20 | 28 | 34 | 0.81x |
| line_10k | 22 | 28 | 0.80x |
| line_100k | 39 | 72 | 0.54x |
| Case | Agg (ms) | Rust (ms) | Ratio |
|---|---|---|---|
| pie | 7 | 5 | 1.30x |
| polar_line | 25 | 23 | 1.08x |
| polar_scatter | 27 | 25 | 1.08x |
| polar_bar | 27 | 26 | 1.05x |
| stackplot | 20 | 19 | 1.03x |
| multiaxis | 24 | 24 | 1.02x |
| stem | 15 | 15 | 1.00x |
| quiver | 22 | 23 | 0.96x |
| streamplot | 36 | 39 | 0.91x |
| 3d_scatter | 23 | 25 | 0.90x |
| log_scale | 134 | 156 | 0.86x |
| 3d_bar | 22 | 25 | 0.86x |
| heatmap_text | 23 | 27 | 0.86x |
| contourf | 16 | 19 | 0.84x |
| 3d_wireframe | 23 | 32 | 0.74x |
| contour | 18 | 24 | 0.73x |
| 3d_surface | 41 | 60 | 0.68x |
| Metric | Value |
|---|---|
| Total tests | 32 |
| Geometric mean | 0.95x |
| Median speedup | 0.95x |
| Rust faster | 10/32 |
| Parity (0.95-1.05x) | 6/32 |
| Rust slower | 16/32 |
For typical charts (scatter, lines <10K pts, bars, histograms, images, pie, polar, stackplot), the Rust backend is at parity or faster than Agg. Scatter plots are 1.1-1.3x faster at any size thanks to stamp-cached marker blitting with clip-mask-aware temp-pixmap compositing. Image-heavy workloads are up to 1.4x faster. Log-scale and 3D scatter performance improved significantly via clip-mask pass-through and per-point color markers.
Large-line cases (>10K line points) and 3D scenes remain slower due to scene graph serialization overhead scaling with vertex count.
| Case | Agg (ms) | Rust (ms) | Ratio |
|---|---|---|---|
| scatter_10k | 104 | 16 | 6.6x |
| imshow_100 | 13 | 12 | 1.07x |
| line_10k | 13 | 13 | 1.01x |
| bar_20 | 17 | 17 | 0.98x |
SVG scatter plots are ~6x faster than matplotlib's default SVG backend.
Current engine-level optimizations:
| Optimization | Impact |
|---|---|
| Clip-mask pass-through | Renders children directly with clip mask instead of allocating temp pixmaps — log_scale 0.54x→0.86x |
| Temp-pixmap stamp compositing | Stamp-cache markers blit into temp pixmap, composite once through clip mask — scatter 50K: 0.16x→1.28x |
| Per-point color markers | Packs curved-path collections as single MarkersData node with per-point RGBA — 3D scatter 0.55x→0.90x |
Path simplification (draw_path) | Uses matplotlib's C-extension to reduce vertices before transport — line 100K: 88% vertex reduction |
draw_path_collection batching | Batches polygon collections into single PolygonsData node — quiver 0.55x→0.96x, 3D surface 0.38x→0.68x |
| f32 vertex transport | Halves PathData blob size vs f64 — tiny-skia uses f32 internally |
| Stamp-cached marker blitting | Rasterize marker once, blit at each position — scatter 10K: 3.8x faster |
| Binary path transport (PathData) | Eliminates Python dict loop + JSON for paths >20 vertices |
| Binary marker transport (MarkersData) | Zero-copy f32 position arrays for >30 markers |
| PolylineData stroke-only paths | Skips codes array for stroke-only polylines |
| Raw image blobs (ImageBlob) | Skips base64 encode/decode for all images |
| Numpy-vectorized marker positions | Eliminates Python for loop in draw_markers |
| OnceLock env-var caching | Reads 20+ config env vars once, not per frame |
| Enum scene types (PathCmd, LineCap, LineJoin) | Zero-cost serde deserialization, no heap allocation |
| LTO + codegen-units=1 | Cross-crate inlining in release builds |
orjson auto-detection | 3-5x faster JSON serialization when available |
+-- python/mpl_rust_backend/
| +-- __init__.py # Backend registration
| +-- _canvas.py # FigureCanvasRust (print_png, print_svg)
| +-- _renderer.py # RendererRust (draw_path, draw_markers, ...)
| +-- _scene_builder.py # Scene graph accumulator + binary blob transport
| +-- _translators.py # matplotlib -> scene graph type converters
+-- rust/
| +-- mpl-rust-core/ # Rendering engine (tiny-skia, scene, text)
| +-- mpl-rust-pybridge/ # PyO3 FFI bridge
+-- assets/ # Gallery images
+-- benchmarks/
+-- tests/
# Build in development mode
maturin develop
# Build optimized
maturin develop --release
# Run tests
pytest tests/
# Run fidelity comparison (basic 8 types)
python compare_mpl.py
# Run extended comparison (18 types including polar, 3D, contour)
python compare_extended.py
# Run speed benchmark
python bench_speed.py
This project demonstrates that matplotlib's rendering layer can be replaced without touching user code. By swapping one line (matplotlib.use('module://mpl_rust_backend')), the entire rendering pipeline shifts from C++/Agg to Rust/tiny-skia — and produces pixel-accurate output across 26 chart types.
This matters because:
| Advantage | Detail |
|---|---|
| Drop-in replacement | Zero changes to existing matplotlib code — just switch the backend |
| Pixel-accurate fidelity | 26/26 chart types MATCH (MSE < 500 for all) |
| Memory safety | Rust's ownership model eliminates buffer overflow and use-after-free bugs |
| Scatter performance | 1.1-1.3x faster than Agg for scatter plots of any size via stamp-cached blitting |
| SVG performance | Up to 6x faster SVG output for data-heavy plots |
| Image rendering | 1.3x faster than Agg for image-heavy workloads (imshow, heatmaps) |
| Modular architecture | Scene graph decouples matplotlib from the raster |