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:
- Rotating an Option, 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
Keep reading
Investment Committee
Score any stock across five weighted dimensions and get a letter grade with a written committee verdict.
Market's Best
The top-graded stocks from the latest market scan. No sign-in needed.
Grade my portfolio
Run a sample portfolio through the investor committee.
Factor-First AI Investment Platform Narrated by a Six-Persona Committee
Grew a single-ticker grader into a full investment platform: a four-factor composite (Quality, Valuation, Momentum, Health) narrated by a six-persona committee, a nightly scan of ~400 large caps, portfolio and net-worth tracking, and a grade scale validated by a daily backtester.
How to backtest without fooling yourself
A backtest's job is not to find an edge. It is to stop you from believing in one that is not there. The toolkit I used to test my own trading engine, and the part where it killed my single best signal.
The Aquarium That Became a Trading Floor
A screensaver joke, every stock a fish, grew into a six-lens market board I leave running on a wall. It lives in one HTML file on purpose, its data is baked because the browser is not allowed to fetch it, and the feature that finally made it work on a phone is the URL itself.
Follow the work
New tools and writing as they ship — pick a channel.