Type instability when using map with closures

I have observed a surprising behaviour of map with closures. It seems that type instabilities appear when the function called by map overwrites a variable that belongs to an outer scope (only changing its value while keeping the same type). Here is a MWE:

function map_bad(x)
    r = rand(typeof(first(x)))
    c = r
    map(x) do xi
        r = rand(typeof(xi))
        xi + c + r
    end
end

function map_good(x)
    r = rand(typeof(first(x)))
    c = r
    map(x) do xi
        ri = rand(typeof(xi))
        xi + c + ri
    end
end

using BenchmarkTools
x = rand(10)
@btime map_bad($x);  # 3.193 Ξs (44 allocations: 1.00 KiB)
@btime map_good($x); # 40.237 ns (1 allocation: 144 bytes)

I am quite surprised by the difference in execution time and memory allocations. I inspected both functions with @code_warntype:

MethodInstance for map_bad(::Vector{Float64})
  from map_bad(x) @ Main REPL[2]:1
Arguments
  #self#::Core.Const(map_bad)
  x::Vector{Float64}
Locals
  #5::var"#5#6"
  c::Any
  r@_5::Core.Box
  r@_6::Union{}
Body::Vector
1 ─       Core.NewvarNode(:(#5))
│         Core.NewvarNode(:(c))
│         (r@_5 = Core.Box())
│   %4  = Main.first(x)::Float64
│   %5  = Main.typeof(%4)::Core.Const(Float64)
│   %6  = Main.rand(%5)::Float64
│         Core.setfield!(r@_5, :contents, %6)
│   %8  = Core.isdefined(r@_5, :contents)::Bool
└──       goto #3 if not %8
2 ─       goto #4
3 ─       Core.NewvarNode(:(r@_6))
└──       r@_6
4 ┄       (c = Core.getfield(r@_5, :contents))
│   %14 = Main.:(var"#5#6")::Core.Const(var"#5#6")
│   %15 = Core.typeof(c)::DataType
│   %16 = Core.apply_type(%14, %15)::Type{var"#5#6"{_A}} where _A
│   %17 = c::Any
│         (#5 = %new(%16, %17, r@_5))
│   %19 = #5::var"#5#6"
│   %20 = Main.map(%19, x)::Vector
└──       return %20
MethodInstance for map_good(::Vector{Float64})
  from map_good(x) @ Main REPL[3]:1
Arguments
  #self#::Core.Const(map_good)
  x::Vector{Float64}
Locals
  #7::var"#7#8"{Float64}
  c::Float64
  r::Float64
Body::Vector{Float64}
1 ─ %1  = Main.first(x)::Float64
│   %2  = Main.typeof(%1)::Core.Const(Float64)
│         (r = Main.rand(%2))
│         (c = r)
│   %5  = Main.:(var"#7#8")::Core.Const(var"#7#8")
│   %6  = Core.typeof(c)::Core.Const(Float64)
│   %7  = Core.apply_type(%5, %6)::Core.Const(var"#7#8"{Float64})
│         (#7 = %new(%7, c))
│   %9  = #7::var"#7#8"{Float64}
│   %10 = Main.map(%9, x)::Vector{Float64}
└──       return %10

In the case of map_bad, I do not understand why r is created this way. I am also surprised that the type of c cannot be inferred at compile time. Does anyone have an idea of what is happening?

https://docs.julialang.org/en/v1/manual/performance-tips/#man-performance-captured

You can also read The Great Debate if you want to get into the weeds!

Thank you very much for pointing this out, I will definitely keep an eye on this thread!

1 Like