Pricing 215 million options a second with SIMD C++
I built a Black-Scholes options pricer in C++. A clean first version priced fifteen million options a second; the version I finished prices about 215 million. The headline is the speed. The real story is how often I was wrong about where that speed would come from, and how measurement kept correcting me.
The first version was the textbook version: clean, allocation-free, correct, checked against published prices and put-call parity. On a single core it priced about fifteen million options a second. That sounds fast until you learn what a modern core can actually do. Fifteen million was the easy part. Everything worth telling happened on the way to 215 million.
Why I built it#
One side project, three goals. I wanted to relearn the numerical methods behind option pricing, the ones I last touched years ago. I wanted to practice low-latency code, the kind I never get to write in a web backend, where the clock is a network round trip and not a nanosecond. And I wanted an artifact, something I could point at instead of describe.
The volatility math behind the 3D surfaces in my playground was cross-checked against it before I trusted a single number on screen.
What I built#
At its core the engine prices an option and its full set of Greeks with no memory allocation, verified against known values. Around that core sits the rest of the roadmap: a Monte Carlo engine with the standard variance-reduction tricks, a risk view that revalues a hundred-thousand-position book about a thousand times a second, and a Python binding that holds roughly 133 million options a second even when called from NumPy, about fifty times what a pure-array version manages.
So the shape of it is a small, fast library, fast enough to be interesting, with a test behind every number it reports. It is a learning artifact, not a trading system, and the market data it serves is the educational playground on this site, not an execution engine.
How it got fast: three times I was wrong#
The climb from fifteen million to 215 million was not a straight line of clever ideas. It was a sequence of confident assumptions, each killed by a measurement.
The bottleneck was not where the guides said. Every performance guide opens the same way: restructure your data so memory access goes linear. I did it carefully and the loop got slightly slower. The advice was not wrong in general, it was wrong here, because this loop never touched much memory. An option pricer lives inside exponentials, logarithms, and the normal distribution, which is to say inside the math. Reorganizing memory for a loop that is starved on arithmetic solves a problem it does not have. Once I attacked the math instead, swapping one modern library call for a sixty-year-old polynomial and threading a loop that was embarrassingly parallel, the number jumped from fifteen million to about 114 million.
The compiler claimed a win it never delivered. The plan always included SIMD, the vector instructions that handle four numbers at once, and modern advice says the compiler does that for you on clean loops. My build logs agreed and reported the loop vectorized. They were reporting on a different loop, a trivial one at the end of the benchmark. My compiler would not fold a loop full of exp and log calls into vector form. Some toolchains will; mine did not. So the loop that mattered stayed scalar every single time, its failure notice sitting in a report I had not opened. None of my first big speedup came from SIMD at all.
So I wrote the math by hand. Four-wide exponential and logarithm, built from classic polynomial cores, intrinsics all the way down. The discipline that saved me here was borrowed and simple: test each function in isolation against the standard library before wiring it into anything. Not the pricer against the pricer, the exponential against the exponential. When a bug showed up, the isolated test named the exact function instead of leaving me to hunt a wrong price three layers downstream. With real SIMD underneath, one core gained a clean four times, right at the theoretical width, and across all cores the engine reached about 215 million a second.
What I keep from it#
I set out to relearn some numerical methods and end up with a small, fast library I could point at, and I got both. The single biggest win was trading a modern function for an old approximation I could reason about. Fifteen million a second was free; the next two hundred million were an education in checking what is actually true, one report line at a time.
Two caveats belong on the record. Desktop timings wander ten or twenty percent run to run, so read every number here as a shape, not a decimal. And none of this is a trading system. It is a learning artifact that happens to be fast, which was the point.
Related:
- Rendering an options Greeks surface in 3D in the browser, the 3D surfaces this engine's volatility math feeds
- How to backtest without fooling yourself, the same measure-before-believing discipline pointed at signals instead of speed
Questions this post answers
- How fast can a Black-Scholes pricer run on one CPU?
- A clean, allocation-free C++ implementation checked against published prices priced about fifteen million options a second on one core. Replacing one modern library call with a sixty-year-old polynomial approximation and threading the embarrassingly parallel loop took it to about 114 million. Hand-written four-wide SIMD exponential and logarithm took one core up a clean four times, and across all cores the engine reached about 215 million a second. Desktop timings wander ten to twenty percent run to run.
- Why did my compiler report a loop vectorized when it was not?
- Because the report was about a different loop. The build log said a loop had vectorized, and it had: a trivial one at the end of the benchmark. The loop that mattered, full of exp and log calls, stayed scalar every time because that toolchain would not fold transcendental calls into vector form. The failure notice was in a report I had not opened. Read the vectorization report per loop, not per build.
- How do you test hand-written SIMD exp and log?
- Each function in isolation against the standard library, before wiring it into the pricer. The exponential against the exponential, the logarithm against the logarithm, over the input range the pricer will use. A bug then names the exact function instead of appearing as a wrong price three layers downstream.
- Why did restructuring memory for linear access make the pricer slower?
- Because the loop was starved on arithmetic, not memory. An option pricer lives inside exponentials, logarithms and the normal distribution; reorganizing memory for a loop that barely touches memory solved a problem it did not have, and cost a little. The speed came from the math.