Parametric types and StaticArrays

As I’ve worked through a couple projects now, I realized I don’t fully understand the nuances of parametric types, and specifically when used with StaticArrays. My goal of course is to get all the performance I can, but with as little parametric definition as possible. I set up some tests to better understand the type instabilities I consistently see in my models. I basically tested every combination I could think of from no parametric types at all, to sort of partially parameterized types i.e.

struct Type3{T<:AbstractFloat} <: AbstractType1
    value::T
    array::SVector{3,T}
    matrix::SMatrix{3,3,T}
end

to fully parameterized type i.e.

struct Type4{T1<:AbstractFloat,T2<:SVector{3,T1},T3<:SMatrix{3,3,T1}} <: AbstractType1
    value::T1
    array::T2
    matrix::T3
end

and then parameterized vs not parameterized functions and abstract types.

A couple questions:

The following produces 2 allocations per call

abstract type AbstractType1 end

struct Type3{T<:AbstractFloat} <: AbstractType1
    value::T
    array::SVector{3,T}
    matrix::SMatrix{3,3,T}
end

t3 = Type3(1.0, a, m)
f1(in::AbstractType1) = in.matrix * in.array
julia> @btime f1($t3)
  113.508 ns (2 allocations: 64 bytes)

I noticed during @code_warntype in later tests that when I had no allocations, SMatrix was defined as SMatrix{3,3,T,9}. For the above case, @code_warntype had SMatrix{3,3,T} in red text. If I change Type3.matrix to be SMatrix{3,3,T,9}, I get:

struct Type8{T<:AbstractFloat} <: AbstractType1 #similar to Type3 but with a 9 in SMatrix
    value::T
    array::SVector{3,T}
    matrix::SMatrix{3,3,T,9}
end
julia> @btime f1($t8)
  2.500 ns (0 allocations: 0 bytes)

Question 1: Why is that? I’ve pretty much never defined that 4th type argument in my SMatrices, but I suppose now I will.

Going through my test cases, the minimum parameter definition required to achieve 0 allocations was :

abstract type AbstractType1 end

struct Type9 <: AbstractType1
    value::Float64
    array::SVector{3,Float64}
    matrix::SMatrix{3,3,Float64,9}
end

f1(in::AbstractType1) = in.matrix * in.array

I didn’t have to parameterize the type, abstract type or the function i.e.

abstract type AbstractType2{T} end
f2(in::T) where {T<:AbstractType1} = in.matrix * in.array

This is probably too generic to ask but

Question 2: Are there additional advantages above allocations for parameterizing the type, abstract type or the function? Should it be considered a best practice to always parameterize if it can be?

Perhaps my examples are too simple to really see the nuance, but maybe there’s an obvious answer there. Again, the goal is to not parameterize those if I don’t need to (or to really understand why I need to).

Question 3: Is there an advantage for writing what I called fully parameterized types vs partially parameterized types? i.e.

struct Type3{T<:AbstractFloat} <: AbstractType1
    value::T
    array::SVector{3,T}
    matrix::SMatrix{3,3,T}
end

struct Type4{T1<:AbstractFloat,T2<:SVector{3,T1},T3<:SMatrix{3,3,T1}} <: AbstractType1
    value::T1
    array::T2
    matrix::T3
end

Obviously for really big structs, Type3 would be preferred vs defining a T for every field, but I have seen code that does that. I’m wondering if maybe it’s safer and makes more sense to the compiler to parameterize every field?

I feel like I always go over board with parameterization, yet still run in to type instabilities regularly. I may have asked the questions in a weird way since I clearly don’t fully understand these concepts yet, but I’m hoping to gain some more intuition on these topics.

For question 1, SMatrix{3,3,T,9} is a concrete type while SMatrix{3,3,T} is not. You can find more discussions in this post.

I’m also interested to know the answer to Q2 and 3. Hopefully someone else can provide some answers.

1 Like

This depends very much on your use case. In this specific example, there is little need to add the arrays as parameters. The general reason to define a parameterize struct is to accept a range of array types. E.g. if one writes

struct Type4{T1<:AbstractFloat,N,T2<:SVector{N,T1},T3<:SMatrix{N,N,T1}} <: AbstractType1
    value::T1
    array::T2
    matrix::T3
end

this allows one to accept SVectors of any length. Importantly, this also automatically infers T2 and T3 from the arguments to the constructor, so that these are always concrete without you needing to know the exact types of the arguments at the time of writing the code. This is important if you expect your code to be used with generic array types. Every time this code is called with a unique set of argument types, a new method instance will be compiled for the specific combination.

In your case, since you are restricted to SVectors of length 3, the types are all known to you. In this case, you may add the types explicitly as

struct Type3{T<:AbstractFloat} <: AbstractType1
    value::T
    array::SVector{3,T}
    matrix::SMatrix{3,3,T,9}
end

Note that the types must be concrete if you want type-stability, which means passing the length to the SMatrix type. In this form, you must know the types when you write the code, and not when you run it. Since this only accepts one set of types for each T, it would only compile once.

In your use case, parameterizing the type as

struct Type4{T1<:AbstractFloat,T2<:SVector{3,T1},T3<:SMatrix{3,3,T1}} <: AbstractType1
    value::T1
    array::T2
    matrix::T3
end

has little advantage other than inferring the concrete types T2 and T3 from the arguments. Note that SVector{3,T} is already concrete for a specific T:

julia> Base.isconcretetype(SVector{3,Float64})
true

so you’re not gaining anything from this type parameter. The third one, T3<:SMatrix{3,3,T1}, may help if you don’t know the exact type parameters for an SMatrix.

In which case should you add extra type-parameters? Beyond the general case where you may want to accept arbitrary arrays, it may make sense in your specific example as well. Let’s consider a hypothetical case where an SMatrix has several trailing type-parameters that are internal to its implementation, and only the first three are a part of the public API. In such cases, you shouldn’t add the SMatrix{3,3,T,A,B,C...} explicitly to your struct, but add a parameter T3<:SMatrix{3,3,T} that is automatically inferred from the arguments. In this specific case, it turns out that the length of the SMatrix is also publicly documented as the fourth parameter, so it’s safe to use this explicitly.

2 Likes