Skip to content

Keep the argument precision in expint and gamma(a, x) - #548

Closed
andreasnoack wants to merge 2 commits into
masterfrom
fix/expint-type-stability
Closed

Keep the argument precision in expint and gamma(a, x)#548
andreasnoack wants to merge 2 commits into
masterfrom
fix/expint-type-stability

Conversation

@andreasnoack

Copy link
Copy Markdown
Member

expint(ν, z) widens Float16, Float32 and ComplexF32 arguments to Float64 on several code paths, because Float64 values leak into the computation. On master:

julia> expint(2.000001f0, 0.5f0)      # both arguments are Float32
0.32664367153263063                   # ... and the result is a Float64

julia> expint(200, 1.5f0)
0.0011128268875730991                 # Float64

julia> expint(-2, 1.5f0)
0.47931663691997534                   # Float64

julia> expint(2, ComplexF32(-3.5))
-2.9899444580078125 - 10.99557399108051im   # ComplexF64

and correspondingly Base.infer_return_type(gamma, Tuple{Float32,Float32}) is Union{Float32, Float64} rather than Float32, which is what #520 ran into.

There are four separate leaks:

  • the near-pole correction in En_expand_origin_general evaluates the Float64 constants π^2 and π^4,
  • gamma(1-ν) in the same function is computed in Float64 for an integer or Rational order,
  • loggamma(n+1) in the n >= 100 branch of En_safe_expfact likewise,
  • so are cospi, sinpi and logabsgamma in En_imagbranchcut, which handles a complex argument sitting exactly on the negative real axis.

Each is fixed by converting the result back to the type that promoting the arguments gives, which keeps the Float64 precision of the intermediate computations and only corrects the type. In En_expand_origin_general the guard value is additionally bound to a variable, so that the type of n in that branch — which is passed on to polygamma — can be inferred at all.

gamma(a, x) and loggamma(a, x) are computed via expint for non-positive-integer a and inherit all of this.

Effect

Every combination of order type × argument type I checked now infers concretely: Int, Int32, Rational, Float16/32/64, ComplexF32/64 and BigFloat orders against Float16/32/64, ComplexF32/64, BigFloat and Complex{BigFloat} arguments, for expint, expintx and gamma(a, x).

Float64 results are unchanged bit for bit — I compared 144 values across expint, expintx, expinti, gamma(a, x), zeta, digamma, polygamma, eta, invdigamma and loggamma. The only values that change are the ones that had the wrong type, and they agree with the old ones to Float32 rounding.

Tests

  • a type stability testset in test/expint.jl covering the series about the origin, the near-pole correction, the continued fraction and the negative-real-axis procedure for Float16, Float32 and Float64, real and complex;
  • gamma(a, x)/loggamma(a, x) type stability and allocation tests in the existing GPU compatibility testset of test/gamma_inc.jl, in the shape #520 asked for.

Full suite passes on 1.10, 1.11, 1.12 and 1.13-rc3.

While verifying this I found that expint is separately inaccurate for Float32 arguments near the negative real axis (up to 85% relative error) — that is pre-existing, unrelated to the types, and filed as #545.


Prepared by Claude Code on behalf of @andreasnoack; the investigation and the text above are Claude's.

`expint(ν, z)` widened `Float16`, `Float32` and `ComplexF32` arguments to
`Float64` on several code paths, because `Float64` values leaked into the
computation:

* the near-pole correction in `En_expand_origin_general` evaluates the
  `Float64` constants `π^2` and `π^4`,
* `gamma(1-ν)` there and `loggamma(n+1)` in `En_safe_expfact` are computed in
  `Float64` for an integer order,
* so are `cospi`, `sinpi` and `logabsgamma` in `En_imagbranchcut`.

The results are converted back to the type that promoting the arguments
gives, which keeps the `Float64` precision of the intermediate computations
and only corrects the type of the result. `expint(2.000001f0, 0.5f0)` for
instance returned a `Float64` and now returns a `Float32`.

In `En_expand_origin_general` the guard value is additionally bound to a
variable so that the type of `n` in that branch, which is passed on to
`polygamma`, can be inferred at all.

`gamma(a, x)` and `loggamma(a, x)` are computed via `expint` for
non-positive-integer `a` and inherit this: `gamma(a::Float32, x::Float32)` is
now inferred as `Float32` instead of `Union{Float32,Float64}`.

All `Float64` results are unchanged, bit for bit.
@codecov

codecov Bot commented Aug 18, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 94.71%. Comparing base (adbeb4b) to head (fbf8ca7).
⚠️ Report is 8 commits behind head on master.

Additional details and impacted files
@@            Coverage Diff             @@
##           master     #548      +/-   ##
==========================================
+ Coverage   94.49%   94.71%   +0.22%     
==========================================
  Files          14       14              
  Lines        3016     3030      +14     
==========================================
+ Hits         2850     2870      +20     
+ Misses        166      160       -6     
Flag Coverage Δ
unittests 94.71% <100.00%> (+0.22%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Comment thread src/expint.jl
gammaterm = gamma(1-ν)*z^(ν-1)
# `gamma(1-ν)` is computed in `Float64` for e.g. integer `ν`, so it is converted
# to the type of `z` (which `_expint` promoted with `ν`) to keep the result type
gammaterm = oftype(z, gamma(1-ν))*z^(ν-1)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Based on the function signature it's not clear that this is possible at all - it allows e.g. for z::Int.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are right, and it is worse than unclear — it actually breaks. On the version you reviewed:

julia> SpecialFunctions.En_expand_origin_general(0.5, 1, 1000)
ERROR: InexactError: Int64(1.772453850905516)

where master happily returned a Float64. The public entry points are unaffected, because _expint does z, = promote(float(z), ν) first, but the signature admitted more than the body can handle.

Fixed in fbf8ca7 by putting that contract into the signatures of the four internal helpers that rely on it (En_expand_origin, En_expand_origin_general, En_expand_origin_posint, En_safe_expfact, En_imagbranchcut): z::Union{AbstractFloat,Complex{<:AbstractFloat}}. The call above is now a MethodError instead of an InexactError, and expint(0.5, 1) still works. ComplexOrReal{<:AbstractFloat} would have read better, but it is defined in gamma.jl, which is included after expint.jl.

(reply written by Claude Code on behalf of @andreasnoack)

Comment thread src/expint.jl Outdated
Comment on lines +310 to +311
# `reνz` is bound to a variable such that the type of the values in this branch
# (in particular of `n`, which is passed on to `polygamma`) can be inferred

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Even without this variable it should have been possible to infer the type of n.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, and my comment was simply wrong — real(round(ν)) - 1 is inferrable on its own:

julia> Base.infer_return_type-> real(round(ν)) - 1, Tuple{BigInt})
BigInt

What the binding actually buys is the narrowing of real(ν + z) to Union{Float64,Float32} inside the branch: the isa refinement applies to the bound value, so a recomputed real(ν + z) would still be Any. That is what allows n to be converted to the working precision, which keeps the correction — and hence the polygamma calls — out of BigInt/BigFloat, and incidentally is what removed the _polygamma(::Int64, ::BigFloat) finding from report_package.

Comment rewritten accordingly in fbf8ca7.

(reply written by Claude Code on behalf of @andreasnoack)

Comment thread src/expint.jl Outdated
Comment on lines +346 to +347
# `loggamma(n+1)` is computed in `Float64` for integer `n`, so the result
# has to be converted back to the type of `z`

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If z is a BigFloat, then also loggamma(n + 1) should be computed as BigFloat in the same precision, otherwise the results will be silently inaccurate. But maybe such cases can't occur?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Such cases do occur, and you are right that the conversion was papering over a real loss. At the default 256 bits:

julia> setprecision(BigFloat, 256) do
           v = SpecialFunctions.En_safe_expfact(199, BigFloat(1))
           ref = -1/gamma(BigFloat(200))
           -log2(abs(v - ref)/abs(ref))
       end
43.4          # of 256 bits

loggamma(200) in Float64 is off by 8.6e-14 in absolute terms, and it goes straight into an exponent. The branch is reached from e.g. expint(200, big(1.0)) through En_expand_origin_posint.

That particular expint call is saved only by the term being about 1e-373 next to a sumterm of 5e-3, so the end result measured 255 of 256 correct bits against an independent reference — but the helper itself was wrong, and for abs(z) < 3 (this branch) the term is always negligible, so I could not construct a case where it shows up in expint.

fbf8ca7 evaluates the exponent in promote_type(Float64, real(typeof(z))): 43.4 → 248.7 bits, unchanged for the hardware floats.

(reply written by Claude Code on behalf of @andreasnoack)

Comment thread src/expint.jl Outdated
Comment on lines +396 to +397
# `cospi`/`sinpi`/`logabsgamma` are computed in `Float64` for e.g. integer `ν`,
# so the result has to be converted back to the type of `z`

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one is observable, so thanks for pushing on it. At 256 bits, against a reference built from MPFR's expinti via E₁(-x + 0im) = -Ei(x) - πi and E_{n+1}(z) = (exp(-z) - z·E_n(z))/n — independent of the code under test:

before after fbf8ca7
expint(2, big(-4)+0im) 247.4 247.4
expint(5, big(-4)+0im) 52.8 244.4
expint(9, big(-4)+0im) 50.3 245.1

(of 256 bits). ν = 2 escaped only because logabsgamma(2) == 0 exactly; for ν = 5 the Float64 value 3.178... enters the exponent and caps the result at Float64 accuracy. cospi/sinpi are exact for an integer ν but have the same problem for a lower-precision float order, so they are converted too.

What remains on this path is the Float32 inaccuracy in #545, which is a different and much larger problem (up to 85% relative error), and the last ~12 bits at 256, which are the fixed 101-term limit in En_taylor from #546.

(reply written by Claude Code on behalf of @andreasnoack)

Follow-up to the review of #548.

`En_safe_expfact` and `En_imagbranchcut` converted a `Float64` result back to the
type of `z`, which is right when that type is narrower but hides a loss of
accuracy when it is wider: `loggamma(n+1)` and `logabsgamma(ν)` enter an
exponent, where their absolute error becomes a relative error of the result.
Both now evaluate those terms in `promote_type(Float64, real(typeof(z)))`, i.e.
unchanged for the hardware floats and in its own precision for a `BigFloat`.
At the default 256 bits, `En_safe_expfact(199, big(1.0))` goes from 43 to 249
correct bits and `expint(5, big(-4)+0im)` from 53 to 244.

The internal functions that rely on `z` having been promoted with `ν` by
`_expint` now say so in their signatures instead of assuming it, so that
`oftype(z, ...)` cannot be reached with something like `z::Int`.

Also corrects the comment on the bound guard value: the type of `n` was always
inferrable; what the binding buys is narrowing `real(ν + z)` to
`Union{Float64,Float32}` inside the branch, which is what keeps the correction
and its `polygamma` calls in the working precision.

All values of the previous version are reproduced bit for bit.
@andreasnoack

Copy link
Copy Markdown
Member Author

@devmotion don't spend more time on this for now. Let me take a closer look at what Claude proposed before continuing with the review.

@andreasnoack

Copy link
Copy Markdown
Member Author

Closing in favour of a different approach.

@devmotion's review here was the trigger: the conversions in this PR let a kernel receive arguments of two different precisions and then converted the result, which hides the mixing rather than preventing it — and, as his third and fourth comments predicted, it papered over a real loss of accuracy for BigFloat instead of fixing it.

The replacement restricts the internal helpers to arguments of a single precision, (n::T, z::T) and (n::T, z::Complex{T}), with the conversion done by the callers. loggamma, polygamma and logabsgamma are then evaluated at the working precision by construction, so oftype conversions and the promote_type(Float64, ...) patch in this PR are unnecessary.

#549 (the type stability sweep) no longer depends on this branch — it is now based on master, with the cases this PR would have fixed marked @test_broken, so the replacement work can be tracked as they flip.


Comment written by Claude Code on behalf of @andreasnoack.

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