Type instability with tuples of functions and parameters

My general application of interest requires a vector of functions of the form f(x, y, p), for some known parameters p. I put the functions in a Tuple so that their types are known, and define for example the following struct:

struct Point{T,N}
    x::T
    y::T
    F::Tuple{Vararg{Function,N}}
    F_params::Tuple{Vararg{Union{Nothing, AbstractVector{T}}, N}}
end 

The F_params is given the type shown so that a user can provide a Tuple with an element for each function, and either provide a vector of parameters or no parameters at all (nothing). I need to have functions that take in a Point as input, for example:

function TestFnc(pt::Point{T,N}) where {T,N} 
    res = 0.0
    for (f, p) in zip(pt.F, pt.F_params)
        res = res + f(pt.x, pt.y, p)
    end
    return res
end
F1 = (x, y, p) -> x^2 + y^2 - p[1]
F2 = (x, y, p) -> x*y 
F3 = (x, y, p) -> x + y + p[2] - p[3]*p[1]
p₁ = [1.0]; pβ‚‚ = nothing; p₃ = [1.0, 2.0, 3.0]
x = 0.2; y = 0.7 
pt = Point(x, y, (F1, F2, F3), (p₁, pβ‚‚, p₃))

When I use these definitions, I get type instabilities according to @code_warntype (see below). How can I improve my struct so that all types are properly inferred?

@code_warntype TestFnc(pt)
MethodInstance for TestFnc(::Point{Float64, 3})
  from TestFnc(pt::Point{T, N}) where {T, N} in Main at c:\Users\licer\testjl.jl:7
Static Parameters
  T = Float64
  N = 3
Arguments
  #self#::Core.Const(TestFnc)
  pt::Point{Float64, 3}
Locals
  @_3::Union{Nothing, Tuple{Tuple, Tuple}}
  res::Any
  @_5::Int64
  p::Any
  f::Any
Body::Any
1 ─       (res = 0.0)
β”‚   %2  = Base.getproperty(pt, :F)::Tuple{Function, Function, Function}
β”‚   %3  = Base.getproperty(pt, :F_params)::Tuple{Union{Nothing, AbstractVector{Float64}}, Union{Nothing, AbstractVector{Float64}}, Union{Nothing, AbstractVector{Float64}}}
β”‚   %4  = Main.zip(%2, %3)::Base.Iterators.Zip
β”‚         (@_3 = Base.iterate(%4))
β”‚   %6  = (@_3 === nothing)::Bool
β”‚   %7  = Base.not_int(%6)::Bool
└──       goto #4 if not %7
2 β”„ %9  = @_3::Tuple{Tuple, Tuple}
β”‚   %10 = Core.getfield(%9, 1)::Tuple
β”‚   %11 = Base.indexed_iterate(%10, 1)::Core.PartialStruct(Tuple{Any, Int64}, Any[Any, Core.Const(2)])
β”‚         (f = Core.getfield(%11, 1))
β”‚         (@_5 = Core.getfield(%11, 2))
β”‚   %14 = Base.indexed_iterate(%10, 2, @_5::Core.Const(2))::Core.PartialStruct(Tuple{Any, Int64}, Any[Any, Core.Const(3)])
β”‚         (p = Core.getfield(%14, 1))
β”‚   %16 = Core.getfield(%9, 2)::Tuple
β”‚   %17 = res::Any
β”‚   %18 = Base.getproperty(pt, :x)::Float64
β”‚   %19 = Base.getproperty(pt, :y)::Float64
β”‚   %20 = (f)(%18, %19, p)::Any
β”‚         (res = %17 + %20)
β”‚         (@_3 = Base.iterate(%4, %16))
β”‚   %23 = (@_3 === nothing)::Bool
β”‚   %24 = Base.not_int(%23)::Bool
└──       goto #4 if not %24
3 ─       goto #2
4 β”„       return res
julia> isabstracttype(Function)
true
1 Like

I don’t know what you are suggesting here, sorry.

The comment is that your type has abstractly-typed fields. Maybe this would suit your needs:

struct Point{T,N,FTup<:NTuple{N,Function},PTup<:NTuple{N,Union{Nothing, AbstractVector{T}}}}
    x::T
    y::T
    F::FTup
    F_params::PTup
end 
1 Like

Ah, I didn’t know that having abstract types was a bad thing here. Thank you. When I make this change, i still get some instances of red text with unions in @code_warntype. Are there any problems here, or are these unions fine despite the red text? I list below these unions it warns me of, and also the full code/output after.

  • f::Union{var"#11#12", var"#13#14", var"#15#16"}
  • @_3::Tuple{Tuple{Union{var"#11#12", var"#13#14", var"#15#16"}, Union{Nothing, Vector{Float64}}}, Tuple{Int64, Int64}}
  • Core.getfield(%9, 1)::Tuple{Union{var"#11#12", var"#13#14", var"#15#16"}, Union{Nothing, Vector{Float64}}}
  • Base.indexed_iterate(%10, 1)::Tuple{Union{var"#11#12", var"#13#14", var"#15#16"}, Int64}
  • Base.indexed_iterate(%10, 2, @_5)::Union{Tuple{Nothing, Int64}, Tuple{Vector{Float64}, Int64}}
    Code:
struct Point{T,N,FTup<:NTuple{N,Function},PTup<:NTuple{N,Union{Nothing, AbstractVector{T}}}}
    x::T
    y::T
    F::FTup
    F_params::PTup
end 
function TestFnc(pt::Point{T,N,FTup,PTup}) where {T,N,FTup,PTup} 
    res = 0.0
    for (f, p) in zip(pt.F, pt.F_params)
        res = res + f(pt.x, pt.y, p)
    end
    return res
end
F1 = (x, y, p) -> x^2 + y^2 - p[1]
F2 = (x, y, p) -> x*y 
F3 = (x, y, p) -> x + y + p[2] - p[3]*p[1]
p₁ = [1.0]; pβ‚‚ = nothing; p₃ = [1.0, 2.0, 3.0]
x = 0.2; y = 0.7 
pt = Point(x, y, (F1, F2, F3), (p₁, pβ‚‚, p₃))
@code_warntype TestFnc(pt)
MethodInstance for TestFnc(::Point{Float64, 3, Tuple{var"#11#12", var"#13#14", var"#15#16"}, Tuple{Vector{Float64}, Nothing, Vector{Float64}}})
  from TestFnc(pt::Point{T, N, FTup, PTup} where {FTup<:Tuple{Vararg{Function, N}}, PTup<:Tuple{Vararg{Union{Nothing, AbstractVector{T}}, N}}}) where {T, N} in Main at c:\Users\licer\testjl.jl:7       
Static Parameters
  T = Float64
  N = 3
Arguments
  #self#::Core.Const(TestFnc)
  pt::Point{Float64, 3, Tuple{var"#11#12", var"#13#14", var"#15#16"}, Tuple{Vector{Float64}, Nothing, Vector{Float64}}}
Locals
  @_3::Union{Nothing, Tuple{Tuple{Union{var"#11#12", var"#13#14", var"#15#16"}, Union{Nothing, Vector{Float64}}}, Tuple{Int64, Int64}}}
  res::Float64
  @_5::Int64
  p::Union{Nothing, Vector{Float64}}
  f::Union{var"#11#12", var"#13#14", var"#15#16"}
Body::Float64
1 ─       (res = 0.0)
β”‚   %2  = Base.getproperty(pt, :F)::Core.Const((var"#11#12"(), var"#13#14"(), var"#15#16"()))
β”‚   %3  = Base.getproperty(pt, :F_params)::Tuple{Vector{Float64}, Nothing, Vector{Float64}}
β”‚   %4  = Main.zip(%2, %3)::Base.Iterators.Zip{Tuple{Tuple{var"#11#12", var"#13#14", var"#15#16"}, Tuple{Vector{Float64}, Nothing, Vector{Float64}}}}
β”‚         (@_3 = Base.iterate(%4))
β”‚   %6  = (@_3::Core.PartialStruct(Tuple{Tuple{var"#11#12", Vector{Float64}}, Tuple{Int64, Int64}}, Any[Tuple{var"#11#12", Vector{Float64}}, Core.Const((2, 2))]) === nothing)::Core.Const(false)        
β”‚   %7  = Base.not_int(%6)::Core.Const(true)
└──       goto #4 if not %7
2 β”„ %9  = @_3::Tuple{Tuple{Union{var"#11#12", var"#13#14", var"#15#16"}, Union{Nothing, Vector{Float64}}}, Tuple{Int64, Int64}}
β”‚   %10 = Core.getfield(%9, 1)::Tuple{Union{var"#11#12", var"#13#14", var"#15#16"}, Union{Nothing, Vector{Float64}}}
β”‚   %11 = Base.indexed_iterate(%10, 1)::Tuple{Union{var"#11#12", var"#13#14", var"#15#16"}, Int64}
β”‚         (f = Core.getfield(%11, 1))
β”‚         (@_5 = Core.getfield(%11, 2))
β”‚   %14 = Base.indexed_iterate(%10, 2, @_5)::Union{Tuple{Nothing, Int64}, Tuple{Vector{Float64}, Int64}}
β”‚         (p = Core.getfield(%14, 1))
β”‚   %16 = Core.getfield(%9, 2)::Tuple{Int64, Int64}
β”‚   %17 = res::Float64
β”‚   %18 = Base.getproperty(pt, :x)::Float64
β”‚   %19 = Base.getproperty(pt, :y)::Float64
β”‚   %20 = (f)(%18, %19, p)::Float64
β”‚         (res = %17 + %20)
β”‚         (@_3 = Base.iterate(%4, %16))
β”‚   %23 = (@_3 === nothing)::Bool
β”‚   %24 = Base.not_int(%23)::Bool
└──       goto #4 if not %24
3 ─       goto #2
4 β”„       return res

You seem to be emulating a class-based OOP approach when putting functions inside your structs. It’s not exactly clear from your example if this is necessary.

Can you reftame your problem in terms of dispatch with external methods, or must each instance of Point have its own individual tuple of functions?

Basically, your code is not idiomatic for Julia, and it’s not clear if that is unavoidable.

3 Likes

The actual application I’m considering is for some PDE code. These functions in this simple example are to resemble a user providing functions for certain parts of a PDE, and for mixed boundary conditions (for which there can be arbitrarily many, hence my Vararg initially). So I think for your second question it’s the latter, i.e. each instance of Point does need its own set of functions. So do you think the current state of that code is about as far as I might get, then?

No, most likely there is a different approach and better design. Julia is used a lot for PDEs and ODEs, so there’s a lot of expertise.

Unfortunately, I have no experience with this.

But do you anticipate that each point on your grid will have its own unique and individual set of functions? That sounds a bit exotic to me. Is there no grouping, like interior points vs boundary points?

3 Likes

Sorry, I was a bit unclear (and I probably should have used a different name than Point). These functions are primarily for boundary conditions. I’m considering for example a domain \Omega split into N separate segments (e.g. a square with a boundary condition for each side, or a triangle), each with a different boundary condition, and these functions are my F in my initial example and their parameters are F_params.

You can make it type stable / get rid of any unions if N is not too big by using a generated function and unrolling the loop (see below). (There is also the Unrolled.jl package, but it would require changing your function signature I think.)

If N is large, you could wrap your functions in FunctionWrappers, and store those in the function tuple. However, note this is essentially calling them via a pointer so adds a bit of overhead.

Whatever you decide, you can set pβ‚‚ = Float[] and drop the Union type in the definition of Point so the parameter tuple has the same type for all elements.

So something like this (which I’m sure someone else can point out a better way to do) is then type stable:

 struct Point{T,N,FTup,PTup<:NTuple{N,AbstractVector{T}}}
    x::T
    y::T
    F::FTup
    F_params::PTup
 end

@generated function TestFnc(pt::Point{T,N,FTup,PTup}) where {T,N,FTup,PTup} 
    quote
        res = 0.0
        Base.Cartesian.@nexprs $N i -> res = res + pt.F[i](pt.x, pt.y, pt.F_params[i])
        return res
    end
end
1 Like

Thank you very much @isaacsas. I’ll have to think about this for quite a while since I’ve never used @generated, but this looks very nice. Can Base.Cartesian.@nexprs also be used index range is not something like 1:N? e.g. can it be extended to work for something like

for j in idx

where idx could be say [1, 3, 5, 10, 17] (just a vector of indices in general)? Looking at the documentation julia/cartesian.jl at 742b9abb4dd4621b667ec5bb3434b8b3602f96fd Β· JuliaLang/julia Β· GitHub doesn’t seem to suggest this is a feature.

No, N would need to be known at compile time via the type I believe. (But I have only used @generated a very few times, so am not an expert myself, and I’ve never used @nexprs.)

One last option is to use recursion to keep splitting off one element of the tuple. As long as N is not gigantic the compiler can make it type stable:

 struct Point{T,FTup <: Tuple}
    x::T
    y::T
    F::FTup  # I'll make it a tuple of tuples to make the recursion easier
end

pt = Point(x, y, ((F1,p₁), (F2,pβ‚‚), (F3,p₃)))

function TestFnc(x,y,F,args...)
    F[1](x,y,F[2]) + TestFnc(x,y,args...)
end

function TestFnc(x,y,F)
    F[1](x,y,F[2])
end

function TestFnc(pt::Point) 
    TestFnc(pt.x,pt.y,pt.F...)
end

I think this should also be type stable.

2 Likes

Oh yeah, there is also GitHub - tisztamo/FunctionWranglers.jl: Fast, inlined execution of arrays of functions which I haven’t used but would be another option.

1 Like

Another option is to use ntuple instead of a loop with zip:

function TestFnc(pt::Point{T,N,FTup,PTup}) where {T,N,FTup,PTup} 
    res = sum(ntuple(i -> pt.F[i](pt.x, pt.y, pt.F_params[i]), length(pt.F)))
    return res
end

(ntuple should expand into recursion for small tuple, so that is unroll-friendly as well)

1 Like