# Type inference failure with array comprehension

**URL:** <https://discourse.julialang.org/t/type-inference-failure-with-array-comprehension/140154>\
**Category:** Performance\
**Created:** [October 22, 2026, 11:58am UTC](https://discourse.julialang.org/t/type-inference-failure-with-array-comprehension/140154 "2026-10-22T11:58:57Z")\
**Posts on this page:** 4\
**Page:** 1

<div class="post-metadata">

**Author:** ![jer](https://avatars.discourse-cdn.com/v4/letter/j/8baadc/32.png) [@jer](https://discourse.julialang.org/u/jer)\
**Post date:** [October 22, 2026, 11:58am UTC](https://discourse.julialang.org/t/type-inference-failure-with-array-comprehension/140154/1 "2026-10-22T11:58:57Z")

</div>

Julia Version 1.13.1  
I wrote some code for a raytracer:

```julia
module EfficientRayTracer

using LinearAlgebra
using StaticArrays
using Images
using ImageView
using Infiltrator
using Profile
using InteractiveUtils

struct LightProperties
    id::Float64 # diffuse
    is::Float64 # specular
    α::Float64 
    color::SVector{3, Float64}
    r::Float64
end

struct Sphere
    o::SVector{3, Float64} # location
    r::Float64 # radius
    p::LightProperties
    Sphere(o, r, p) = new(SVector{3, Float64}(o), Float64(r), p)
end

struct Screen
    xpix::Int
    ypix::Int
    xlims::SVector{2, Float64}
    ylims::SVector{2, Float64}
    function Screen(pix, xlims, ylims)
        xlen = xlims[2] - xlims[1]
        ylen = ylims[2] - ylims[1]
        xpix = √(xlen / ylen * pix)
        ypix = ylen / xlen * xpix
        new(round(xpix), round(ypix), xlims, ylims)
    end
end

struct LightSource
    r::SVector{3, Float64} # location
    # ambient, diffuse and specular intensities
    ia::Float64 
    id::Float64
    is::Float64
    color::SVector{3, Float64}
end

function solve_quadratic(a, b, c)
    D = b^2 - 4a*c
    if D < 0
        return nothing
    end
    sD = √D
    x1 = (-b + sD) / 2a
    x2 = (-b - sD) / 2a
    return (x1, x2)
end

"""
Return minimum parameter value t for which intersection occurs or -Inf if not intersecting.
"""
function ray_intersect(r, d, sphere::Sphere)
    a = sum(d .^ 2)
    b = 2 * (r - sphere.o) ⋅ d 
    c = sum((r - sphere.o).^2) - sphere.r .^2
    res = solve_quadratic(a, b, c)
    if res !== nothing
        t1, t2 = res
        if t1 > 0 && t2 <= 0
            return t1
        elseif t2 > 0 && t1 <= 0
            return t2
        else
           return min(t1, t2)
        end
    end
    # no intersections
    -Inf 
end

@inline function outward_normal(r, sphere::Sphere)
    return normalize(r .- sphere.o)
end

function closest_intersect(r, d, objects)
    t_min = Inf
    closest_object = nothing
    for object in objects
        t = ray_intersect(r, d, object)
        if t > 0 && t < t_min
            t_min = t
            closest_object = object
        end
    end
    return (t_min, closest_object)
end

function new_d_and_n(r, d, t_min, closest_object)
    if t_min < Inf
        r .+= t_min * d
        n = outward_normal(r + t_min * d, closest_object)
        d_refl = d - 2 * d ⋅n * n
        return (d_refl, n)
    end
end

"""
Show the defined scenery.
Optimized for minimum memory footprint and maximum speed by using SVectors.
"""
function show_scenery(zcam, screen::Screen, sources::Tuple{LightSource}, objects::Tuple)
    max_refl = Inf
    total_ambient = SA[0.0, 0, 0]
    for source in sources
        total_ambient += source.ia .* source.color
    end
    total_ambient::SVector{3, Float64} = min.(SA[1.0, 1, 1], total_ambient)
    # swap x, y for correct image
    # intensities = fill(total_ambient, screen.ypix, screen.xpix)
    # for mysterious reasons, below code will cause the compiler
    # to allocate in the hot loop when updating the intensities ...
    # Also, adding a SINGLE @time in some places causes allocations
    intensities = [total_ambient for i in 1:screen.ypix, j in 1:screen.xpix]
    nrays = screen.xpix * screen.ypix
    xrange = range(screen.xlims..., screen.xpix)
    # swap y-axis for correct image
    yrange = range(screen.ylims[2], screen.ylims[1], screen.ypix)
    # CRITICAL: removed these unnecessary allocations
    # rays_d = [normalize(SVector{3, Float64}(x, y, -zcam)) for x in xrange, y in yrange]
    nmax_refl = 0

    # hot loop is allocation-free
    for idx in eachindex(intensities)
        it = SVector{3, Float64}(0, 0, 0)
        cart_idx = CartesianIndices(intensities)[idx]
        d = normalize(SVector{3, Float64}(xrange[cart_idx[2]], yrange[cart_idx[1]], -zcam))
        r = SVector{3, Float64}(0, 0, zcam)
        refl_factor = 1
        nrefl = 0
        (t, obj) = closest_intersect(r, d, objects)
        while obj !== nothing && nrefl < max_refl
            nrefl += 1
            r_new::SVector{3, Float64} = r + t * d
            n_obj::SVector{3, Float64} = outward_normal(r_new, obj)
            d_new = d - 2 * n_obj ⋅ d * n_obj
            r_new = r_new + 1e-8 * d_new
            for source in sources
                d_src::SVector{3, Float64} = normalize(source.r - r_new)
                if closest_intersect(r_new, d_src, objects)[2] === nothing
                    i_d = source.id * obj.p.id * d_src ⋅ n_obj
                    i_s = source.is * obj.p.is * max(d_src ⋅ d_new, 0) ^ obj.p.α
                    i = max(0, i_d) + max(0, i_s)
                    c = i * refl_factor * obj.p.color .* source.color
                    it += c
                end
            end
            r = r_new
            d = d_new
            refl_factor *= obj.p.r
            (t, obj) = closest_intersect(r, d, objects)
            if nrefl == max_refl
                nmax_refl += 1
            end
        end

        intensities[idx] += it
    end

    # CRITICAL: reinterpret and not convert or list comprehension for 0 allocations
    image = reinterpret(RGB{Float64}, intensities)
    # imshow(image)
end

function update_value!(mat::Matrix{SVector{3, Float64}}, val::SVector{3, Float64}, idx::Int)
    mat[idx] += val
end

function main()
    screen = Screen(1000000, (-2.0, 2.0), (-1, 1.2))
    light_props1 = LightProperties(.4, .6, 4, (0, 1, 0), .8)
    light_props2 = LightProperties(.4, .6, 4, (1, 0, 0), .8)
    light_props3 = LightProperties(.4, .6, 4, (1, 0, 1), .8)
    light_props_plane = LightProperties(.4, .6, 4, (1, 1, 1), .5)
    sphere1 = Sphere((-1, 0, 1), 1.0, light_props1)
    sphere2 = Sphere((1, 0, .7), .3, light_props2)
    sphere3 = Sphere((.8, .5, .2), .1, light_props3)
    plane = Sphere((0, -10^4, 0), 10^4 - 1, light_props_plane)
    source1 = LightSource((4.0, 1, 0.0), .1, 1, 1, (1, 1, 1))
    source2 = LightSource((2, 2, -2), .1, 1, 1, (1, 1, 1))
    # @code_warntype show_scenery(-2, screen, (source2,), (sphere1, sphere2, sphere3, plane))
    show_scenery(-2, screen, (source2,), (sphere1, sphere2, sphere3, plane))
end

end

using .EfficientRayTracer

```

The version above has no allocations in the hot loop of the “show\_scenery” function.  
However, I noticed that by removing the type label of “total\_ambient”, the compiler fails to see that “total\_ambient” must be of type SVector{3, Float64}.  
Curiously, this **only** happens whenever I use list comprehension allocation

```julia
    intensities = [total_ambient for i in 1:screen.ypix, j in 1:screen.xpix]

```

If I instead use the “fill” allocation (which should be exactly identical), as

```julia
 intensities = fill(total_ambient, screen.ypix, screen.xpix)

```

then the type of total\_ambient is correctly inferred without the SVector annotation.  
As a sidenode, the line causing the allocations when total\_ambient is not correctly inferred is

```julia
        intensities[idx] += it

```

which suggests Julia is converting it to an intermediate representation before adding SVectors directly (which is allocation-free in principle).

Let it be clear that type-annotating total\_ambient solves the issue; however, I feel like the compiler’s type inference should have also been able to infer the SVector type without it (and the performance cost is massive). My main confusion here is that changing the list apprehension to a fill allocation **solves the issue completely** even without the type annotation. Please enlighten me in how the type inference works, and why it could be influenced indirectly like so.

---

<div class="post-metadata">

**Author:** ![StevenWhitaker](https://sea2.discourse-cdn.com/julialang/user_avatar/discourse.julialang.org/stevenwhitaker/32/9749_2.png) [@StevenWhitaker](https://discourse.julialang.org/u/StevenWhitaker)\
**Post date:** [October 22, 2026, 2:42pm UTC](https://discourse.julialang.org/t/type-inference-failure-with-array-comprehension/140154/2 "2026-10-22T14:42:10Z")

</div>

I’m guessing this is an issue with closures, captured variables, and boxing (see [Performance Tips · The Julia Language](https://docs.julialang.org/en/v1/manual/performance-tips/#man-performance-captured)). In particular, you assign `total_ambient` twice:

```julia-auto
total_ambient = SA[0.0, 0, 0]
...
total_ambient::SVector{3, Float64} = min.(SA[1.0, 1, 1], total_ambient)

```

And then you use it in a closure:

```julia-auto
intensities = [total_ambient for i in 1:screen.ypix, j in 1:screen.xpix]

```

(Comprehensions create an anonymous function under the hood; in this case it would be `(i, j) -> total_ambient`, which captures `total_ambient`.) This pattern of assigning a variable more than once and then using it in a closure usually (if not always) causes Julia to box the variable, ruining inferability. Adding the type annotation restores inferability, and using `fill` sidesteps the issue.

---

<div class="post-metadata">

**Author:** ![nsajko](https://sea2.discourse-cdn.com/julialang/user_avatar/discourse.julialang.org/nsajko/32/221187_2.png) [@nsajko](https://discourse.julialang.org/u/nsajko)\
**Post date:** [October 22, 2026, 2:50pm UTC](https://discourse.julialang.org/t/type-inference-failure-with-array-comprehension/140154/3 "2026-10-22T14:50:01Z")

</div>

> [@StevenWhitaker](#):
>
> an issue with closures, captured variables, and boxing (see [Performance Tips · The Julia Language](https://docs.julialang.org/en/v1/manual/performance-tips/#man-performance-captured))

In Julia v1.14 and above [`detect_closure_boxes`](https://docs.julialang.org/en/v1.15-dev/stdlib/Test/#Test.detect_closure_boxes) will be available to help with checking for this issue automatically.

> [@StevenWhitaker](#):
>
> This pattern of assigning a variable more than once and then using it in a closure usually (if not always) causes Julia to box the variable, ruining inferability.

The compiler is certainly getting better at optimizing cases like this. I believe simple reassignment is not necessarily an issue, however the example code above also reassigns the variable in a loop. A different coding style approach could have prevented the issue.

---

<div class="post-metadata">

**Author:** ![StevenWhitaker](https://sea2.discourse-cdn.com/julialang/user_avatar/discourse.julialang.org/stevenwhitaker/32/9749_2.png) [@StevenWhitaker](https://discourse.julialang.org/u/StevenWhitaker)\
**Post date:** [October 22, 2026, 5:27pm UTC](https://discourse.julialang.org/t/type-inference-failure-with-array-comprehension/140154/4 "2026-10-22T17:27:31Z")

</div>

> [@nsajko](#):
>
> simple reassignment is not necessarily an issue

I tried finding a reproducer, and from what I can see simple reassignment really is enough to trip the compiler (Julia 1.13.2):

```julia-repl
julia> function captured()
           x = 1
           x = 2
           () -> x
       end
captured (generic function with 1 method)

julia> @code_warntype captured()
MethodInstance for captured()
  from captured() @ Main REPL[1]:1
Arguments
  #self#::Core.Const(Main.captured)
Locals
  #1::var"#captured##0#captured##1"
  x::Core.Box
Body::var"#captured##0#captured##1"
1 ─ (x = Core.Box())
│ %2 = x::Core.Box
│ Core.setfield!(%2, :contents, 1)
│ %4 = x::Core.PartialStruct(Core.Box, Union{Nothing, Bool}[0], Any[Any])
│ Core.setfield!(%4, :contents, 2)
│ %6 = Main.:(var"#captured##0#captured##1")::Core.Const(var"#captured##0#captured##1")
│ %7 = x::Core.PartialStruct(Core.Box, Union{Nothing, Bool}[0], Any[Any])
│ (#1 = %new(%6, %7))
│ %9 = #1::Core.PartialStruct(var"#captured##0#captured##1", Union{Nothing, Bool}[0], Any[Core.PartialStruct(Core.Box, Union{Nothing, Bool}[0], Any[Any])])
└── return %9

julia> @code_warntype captured()()
MethodInstance for (::var"#captured##0#captured##1")()
  from (::var"#captured##0#captured##1")() @ Main REPL[1]:4
Arguments
  #self#::var"#captured##0#captured##1"
Locals
  x::Union{}
Body::Any
1 ─ %1 = Core.getfield(#self#, :x)::Core.Box
│ %2 = Core.isdefined(%1, :contents)::Bool
└── goto #3 if not %2
2 ─ goto #4
3 ─ Core.NewvarNode(:(x))
└── x
4 ┄ %7 = Core.getfield(%1, :contents)::Any
└── return %7

```
