← Back to Blog

Fifteen Million Was the Easy Part

I wanted three outcomes from one side project: relearn the numerical methods behind option pricing, practice the kind of low-latency code I do not get to write in web backends, and end up with an artifact I could point at. So I wrote a Black-Scholes pricing engine in C++, measured everything, and kept a ledger of what actually made it faster. The volatility math behind the surfaces in my playground runs on this engine, cross-checked against a reference implementation before I trusted a single number.

The first version was the textbook: a clean, header-only pricer producing price and the full set of Greeks, allocation-free, verified against published values and put-call parity. Single-threaded, it priced about fifteen million options a second. That number sounds impressive until you learn what a modern desktop core can actually do, and it was the easy part. Everything worth writing about happened between fifteen million and 215 million, and most of it was my assumptions dying one measurement at a time.

The bottleneck was not where I thought#

Every performance guide says the same opening move: restructure your data. Arrays of structs become structs of arrays, memory access goes linear, the prefetcher loves you, and the speedup arrives. I did the restructuring carefully and measured a 0.94x change. Slightly slower.

The layout change was not wrong in general. It was wrong for this loop, because this loop was never memory-bound. An option pricer spends its time inside exponentials, logarithms, and the normal CDF, which is to say inside the math library. The arithmetic units were saturated while the memory system idled. Reorganizing memory for a loop bound on transcendental functions is rearranging chairs in a room where nobody is sitting down.

That reframing paid immediately. If the expensive part is the math functions, attack the math functions. The standard library's erfc turned out to be a luxury: swapping the normal CDF to a classic polynomial approximation bought 1.4x on its own, at a cost I could state precisely, about 1.6e-5 of absolute error, which for this use is nothing. Threading the loop, which is embarrassingly parallel, bought another 5x. Those two changes together took fifteen million to about 114 million per second, and neither of them was exotic.

The compiler said yes and meant no#

The plan always included SIMD, the vector instructions that process four doubles at once. The modern advice is that you rarely write these by hand, because the compiler's auto-vectorizer handles clean loops for you. My build logs agreed: they said "loop vectorized."

They were talking about a different loop. When I turned on the detailed vectorization report, the pricing loop itself was failing to vectorize every single time, with a terse reason code, while the message I had been reading belonged to the little checksum reduction at the end of the benchmark. The part of the program that mattered was scalar. The part that did not matter was vectorized, and its success message sat in the log exactly where I wanted to see one.

The root cause is mundane: the compiler will not widen calls like std::exp and std::log into their four-wide forms here, and a pricing loop is mostly those calls. No amount of __restrict annotations or loop hygiene changes that. Which means the honest accounting of my first 8.5x of speedup is: zero of it came from SIMD. Every guide told me the auto-vectorizer would do this work. For this loop, on this toolchain, it simply does not, and the only way I found out was reading the report instead of the summary line.

Writing the math by hand#

So the four-wide exponential and logarithm had to be written by hand, using the classic Cephes polynomial cores, intrinsics all the way down. This is the part of the project where a piece of borrowed discipline earned its keep: before wiring the vector math into the pricer, I tested it in isolation against the standard library across the whole input range. Not the pricer against the pricer, the exponential against the exponential. The hand-written versions agreed with the library to about a unit in the last place, and when a bug did appear during development, the isolated tests pointed at the exact function instead of at a wrong option price three layers downstream.

With real SIMD underneath, the kernel gained a clean 4x on a single core, right at the theoretical width of the instructions. Across all cores, the engine now prices about 215 million options a second on a desktop. And the error ledger stayed honest: the vectorized math agreed with the scalar version to about 9.6e-14, which is noise. Effectively all of the engine's deviation from exact, the same 1.6e-5, comes from the CDF approximation I chose on purpose back when I could still state the tradeoff in one sentence.

What the ledger says#

The finished project has the rest of the roadmap in it, a Monte Carlo engine with the variance tricks, a risk view that revalues a hundred-thousand-position book about a thousand times a second, and a Python binding that keeps roughly 133 million options a second even when called from NumPy, which is around fifty times what the pure-array version manages. The binding releases Python's lock during the heavy work, and proving that with a scaling test was its own small satisfaction.

Two honest caveats belong in the record. Single-run timings on a desktop are noisy, easily ten or twenty percent run to run, so every number here is a shape, not a decimal. And none of this is a trading system; it is a learning artifact that happens to be fast, and the market data it ultimately serves is the educational playground on this site, not an execution engine.

What I actually keep from the project is the ledger of misses. The data layout that every guide recommends did nothing, because the bottleneck was arithmetic, not memory. The compiler reported a vectorization that belonged to a loop nobody cared about. The biggest single win was replacing one library call with a sixty-year-old polynomial. Fifteen million a second was free, and the next 200 million were an education in checking what is actually true, one report line at a time.


Related:

Keep reading

Follow the work

New tools and writing as they ship — pick a channel.

Written by Eric Caskey. I build AI tools you can actually use. Explore the Tools or see the case studies.