Skip to content

Reduce allocations and redundant work in tree lattice pricing - #331

Merged
amaggiulli merged 1 commit into
amaggiulli:developfrom
seanat280:feature/lattice-allocation-performance
Oct 27, 2026
Merged

amaggiulli merged 1 commit into
amaggiulli:developfrom
seanat280:feature/lattice-allocation-performance

Conversation

@potatman

Copy link
Copy Markdown
Contributor

Performance-only changes to the short-rate tree path (callable bond OAS / tree pricing). Every numeric result is bit-identical to develop. This is built on top of #330.

Changes

  • TermStructureFittingParameter.NumericalImpl.value(): binary search instead of FindIndex with a capturing lambda. This lookup runs for every node in computeStatePrices and every stepback, so the linear scan made the tree build roughly cubic and allocated a closure per call. Exactness: IsEqual accepts at most double.Epsilon difference, so while times are strictly increasing (tracked in setvalue, reset in reset()) only the lower bound and its two neighbours can match; they are checked in index order, returning the same first match as FindIndex. Otherwise it falls back to a plain linear scan.
  • Hot-path preconditions (TimeGrid.index, TreeLattice.partialRollback, TermStructure.checkRange, TrinomialTree construction): if (!cond) Utils.QL_FAIL(...) instead of QL_REQUIRE with a capturing lambda, which allocates its closure on every call even when the check passes. Exception types and messages are unchanged.
  • TrinomialTree: branching lists presized to their node count.
  • TreeLattice.stepback: removes the dead locals that repeated each probability/descendant lookup; summation order unchanged.
  • DiscretizedCallableFixedRateBond: resolves each coupon/callability grid time once per lattice instead of calling TimeGrid.index at every step (lazily, in the original order, so failing lookups throw at the same point).

Measurements

Black-Karasinski, 100 steps, one quote = OAS + cleanPriceOAS at base, +1bp and -1bp; median of 3 runs, net10.0.

Bond a Time before Time after Alloc before Alloc after
30y, monthly calls 0 350 ms 125 ms 188 MB 19 MB
20y, semiannual calls 0 48 ms 24 ms 34 MB 4 MB
10y, annual calls 0 31 ms 17 ms 23 MB 2.9 MB
30y, monthly calls 0.03 363 ms 133 ms 196 MB 19 MB

Verification

  • Bit-identical outputs (BitConverter.DoubleToInt64Bits, byte-for-byte compare of the two runs): 1,584 values (OAS, semiannual OAS, clean price at the OAS and at +/-1bp, OASD, clean price at a fixed spread, NPV) over 11 bonds (callable, putable, non-callable, sinking, premium, deep discount), 2 curves, Black-Karasinski a=0 and a=0.03 and Hull-White, 25/50/100 steps.
  • 492,196 fitting-parameter lookups on adversarial inputs (subnormals, +/-0, NaN, infinities, unsorted and duplicate times, reset after unsorted) return the same index as FindIndex.
  • Failing preconditions throw the same exception types and messages.
  • QLNet.Tests: 540 passed, 12 skipped, before and after.

A further ~1.7x is available by fitting the Black-Karasinski tree with an analytic derivative (NewtonSafe seeded from the previous step's theta instead of Brent over [-50, 50]), but that changes results around the 13th significant digit, so it is not part of this PR. Happy to send it separately if you want it.

🤖 Generated with Claude Code

Performance-only changes to the short-rate tree path. Every numeric
result is bit-identical to the previous code.

- TermStructureFittingParameter.NumericalImpl.value(): binary search
  instead of a FindIndex scan with a capturing lambda. Exact: while
  times are strictly increasing only the lower bound and its two
  neighbours can satisfy IsEqual, and they are checked in index order;
  otherwise it falls back to a linear scan.
- Hot-path precondition checks (TimeGrid.index, TreeLattice
  partialRollback, TermStructure.checkRange, TrinomialTree construction)
  use if + QL_FAIL, so no closure is allocated when the check passes.
  Exception types and messages are unchanged.
- TrinomialTree branchings are presized to their node count.
- TreeLattice.stepback: drop the dead locals that repeated the
  probability/descendant lookups; summation order is unchanged.
- DiscretizedCallableFixedRateBond resolves each coupon/callability grid
  time once per lattice instead of calling TimeGrid.index at every step.

Callable-bond OAS workload (Black-Karasinski, 100 steps, one quote =
OAS + cleanPriceOAS at base/+1bp/-1bp), median of 3 runs, net10.0:

  30y, monthly calls  a=0     350 -> 125 ms   188 -> 19 MB
  20y, semi calls     a=0      48 ->  24 ms    34 ->  4 MB
  10y, annual calls   a=0      31 ->  17 ms    23 ->  3 MB
  30y, monthly calls  a=0.03  363 -> 133 ms   196 -> 19 MB

Verified bit-identical (BitConverter.DoubleToInt64Bits) against the
previous code: 1584 values (OAS, semiannual OAS, clean price at OAS and
+/-1bp, OASD, clean price at a fixed spread, NPV) over 11 callable,
putable, non-callable and sinking bonds, two curves, Black-Karasinski
(a=0, 0.03) and Hull-White, 25/50/100 steps; 492196 fitting-parameter
lookups on adversarial inputs (subnormals, +/-0, NaN, infinities,
unsorted and duplicate times) matching FindIndex; and identical
exception types and messages on failing preconditions. Test suite
unchanged: 540 passed, 12 skipped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@amaggiulli
amaggiulli merged commit c216ad2 into amaggiulli:develop Oct 27, 2026
1 check passed
@amaggiulli
amaggiulli deleted the feature/lattice-allocation-performance branch October 27, 2026 21:53
igitur added a commit to igitur/qlnet that referenced this pull request Nov 3, 2026
The grid time of an event is the event time. TimeGrid.index(t) throws unless
the closest node is close to t, so grid[grid.index(t)] is t itself, and
TreeCallableBondEngine builds the lattice grid from mandatoryTimes(), which
already contains every coupon and callability time.

Compare t with the current time directly instead of looking it up at every
time step, and drop the two arrays and the NaN sentinels added in amaggiulli#331. The
grid is still checked, but once per lattice rather than once per event and
step: validateGridTimes walks the coupon and callability times through
grid.index on first use, so an inadequate grid - including one supplied
through the TimeGrid constructor - still throws instead of silently dropping
events.

Callable bond OAS, 30-year semiannual bond with a monthly call schedule,
360 time steps, 20 OAS solves per measurement, six measurements over two
interleaved cycles, medians:

    develop (cached)   1556 ms wall   1515 ms cpu
    this branch        1493 ms wall   1420 ms cpu

The run-to-run spread on the 2 vCPU test machine is +-10%, so the honest
reading is that this is not slower and the gain is at most a few percent;
the point of the change is that the per-step lookup and its cache are gone.
Allocations per measurement are unchanged to within 1 kB.
igitur added a commit to igitur/qlnet that referenced this pull request Nov 3, 2026
The grid time of an event is the event time. TimeGrid.index(t) throws unless
the closest node is close to t, so grid[grid.index(t)] is t itself, and
TreeCallableBondEngine builds the lattice grid from mandatoryTimes(), which
already contains every coupon and callability time.

Compare t with the current time directly instead of looking it up at every
time step, and drop the two arrays and the NaN sentinels added in amaggiulli#331. The
grid is still checked, but once per lattice: validateGridTimes walks the
coupon and callability times through grid.index on first use, so a grid that
omits an event time still throws - including one supplied through the
TimeGrid constructor - and a grid that holds two nodes within the matching
tolerance of one event is rejected as ambiguous instead of applying that
event twice.

Callable bond OAS, 30-year semiannual bond with a monthly call schedule,
360 time steps, 20 OAS solves per measurement, six measurements over two
interleaved cycles, medians:

    develop (cached)   1556 ms wall   1515 ms cpu
    this branch        1493 ms wall   1420 ms cpu

The run-to-run spread on the 2 vCPU test machine is +-10%, so the honest
reading is that this is not slower and the gain is at most a few percent;
the point of the change is that the per-step lookup and its cache are gone.
Allocations per measurement are unchanged to within 1 kB.
igitur added a commit to igitur/qlnet that referenced this pull request Nov 3, 2026
The grid time of an event is the event time. TimeGrid.index(t) throws unless
the closest node is close to t, so grid[grid.index(t)] is t itself, and
TreeCallableBondEngine builds the lattice grid from mandatoryTimes(), which
already contains every coupon and callability time.

Compare t with the current time directly instead of looking it up at every
time step, and drop the two arrays and the NaN sentinels added in amaggiulli#331. The
grid is still checked, but once per lattice: validateGridTimes walks the
coupon and callability times through grid.index on first use, so a grid that
omits an event time still throws - including one supplied through the
TimeGrid constructor - and a grid that holds two nodes within the matching
tolerance of one event is rejected as ambiguous instead of applying that
event twice.

Callable bond OAS, 30-year semiannual bond with a monthly call schedule,
360 time steps, 20 OAS solves per measurement, six measurements over two
interleaved cycles, medians:

    develop (cached)   1556 ms wall   1515 ms cpu
    this branch        1493 ms wall   1420 ms cpu

The run-to-run spread on the 2 vCPU test machine is +-10%, so the honest
reading is that this is not slower and the gain is at most a few percent;
the point of the change is that the per-step lookup and its cache are gone.
Allocations per measurement are unchanged to within 1 kB.
igitur added a commit to igitur/qlnet that referenced this pull request Nov 3, 2026
The grid time of an event is the event time. TimeGrid.index(t) throws unless
the closest node is close to t, so grid[grid.index(t)] is t itself, and
TreeCallableBondEngine builds the lattice grid from mandatoryTimes(), which
already contains every coupon and callability time.

Compare t with the current time directly instead of looking it up at every
time step, and drop the two arrays and the NaN sentinels added in amaggiulli#331. The
grid is still checked, but once per lattice: validateGridTimes walks the
coupon and callability times through grid.index on first use, so a grid that
omits an event time still throws - including one supplied through the
TimeGrid constructor - and a grid that holds two nodes within the matching
tolerance of one event is rejected as ambiguous instead of applying that
event twice.

Callable bond OAS, 30-year semiannual bond with a monthly call schedule,
360 time steps, 20 OAS solves per measurement, six measurements over two
interleaved cycles, medians:

    develop (cached)   1556 ms wall   1515 ms cpu
    this branch        1493 ms wall   1420 ms cpu

The run-to-run spread on the 2 vCPU test machine is +-10%, so the honest
reading is that this is not slower and the gain is at most a few percent;
the point of the change is that the per-step lookup and its cache are gone.
Allocations per measurement are unchanged to within 1 kB.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants