The short answer is “because you’re writing to a location in memory outside of your current scope”.
I don’t know the long answer, but it probably has to do with escape analysis Another guess would be that the compiler can’t properly see “into” the inner function from the outside and see that it will always assign a Int
to the location in the outer scope. That’s just a guess though.
In any case, you can keep your pattern and type stability:
function better_last(vs::AbstractVector)
x = Ref(first(vs))
map(vs) do v
x[] = v
end
x[]
end
And now it’s type stable:
julia> @code_warntype better_last([1,2,4,4])
MethodInstance for better_last(::Vector{Int64})
from better_last(vs::AbstractVector) in Main at REPL[3]:1
Arguments
#self#::Core.Const(better_last)
vs::Vector{Int64}
Locals
#3::var"#3#4"{Base.RefValue{Int64}}
x::Base.RefValue{Int64}
Body::Int64
1 ─ %1 = Main.first(vs)::Int64
│ (x = Main.Ref(%1))
│ %3 = Main.:(var"#3#4")::Core.Const(var"#3#4")
│ %4 = Core.typeof(x)::Core.Const(Base.RefValue{Int64})
│ %5 = Core.apply_type(%3, %4)::Core.Const(var"#3#4"{Base.RefValue{Int64}})
│ (#3 = %new(%5, x))
│ %7 = #3::var"#3#4"{Base.RefValue{Int64}}
│ Main.map(%7, vs)
│ %9 = Base.getindex(x)::Int64
└── return %9
However, performance can’t measure up (down?) to last
, which just computes the last index and directly indexes into the array:
julia> @benchmark better_last(arr) setup=(arr=rand(UInt, 8192)) evals=1
BenchmarkTools.Trial: 10000 samples with 1 evaluation.
Range (min … max): 3.357 μs … 937.875 μs ┊ GC (min … max): 0.00% … 98.24%
Time (median): 7.968 μs ┊ GC (median): 0.00%
Time (mean ± σ): 11.191 μs ± 26.418 μs ┊ GC (mean ± σ): 7.68% ± 3.50%
██▄▅▅▅▃▁
▁▁▂████████▇▆▄▃▃▂▂▂▂▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▂▂▃▂▂▂▂▂▁▁▁▁▁▁▁▁▁▁▁ ▂
3.36 μs Histogram: frequency by time 37.4 μs <
Memory estimate: 64.06 KiB, allocs estimate: 3.
julia> @benchmark last(arr) setup=(arr=rand(UInt, 8192)) evals=1
BenchmarkTools.Trial: 10000 samples with 1 evaluation.
Range (min … max): 24.000 ns … 1.973 μs ┊ GC (min … max): 0.00% … 0.00%
Time (median): 29.000 ns ┊ GC (median): 0.00%
Time (mean ± σ): 33.343 ns ± 33.082 ns ┊ GC (mean ± σ): 0.00% ± 0.00%
▂▆█▆▅▅▃▂▄▂▃▁▁▂▁▁▂▁▂ ▁ ▂
███████████████████████▇▇▇▇▆▅▆▅▆▅▅▆▁▄▄▄▁▅▃▅▆▄▄▃▅▅▅▆▅▆▅▅▆▅▅▆ █
24 ns Histogram: log(frequency) by time 105 ns <
Memory estimate: 0 bytes, allocs estimate: 0.
The intuitive reasoning for this working is that the compiler now has a deterministic place to put data and using Ref
makes the location where overwrites occur explicit (with x[]
). Further, since that’s another call to getindex
/setindex
, it can do type inference to check whether types will match, since Ref
has a type parameter to indicate what it holds while Box
intentionally does not, as far as I know. I’m not 100% certain on this, but I’m fairly certain that using Ref
when modifying captured variables should work every time.
Said differently, type inference sees that the variable gets captured by the inner function, so it has to Box
it. This however loses type information when reading from it, which Ref
does not suffer from. This leads to the interesting situation that a slightly modified version appears type stable:
julia> function unstable_last(vs::AbstractVector)
x = Ref(first(vs))
map(vs) do v
x[] = string(v)
end
x[]
end
unstable_last (generic function with 1 method)
julia> @code_warntype unstable_last([1,2,4,4])
MethodInstance for unstable_last(::Vector{Int64})
from unstable_last(vs::AbstractVector) in Main at REPL[33]:1
Arguments
#self#::Core.Const(unstable_last)
vs::Vector{Int64}
Locals
#9::var"#9#10"{Base.RefValue{Int64}}
x::Base.RefValue{Int64}
Body::Int64
1 ─ %1 = Main.first(vs)::Int64
│ (x = Main.Ref(%1))
│ %3 = Main.:(var"#9#10")::Core.Const(var"#9#10")
│ %4 = Core.typeof(x)::Core.Const(Base.RefValue{Int64})
│ %5 = Core.apply_type(%3, %4)::Core.Const(var"#9#10"{Base.RefValue{Int64}})
│ (#9 = %new(%5, x))
│ %7 = #9::var"#9#10"{Base.RefValue{Int64}}
│ Main.map(%7, vs)
│ %9 = Base.getindex(x)::Int64
└── return %9
But in fact crashes horribly when trying to run it, due to a type mismatch:
julia> unstable_last([1,2,4,4])
ERROR: MethodError: Cannot `convert` an object of type String to an object of type Int64
Luckily, JET.jl can still find it!
julia> using JET
julia> @report_call unstable_last([1,2,4,4])
═════ 1 possible error found ═════
┌ @ REPL[33]:4 Main.map(#9, vs)
│┌ @ abstractarray.jl:2853 Base.collect_similar(A, Base.Generator(f, A))
││┌ @ array.jl:713 Base._collect(cont, itr, Base.IteratorEltype(itr), Base.IteratorSize(itr))
│││┌ @ array.jl:804 y = Base.iterate(itr)
││││┌ @ generator.jl:47 Base.getproperty(g, :f)(Base.getindex(y, 1))
│││││┌ @ REPL[33]:5 Base.setindex!(Core.getfield(#self#, :x), Main.string(v))
││││││┌ @ refvalue.jl:57 Base.setproperty!(b, :x, x)
│││││││┌ @ Base.jl:39 Base.convert(Base.fieldtype(Base.typeof(x), f), v)
││││││││ no matching method found for call signature (Tuple{typeof(convert), Type{Int64}, String}): Base.convert(Base.fieldtype(Base.typeof(x::Base.RefValue{Int64})::Type{Base.RefValue{Int64}}, f::Symbol)::Type{Int64}, v::String)
You should also know that julia does not do/guarantee tail call optimizations - the reasons for which have to do with semantics.