Performance of applying a struct as a function

I’m not sure what the exact name of this phenomenon is, so I’ll change the title if necessary, but my question involves the following situation: I have some structs that determine how certain data x is transformed:

abstract type FooType end
struct Foo1{T<:Real} <: FooType
    a::T
end 
struct Foo2{T<:Real} <: FooType
    b::T
end

transform(x, a::Foo1) = x .* a.a
transform(x, b::Foo2) = x ./ b.b

now, I know that it is possible to also write the following (which I describe as “applying a struct as a function” in the title:

(a::Foo1)(x) = x .* a.a
(b::Foo2)(x) = x ./ b.b

The latter leads to more readable code in my case (in my case, my function name would e.g. be something like interpolate while the name which I’ll attach to the instance will be something like interpolation, in which case you’d get long expressions like interpolate(interpolation, x). Of course you can abbreviate things, but at some point that increases cognitive overhead for me).

Before I embark on a journey of slowly making a transition to this latter way of writing this throughout my code-base, are there are performance pitfalls I must be aware of? Would implementing interpolation(x) in the obvious way just amount to nicer syntax compared to interpolate(interpolation), or is there a difference in how julia treats this?

For what it is worth: I will not be defining only one such method per struct, I need to dispatch based on input types quite often.

Yes. It should have equivalent performance to an ordinary function call.

4 Likes

Aside: I would tend to just define (a::Foo1)(x) = x * a.a. If you want to vectorize the application of a function, do it at the call site as a.(x). This way it will fuse with other “dot calls”, not to mention being clearer.

6 Likes

Thanks for confirming! And you’re right of course about the dot-calls!

Perhaps a follow-up question: what if I have an in-place and a not in-place version of a method, say

transform(x, a::Foo1) = x * a.a
transform!(x, a::Foo1) = (x *= a.a; x)

I can write the first as

(a::Foo1)(x) = x * a.a

but is there a similar way to write the second?

Just one thing. If you have vectors of the abstract FooType, i.e. some entries are Foo1, some are Foo2, Foo3 etc. This dispatch thing may not be the most efficient. In that case it might be faster to do manual dispatch based on a stored Int or something.

Thanks for the reminder! My use-case fortunately involves only one FooType at the time, I’m just dispatching on the type of x.

Just to clarify (note the !):

    (a::Foo1)!(x) = (x *= a.a; x)

will give an error, but is intuitively kind of what I’m after. Is the simplest approach really to add an additional argument to dispatch on so I can get the in-place version, such as:

(a::Foo)(x, ::Nothing) = (x *= a.a; x)

or is there an elegant solution?

Note that x *= a.a is not an inplace operation, it’s just a rebinding.

And yes, if you want a different method you need a different dispatch. One thing you could do which is maybe a bit too ‘cute’ would be this:

(a::Foo)(::typeof(!), x) = (x .*= a.a; x)

and then you’d have

a(!, x)

mean the in-place version. Probably best to just use transform! or whatever though here instead of callable structs.

3 Likes

I’d do

f = Foo()
x .= f.(x)
2 Likes

Yes, if the in-place logic is simple enough that it doesn’t actually need to be in its own function and everything can be thought of elementwise that’s definitely nicer. Sometimes the real cases are a lot messier than that though and one does want it wrapped up in a function.

1 Like

These are all good points, I’ve written a slightly better MWE. If I’m going with the fourth version (call syntax, in-place), then I’d probably use a different symbol to avoid accidentally triggering it if I made a mistake while typing a negation…

using BenchmarkTools

struct Foo1 a::Float64 end

# Apply function
apply_foo(foo::Foo1, x::AbstractVector) = x .- (foo.a * mean(x))
# Apply function in-place
apply_foo!(foo::Foo1, x::AbstractVector) = (x .-= foo.a * mean(x); x)
# Apply function using call syntax
(foo::Foo1)(x::AbstractVector) = x .- (foo.a * mean(x))
# Apply function using call syntax, in-place
(::typeof(!))(foo::Foo1, x::AbstractVector) = (x .-= foo.a * mean(x); x)

function test()
    foo = Foo1(0.1)
    x = [1.,2.,3.]

    # Benchmarking different ways to apply the function
    apply_foo(foo, x); apply_foo!(foo, x); foo(x); !(foo, x) # Warm up
    display(@benchmark apply_foo($foo, $x))
    display(@benchmark apply_foo!($foo, $x))
    display(@benchmark ($foo)($x))
    display(@benchmark !($foo, $x))
end
test()

Gives

BenchmarkTools.Trial: 10000 samples with 998 evaluations per sample.
 Range (min … max):  18.578 ns …  48.754 μs  ┊ GC (min … max):  0.00% … 99.91%
 Time  (median):     22.754 ns               ┊ GC (median):     0.00%
 Time  (mean ± σ):   28.048 ns ± 487.510 ns  ┊ GC (mean ± σ):  17.84% ±  1.39%

  ▂▆▄      ▃▄▄▅██▇▆▅▄▃▃▂▂▁▁▁▁▁  ▁                              ▂
  ████▆▆▇█▆██████████████████████▇██▇█▇▇▆▆▆▇▆▆▅▇▅▆▂▅▅▅▄▄▄▄▂▄▄▃ █
  18.6 ns       Histogram: log(frequency) by time        36 ns <

 Memory estimate: 80 bytes, allocs estimate: 1.
BenchmarkTools.Trial: 10000 samples with 1000 evaluations per sample.
 Range (min … max):   9.833 ns … 58.625 ns  ┊ GC (min … max): 0.00% … 0.00%
 Time  (median):     10.250 ns              ┊ GC (median):    0.00%
 Time  (mean ± σ):   10.429 ns ±  1.441 ns  ┊ GC (mean ± σ):  0.00% ± 0.00%

     ▅▇█▄    ▁                                                ▁
  ▅▇▆████▆▄▃▇█▇▆▆▆▇▆▅▅▅▅▅▃▅▃▄▄▄▅▃▅▄▂▄▄▄▅▃▃▄▄▄▄▄▃▂▄▄▆▆▅▄▅▅▅▆▆▅ █
  9.83 ns      Histogram: log(frequency) by time      14.7 ns <

 Memory estimate: 0 bytes, allocs estimate: 0.
BenchmarkTools.Trial: 10000 samples with 998 evaluations per sample.
 Range (min … max):  18.537 ns …  48.947 μs  ┊ GC (min … max):  0.00% … 99.90%
 Time  (median):     21.585 ns               ┊ GC (median):     0.00%
 Time  (mean ± σ):   27.304 ns ± 489.328 ns  ┊ GC (mean ± σ):  18.20% ±  1.39%

  ▁▅▃   ▅▆█▆▅▄▃▂▃▂▃▂▂▁▁▁▁                                      ▂
  ████▆▇██████████████████████▇▇▇▇▇▇▆▇▆▅▆▆▆▅▄▅▅▄▅▄▆▄▁▄▄▅▄▄▅▃▅▃ █
  18.5 ns       Histogram: log(frequency) by time      40.1 ns <

 Memory estimate: 80 bytes, allocs estimate: 1.
BenchmarkTools.Trial: 10000 samples with 1000 evaluations per sample.
 Range (min … max):   9.792 ns … 45.917 ns  ┊ GC (min … max): 0.00% … 0.00%
 Time  (median):     10.209 ns              ┊ GC (median):    0.00%
 Time  (mean ± σ):   10.408 ns ±  1.349 ns  ┊ GC (mean ± σ):  0.00% ± 0.00%

     ▄██▅    ▁▁                                               ▂
  ███████▆▄▅▆██▆▆▆▇▇▇▅▅▆▅▅▅▁▃▅▅▄▅▅▄▅▅▃▅▄▅▅▅▃▅▅▅▃▅▅▅▇▆▅▆▄▃▇▆▇▆ █
  9.79 ns      Histogram: log(frequency) by time      14.7 ns <

 Memory estimate: 0 bytes, allocs estimate: 0.

What I usually do when my function is too complex (e.g. because you need to preallocate the outputs) such that the simple solution by @mbauman works

In this case, I just use different arities:

(a::Foo1)(x) = a(similar(x), x) # return freshly allocated result
(a::Foo1)(out, x) = out .= x .* a.a # writes result into existing memory

the downside is of course that you lose the ! for the mutating call.

For an in-place/mutating method I would just use a different function name with a !, e.g. evalfoo!(foo, x). This way you follow the standard Julia convention for naming mutating functions.

Yes, it’s a little more verbose, but it’s worth it IMO to be explicit about the side effect. Code that uses mutating functions is usually lower-level performance-critical code anyway, which can afford to be more verbose.

But at this point we’re down to bikeshedding, not performance questions as in the original post.

4 Likes

One way to avoid bikeshedding for the mutating function name is to instead implement the MutableArithmetics.jl interface (operate!) for the non-mutating version, providing the mutating version.