Skip to content

Speed up tree-lattice pricing of callable bonds - #2962

Closed
igitur wants to merge 1 commit into
lballabio:masterfrom
igitur:backport-qlnet-331-tree-lattice-perf
Closed

igitur wants to merge 1 commit into
lballabio:masterfrom
igitur:backport-qlnet-331-tree-lattice-perf

Conversation

@igitur

@igitur igitur commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Two lookups on the per-node path of tree-lattice pricing were linear or repeated. Both are removed; every numeric result is bit-identical.

This follows the same direction as QLNet PR amaggiulli/QLNet#331, but only part of that PR applies here: in C++ QL_REQUIRE only formats its message on failure, so the closure-allocation fixes have no counterpart, and TreeLattice::stepback already caches probability/descendant in the inner loop. What does apply is the linear scan in the fitting parameter, which is the dominant cost.

Changes

  • TermStructureFittingParameter::NumericalImpl::value() (ql/models/parameter.hpp): std::find over the vector of times, replaced by std::lower_bound with a fallback to the linear scan if the times are not ascending. Black-Karasinski, Hull-White, ECIR and G2 calibration call this once per node while fitting phi to the curve, so the scan made building the tree quadratic in the number of steps.

    Exactness: on ascending data every element before the lower bound is strictly less than t, so if any element equals t the lower bound is the first such element. Checking lower != end() and *lower == t therefore returns the same index std::find would, duplicates included. set() flips a flag if it is ever given a time below the last one, and reset() clears it, so unsorted use keeps the old behaviour rather than becoming undefined.

  • DiscretizedCallableFixedRateBond (ql/experimental/callablebonds/): preAdjustValuesImpl()/postAdjustValuesImpl() test every coupon and callability against the current grid time at every step of the rollback, and each test runs TimeGrid::index() (twice, via isOnTime). The grid node of an event depends only on the lattice, so it is resolved lazily on the first adjustment after the method changes and reused afterwards. The cache is invalidated if the lattice pointer changes or the lattice is the same object rebuilt on a different grid. Lazy resolution also means a lookup that throws still throws at the same point, in the same order.

Measurements

Black-Karasinski, 100 steps, one quote = OAS + cleanPriceOAS at base, +1bp and -1bp; median of 3 runs, gcc 13.3 -O2, no OpenMP (2 vCPU). Bond prices at 98.0 clean.

Bond a Before After After, fitting-param change only
30y, monthly calls 1e-4 1432 ms 704 ms 778 ms
30y, monthly calls 0.03 1389 ms 664 ms 754 ms
20y, semiannual calls 1e-4 122 ms 95 ms 100 ms
20y, semiannual calls 0.03 101 ms 84 ms 96 ms
10y, annual calls 1e-4 75 ms 63 ms 76 ms
30y, no calls 1e-4 79 ms 61 ms 69 ms

Three interleaved rounds, ranges: 30y monthly calls 1417–1482 ms → 664–705 ms (about 2.1x); 20y semiannual 112–132 ms → 84–105 ms; 10y annual 74–94 ms → 59–68 ms.

The same lookup is on the tree-building path of every short-rate model, so the gain is not limited to callable bonds. Same machine, median of 5, three interleaved rounds:

Instrument Before After
Bermudan swaption, Hull-White, 800 steps, 10 exercise dates 1059–1083 ms 138–143 ms (7.6x)
Cap, Hull-White, 400 steps, 10y 88–93 ms 23–24 ms (3.8x)
American option, CRR, 2000 steps 27.9 ms 27.5 ms (no fitting parameter involved)
Convertible, TF lattice, 1000 steps 6.9 ms 7.5 ms (no fitting parameter involved)

All values identical to the last digit between the two libraries.

What I looked at and did not change

Profiling the other lattice engines (callgrind) turned up no further redundant work worth a change, so this PR stays minimal:

  • TreeLattice1D::grid(t) is called once per step by vanilla and convertible assets, not several times; caching it last computed array changes the instruction count by 0.004% (90,290,471 vs 90,294,439 on a barrier option) because the per-node evaluation is the work.
  • isOnTime() grid-node lookups in the swap/cap-floor/convertible adjustment loops are about 2% of a convertible run.
  • TreeLattice::stepback, computeStatePrices and the exercise-application paths are already tight per-node loops; Array indexing has no release-build bounds check, and the binomial/TF lattices use precomputed up/down probabilities.
  • A one-entry memo inside value() (instead of the binary search) buys only 6% on a swaption and nothing elsewhere, because successive lookups do not repeat the same time; it is not in the PR.

Verification

  • Bit-identical output compared through BitConverter-style integer bit patterns (XOR of all five results per configuration): 8 configurations over callable, puttable and non-callable bonds, two mean-reversion values, three step counts (25/50/100) and a down to 1e-4. Diffed as text as well; the two runs are identical character for character.
  • Separately, 430,433 lookups through NumericalImpl::value() on adversarial inputs — duplicates, -0.0 vs 0.0, subnormals, DBL_MIN, values between grid points, unsorted insertion orders and reset() in between — return exactly the same index as std::find, and the same exception when the parameter is unset. No mismatches.
  • Exception types and messages are untouched; QL_REQUIRE is still used where it was.

The benchmark and the differential harness are not part of this PR; I can add them to test-suite/ if you want them kept.

Two lookups on the per-node path were linear or repeated:

- TermStructureFittingParameter::NumericalImpl::value() scans its
  vector of times for each call.  Black-Karasinski, Hull-White, ECIR
  and G2 calibration call it once per node while fitting the term
  structure, which made building the tree quadratic in the number of
  steps.  The times are appended in ascending order, so binary-search
  them and fall back to the linear scan if they ever are not.  On
  sorted data lower_bound() gives the first element equal to t if one
  exists, so the result is the same index std::find() returns.

- DiscretizedCallableFixedRateBond::preAdjustValuesImpl() and
  postAdjustValuesImpl() test every coupon and callability against
  the current grid time at every step of the rollback.  Each test
  runs TimeGrid::index() twice (once through isOnTime()).  The grid
  node of an event only depends on the lattice, so resolve it on the
  first adjustment after the method changes and reuse it.

Results are bit-identical on 11 callable, puttable, non-callable,
sinking, premium and deep-discount bonds over 2 curves, three
mean-reversion values and 25/50/100 steps.
@igitur
igitur marked this pull request as ready for review October 28, 2026 07:58
@lballabio

Copy link
Copy Markdown
Owner

Thanks, François. Would you mind splitting this into a PR with the TermStructureFittingParameter::NumericalImpl::value() change to be merged as is, and another PR with the callable-bond changes? It might be useful to have a discussion about those and see if all the checks and the lookups we're doing in there are really needed.

@igitur

igitur commented Oct 29, 2026

Copy link
Copy Markdown
Contributor Author

Split as asked: #2972 now carries only the TermStructureFittingParameter::NumericalImpl::value() change, to be merged as is. #2971 carries the callable-bond grid-time change on its own, so the checks and lookups there can be discussed separately — I have put the measured cost of the current mechanisms and the alternative (a cursor per sorted event list instead of testing every event at every step) in that PR.

Closing this one; both branches come from the same base, so nothing is lost.

@igitur igitur closed this Oct 29, 2026
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