Performance engineering
The simulator's Monte Carlo in Python and in Rust, and why the speed-up is what it is.
What this stage does
The simulator checks its analytic error rates with a Monte Carlo that pushes symbols one by one through the analogue chain. At low LO power that needs hundreds of millions of symbols, so speed matters. The reference is NumPy, vectorised over blocks of symbols; a Rust extension does the same work in a per-symbol loop.
How it’s modelled
Both paths draw from a counter-based SplitMix64 stream (eight 64-bit words per symbol, so any block can be generated on its own) and use only +, −, ×, ÷ and √ per symbol. The logarithm, sine and cosine needed by the Gaussian draws are written out as polynomials, because the two languages’ maths libraries round differently. Rust must not fuse multiply-adds. The result: identical symbol decisions and error counts for a given seed, checked symbol by symbol, and reproduced here by the TypeScript port. The master seed is 2.02608 × 10⁷ illustrative.
What we found
At 100,000,000 symbols on one core the Rust path runs 4.745 × 10⁶ symbols/s against 1.033 × 10⁶ symbols/s for NumPy, a 4.595× speed-up (3.686× at 100,000 symbols, where start-up costs weigh more). Both paths count exactly 6,649,811 word errors, 10,941 of them undetected.
Why not more? NumPy is already vectorised: each operation runs over a quarter of a million symbols at once, so it is bound by memory traffic, not by Python. The Rust loop handles one symbol at a time, and each symbol carries long chains of dependent operations: the ln and sin/cos polynomials in Horner form, a square root and a division. The loop runs at the latency of those chains, and it cannot reorder or fuse them without breaking bit-exactness with NumPy. Experiment 2 as a whole gains less than the Monte Carlo because its analytic sweeps dominate. These timings are measurements on one machine and vary between machines and runs.
Data table
| symbols | NumPy (symbols/s) | Rust (symbols/s) | speed-up |
|---|---|---|---|
| 100,000 | 1.351 × 10⁶ | 4.979 × 10⁶ | 3.686× |
| 1,000,000 | 752,000 | 4.924 × 10⁶ | 6.548× |
| 10,000,000 | 986,200 | 4.68 × 10⁶ | 4.745× |
| 100,000,000 | 1.033 × 10⁶ | 4.745 × 10⁶ | 4.595× |
What would need measured data
Nothing here depends on the module: it is about the simulator. What would change the picture is hardware, not data: a wider SIMD Rust loop over many symbols at once could hide the chain latency, at the cost of a different operation order that would then have to be matched in NumPy too. The parity tests (words, symbols and counts on three configurations and three seeds) make that change safe to try.