Skip to content

Experiment: full-digit gamma in O(prec^1.5 log prec) by multipoint evaluation - #554

Draft
tompng wants to merge 11 commits into
ruby:masterfrom
tompng:gamma_multipoint_evaluation
Draft

Experiment: full-digit gamma in O(prec^1.5 log prec) by multipoint evaluation#554
tompng wants to merge 11 commits into
ruby:masterfrom
tompng:gamma_multipoint_evaluation

Conversation

@tompng

@tompng tompng commented Sep 4, 2026

Copy link
Copy Markdown
Member

Status: experimental, not intended to be merged.
This draft PR exists only as a record of an experiment, so that the code, the
measurements and the design notes stay findable from the repository. It is
stacked on the Lagrange interpolation gamma branch (#524); only the
commits after that branch belong to this PR.

Summary

BigMath.gamma / BigMath.lgamma for a full-digit argument (an x with as
many significant digits as the requested precision) currently cost
O(prec² · log log prec) with the BSGS branch of the Lagrange interpolation.
This branch evaluates the same barycentric interpolation sum in
O(prec^1.5 · log prec) and dispatches to it automatically when the
precision is at least 3000 digits and Integer multiplication is GMP-backed.
Results agree with the BSGS branch to the last digit in every test.

Measured on Apple Silicon with GMP 6.3.0, gamma(sqrt(2), prec):

digits BSGS this branch speedup
2,000 0.28s 0.28s crossover
5,000 2.0s 1.4s 1.5x
10,000 7.9s 4.0s 2.0x
50,000 207s 45s 4.6x

The measured local exponent between 10,000 and 50,000 digits drops from 2.03
(BSGS) to 1.53.

How it works

The Lagrange interpolation of f(x) = b**x / x! at consecutive integer nodes
is a sum whose consecutive term ratio is a rational function of the node
index: rho(t) = -b * (n1 - t) / (t * (a0 + t)) times the ratio of two node
distances. Such a sum is a "matrix factorial": the partial sum state advances
by the 2x2 matrix [[den_t, 0], [num_t, num_t]] per node, and the product of
s consecutive matrices is a polynomial in the batch start z. This is the
setting of Chudnovsky-Chudnovsky / Bostan-Gaudry-Schost (BGS).

The implementation is the value-domain variant of BGS ("shift of evaluation
values"):

  • Split the ~2l nodes into S ≈ sqrt(2l) batches of S nodes.
  • Represent the batch product P_s(z) by the values of its three entries
    (D, N, M) at z = u * s, not by coefficients.
  • Double P_2s(z) = P_s(z) * P_s(z + s): extend the value tables by shifting
    evaluation values (one convolution with exact binomial weights and a
    small-integer reciprocal kernel), then combine pointwise. There is no product
    tree and no separate multipoint evaluation step; the total cost is a
    geometric sum over the doublings.
  • Values are fixed-point Integers with one shared exponent per table.
    Convolutions run by Kronecker substitution onto Integer multiplication,
    which is why GMP is required: with Toom-Cook the pipeline is asymptotically
    worse than BSGS, and it stays off automatically without
    Integer::GMP_VERSION.
  • The remaining full-precision BigDecimal work is O(S) operations: one
    c_k * N_k / D_k per batch for the sum and one D_k factor per batch for
    the product.
  • The shift product prod (x - i) that moves x above 2 * prec uses the
    same doubling with a single-entry table.

Numerical stability

The fixed-point tables lose precision through the dynamic range of the values
across batches, not through the extrapolation of the value shift: the
measured loss with zero guard bits is 1.35 .. 1.49 * S * n1.bit_length
bits, stable over 300 .. 10,000 digits and identical for x next to a node.
The guard is set to 2 * S * (n1.bit_length + 4) + 256 bits.

An x within 10**-q of an interpolation node cancels q digits in one
batch denominator. The batch factor of prod is derived from the same
computed D_k that the sum divides by, so this cancellation is exact in
prod * sum, the same contract as the batch_prod reuse in the BSGS branch.
Measured against BSGS at 3000 .. 4100 digits with x within
10**-(prec-10) of a node, the error stays below 0.5 ulp.

What is in the branch

  • lib/bigdecimal/math/gamma_multipoint.rb (345 lines): the engine, plus
    Multipoint.use?, Multipoint.enabled (kill switch) and
    Multipoint.min_prec (3000; the measured crossover against BSGS is
    ~2500 digits).
  • lib/bigdecimal/math/gamma.rb: a one-line dispatch at the top of
    Gamma.gamma_lagrange. Reflection (x < 0.5), lgamma and the factorial
    doubling all reach the engine through this single point.
  • test/bigdecimal/test_bigmath.rb: convergence tests that cross the dispatch
    threshold, including an x next to a node.
  • gamma_mp_check.rb: accuracy sweep and benchmark against BSGS
    (Multipoint.enabled toggle).
  • incgamma_mp_check.rb: a second client of the same doubling driver, the
    incomplete gamma series gamma(a) ≈ r**a * e**-r / a * (1 + sum prod r/(a+i))
    for full-digit a in [0.5, 3]. Its constant numerator degenerates the 2x2
    matrix to two tables, and it runs 1.4x .. 2.3x faster than the Lagrange
    engine at 5,000 .. 50,000 digits with exact agreement. Left as a script; it
    suggests that a future lane split (incomplete gamma series for small x,
    Lagrange only for the duplication formula at large x) would be simpler and
    faster than this PR's single engine.

History of the experiment

The commit sequence records the path, including the parts that were retired:

  1. Coefficient-domain engine: batch polynomials built by a product tree,
    evaluated per point by Horner (O(prec^1.5 · log² prec)).
  2. Remainder-tree multipoint evaluation; measured crossover against Horner was
    ~250,000 digits, so it never won in practice.
  3. Kronecker pack/unpack constants (borrow-folded signed packing) and a guard
    formula fitted to measured loss.
  4. Barycentric pair tree: only two wide-by-wide products per merge instead of
    four (50,000 digits: 92s to 70s).
  5. Dispatch wiring into Gamma.gamma_lagrange.
  6. Value-domain engine (this PR's final form), then the shift product moved to
    the value domain, then the coefficient-domain engine removed: one engine,
    one error model, one code path (650 to 345 lines).
  7. Incomplete gamma series as a second client of the driver.

Why this is not being merged

  • It only helps full-digit arguments above ~2500 digits on GMP builds. The
    BSM and BSGS branches cover the common cases and stay unchanged.
  • The engine is a fixed-point Integer pipeline with an empirically fitted
    guard formula. It is verified over 300 .. 50,000 digits, not proved, and the
    guard has not been re-checked for S > 1000 (above ~10^6 digits).
  • Memory: the value tables hold ~prec^1.5 digits, hundreds of MB at 10^5
    digits.
  • I do not have the capacity to maintain it. The design notes and
    measurements are in the commit messages, so the branch can serve as a
    starting point if someone wants to pick it up.

Try it

ruby -Ilib -e 'require "bigdecimal/math"; require "benchmark";
  x = BigMath.sqrt(BigDecimal(2), 20000)
  puts Benchmark.realtime { BigMath.gamma(x, 20000) }
  BigMath.const_get(:Gamma)::Multipoint.enabled = false
  puts Benchmark.realtime { BigMath.gamma(x, 20000) }'
ruby -Ilib gamma_mp_check.rb acc     # accuracy sweep against BSGS
ruby -Ilib gamma_mp_check.rb bench   # timings

tompng and others added 11 commits August 7, 2026 22:30
Calculates `gamma(x)` by Lagrange interpolation of `b^x/x!` where `b` is `x.round`.
Implements Binary Splitting Method version for small-digit number and Baby-Step Giant-Step version for full-digit number.
Fallback to Stirling's asymptotic expansion if `x` is extremely large.
Lift the BSM [sum_num, mult_num, den] triple to polynomials in the batch
offset z, build them with a product tree over fixed-point coefficients via
Kronecker substitution onto Integer (GMP) multiplication, and evaluate at
the arithmetic progression z = 0, m, 2m, ... Polynomial work is
O(PREC^1.5 * polylog); evaluation is currently per-point Horner.

Matches the BSGS implementation exactly up to 50000 digits in tests.
Crossover is around 10000 digits (1.9x faster at 50000 digits).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace the per-point Horner evaluation (the only PREC^2 term of the
pipeline) with classical remainder-tree multipoint evaluation over the
falling-factorial subproduct moduli:
- power series inverses of the reversed moduli are memoized on the shared
  subproduct tree,
- wide dividends are pre-reduced blockwise with R = t**count mod M_root,
  whose coefficients stay small, so no division wider than 2*count occurs,
- Kronecker slot width now uses the two operands' separate maxima, which
  also speeds up small-by-large coefficient products elsewhere.

Values agree exactly with the Horner path in all tests. Measured crossover
against Horner is around m = 700 batches (roughly 250000 digits): below it
Horner's machine-word constant wins, so eval_mode defaults to :auto.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Pack folds negative coefficients borrow-style into the next slot, so one
hex-join replaces the positive/negative double pack and giant subtraction.
Unpack recovers signed slots by borrow propagation instead of adding a
giant per-slot bias constant.

Guard bits: measured loss with guard = 0 is 2.9 - 3.3 * m * n1.bit_length
bits over prec = 300..10000, identical for both eval modes and for
near-node x (the value dynamic range across batches dominates all other
roundings). Set guard = 4 * m * n1.bit_length + 256, a ~20% margin, and
record the measurement in the comment.

gamma(sqrt2): 10000 digits 7.4s -> 6.1s, 50000 digits 104.9s -> 91.9s
(horner mode); fast mode 123.9s -> 107.6s at 50000 digits. Results still
agree exactly with the BSGS implementation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The batch leaf factors decompose as den_j = L_j * B_j * I_j and
num_j = -b * L_(j-1) * G_j, where only L_j = (x - a0 - j) - z carries
full-precision coefficients and B, I, G are small exact-Integer linears.
The series numerator then takes the barycentric form
  F01 = sum_j (Omega / L_j) * w_j,  Omega = prod L_i,
with w_j collecting only small-coefficient factors. Tree nodes carry
[Omega, Phi, BI, GX] with exact-Integer BI/GX side products, so the only
wide-by-wide multiplications per merge are Phi_A * Omega_C and
Omega_A * Phi_C against the degree-d Omega, instead of four products of
degree-3d triples. The j = 0 term is attached at the root, where its
Omega * BI part is F2 itself.

gamma(sqrt2): 10000 digits 6.1s -> 4.8s, 50000 digits 91.9s -> 69.9s
(2.96x over BSGS; crossover is now around 2000 digits). Results still
agree exactly with the BSGS implementation in all tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Gamma.gamma_lagrange now routes full-digit x to the multipoint pipeline
when Integer multiplication is GMP-backed (Integer::GMP_VERSION) and prec
is at least Multipoint.min_prec (3000, above the measured ~2000-digit
crossover against BSGS). Multipoint.enabled = false is the kill switch;
without GMP the pipeline stays off automatically because Toom-Cook
multiplication would make it asymptotically worse than BSGS.

This also removes the trap that reflected arguments (x < 0.5) fell back
to the O(PREC^2) BSGS: reflection, lgamma and factorial doubling all
reach the dispatch through gamma_lagrange. The test-only Multipoint.gamma
wrapper is gone; gamma_mp_check.rb toggles Multipoint.enabled instead.

BigMath.gamma(sqrt(2)/3): 8000/16000/32000 digits 4.3/16.8/65.7s ->
2.6/8.3/26.4s. BSM and factorial-doubling paths are unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Represent the 2x2 batch transition product
  P_s(z) = prod [[den_t, 0], [num_t, num_t]]
by the values of its entries at z = u * s instead of coefficients, and
double via P_2s(z) = P_s(z) * P_s(z + s): the tables are extended by
shift of evaluation values (Bostan-Gaudry-Schost) - one convolution with
exact binomial weights, small-integer reciprocal kernel and exact
incremental delta - then combined pointwise. No product tree and no
separate evaluation step remain, so the total cost is a geometric sum
over doublings: O(PREC^1.5 * log PREC), one log less than the
coefficient engine. S = 2**kappa is even, which also removes the odd
node count constraint of the coefficient engine.

Measured loss with guard = 0 is 1.35 - 1.49 * S * n1.bit_length bits
(prec 300..10000, near-node identical): the feared extrapolation
amplification does not appear beyond the table dynamic range, so the
guard is set to 2 * S * (bit_length + 4) + 256, smaller than the
coefficient engine needs.

gamma(sqrt2) 50000 digits: 49.1s vs 65.2s coefficient engine (4.2x over
BSGS); crossover between engines is around 7000 digits, so engine
defaults to :auto (:values from 8000 digits). Exact agreement with the
coefficient engine and BSGS in all tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The shift product prod (x - i) is a single-entry batch factorial, so the
same value-table doubling applies with tables of degree s (a fifth of the
main tables' work). The batch size is now chosen inside shift_prod_factor
as a power of two, independent of the caller's batch count.

This removes the last coefficient-domain component from the value engine:
gamma(sqrt2, 50000) 49.2s -> 45.0s, and the whole value pipeline is now
uniformly O(PREC^1.5 * log PREC).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The value-domain engine now covers the whole multipoint range: its
crossover against BSGS is ~2500 digits, inside the existing
min_prec = 3000 dispatch threshold, so the coefficient engine's niche
is gone. Remove the barycentric pair tree, the Horner and remainder-tree
evaluation modes with their subproduct/inverse-series machinery, the
coefficient-polynomial helpers and the engine/eval_mode switches: the
file shrinks from ~650 to 337 lines with a single error model
(loss = 1.4 * S * n1.bit_length, guard = 2 * S * (bit_length + 4) + 256).

The whole pipeline is O(PREC^1.5 * log PREC). Accepting ~1.3x in the
3000-8000 digit band compared to the retired engine buys one engine, one
guard law and one code path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Generalize batch_value_tables to take the leaf values [den_t, num_t] from
a block, making the doubling driver client-independent; the gamma leaf
definition moves into gamma_lagrange.

incgamma_mp_check.rb computes gamma(x) for full-digit x in [0.5, 3] via
  gamma(a) =~ r**a * e**-r * (1/a) * (1 + sum prod r/(a+i)),
reusing the layer's primitives. The constant numerator degenerates the
2x2 matrix: M_s = r**s is an exact scalar and only two degree-s tables
remain, so the doubling is ~3x lighter per term than the gamma client's.

Measured: exact agreement with BigMath.gamma at 200..50000 digits, and
1.4x - 2.3x faster than the Lagrange multipoint gamma (0.77s vs 1.74s at
5000 digits, 32.5s vs 44.7s at 50000). Loss law 0.26 - 0.50 * S * bl,
smaller than the gamma client's (positive terms, narrower tables).

The loss measurement also caught a wrong term-count estimate: the
Gaussian tail approximation (1+sqrt(2))*r undercounts by ~13% (the true
Poisson tail exponent gamma-(1+gamma)ln(1+gamma) gives ~2.72*r), which
had cost a fixed ~28% fraction of the precision.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The rounding of x before the integer test happens in Gamma.gamma/lgamma
ahead of the dispatch, so the multipoint path never sees an integer x.
Inside the path the batch denominator D_k is the same computed value in
the sum and in prod, so the near-node cancellation is exact. Measured
against BSGS at prec 3000..4100 with x within 1e-(prec-10) of a node
the error stays below 0.5 ulp. Add a test that crosses the dispatch
threshold with such an x.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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.

1 participant