expint(ν, z) loses most of its accuracy for Float32 and Float16 arguments when z is on or close to the negative real axis. The error is far above the resolution of the type: up to ~85% relative error, i.e. the result is simply wrong rather than merely rounded.
Tested with SpecialFunctions v2.8.3 on Julia 1.12.7.
julia> using SpecialFunctions
julia> expint(2, ComplexF32(-3.5))
-2.9899444580078125 - 10.99557399108051im # ← wrong
julia> expint(2, ComplexF64(-3.5))
-15.623287024340843 - 10.995574287564276im
julia> setprecision(BigFloat, 128) do; expint(2, Complex{BigFloat}(-7//2, 0)); end
-15.62328702434085976994732524631780778557 - 10.99557428756427633461925184147826009474im
so the Float64 result is correct and the Float32 one is off by a factor of 5 in the real part. Float16 is likewise wrong (-3.19140625 - 10.994015112188281im).
A few more points, Float32 versus Float64 input:
| ν |
z |
Float32 input |
Float64 input |
rel. error |
1 |
-3.5f0 + 0.0f0im |
-11.107379f0 - 3.1415927f0im |
-13.92535399515229 - 3.141592653589793im |
0.197 |
2 |
-3.5f0 + 0.0f0im |
-2.9899445f0 - 10.995574f0im |
-15.623287024340843 - 10.995574287564276im |
0.661 |
2 |
-3.5f0 + 0.01f0im |
-3.0192275f0 - 0.69151247f0im |
-15.654229875324825 - 10.856321873984799im |
0.851 |
3 |
-3.5f0 + 0.0f0im |
-8.832054f0 - 19.242254f0im |
-10.783026313250343 - 19.24225500323748im |
0.0884 |
2 |
-4.0f0 + 0.0f0im |
-25.642519f0 - 12.566371f0im |
-23.925347847080612 - 12.566370614359172im |
0.0635 |
2 |
-10.0f0 + 0.0f0im |
-2854.4534f0 - 31.415928f0im |
-2895.823967611959 - 31.41592653589794im |
0.0143 |
1 |
-5.0f0 + 0.0f0im |
-39.963936f0 - 3.1415927f0im |
-40.18527535580315 - 3.141592653589793im |
0.00549 |
1.5 |
-10.0f0 + 0.0f0im |
-2653.0286f0 - 11.209983f0im |
-2676.2869535640534 - 11.209982432795858im |
0.00869 |
The order ν can be an Integer or a float of the same type as z; it makes no difference. Points with a positive real part are fine (relative error ~1e-7 for Float32), and Float64 and BigFloat are fine everywhere I looked, so this is specific to the low-precision types on the negative real axis.
Where it seems to come from
The real(z) < 0 branch of _expint (the Amos procedure in src/expint.jl) walks in from a point with a larger imaginary part:
imstart = (imz == 0) ? abs(z)*sqrt(eps(typeof(real(z)))) : imz
z₀ = rez + imstart*im
...
nsteps = ceil(2 * (imstart - imz))
Δ = (imz - imstart)*im / nsteps
for j = 1:nsteps
E_start = En_taylor(ν, E_start, z₀, Δ)
z₀ += Δ
end
Several parts of this are tied to the precision of the argument in a way that does not hold up for Float32/Float16:
imstart is abs(z)*sqrt(eps(T)), i.e. ~3.4e-4 for Float32 and ~3.4e-2 for Float16, versus ~1.5e-8 for Float64. The starting point is much further from the axis.
nsteps = ceil(2*(imstart - imz)) is then 1 for all these cases, so the whole distance is covered in a single Taylor step of En_taylor.
- the
while i == quick_niter loop that doubles imstart uses niter >> 4 iterations irrespective of the precision.
So the number of steps and the truncation of the Taylor step are effectively tuned for Float64 while the starting distance grows as sqrt(eps(T)), and for Float32/Float16 the walk back to the axis is too coarse.
Not covered by the tests
test/expint.jl exercises the negative real axis only in Float64, so nothing catches this. Some Float32/Float16 points with real(z) < 0 compared against a BigFloat or Float64 reference would.
Note that the return type is currently widened to ComplexF64 even for ComplexF32 input, which hides the problem in a typeof check; that part is a separate (type stability) issue.
Filed by Claude Code on behalf of @andreasnoack; the investigation and the text above are Claude's.
expint(ν, z)loses most of its accuracy forFloat32andFloat16arguments whenzis on or close to the negative real axis. The error is far above the resolution of the type: up to ~85% relative error, i.e. the result is simply wrong rather than merely rounded.Tested with SpecialFunctions v2.8.3 on Julia 1.12.7.
so the
Float64result is correct and theFloat32one is off by a factor of 5 in the real part.Float16is likewise wrong (-3.19140625 - 10.994015112188281im).A few more points,
Float32versusFloat64input:Float32inputFloat64input1-3.5f0 + 0.0f0im-11.107379f0 - 3.1415927f0im-13.92535399515229 - 3.141592653589793im2-3.5f0 + 0.0f0im-2.9899445f0 - 10.995574f0im-15.623287024340843 - 10.995574287564276im2-3.5f0 + 0.01f0im-3.0192275f0 - 0.69151247f0im-15.654229875324825 - 10.856321873984799im3-3.5f0 + 0.0f0im-8.832054f0 - 19.242254f0im-10.783026313250343 - 19.24225500323748im2-4.0f0 + 0.0f0im-25.642519f0 - 12.566371f0im-23.925347847080612 - 12.566370614359172im2-10.0f0 + 0.0f0im-2854.4534f0 - 31.415928f0im-2895.823967611959 - 31.41592653589794im1-5.0f0 + 0.0f0im-39.963936f0 - 3.1415927f0im-40.18527535580315 - 3.141592653589793im1.5-10.0f0 + 0.0f0im-2653.0286f0 - 11.209983f0im-2676.2869535640534 - 11.209982432795858imThe order
νcan be anIntegeror a float of the same type asz; it makes no difference. Points with a positive real part are fine (relative error ~1e-7 forFloat32), andFloat64andBigFloatare fine everywhere I looked, so this is specific to the low-precision types on the negative real axis.Where it seems to come from
The
real(z) < 0branch of_expint(the Amos procedure insrc/expint.jl) walks in from a point with a larger imaginary part:Several parts of this are tied to the precision of the argument in a way that does not hold up for
Float32/Float16:imstartisabs(z)*sqrt(eps(T)), i.e. ~3.4e-4 forFloat32and ~3.4e-2 forFloat16, versus ~1.5e-8 forFloat64. The starting point is much further from the axis.nsteps = ceil(2*(imstart - imz))is then 1 for all these cases, so the whole distance is covered in a single Taylor step ofEn_taylor.while i == quick_niterloop that doublesimstartusesniter >> 4iterations irrespective of the precision.So the number of steps and the truncation of the Taylor step are effectively tuned for
Float64while the starting distance grows assqrt(eps(T)), and forFloat32/Float16the walk back to the axis is too coarse.Not covered by the tests
test/expint.jlexercises the negative real axis only inFloat64, so nothing catches this. SomeFloat32/Float16points withreal(z) < 0compared against aBigFloatorFloat64reference would.Note that the return type is currently widened to
ComplexF64even forComplexF32input, which hides the problem in atypeofcheck; that part is a separate (type stability) issue.Filed by Claude Code on behalf of @andreasnoack; the investigation and the text above are Claude's.