Every CDS that clears anywhere in the world settles against one model: the ISDA CDS Standard Model, published in 2009 as an open-source C library. If you want your credit analytics taken seriously, there is one bar to clear. Reproduce its numbers, to the cent.
Helpfully, cdsmodel.com publishes test grids. For each currency and valuation date there are a few thousand combinations of quoted spread, coupon, recovery and maturity, each with its expected upfront on $10 million notional: six currencies on the post-LIBOR RFR curves, several valuation dates each, plus the legacy 2009 USD LIBOR grid (one date; the input quotes for the other legacy dates are no longer to be found).
cpStudio reproduces all of them to within $0.01 on $10 million. One flat tolerance across every currency, date and row, with exactly two documented exceptions, both defects in the published data itself. The worst case on the RFR grids is about half a cent. The legacy USD grid alone is roughly 10,900 assertions.
The last cent is the interesting part. Getting there forced us to recover conventions the specification never states, and then to find the defects in the published data.
Convention archaeology
Three examples.
"ACT/365 (considers leap years)". The RFR curve specification gives the swap day
count as ACT/365, with that parenthetical. Every quant reads ACT/365 as Act/365 Fixed: a
constant 365 denominator, which never considers leap years. Our GBP, AUD and JPY grids
disagreed with the reference by up to $138 per $10M, growing with maturity. The first
failing pillar spanned 29 February 2024, and the error scaled with the rate level. In the
reference C source, the string "ACT/365" parses to JPMCDS_ACT_365
(yearfrac.c:185–200), and ldate.h:44 declares that constant equal to
JPMCDS_ACT_ACT, the leap-weighted 365/366 day count (ldate.c:313ff). Plain days/365
lives under a different name, JPMCDS_ACT_365F. The string must contain an "F". A
one-line fix; three currencies snapped to half a cent.
The benchmark contract. Our EUR grid carried a stubborn ~$80 residual. USD showed only ~$3, and the ratio matched the short-rate levels, which was itself a clue. The diagnostic that cracked it: on every row where the quoted spread equals the coupon, the published clean upfront is exactly 0.0. That is only possible if the flat hazard curve is bootstrapped from a benchmark CDS identical to the priced contract, previous-IMM accrual start, with the par condition zeroing its clean price. We had assumed a spot-starting benchmark, and I suspect most reimplementations do. Fixing the par condition took EUR from $80 to $0.005.
Memorial Day, 2009. The legacy grid held out at $70.86 until we noticed its spot date: 25 May 2009. The ISDA curve builder rolls deposit maturities on a weekends-only calendar, not the holiday calendar you would naturally reach for. Real calendar: deposits mature on the 26th. ISDA's calendar: the 25th. One bank holiday, $70 on $10 million.
None of this is in the specification. All of it is in the C source, if you know which of its roughly 40,000 lines to interrogate.
Where the reference model gets strange
Reproducing a model to the cent means reproducing its internal conventions exactly, and some of them are odd. None of what follows makes the reference model's settled upfronts wrong. Each one is a trap for a reimplementer, because the specification mentions none of it. At least one is a genuine mathematical break.
Discount factors greater than one. The discount curve anchors at the spot date, not
the trade date, and extrapolates flat backwards off its first node. This is deliberate;
the reason is in a comment (zcall.c:1150: "Do flat extrapolation only when going
backwards… so that swaps which have payments before the beginning of the stub zero curve
will still value to par"). JpmcdsZeroPrice computes exp(−rate·time) with a signed
time (cxzerocurve.c:72–74), so querying the trade date, two days before the anchor,
returns a discount factor above one: 1.0000009 in the USD 2021 grid. The model never
exposes this. Every consumer takes a ratio, JpmcdsForwardZeroPrice = Z(maturity)/Z(start) (cxzerocurve.c:42–50), and each leg finally divides by
Z(today, valueDate), so the factor cancels. cpStudio normalises the same way. But the
value lives in the internal representation, and if your reimplementation clamps discount
factors at one, a natural sanity check, you will never match.
Survival probabilities greater than one. ISDA protection covers the whole of the
protection-start day. The C implements this by shifting the effective protection start
back one day (cx.c:43–45) and observing survival a day early on the fee leg, again with
the reason in a comment (feeleg.c:221: "Because survival is calculated at the end of
the day, then if we observe survival at the start of the day, we need to subtract one
from the date"). But the credit curve is anchored at the trade date
(cdsbootstrap.c:214). Put those together and the first survival query lands one day
before the curve's base date. The same backward extrapolation applies, and
S(today − 1) > 1. A probability greater than one, by construction. The asymmetry is
visible in the source: the discount factor on the adjacent line is floored at today,
MAX(today, startDate); the survival probability is not (contingentleg.c:215–216).
Does it matter? Yes. The day boundary is worth roughly one day of spread on the
protection leg. To first order it is N·S/365, since hazard times LGD is approximately
the spread. On $10 million that is $68 at the grid's 25bp rows and over $2,000 at 755bp.
Our tolerance is $0.01. Clamp the survival probability at one, or start protection a day
late, and every row moves by dollars.
An "exact" integral that depends on the knots. The protection leg is integrated over
a timeline built by merging the discount curve's node dates with the credit curve's
(timeline.c:24–29), assuming the hazard rate and the forward rate are both flat within
each interval. "Assuming flat forwards on each part of the integration, this is an exact
integral", says the comment (contingentleg.c:205–213). Except that survival is sampled
at d − 1 while discounting is sampled at d, because of whole-day cover. The two
curves are evaluated on time axes offset by one day, and the accrual-on-default clock
gets a further half-day shift (feeleg.c:392–393). The flat-forward assumption cannot be
exact for both curves on the same partition. So the "exact" integral depends on the node
set: add a redundant knot to either curve, which should be value-neutral, and the price
moves. To reproduce the reference you must integrate over exactly its pillar dates. That
is not a convention. That is a mathematical break, quietly load-bearing.
These quirks cancel, or stay small, by the time an upfront settles. The published test data has real defects too, and matching everything else to half a cent gives you the standing to prove it:
- The JPY 27–29 April 2021 grids were generated against a stale curve. Each grid ships with fresh rates for its date. Feed the reference model those rates and you reproduce our residuals ($159, $1.6k, $3.6k, growing as the frozen curve ages). Feed it the 26 April curve and the grids match to ~$2. The values were computed against Monday's curve all week, while the published inputs moved on.
- EUR 29 April 2021 has ~$0.63 of root-finder noise on one corner of the grid (25bp spread, 20% recovery, 500bp coupon). Tiny hazard rates under negative interest rates push the reference bootstrap to its numerical edge. Change the recovery to 40% and it vanishes.
In our test suite these are documented exclusions, not widened tolerances.
What it looks like in cpStudio
After all that archaeology, the model definition in cpStudio is small. A CDS is a record; a market is a declarative block:
let cds =
{ Obligor = { Entity = Entity.create "ISDA_TEST_GRID"; Currency = USD }
Tenor = Years 5 }
let market =
Market.empty {
addDiscountCurve USD tradeDate curve
addIsdaDiscountCurve USD tradeDate curve
}
There is no CDS pricer in the codebase. The instrument declares its payoff, a survival-gated premium leg and a default-contingent protection leg, and the framework derives the valuation equation, the hazard bootstrap and the upfront from that declaration. The ISDA conventions (the benchmark contract, the step-in rule, that day count) live in one conventions module.
And then we compiled it
cpStudio's pricing code is written once, against a real number type. In the plain
build, real is a float and the model computes prices directly. That is the float
row in the charts below. In the symbolic build, real is an expression node: running the
same pricer once traces the whole computation, every day count, discount factor and
hazard solve, into an abstract syntax tree, with the root-finds as first-class nodes and
the inputs we care about left as free variables. Walking that tree evaluates the model by
interpretation (Expr). Handing it to our Jita compiler emits .NET intermediate
language (MSIL), Metal shader source on macOS (Metal32, float32-only — Metal has no
doubles), or CUDA source for NVIDIA in either precision (Cuda32, Cuda64). One
definition, traced once, five ways to run it. The same tree is what differentiates our
sensitivity engine.
For the grid, the traced computation is the hazard calibration inside the upfront pricing, with coupon, recovery and quoted spread as the free variables. Each row prices through one compiled model.
The numbers below are BenchmarkDotNet steady-state means against the USD SOFR 2021 grid:
7,164 rows, each one a flat-hazard calibration plus an upfront. Everything except the CUDA
rows runs on an Apple M4 laptop, with the reference C library in-process on the same
machine, compiled -O2. The CUDA rows need NVIDIA hardware the laptop does not have, so
they come from rented GPUs and are marked where they appear. Cold-start timings flatter
nobody and mislead everybody; every figure here is a warm mean over many iterations, with
the solver's caches deliberately defeated between calls.
Comparing across machines needs care, so every rented host also ran the MSIL row as a control. It is the same managed work everywhere, and the hosts are not close: the M4 compiles the grid pricer in 36 ms where the rented host's CPU takes 107 ms. GPU compilation is CPU-bound, so a CUDA compile time measured on a rented Xeon says as much about the Xeon as about the compiler, and does not compare with the Metal figure. Repricing is GPU-bound and does compare.
Compiling the grid pricer to MSIL takes 36 ms, once. After that, cpStudio reprices the entire grid in 49 ms. The reference C library takes 1.31 s for the same grid, about 27×, and pays that price on every repricing, where the compiled model is reused across every scenario, bump and date. The worst-row disagreement against the published values is under a millionth of a dollar on $10M notional. Our uncompiled float build, for reference, prices the grid at parity with the C library. The compiler is the 27×.
Bootstrapping the discount curve, 19 pillar quotes in, calibrated curve out, a root-find per pillar, takes 40 µs compiled against 67 µs for the reference C bootstrap on the same machine. I did not expect generated MSIL to beat optimised C at its own game either.

On the GPU targets, root-finding included: CUDA in single precision reprices the grid in 4.7 ms on a rented V100, and 3.7 ms on an A100; in double precision, 34.4 ms and 20.8 ms. Precision is most of the story there, and it is not free — float32 buys roughly 6–7× the speed and costs accuracy, with a worst row off by $45.65 on $10M notional against double precision's millionth of a dollar. Metal is float32-only, so it carries that same $45.65 error, and it reprices in 59 ms — still slower than the 49 ms MSIL build running on the same laptop's CPU, though no longer by much. None of these is a hand-ported GPU pricer maintained in parallel: it is the same model definition, compiled for a different target.
The Metal number deserves an explanation rather than a footnote, because it is not measuring what it looks like. The GPU does the arithmetic in a fraction of the elapsed time; most of the rest is submission latency. The grid compiles to six kernels and our Metal dispatch blocks until each one returns, so six blocking round trips cost more than the work inside them — commit all six and wait once and the same dispatches drop by roughly four-fifths. That is an API shape rather than a hardware limit, and it is ours to fix; hoisting one object out of the per-dispatch path recently took this row from 114 ms to 59 ms on its own.
Compilation is the counterweight, and it is brutal. The grid pricer compiles to MSIL in 36 ms and to Metal in 2.21 s on the same laptop. CUDA is worse still — 14.5 s in single precision and 26.4 s in double on the rented host — though that figure is CPU-bound and that host is about three times slower than the M4 at the same compile work, so read it as an order of magnitude rather than a number. Almost all of the GPU cost is the vendor's own toolchain rather than our code generation.
For this workload, that settles it: the GPU backends do not earn their compile time. Put the two halves together and the arithmetic is unkind. Metal never breaks even at all — it spends 2.21 s compiling and is then 11 ms slower per repricing than MSIL, so every repricing widens the gap rather than closing it. CUDA in double precision, the only GPU configuration accurate enough to settle against, saves 14 ms a repricing on the V100 against a 26.4 s compile: about 1,800 repricings to break even, or roughly 1,000 on the A100. Single-precision CUDA breaks even inside 350 — but at $45.65 of error on $10M, which is not a trade-off available to anyone reconciling to the cent.
Plotted, the whole trade-off is one shape. Each curve is flat while you are still paying off the compiler and straightens out once the repricing dominates, so the length of the flat part is the debt and the elbow is where it clears.
Which end of that chart you live at is the actual question. Reprice once — a valuation, an overnight batch, an ad-hoc what-if — and you are at the far left, where compile time is most of the bill and MSIL is ahead from the first repricing. Compile at start of day and reprice all day against moving quotes, and you are at the far right, where the one-off is long since paid and only the slope matters. The CDS grid sits at the left-hand end, which is why it settles the way it does; a desk running the same model continuously is a different question with a different answer.
Those margins move, though.1
That is a verdict on this workload, not on GPUs. A CDS grid is a wide, shallow, one-shot calculation: 7,164 independent little root-finds, run once, over in tens of milliseconds. It is precisely the shape that cannot absorb a compile measured in seconds, and the shape that punishes per-dispatch latency hardest. GPU compilation pays where one compile buys far more work — Monte Carlo, or anywhere the kernel is built ahead of time and reused through the trading day. For repricing a CDS grid, MSIL is the right target, and it is the one we ship.
Why we did this
The test grids are the entrance exam for credit analytics. Passing them to the cent is the difference between "our numbers are close" and "our numbers are the market's numbers". cpStudio clears that bar across every published grid, and the same declarative core prices rates, FX, commodities, hedge accounting and xVA.
If your CDS books reconcile to the reference model only approximately, we can probably tell you which convention you have missed. Get in touch.
1. Every number here is a snapshot of a moving
target, and they moved while the article was being written. Metal's repricing went from
114 ms to 59 ms on a single change to how the backend reuses one object; over the same
few days the MSIL figure the headline rests on drifted the other way, which is why the
grid is reported at 27× the reference C library rather than the 32× an earlier draft
carried. There is nothing unusual in that, and we suspect it is the ordinary condition of
most published benchmarks — you measure a thing, the measurement shows you where the time
goes, and you go and fix it, so the number was only ever true of the version you measured.
It is worth saying out loud because performance figures are usually quoted as though they
were properties of a system rather than of a commit. These belong to
cpdelta ade39725 against Jita 0.11.15; the conclusions we draw from them are
about the shape of the workload, which does not move.
↩
CP Dynamics builds cpStudio, a cross-asset pricing and risk platform. david.naylor@cpdynamics.co.za