Allocations on field access even though no abstract types are involved

Hi, after reading the manual, I was thinking to avoid dynamic lookup by parametrizing my type for a function with attached data, like so:

Original type:

struct FunctionWithExtra{A}
    f::A
    extra
end

where f is supposed to be a function called in a hot loop.

fwe = FunctionWithExtra(x -> (x*x; nothing), "some other info")
for i in 1:1.0:1000
    fwe.f(i)
end

Although the field f is now concretely typed, this allocates,

@allocated fwe.f(3.)

I then tried

struct FunctionBare{A}
    f::A
end
fbare = FunctionBare(x->(x*x; nothing))
fbare.f(2.)
@allocated fbare.f(2.)

which as expected does not allocate, but does not give me the extra attached
functionality I want.

But even this

struct FunctionWithConcreteExtra{A}
    f::A
    extra::Int64
end
fwce = FunctionWithConcreteExtra(x ->(x*x; nothing), 34)
fwce.f(2.)
@allocated fwce.f(2.)

does allocate.

How can I make a wrapped function type that does not allocate on field access? In my actual code, there is a whole collection of “extras” with diverse abstract types, so I think it’s not great to try and parametrize them all?

JuliaLang/FunctionWrappers.jl: Type stable and efficient wrapper of arbitrary Julia functions

Are you certain that it’s the field access and not the retrieval of the non-const global variable fw that allocates?

@Sukera I think it’s the field access, because otherwise fbare should also allocate, no?

@BdeKoning To me that reads like it ensures type stability in presence of callable fields. From another thread I picked up the technique to parametrize the struct on the type of the function, FunctionWithExtra{A} for example, which gives a concrete type to the struct. This works to avoid alloction with FunctionBare, for example. Also,

julia> typeof(fwe)
FunctionWithExtra{var"#112#113"}

appears to have a concrete type. Am I mistaken?

Here is a surprising (to me) workaround:

(fwe::FunctionWithExtra)(arg) = fwe.f(arg)
@allocated fwe.f(4.) # gives 16
fwe(4.)
@allocated fwe(4.) # gives 0 

confusing, but maybe workable for my use case.

It’s doing things in global scope I’d say:

test(x) = x.f(3.0)
@allocated test(fwe) == 0
1 Like

I stumbled on that as well, so I have been rewriting my loops like this:

fwe = FunctionWithExtra(x -> (x*x; nothing), "some other info")
fwrap = fwe.f
for i in 1:1.0:1000
    fwrap(i)
end

which also avoided allocations in the loop. But it feels very clunky.

EDIT: I didn’t get what you showed at first. Now I do.

It’s not the field access, @Sukera is correct. fwe is a non-constant global variable, so calling it allocates.

julia> struct FunctionWithExtra{A}
           f::A
           extra
       end

julia> fwe = FunctionWithExtra(x -> (x*x; nothing), "some other info");

julia> fwe.f(1.0); # get complation out of the way

julia> @allocated fwe.f(1.0)
16

julia> let f = fwe.f
           @allocated f(3.0)
       end
0

The fbare version doesn’t allocate because fbare is an isbits type.

4 Likes

Ah, learned something new. So all of this is a red herring? If I define fwe inside a function and later call fwe.f(x) that will be fine, yes?

Yes, that’s what @sdanisch showed.

1 Like