Can Rust Make corrplot Faster? A Cross-Language Benchmark
Published on October 30, 2026 by Viliam Simko
Introduction
What should the next generation of corrplot look like under the hood?
The existing R package has a large investment in compatibility, and that should not be put at risk. At the same time, corrplot-ng gives us a chance to build a more modular foundation that can support other environments in the future. A practical question follows: can a small native component make the work behind a plot faster without making the project harder to maintain?
To explore that question, we built the same scene-building task in Python, pure R, C++ through Rcpp, and Rust through rextendr. We compared the resulting scenes and measured the time needed to build and encode them.
This is an early engineering experiment, not a contest between programming languages. It helps us understand the trade-offs in this prototype—and, just as importantly, what still needs to be measured.
One contract, four implementations
The experiment uses an experimental scene-v1 representation: a structured description of a visualization that can be serialized as JSON and consumed by a renderer.
We implemented the same task in four ways:
- Python — a high-level reference implementation.
- Pure R — an implementation that stays within R.
- C++/Rcpp — native C++ code callable from R.
- Rust/rextendr — native Rust code exposed to R through rextendr.
The purpose was not to compare language features in isolation. Each candidate had to produce the same logical scene for the same input. The benchmark therefore included correctness checks against shared deterministic fixtures, in addition to timing.
This is an architectural spike, not a production implementation of corrplot-ng. It does not replace the existing corrplot package, and it does not yet implement the full behavior of a plotting system.
What did we measure?
We focused on end-to-end scene construction plus JSON encoding. This is an important boundary: a native implementation is useful only if the complete path from R inputs to a renderer-ready representation is efficient and reliable.
The workload set included:
- B01: a small 20 × 20 matrix.
- B02: a 100 × 100 matrix.
- B05: a matrix with 10% missing values.
- B06: longer labels.
- B07: reordered input.
The measurements ran on a GitHub-hosted Linux runner. Because this is a shared environment, hardware can vary between runs; the results should be read as a comparison from this setup, not as universal performance figures. The workflow retains the inputs and raw measurements so the experiment can be repeated.
For the regular workloads, each implementation used five warmup iterations followed by 30 measured samples. Batch sizes were calibrated so each sample took roughly 100 ms, helping reduce timing noise for short operations. Python used a high-resolution performance timer; the R-based implementations used R’s elapsed-time clock. We report per-operation medians in milliseconds. Lower is better. The timings cover the full path measured here: building a scene and encoding it as JSON—not drawing the final visualization.
Results: native candidates are close, pure R exposes a serialization bottleneck
Across the five reported workloads, the pattern was similar:
| Workload | Python (ms) | Pure R (ms) | C++/Rcpp (ms) | Rust/rextendr (ms) |
|---|---|---|---|---|
| B01 — 20 × 20 | 0.94 | 278.00 | 0.88 | 0.73 |
| B02 — 100 × 100 | 23.81 | 6599.50 | 21.63 | 18.37 |
| B05 — 10% missing | 23.54 | 6570.50 | 21.13 | 18.25 |
| B06 — long labels | 24.57 | 6635.00 | 21.69 | 18.38 |
| B07 — reordered | 25.01 | 6683.00 | 21.50 | 18.31 |
In this run, Rust/rextendr was roughly 15% faster than C++/Rcpp and around 23% faster than Python across the regular workloads. C++/Rcpp remained close to Python. The smallest workload is still sub-millisecond for the non-R implementations, so small differences there should not be treated as a decisive ranking.
The pure-R end-to-end result looks dramatically slower. However, breaking the B02 measurement into phases changes the interpretation: scene construction itself had a median of roughly 38.25 ms, while JSON encoding alone took about 6.47 seconds.
That points to a serialization bottleneck in this particular pure-R path—not evidence that R is inherently thousands of times slower at the underlying computation. It also shows why measuring only the core algorithm can be misleading: the cost of converting data at a language boundary may dominate the total.
Large-matrix cross-language stress test: B03/B04
We also tested two larger matrices with Python, C++/Rcpp, and Rust/rextendr. Pure R was left out at these sizes because its JSON-encoding path had already dominated the smaller test. All three implementations produced scenes that matched the shared expected result before timing. The validation run #41083057481 covered both the regular fixtures and the larger stress cases. Its uploaded cross-language-scene-v1-benchmark-results artifact contains the regular and stress result JSON files used for the tables below. Each stress case used one warmup and five measured runs, with a batch size of one.
The table reports median end-to-end scene construction plus JSON encoding in milliseconds:
| Workload | Matrix | Python (ms) | C++/Rcpp (ms) | Rust/rextendr (ms) |
|---|---|---|---|---|
| B03 | 500 × 500 | 665.19 | 551.00 | 470.00 |
| B04 | 1,000 × 1,000 | 2700.91 | 2239.00 | 1897.00 |
For these implementations, Rust/rextendr was about 29.3% faster than Python on B03 and 29.8% faster on B04. C++/Rcpp was about 17.2% faster than Python on both workloads. Rust was about 14.7% faster than C++/Rcpp on B03 and 15.3% faster on B04. These are results for this prototype and runner, not general language performance claims.
As with any benchmark, the result has limits. The Python and R-based measurements use different timers, and the serialized files are not exactly the same size even though the scenes are semantically equivalent. The benchmark job completed its measurement and artifact-upload steps, although GitHub marked the overall job as cancelled afterward; all recorded regular and stress results in the artifact passed their semantic checks. The figures therefore compare each implementation’s complete path, rather than perfectly identical serialization work. These differences are worth remembering, but they do not change the broad pattern in these larger tests.
Correctness and portability matter as much as speed
A benchmark is useful only if the candidates are doing equivalent work. The CI workflow checks all implementations against the same deterministic fixtures and verifies semantic parity of the resulting scenes.
We also added a separate package smoke-test matrix for Ubuntu, macOS, and Windows. On all three platforms, the workflow built and installed the C++/Rcpp and Rust/rextendr prototype packages and ran the scene-v1 parity checks.
This is an encouraging portability signal. It is not yet proof of CRAN readiness, polished binary distribution, or equal performance across operating systems. The performance comparison itself was conducted on Linux; the other platforms were used for build and correctness checks, not timing. In run #41083057481, the benchmark job’s measurement and artifact-upload steps completed successfully, but GitHub recorded the job’s final conclusion as cancelled. The separate Ubuntu, macOS, and Windows package-smoke jobs each concluded successfully. The successful smoke checks should therefore not be read as a successful conclusion for the benchmark job itself.
What the numbers do—and do not—tell us
There are several useful conclusions:
- The native approaches are viable enough to investigate further. Both Rcpp and rextendr built on the three tested operating systems and passed the shared correctness checks.
- Rust leads this particular benchmark, but not by an overwhelming margin. A 14–16% difference over this small prototype is worth noting, not enough to decide an entire product architecture.
- Serialization deserves its own design decision. The pure-R result is dominated by JSON encoding. We should investigate the serializer, data representation, and whether JSON is even the right internal boundary before drawing conclusions about R itself.
- End-to-end measurements are essential. Native code can make one phase faster while conversion, allocation, packaging, or integration costs erase the gain elsewhere.
There are also important limitations. This is one experimental scene representation, five workloads, one Linux performance environment, and prototype code. The cross-language comparison does not measure rendering, interactive behavior, process-level memory consumption, package installation time as a user experiences it, or the cost of maintaining each implementation over years. The small timing differences should also be interpreted in the context of the benchmark’s measurement procedure and runner variability.
What comes next?
The next step is not to rewrite corrplot in Rust. It is to make the experiment more representative.
We should expand the scene contract and workloads, measure memory and allocation behavior across implementations with a comparable methodology, inspect the JSON serialization path, and test realistic data sizes and visualization features. We should also compare the long-term costs of Rcpp and rextendr: toolchains, generated bindings, debugging, documentation, contributor experience, and release engineering.
For corrplot-ng, the direction worth exploring is a stable R-facing API around a clearly separated, independently testable core. That would let the existing R package preserve the behavior users rely on, while giving future front ends room to share the same underlying logic. A native backend should be added only where it brings a measured benefit that justifies its build, release, and maintenance costs.
A follow-up: calling the shared Rust core from R
The benchmark above compares scene construction and JSON serialization across language implementations. A separate experiment asks a different question: once the Rust core exists, what is the most efficient way for R to call it? The follow-up compares V8 + WebAssembly, Wasmtime + WebAssembly, and native Rust through extendr: WASM It Worth It? Calling Rust from R.
Conclusion
This experiment gives us a useful starting point. Rust/rextendr leads in these tests, C++/Rcpp is close, and the pure-R prototype points to a JSON-encoding cost worth investigating.
But choosing the technology for corrplot-ng is not simply a matter of picking the fastest column in a table. We need to preserve the familiar R interface, keep the core testable, and choose an implementation that can be built and maintained across platforms. The benchmark gives us evidence for the next round of experiments—not a reason to rewrite the existing package or commit to Rust today.
This article describes an experimental benchmark, not a production performance claim or a final technology decision.