State of closures, Fix1/Fix2

I’ve been seeing more Base.Fix1 and Base.Fix2 inside code-bases these days, even though I thought most of these performance issues were fixed with v1.0. Apparently they aren’t? In Performance Tips it looks like we still need to use let-block shenanigans or use FastClosures if we need to avoid accidental type-instabilities (but this is something the community is actively working on improving). The SciML style guide also tells us to avoid closures, recommending Base.Fix1, or Base.Fix2.

Using Fix1/Fix2 is a solution that only applies to the simplest cases; moreover, while this pattern appears to be idiomatic now, the fact that Fix1/Fix2 aren’t exported from base makes it feel like this is a temporary hack that won’t be idiomatic in the future. This is reinforced by the fact that anonymous function arguments (discouraged by SciML style guide) proliferate in the base documentation, like filter. Now these examples aren’t technically closures, but they can easily turn into closures.

My question is, which way is the community going on this? Are we going to end up in a place where closures aren’t a problem, or are we going to be more encouraged to use Fix1/Fix2 sorts of objects?

3 Likes
4 Likes

Fix1 and Fix2 are not related to the accidental boxing thing which is in general the main performance issue with closures and why people use let blocks or FastClosures.jl.
Any anon function that can be written to use Fix1 or Fix2 is pretty much certain not to be possible to write in a way that runs into that issue.

I suspect the SciML style-guide suggests avoiding them either

  • Purely as a stylistic preference: that it was felt to be easier to read;
  • or as a microoptimization on compile times. Since identical anon functions do still compile sperately but Fix1 and Fix2 uses do not

But you would need to talk to author of that rule to know

3 Likes

This is a confused post.

AFAIK some cases are fixed with each new Julia release, but a complete fix will probably only happen when the lowering gets rewritten in Julia, @c42f. Relevant issue:

This is of no significance. It’s just polite not to export from Base, so as to prevent name space pollution, and prevent breaking existing code.

I believe SciML folks have expressed on this forum the opinion that closures are simply too dangerous (performance-wise) to ever use, so they recommend never using them. (I personally think that viewpoint is extreme.)

1 Like

I don’t see any problem in using either of them, both have benefits.
Fix1/Fix2/Fix:

  • can dispatch other functions on them, eg:
julia> using InverseFunctions
# impossible with anonymous function:
julia> inverse(Base.Fix2(+, 123)) |> dump
Base.Fix2{typeof(-), Int64}(-, 123) (function of type Base.Fix2{typeof(-), Int64})
  f: - (function of type typeof(-))
  x: Int64 123
  • somewhat faster to compile

Anonymous function:

  • more general
  • potentially faster to execute (due to constprop)
2 Likes

Or maybe it’s needed to enable static compilation? (Even though this is a niche use case of Julia.)

Yes, this is a confused post, because I was genuinely confused. Thanks everyone for the input.

3 Likes

Is that true? Take the pitfall example from the docs; the boxing can be eliminated by replacing the closure with Base.Fix2, improving performance by a factor of 7. To my understanding, the key issue is whether the captured variable is reassigned at any point in the scope from which it’s captured, which is a property of the outer function, not the closure.

julia> function abmult(r::Int)
           if r < 0
               r = -r
           end
           f = x -> x * r
           return f
       end;

julia> function abmultfix(r::Int)
           if r < 0
               r = -r
           end
           f = Base.Fix2(*, r)
           return f
       end;

julia> f = abmult(4);

julia> ffix = abmultfix(4);

julia> @code_warntype f(2)  # Boxed, type unstable
MethodInstance for (::var"#1#2")(::Int64)
  from (::var"#1#2")(x) @ Main REPL[1]:5
Arguments
  #self#::var"#1#2"
  x::Int64
Locals
  r::Union{}
Body::Any
1 ─ %1 = Core.getfield(#self#, :r)::Core.Box
│   %2 = Core.isdefined(%1, :contents)::Bool
└──      goto #3 if not %2
2 ─      goto #4
3 ─      Core.NewvarNode(:(r))
└──      r
4 ┄ %7 = Core.getfield(%1, :contents)::Any
│   %8 = (x * %7)::Any
└──      return %8


julia> @code_warntype ffix(2)  # Fix2, type stable
MethodInstance for (::Base.Fix2{typeof(*), Int64})(::Int64)
  from (f::Base.Fix2)(y) @ Base operators.jl:1135
Arguments
  f::Base.Fix2{typeof(*), Int64}
  y::Int64
Body::Int64
1 ─ %1 = Base.getproperty(f, :f)::Core.Const(*)
│   %2 = Base.getproperty(f, :x)::Int64
│   %3 = (%1)(y, %2)::Int64
└──      return %3


julia> using BenchmarkTools

julia> @btime $f(2);
  35.534 ns (0 allocations: 0 bytes)

julia> @btime $ffix(2);
  4.780 ns (0 allocations: 0 bytes)
3 Likes

It’s so fine to be confused and to ask questions. That is what this forum is for :heart:

(Not only that, but this particular subject is just very confusing in general. Not a lot of people appreciate just how difficult fixing this is! Even FastClosures.jl has subtly broken semantics, I think, but I’ve not worked on it for a long while.)

I’m occasionally concerned people seem to think I know exactly how to fix the general problem but noope, I do not :laughing:

I have a few little ideas, but IIUC lowering just doesn’t have enough information for this. Perhaps with cooperation between both lowering and the optimizer/type inference we can get close to really fixing this, eventually.

I expect to be rewriting the corresponding part of lowering in November-ish so I’ll have a better appreciation for the challenges at that point.

15 Likes

Sorry, I was rude.

4 Likes

oof, I forgot about that detail.
you are correct

1 Like

THIS was what really confused me. How does Fix2(…) solve the boxed variable problem? I tried something like deepcopy. If you copy “r” you shouldn’t need it anymore, copies of “r” aren’t reassigned. But it still boxes r.

julia> function abmult(r::Int)
                  if r < 0
                      r = -r
                  end
                  f(x) = deepcopy(r)*x
                  return f
              end
abmult (generic function with 1 method)

julia> f = abmult(4); @code_warntype f(2)

MethodInstance for f(::Int64)
  from f(x) @ Main REPL[4]:5
Arguments
  #self#::typeof(f)
  x::Int64
Locals
  r::Union{}
Body::Any
1 ─ %1 = Core.getfield(#self#, :r)::Core.Box
│   %2 = Core.isdefined(%1, :contents)::Bool
└──      goto #3 if not %2
2 ─      goto #4
3 ─      Core.NewvarNode(:(r))
└──      r
4 ┄ %7 = Core.getfield(%1, :contents)::Any
│   %8 = Main.deepcopy(%7)::Any
│   %9 = (%8 * x)::Any
└──      return %9

Oh I just realized deepcopy still gets applied every time that function runs. So if I just create another variable and add it to the expression, boxing doesn’t happen. THAT’s how Fix2 solves the problem.

julia> function abmult(r::Int)
                  if r < 0
                      r = -r
                  end
                  r1 = deepcopy(r); f(x) = r1*x
                  return f
              end
abmult (generic function with 1 method)

julia> f = abmult(4); @code_warntype f(2)
MethodInstance for (::var"#f#3"{Int64})(::Int64)
  from (::var"#f#3")(x) @ Main REPL[7]:5
Arguments
  #self#::var"#f#3"{Int64}
  x::Int64
Body::Int64
1 ─ %1 = Core.getfield(#self#, :r1)::Int64
│   %2 = (%1 * x)::Int64
└──      return %2

So I guess that means if you’re working with closures, don’t capture a variable that gets reassigned to (or at least be VERY careful about it). I’m actually very careful about reassigning variables anyway because I got burned by type instabilities before. I can see why you wouldn’t want to capture something like this.