Repository navigation
Conversation
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.
|
Thanks, François. Would you mind splitting this into a PR with the |
|
Split as asked: #2972 now carries only the Closing this one; both branches come from the same base, so nothing is lost. |
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_REQUIREonly formats its message on failure, so the closure-allocation fixes have no counterpart, andTreeLattice::stepbackalready cachesprobability/descendantin 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::findover the vector of times, replaced bystd::lower_boundwith 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 fittingphito 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 equalstthe lower bound is the first such element. Checkinglower != end()and*lower == ttherefore returns the same indexstd::findwould, duplicates included.set()flips a flag if it is ever given a time below the last one, andreset()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 runsTimeGrid::index()(twice, viaisOnTime). 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+cleanPriceOASat base, +1bp and -1bp; median of 3 runs, gcc 13.3-O2, no OpenMP (2 vCPU). Bond prices at 98.0 clean.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:
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,computeStatePricesand the exercise-application paths are already tight per-node loops;Arrayindexing has no release-build bounds check, and the binomial/TF lattices use precomputed up/down probabilities.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
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) andadown to 1e-4. Diffed as text as well; the two runs are identical character for character.NumericalImpl::value()on adversarial inputs — duplicates,-0.0vs0.0, subnormals,DBL_MIN, values between grid points, unsorted insertion orders andreset()in between — return exactly the same index asstd::find, and the same exception when the parameter is unset. No mismatches.QL_REQUIREis 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.