Are "Closures should be avoided whenever possible" still valid in Julia v1.9+?

There are two scenarios where Fix1 and Fix2 (and possibly generic typed partial applicators, if humanity survives long enough to see them) offer benefit:

  1. To avoid recompilation.
  2. To avoid boxing when variables are captured.

To illustrate the first benefit—avoiding recompilation—consider the following example:

# After warmup

julia> @time let xs=(1, 2.0, 3+0im)
           reduce((x,y)->x+y, map(x->x^2, Iterators.filter(x->isodd(x), xs)))
       end;
  0.304171 seconds (124.78 k allocations: 8.307 MiB, 99.76% compilation time)

julia> @time let xs=(1, 2.0, 3+0im)
           reduce(+, map(Base.Fix2(^,2), Iterators.filter(isodd, xs)))
       end;
  0.000030 seconds (15 allocations: 656 bytes)

Each time you execute code that declares a new anonymous function, it’s compiled from scratch even if the same code has been compiled before. In interactive mode this usually isn’t a big deal, but if you have long data processing chains the compile time can get annoying. For this reason, typed partial-applicators are popular when using @chain with DataFrames (e.g. it’s a preferred idiom of @bkamins, until such a future when function definitions are hashed).

To illustrate the second benefit—avoiding boxing—consider the following example:

julia> fa(xi) = let
           if rand(Bool);  x=xi  else  x=xi  end
           map(i->x[i], eachindex(xi))
       end;

julia> fb(xi) = let
           if rand(Bool);  x=xi  else  x=xi  end
           map(Base.Fix1(getindex, x), eachindex(xi))
       end;

julia> @btime fa($[1:100;]);
       @btime fb($[1:100;]);
  2.544 μs (13 allocations: 1.31 KiB)
  50.760 ns (1 allocation: 896 bytes)

Notice that the closure code experiences a ~50x slowdown compared to the partial applicator. This is because x is “boxed” in a type-unstable mutable container, causing loss of type stability (you can confirm this with @code_warntype). This occurs because there is more than one syntactical assignment to x within the scope that it belongs to, despite the fact that it doesn’t actually need to be boxed (since after the closure’s declaration there is never a code branch in which x is reassigned).

At the moment my preference is to use typed partial applicators where possible, since it avoids recompiling things needlessly and avoids capture boxing, but when that’s not an option I’ll use the let block trick:

julia> fc(xi) = let
           if rand(Bool);  x=xi  else  x=xi  end
           let x=x; map(i->x[i], eachindex(xi)) end
       end;

julia> @btime fc($[1:100;]);
  50.103 ns (1 allocation: 896 bytes)

As argued here, there’s still a lot of room for improvement in how closures’ captures are handled.

Similar considerations apply for untyped global variables:

# after warmup

julia> x=[1:100_000_000;];
       @time map(i->x[i], eachindex(x));
       @time map(Base.Fix1(getindex, x), eachindex(x));
       @time let x=x; map(i->x[i], eachindex(x)) end;
  6.384933 seconds (200.05 M allocations: 3.729 GiB, 21.36% gc time, 1.07% compilation time)
  0.167096 seconds (7 allocations: 762.940 MiB, 0.63% gc time)
  0.245638 seconds (26.92 k allocations: 764.735 MiB, 18.32% gc time, 14.48% compilation time)
4 Likes