How to avoid boxed function names in closures?

Hello! In order to avoid performance problems, I would like to know whether boxes can be avoided in two kinds of closures. Without closures, all problems mentioned below disappear. I’m using Julia 1.6.0.

Keyword argument in the presence of other methods

Consider the following example:

function h1()
    f(x, y) = x+y
    f(x; p = 0) = x+p
    return f
end
f = h1()

Then @code_warntype f(1) contains the lines

Body::Any
1 ─ %1 = Core.getfield(#self#, Symbol("#f#1"))::Core.Box

You also see a box in @code_warntype f(1; p = 1), but not in @code_warntype f(1,1). The problem disappears if the keyword argument is removed from the second method, but also if instead the first method is removed, so that there is only the second method left (with the keyword argument). Is there a way to have multiple methods and keyword arguments at the same time without creating boxes?
The GitHub issue #19668 might be related, but it has been fixed already. (I tried to insert a link, but Discourse wouldn’t let me do it.)

Function calling itself

In the example

function h2()
    f(x, y) = x+y
    f(x) = f(x, x)
    return f
end
f = h2()

the output of @code_warntype f(1) contains

Body::Any
1 ─ %1 = Core.getfield(#self#, :f)::Core.Box

In this specific example I can of course avoid that f calls itself by saying

function h3()
    g(x, y) = x+y
    f(x, y) = g(x, y)
    f(x) = g(x, x)
    return f
end
f = h3()

and this fixes the problem. But what do I do for a truly recursive function, say

function h4()
    f(n) = n == 0 ? 1 : n*f(n-1)
    return f
end
f = h4()

Is there a way to avoid the creation of boxes in this case?

Thanks a lot!

2 Likes

Callable structs seem to solve this particular issue:

struct H1<:Function end

(f::H1)(x, y) = x+y
(f::H1)(x; p = 0) = x+p

f = H1()

#etc...
2 Likes

I recognize that you’re providing a MWE here and that your real code is presumably much more complicated, but just forcing the compiler to specialize a bit more aggressively does the trick for me in h1:

function h1()
  f(x,y) = x+y
  f(x::T, p::T=zero(T)) where{T} = x+p # used , instead of ;, see edit.
  f
end

f=h1()
@code_warntype f(1) # Body::Int64, looks good.

EDIT: So, interestingly, I realize that I mistakenly just put a comma instead of a semicolon for the kwarg version. Changing that comma to the semicolon broke the type inference, so this actually seems like something to do with the keyword arg parsing.

Thanks for this very nice workaround with constructors!
The issue #19668 was about performance problems related to constructors with keyword arguments, and it was fixed in 2018. Could this indicate that the problem I’m describing here can be fixed in a similar way?

Yes, I gave a MWE. My motivation is twofold:

  1. Independently of the application I have in mind, I wonder whether this is a problem with Julia that one should report as an issue on GitHub. It’s unclear to me whether the boxes the compiler inserts in my examples are strictly necessary or could be omitted.
  2. Originally, I was writing a function that extends the methods of a given function (with no restrictions on the return type). Expanding on my first example, I would have
function h1(g::Function)
    f(x...) = g(x...)
    f(x; p = 0) = g(x, p)
    return f
end
f = h1(+)

If I (naively) adapt the callable struct solution mentioned above,

struct H1 <: Function
    g::Function
end
(f::H1)(x...) = f.g(x...)
(f::H1)(x; p = 0) = f.g(x, p)
ff = H1(+)

then it seems I lose type stability completely. For example, I get

julia> @code_warntype ff(1, 2)
Variables
  f::H1
  x::Tuple{Int64, Int64}

Body::Any
1 ─ %1 = Base.getproperty(f, :g)::Function
│   %2 = Core._apply_iterate(Base.iterate, %1, x)::Any
└──      return %2

The closure approach is type stable as long as I use the first method. For example, @code_warntype f(1, 2) reports Body::Int64. Is there a solution that is always type stable?

EDIT: If one uses a parametrized type,

struct H1{T} <: Function where T <: Function
    g::T
end

then one gets type stability.

This is all interesting. Even if you completely statically type the kwarg version you get type instability:

function h1()
  _f(x,y) = x+y
  _f(x::Float64; p::Float64=0.0) = x+p
  _f
end
const f = h1()
@code_warntype f(1.1) # bad

I think I’m really out of my depth here. But I’d definitely be interested to learn the answer about this behavior from somebody who understands it. I’m not even sure I see what’s going on here well enough to suggest if it should be an issue.

EDIT:

So, this code in a script file doesn’t even run with include, and the error message is probably helpful about what’s going on:

# an empty object that we'll add methods to, like the struct suggestion.
struct Example end

# Instantiate some unique one.
sample_fn = Example() 

# A function to add methods.
function addmethods!(fn::F) where{F}
  (f::F)(x, y)   = x+y
  (f::F)(x; p=0) = x+p
  nothing
end

# when you try to include:
ERROR: LoadError: syntax: Global method definition around /home/cg/furthertest.jl:10 needs to be placed at the top level, or use "eval".

If you use inner functions, you restrict the inner function to the scope of the outer function (in which it is defined in). With the syntax (f::F)(x, y) = x+y what you are trying to do is to define a global scope function that is called every time an object of type F is treated as a function (in that scope, fn(1, 2) would return 3). However, this should not be possible to do with an inner function, because it does change how objects of type F behave anywhere in the program, hence the error.

1 Like