After making a few minor modifications to a function, I was surprised that my program went from a few hundred allocations to millions.
It turns out that even though I never used the return value of the function (the last line of the function was just pushing to a StructArray), it was somehow impacting the performance of my program. Adding nothing to the last line of the function cleared up all of the extra allocations.
Should I always be using nothing when I don’t intend to use the return value of a function?
Functions that have nothing to return and are written to return nothing are entirely reasonable. Sometimes, when writing a function that modifies the internal state of a vector, dict, or other indirectly passed data structure, people choose to return that data structure explicitly – so it is available for use in an assignment however that often is a matter of personal style rather than a logical requirement. In situations where returning nothing has no downside, and increases the performance or lessens memory use you should use nothing as the returned value.
When your function has no defined result or does not need to convey something back to the caller via assignment or other use of a returned value, it is best to return nothing as that is the state yielded that most informs your intent.
That makes sense – using an explicit nothing makes the intent more clear. My surprise is more that the nothing is required for good performance, even in cases where the return value of the function is never used. My expectation was that Julia would be smart enough to optimize that away.
A pseudocode example of where I wouldn’t expect the nothing to be required for good performance, but it apparently is:
function modify_state!(s::State)
push!(s.v, (a=1, b=rand(), c=:foo))
nothing # required for good performance
end
function run()
s = init_state()
modify_state!(s) # the return value is never used
println("done")
end
I haven’t run into this problem for quite some time now, but it’s not inconceivable that a stray return value could upset inference or confuse the code generation in other ways. If you have a clear and simple enough case it may be worth filing an issue; it is indeed a missed optimization.
Note that ending the function with nothing, return nothing or just return are all equivalent. Personally I prefer the latter but it’s a matter of taste.
Could you provide an example of this? I seem to find no obvious difference using vectors.
julia> function modify_state!(s::AbstractVector)
push!(s,one(eltype(s)))
nothing # required for good performance
end
julia> function run()
s = Int[]
modify_state!(s) # the return value is never used
s
end
julia> function modify_state2!(s::AbstractVector)
push!(s,one(eltype(s)))
end
julia> function run2()
s = Int[]
modify_state2!(s) # the return value is never used
s
end
julia> @btime run(); @btime run2();
80.253 ns (2 allocations: 128 bytes)
79.752 ns (2 allocations: 128 bytes)
Unfortunately, I wasn’t able to get a minimal reproducer to show the same behavior. There were two functions in my project though that consistently exhibit this behavior. One of them is the following:
function remove_order!(s::St, id::Id, px::Px)
buy = id > 0
ob = ifelse(buy, s.bids, s.asks)
ir = searchsorted(ob.px, px, rev=buy)
i = findfirst(x->x==id, view(ob.id, ir))
if isnothing(i)
error("Tried to remove missing order with id: $id and px: $px")
else
deleteat!(ob, ir[1] + i-1)
end
s.stats && push!(s.stats1, (a=sbestask(s), b=sbestbid(s), id=id, filled=false, open=false, px=px, qty=Qty(0), t=s.lastTime))
nothing
end
Removing the nothing causes total allocations to go from 350 to 4.3 million. s.stats1 is a StructArray of named tuples with isbits field types.
julia> function f(b, sa)
b && push!(sa, (a=rand(),))
return
end
f (generic function with 7 methods)
julia> function g()
sa = StructArray{typeof((a=0.0,))}(undef, 0)
for _ in 1:10^6
f(true, sa)
end
return
end
g (generic function with 5 methods)
julia> @btime g()
10.207 ms (20 allocations: 9.00 MiB)
julia> function f(b, sa)
b && push!(sa, (a=rand(),))
# return
end
f (generic function with 7 methods)
julia> @btime g()
13.307 ms (1000020 allocations: 24.26 MiB)
Check the return types of both versions.
Original is not type stable, because the return type depends on the value of b.
This should also fix the allocations: