Function performance when passed as an argument vs. passed in a struct

question
performance

#1

I am new to Julia, but have looked at the manual and searched online, and can’t seem to find the answer to this question. Apologies if I have missed it.

Is there a recommended way to package together functions when calling another function without sacrificing performance?

Attempt at MWE below, with output:

31.843 ms (299492 allocations: 5.33 MiB)
1.875 ms (3 allocations: 781.34 KiB)

suggesting that passing a Function as an argument works well, but passing a Function as a member of a struct does not. If this is unavoidable, then it does quite significantly affect my design decisions. I am particularly interested in the ability to call user-defined functions very many times within a function. Ideally I would like to package functions into a struct (or some other container) so that function interface is kept relatively clean.

struct model
  f::Function
end

function alg1(m::model, n::Int64)
  array = Vector{Float64}(n)
  for i = 1:n
    array[i] = m.f(i)
  end
  return sum(array)
end

function alg2(f::Function, n::Int64)
  array = Vector{Float64}(n)
  for i = 1:n
    array[i] = f(i)
  end
  return sum(array)
end

function foo(i::Int64)
  return sin(i)
end

m = model(foo)

n = 100000

function test1()
  alg1(m,n)
end

function test2()
  alg2(foo,n)
end

using BenchmarkTools, Compat

@btime test1()
@btime test2()

#2

I think the problem here is that Function is an abstract type. See performance tips: avoid fields with abstract type in the manual. You could do:

struct Model{F<:Function}
    f::F
end

to declare a family of model types, parameterized by the function type. This will allow code to specialize on the specific function, e.g. to inline the function.

You should maybe re-think this design. Creating a struct with a bunch of Function fields is very OOP-like, and OOP idioms are unnatural in Julia code.

There are lots of alternatives. For example, the direct analogue of OOP’s object.method(args...) in Julia is method(object, args...). That is, you can pass object around, and define method functions that take the object as an argument.

But without more information on what you are actually trying to do, i.e. what problem specifically are you trying to solve that leads you to want to pass a struct full of functions around, it is hard to give specific design advice.


#3

Thanks. I agree it is quite OOP-like. This way of doing things is quite natural for me in C++ using templates, where I have quite a bit of confidence regarding what will get inlined.

Regarding what I’m actually trying to do, the idea is that there are algorithms which are quite generic but require a substantial amount of model-specific information, functions, etc. to be supplied by the user. I am comfortable with encapsulating this information in a class/struct in an OOP language. In Julia, I suppose the method(object, args, … ) approach is something I can play with although I am less clear on how to do this nicely from the user’s perspective. I am also generally happy to write fairly ugly code to get performance for the core algorithm, as Julia may be a nice language for users to supply their model-specific information, functions, etc. and everything could then be in one language. Ultimately, I will be comparing performance with a reasonably good C++ implementation.

I would still like to to understand this behaviour, and get a better feel for when things will be inlined in Julia. I understand there were some fairly recent improvements for anonymous functions, and perhaps there are more general improvements still to come?

I did some more investigating, and it does seem that the compiler could in principle inline / achieve good performance with the information given. Specifically if I write:


function alg3(m::model, n::Int64)
  return alg2(m.f,n)
end

function test3()
  alg3(m,n)
end

then I obtain:

@btime test3()
1.735 ms (5 allocations: 781.38 KiB)

An extra two allocations compared to alg2 for the call I suppose, but they are small. If I try to do something similar to this within the larger algorithm, it doesn’t improve the performance:

function alg4(m::model, n::Int64)
  f::Function = m.f
  array = Vector{Float64}(n)
  for i = 1:n
    array[i] = f(i)
  end
  return sum(array)
end

function test4()
  alg4(m,n)
end

@btime test4()
35.222 ms (299492 allocations: 5.33 MiB)

#4

This doesn’t work because Julia will only type-specialize code (e.g. inline a particular function type) across function calls, not within function bodies. Here, the loop won’t get specialized on the type of function, and instead it will use the generic f::Function pointer. Try putting the loop into a separate function, or passing m.f to alg4 instead of m.

(You still haven’t tried my initial suggestion of a parameterized Model type? This is how to get specialized code for fields of structs in Julia.)


#5

I’m sorry, for some reason I didn’t properly process the suggestion:

struct Model{F<:Function}
    f::F
end

That works very well, thanks!


#6

The way you are trying to write this is not the fastest approach. In Julia (like in C++), doing method(object, args...) is faster (although the compilers in both cases will attempt to turn the object.method syntax into the faster version, so ymmv).


#7

Thanks for the comment. I have enjoyed looking at Julia so far, and am quite impressed with the performance I’ve seen.

In terms of these two approaches, I do indeed get the exact same code_native in the simplified but hopefully representative example below.

Personally, I like the first approach in my particular setting because it is the user (i.e. not me) who is going to define the model, e.g. by writing a makeSpecialModel function or otherwise, and passing the struct to my function [the actual model is not just one function]. I think this is slightly simpler than asking the user to pack up the variables they need in a struct, then write functions that take this struct as a parameter. Probably too object-oriented of me, but I find passing in a self-contained struct makes clearer what is going on. I will bear in mind that greater performance in general may be attained by Approach 2; I suppose it would be great to know guidelines on when one might expect Approach 1 will fail to give the same performance?

The helper function is not that necessary for this example, but it does seem to be necessary to achieve performance for more complicated functions f, as the specialization seem to allow the compiler to inline effectively.

In my explorations so far, I have not encountered performance problems, and am about 2x slower than C++ code for serial execution (3x using experimental threads in comparison to OpenMP parallel regions). I suspect some of this may be due to my not knowing how to get more control over where things end up in memory, but I suppose that is a question for another time.

## Approach 1

struct model{F<:Function}
  f::F
end

function makeModel(x::Float64)
  z::Float64 = x*x
  function f(y::Int64)
    return y + z
  end
  return model(f)
end

@inline function fooHelper!{F<:Function}(array::Vector{Float64}, f::F)
  for i=1:length(array)
    @inbounds array[i] = f(i)
  end
end

function foo!(array::Vector{Float64}, m::model)
  fooHelper!(array,m.f)
end

#### Approach 2

struct model2
  xSq::Float64
end

function f2(m::model2, y::Int64)
  return y + m.xSq
end

function bar!(array::Vector{Float64}, m::model2)
  for i=1:length(array)
    @inbounds array[i] = f2(m,i)
  end
end

## Variables
array = ones(Float64,2^24)
m = makeModel(2.0)
m2 = model2(4.0)

# foo!(array, m) is equivalent to bar!(array, m2) in native code


#8

So, alg2(f::function, n::Int) gets actually dispatched on the value of f (in C: on the function pointer).

This means that (1) type inference can figure out everything, (2) the function call can get inlined, and (3) you trigger recompilation for every changed model.

As far as I know, julia is currently incapable of expressing function-types/signatures (in the sense of e.g. alg2(f::Function(float64->float64), …). This would allow to pass a function-pointer at runtime (without triggering recompilation if there already exists code for a parameter with the same signature).

In some sense, your last two examples are honest, whereas the “alg2” variant is dishonest: Julia sneakily specializes on the value of the function pointer / argument and recompiles.

Thanks for this discussion; I shall avoid passing functions as parameters from now on, and instead put them in the type. If I ever need the “type-specialized but not function-pointer specialized” variant, then I will cry and abuse the foreign function interface (make it a C callback).

Besides, approach #1 does not need the ugly helper. I get good native code with

struct model{F<:Function}
  f::F
end

function makeModel(x::Float64)
  z::Float64 = x*x
  function f(y::Int64)
    return y + z
  end
  return model(f)
end

function foo!(array::Vector{Float64}, m::model)
  for i=1:length(array)
    @inbounds array[i] = m.f(i)
  end
end


#9

See FunctionWrappers.jl.