Take same-precision arguments in expint's internal helpers, restrict the incomplete gamma - #550
Take same-precision arguments in expint's internal helpers, restrict the incomplete gamma#550andreasnoack wants to merge 3 commits into
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #550 +/- ##
==========================================
+ Coverage 94.67% 94.68% +0.01%
==========================================
Files 14 14
Lines 3023 3010 -13
==========================================
- Hits 2862 2850 -12
+ Misses 161 160 -1
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
6328744 to
c26740d
Compare
010685a to
c1ec6dd
Compare
c1ec6dd to
523a092
Compare
|
@stevengj Would you be able to take a look? The problem I'm trying to solve here is partly type stability, but also precision. The solution is to restrict methods to have matching floating-point types for both arguments and only allow types for which we can ensure that the results have the expected precision. Hence, I had to drop the claim that the implementation supported arbitrary float inputs since I don't think the implementation actually supported that claim, see #546. Instead, I'm using MPFR when possible and throwing method errors rather than results with large errors. |
The helpers took `(::Real, ::Number)` and `(::Number, ::Number)`, so an order
and an argument of unrelated precisions could meet inside them and every
operation silently adopted whichever operand it touched: `loggamma(n+1)` and
`logabsgamma(ν)` followed the order (`Float64` for an `Integer` one) while the
rest followed the argument. That is what made `expint(::Float32, ::Float32)`
and `gamma(::Float32, ::Float32)` infer `Union{Float32,Float64}`, and what
capped a `BigFloat` result on the negative real axis at `Float64` accuracy.
The helpers now take both arguments at one precision and the callers convert,
which removes the mixing rather than correcting for it afterwards:
* `En_safe_expfact(n::T, z::T)` and `(n::T, z::Complex{T})`, with no promoting
method, so a mismatch is a `MethodError` at the call site. `isodd(n)` works
on floats, so `Int(n)`, the `typemax(Int)` guard and the `(-1)^n` fallback
are gone. The switch to the closed form moves from `n < 100` to `n < 12`:
above that the term is negligible next to the sums the callers add it to,
and the product form is the more accurate of the two.
* `En_expand_origin_general(ν::Union{T,Complex{T}}, z::Union{T,Complex{T}})`,
with the near-pole correction split out into `En_origin_pole_series`. That
series is truncated after δ^4, so it is only defined for the precisions it
can serve, and its trigger is now `abs(δ) < eps(T)^(1/6)/2` rather than a
cancellation ratio: the series error is O(δ^5) and the cancelling form loses
accuracy like eps/δ, so they meet at δ ~ eps(T)^(1/6). The previous ratio
test saturated around 0.036 and could not express the `Float16` boundary.
* `En_imagbranchcut(ν::Union{T,Complex{T}}, z::Union{T,Complex{T}})`.
`Float16` was excluded from the near-pole correction, which left
`expint(Float16(3.002), Float16(1.5))` returning 0 instead of 0.0567 --- the
fallback adds `blowup ≈ 1/δ ≈ 500` to `sumterm ≈ 0.057`, and `Float16`'s
spacing at 500 is 0.5. It is included now; the series is ~11 orders more
accurate than `Float16` can represent.
Real `BigFloat` near a positive integer order is delegated to MPFR's
incomplete gamma, `E_ν(z) = z^(ν-1) Γ(1-ν, z)`, which has no cancellation
there: 258 correct bits of 256 against 119 for the series at δ = 1e-40. Its
cost grows linearly in ν, so the series is kept for large orders. Complex
`BigFloat` has neither route --- `polygamma` is not defined for it and MPFR is
real only --- and now throws instead of returning a badly cancelled result.
Flips the twelve `expint`/`gamma(a, x)` entries in the type stability sweep
from `@test_broken` to `@test`.
… internals
`gamma(a, x)` and `loggamma(a, x)` now throw a `DomainError` for real `x < 0`
(including x == -Inf), where the mathematical value is complex except at
integer `a`. This matches MPFR and the NSWC library, which both reject
negative `x`. The `BigFloat` method is a direct `mpfr_gamma_inc` ccall;
`_gamma_big` and its `expint` fallback for negative arguments are gone, and so
is the separate `gamma(a::Integer, x)` method. The generic `_gamma` and
`_loggamma` are restricted to `Float16`/`Float32`/`Float64` arguments of one
precision, like the `expint` internals.
`expint(::BigFloat, ::BigFloat)` delegates to MPFR through the incomplete
gamma, E_ν(z) = z^(ν-1) Γ(1-ν, z), which does not cancel for `ν` near a
positive integer: 258 correct bits of 256 at δ = 1e-40 where the origin series
kept 119. `expintx` has no `BigFloat` method any more --- MPFR has no scaled
incomplete gamma, and forming exp(z)·E_ν(z) from the unscaled value would
defeat the purpose of the scaled function.
The remaining `expint` internals take both arguments at one precision
(`Union{T,Complex{T}}` with `T` one of the three hardware float types), with
the promotion done once at the public entry: an integer order now adopts the
argument's precision instead of dragging the computation to `Float64`. This
removes the `float`/`promote`/`oftype` shims inside, the unreachable
`En_safe_gamma_term(::Integer, ::Real)` method, and the `typemax(Int)` guard
in `En_expand_origin` (the order is a float there, and `isodd` works on
floats). `En_safeexpmult` and `En_imagbranchcut` are split into real and
complex methods instead of branching on `isa`, using `sincospi`, and the
near-pole series evaluates its `π^2`/`π^4` constants in the working precision.
523a092 to
ef23f33
Compare
* Correct the scale of the continued fraction's convergence test `En_cf_nogamma` compared `abs(Aprev*B - A*Bprev)` against `10*eps(real(B))*abs(B*Bprev)`, which is wrong on three counts. `eps(real(B))` is `eps` of the value `z + ν` rather than of the type, so the tolerance grew with `|z|`: 7.1e-15 at `|z| = 40` instead of 2.2e-16. And dividing the comparison through by `|B*Bprev|` shows it to be `|Aprev/Bprev - A/B| < ϵ`, an absolute test on a convergent of size `|e^z E_ν(z)| ≈ 1/|z|`. Together these give an effective relative tolerance of about `10*eps(one(T))*|z|²`, an error floor that grows with the argument: 2.8e-13 by `z = 32` for `ν = 2` on the positive real axis, against 1.8e-15 now. `B*Bprev` is also the largest of the three products, since `|B| ≈ |z||A|`, so it reaches `floatmax` while the numerator's mixed `A·B` products are still finite, and the comparison then succeeds against `Inf`. That is #545: every `Float32` seed point the `real(z) < 0` branch picks for the cases reported there exits that way, after 20 to 221 iterations, with relative errors from 0.0179 to 0.874. The rescaling below the test cannot prevent it, having fired at most once by then. The test now uses `eps(T)` and `abs(A*Bprev)`, so the threshold is the size of the terms it is compared against, and the rescaling moves above it. The placement matters: the bound has to hold when the test consumes the products, not one iteration earlier, and for `Float16` `floatmax^(1/4)` is only 16, so a single iteration's growth can overflow them. The rescaling also has to consider `B` as well as `A`, since near the negative real axis the two sequences decouple and `A` leads by up to 112x, so testing `A` alone understates the maximum by a factor of `|z|`. `sqrt(floatmax(T))` becomes `sqrt(floatmax(T))/4` to leave margin for the overshoot between checks, there being two recurrence steps per loop iteration. Over a grid of ±60 × ±30 for `ν = 1, 2, 2.5, 1+i` the worst relative error drops from 2.0e-12 to 4.3e-15 and the mean from 8.0e-14 to 3.7e-16, in both half planes, and the `Float32` and `Float16` cases of #545 return to the resolution of their types. The cost per iteration is unchanged; the roughly 15 percent more iterations are the ones the old test skipped. Fixes #545. * Test the continued fraction past the series cutoff Nine points with `abs2(z) > 9`, checked against MPFR's incomplete gamma at `rtol = 50*eps(Float64)`. All nine fail before the previous commit, by 74 to 1313 ulps, and pass after it, within 8.
Stacked on #549 — please merge that one first; this PR targets its branch and GitHub will retarget it to
masterafterwards. It flips the twelveexpint/gamma(a, x)entries in the sweep from@test_brokento@test.The problem
The internal helpers took
(n::Real, z::Number)and(ν::Number, z::Number), so an order and an argument of unrelated precisions met inside them and every operation adopted whichever operand it touched:loggamma(n+1),logabsgamma(ν)andpolygammafollowed the order —Float64for anIntegerone — while the rest followed the argument._expintpromotedzagainstνbut deliberately leftνalone, so the mismatch was the normal case, not an edge case.Consequences:
expint(::Float32, ::Float32)andgamma(::Float32, ::Float32)inferredUnion{Float32,Float64}(what #520 ran into), aBigFloatresult on the negative real axis was capped atFloat64accuracy, andFloat16returned0.0near a positive integer order.The change
Every internal helper takes its arguments at one precision —
Union{T,Complex{T}}withTone ofFloat16,Float32,Float64— and the promotion happens once, at the public entry. No promoting methods on the helpers, so a mismatch is aMethodErrorat the call site rather than a silent mixed-precision computation.Float64.En_safe_expfact(n::T, z::T)/(n::T, z::Complex{T}).isoddworks on floats, soInt(n), then <= typemax(Int)guard and the(-1)^nfallback are gone, and with them thetypemax(Int)dispatch inEn_expand_origin. The switch to the closed form moves fromn < 100ton < 12: measured, the product form is the more accurate of the two everywhere, and aboven ≈ 12the term is negligible next to the sums the callers add it to, in every precision.En_expand_origin_generalkeeps the near-pole correction in a separateEn_origin_pole_series, defined only for the precisions its δ⁴ truncation can serve, and triggered byabs(δ) < eps(T)^(1/6)/2instead of a cancellation ratio: the series error isO(δ⁵)and the cancelling form loses accuracy likeeps/δ, so the two meet atδ ~ eps(T)^(1/6). The old ratio saturated at ~0.036 for δ ≥ 0.1 and could not express theFloat16boundary. Itsπ^2/π^4constants are evaluated inT.En_safeexpmultandEn_imagbranchcutare split into real and complex methods rather than branching onisa, and usesincospi.En_safe_gamma_term(::Integer, ::Real)method and thefloat/promote/oftypeshims inside the helpers are gone.Incomplete gamma
gamma(a, x)andloggamma(a, x)now throwDomainErrorfor realx < 0, where the value is complex except at integera. This is a breaking change:gamma(2, -1.3)returned-1.1008and now throws, and the negative-xassertions intest/gamma_inc.jl— including the hand-computedbig"-1.1007890…"andbig"-2.0801…e101"references — become@test_throws.The rationale is that both available kernels reject it anyway: MPFR's
mpfr_gamma_increturnsNaNforx < 0, and NSWC'sGRATIOdocuments "IT IS ASSUMED THAT A AND X ARE NONNEGATIVE". The old support came from_gamma_bigrouting negativexwith integerathroughx^a * expint(1-a, x), i.e. through the very function this PR is fixing.So
_gamma_bigis deleted along with its four_gammamethods and the separategamma(a::Integer, x); theBigFloatmethod is now a plainmpfr_gamma_incccall. Onlyxis restricted, nota:expintdelegates asgamma(1-ν, z), so the first argument is negative wheneverν > 1, andmpfr_gamma_incaccepts a negative first argument — it is onlyx < 0that returnsNaN.Fixes
Float16near a positive integer order returned zero, because the oldreal(ν+z) isa Union{Float64,Float32}guard excluded it and the fallback then addsblowup ≈ 1/δ ≈ 500tosumterm ≈ 0.057—Float16's spacing at 500 is 0.5, so the sum is annihilated:Float16was never a numerical problem there: at its spacing near a pole the truncation error isδ⁵ ≈ 3e-14, eleven orders beloweps(Float16).Type stability, all now concrete:
expint(::Int,::Float16),expint(::Int,::Float32),expint(::Float32,::Float32),expint(::Int,::ComplexF32),expint(::Float32,::ComplexF32),gamma(::Float32,::Float32).BigFloaton the negative real axis — from the signature, not a conversion:expint(5, big(-4)+0im)goes from 53 to 244 correct bits of 256,expint(9, …)from 50 to 245.Real
BigFloatnear a positive integer order is delegated to MPFR viaE_ν(z) = z^(ν-1) Γ(1-ν, z), which has no cancellation there: 258 correct bits of 256, against 119 for the series at δ = 1e-40. Validated against an independent reference (the series at 4096 bits), not against itself.expintxhas noBigFloatmethod any more. MPFR has no scaled incomplete gamma, and buildingexp(z)·E_ν(z)from the unscaled value would defeat the point of the scaled function — it forms the underflowing quantity and multiplies by an overflowing one. ComplexBigFloathas no route either (polygammais undefined for it, MPFR is real only, andgamma(a,x)for complex arguments routes back throughexpint), so it throws rather than returning a badly cancelled value. A follow-up can restore both with guard bits: recomputing atprecision + ceil(log2(1/abs(δ))) + 10and rounding back gives full precision.Also
JET.report_packageloses the_polygamma(::Int64, ::BigFloat)finding, since the correction no longer reachespolygammawith aBigInt-derived order.Full suite passes on 1.10, 1.12 and 1.13-rc3. New tests cover the near-pole values for all three hardware precisions, the
BigFloatdelegation, and the complexBigFloaterror.Known limitation, not addressed here
The origin series and the continued fraction lose accuracy to cancellation independently of any of this: at
x = 2.94the two terms are 119–255× the result, so ~8 bits go structurally. It is invisible atFloat64but not atFloat32, where it makesexpint(1, x)andexpint(x)disagree by up to 439 ulps (the one-argument entry evaluatesE₁inFloat64and rounds). Filing separately.Prepared by Claude Code on behalf of @andreasnoack; the investigation and the text above are Claude's.