Part I · Today's module · Chapter 11 of 18

Performance engineering

The simulator's Monte Carlo in Python and in Rust, and why the speed-up is what it is.

One symbol through the portable Monte Carlotextbookillustrative
Monte Carlo chain (loads when scrolled into view)
Each step is computed by the browser’s port of the per-symbol chain, which reproduces the NumPy and Rust paths word for word. The strip shows how many dependent floating-point operations the step puts on the critical path: the ln, sin and cos polynomials dominate, and a per-symbol loop has to wait for each of them.
g0=−2 ln⁡u1  cos⁡2πu2g_0 = \sqrt{-2\,\hl{shot}{\ln u_1}}\;\hl{phase}{\cos 2\pi u_2}
ln⁡m=2s (1+s2(13+s2(15+⋯))),s=m−1m+1\ln m = 2s\,\big(1 + s^2\big(\tfrac13 + s^2\big(\tfrac15 + \cdots\big)\big)\big),\quad s = \tfrac{m-1}{m+1}

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.

Monte Carlo throughput, one coreillustrative
10⁵10⁶10⁷10⁸10⁵10⁶10⁷symbols per runsymbols per second
Measured on the simulator's build machine. The error counts behind each point are identical on both paths.
Data table
Timings
symbolsNumPy (symbols/s)Rust (symbols/s)speed-up
100,0001.351 × 10⁶4.979 × 10⁶3.686×
1,000,000752,0004.924 × 10⁶6.548×
10,000,000986,2004.68 × 10⁶4.745×
100,000,0001.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.