When to return an explicit `nothing` from a function?

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.

3 Likes

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.

2 Likes

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)

Relevant section of the Julia docs.

If the return from a function is nothing , use return instead of return nothing .

From the JuMP Style Guide.

2 Likes

Thanks, I’ll use return from now on.

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.

Ok, I’ve found a minimal reproducer:

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:

b ? push!(sa, (a=rand(),)) : (a = 0.0,)

Although I’d prefer the return version.

2 Likes

Ahh… you’re right!

I think I’ll still get in the habit of using return when I don’t intend to return anything to avoid these cases in the future.

1 Like