Repository navigation
Reduce allocations and redundant work in tree lattice pricing - #331
Merged
amaggiulli merged 1 commit intoOct 27, 2026
Merged
amaggiulli merged 1 commit into
amaggiulli merged 1 commit into
Conversation
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>
This was referenced Oct 28, 2026
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 ofFindIndexwith a capturing lambda. This lookup runs for every node incomputeStatePricesand everystepback, so the linear scan made the tree build roughly cubic and allocated a closure per call. Exactness:IsEqualaccepts at mostdouble.Epsilondifference, so while times are strictly increasing (tracked insetvalue, reset inreset()) only the lower bound and its two neighbours can match; they are checked in index order, returning the same first match asFindIndex. Otherwise it falls back to a plain linear scan.TimeGrid.index,TreeLattice.partialRollback,TermStructure.checkRange,TrinomialTreeconstruction):if (!cond) Utils.QL_FAIL(...)instead ofQL_REQUIREwith 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 callingTimeGrid.indexat every step (lazily, in the original order, so failing lookups throw at the same point).Measurements
Black-Karasinski, 100 steps, one quote =
OAS+cleanPriceOASat base, +1bp and -1bp; median of 3 runs, net10.0.Verification
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.FindIndex.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