For statement type instability

Hello all. I encountered the same problem as in the title. Sorry if this is a known problem.
[Added later]
@code_warntype warns @_2::Union{Nothing, Tuple{Union{One, Two}, Int64}} has type instability.
How can I solve this problem?

abstract type AbstractNumber end

struct One <: AbstractNumber end
struct Two <: AbstractNumber end

function test()
    for number in (One(), Two())
    end
end
julia> @code_warntype test()
MethodInstance for test()
  from test() @ Main ~/dev/test/test.jl:6
Arguments
  #self#::Core.Const(test)
Locals
  @_2::Union{Nothing, Tuple{Union{One, Two}, Int64}}
  number::Union{One, Two}
Body::Nothing
1 ─ %1  = Main.One()::Core.Const(One())
│   %2  = Main.Two()::Core.Const(Two())
│   %3  = Core.tuple(%1, %2)::Core.Const((One(), Two()))
│         (@_2 = Base.iterate(%3))
│   %5  = (@_2::Core.Const((One(), 2)) === nothing)::Core.Const(false)
│   %6  = Base.not_int(%5)::Core.Const(true)
└──       goto #4 if not %6
2 ┄ %8  = @_2::Union{Tuple{One, Int64}, Tuple{Two, Int64}}
│         (number = Core.getfield(%8, 1))
│   %10 = Core.getfield(%8, 2)::Int64
│         (@_2 = Base.iterate(%3, %10))
│   %12 = (@_2 === nothing)::Bool
│   %13 = Base.not_int(%12)::Bool
└──       goto #4 if not %13
3 ─       goto #2
4 ┄       return nothing

You are looping over a collect of multiple types so number has different types, but I’m not sure what your question is or what you’re trying to achieve?

I am sorry the lack of clarity.
@code_warntype warns @_2::Union{Nothing, Tuple{Union{One, Two}, Int64}} has type instability.
How can I solve this problem?

You don’t need to do anything. In this case Julia will just unroll the loop later down the compilation pipeline and you won’t notice anything from this type-instability. In any case, the number of types is also small enough for union-splitting (where the magic number is 4 IIRC).

As a general rule of thumb: Small unions of concrete types are likely fine. You don’t need to fix anything. I’d say fixing “type instabilities” just for the sake of it is rather pointless. If you want to do it for performance optimization, you need to demonstrate that this is a performance problem to begin with.

That being said, in general you should strive to avoid code like this in Julia:

  1. generally avoid containers of abstract/heterogeneous types … yes, there are specialized cases like Vector{Union{Missing,T}} that the compiler does a good job with thanks to union-splitting, but they are the exception that proves the rule
  2. be cautious about using types to encode values as in One <: AbstractNumber, Two <: AbstractNumber. This can be useful, e.g. π has its own type, but it’s a specialized tool because of point (1)
3 Likes

Thank you for answers.
This code example is a simplified version of my original code, so it does not make much sense.

@abraemer
In fact, my original code caused an unintended memory allocation that I thought should be resolved. In the end, by adding @inline, I was able to properly optimize the code and eliminate the memory allocations.
Looking at the Julia base functions, @inline is rarely used, so I didn’t think it was necessary.

@stevengj
I knew about that article, but did not know that the rule applied to Tuple.

It’s true that tuples of heterogeneous types are totally fine, as long as the tuple type is known to the compiler; it’s no different than a struct with heterogeneous members. But you have to be very careful about iteration over a heterogeneous tuple.

for loops over heterogeneous tuples, in particular, are not unrolled (even though the compiler knows the length) and can lead to type-instability issues for heterogeneous tuples. Various threads have complained about this, e.g. Unrolling loops over tuples - why so hard? - #15 by matthias314 and Manually unroll operations with objects of tuple - #2 by mauro3, and there is an old package Unrolled.jl that tries to do this for you.

However, what’s less known is that certain functions like the map, mapreduce, and (as of Julia 1.8 thanks to julia#31901) foreach functions can be completely unrolled for tuple arguments, so e.g.

foreach((One(), Two(),3,"4")) do number
    
end

is completely unrolled IIRC, and it can potentially be type stable depending on what you do inside the loop. (One difficulty with foreach is that, because the “loop body” is a closure, you can easily get bitten by julia#15276 if you want to assign to a local variable inside the loop.)

4 Likes

Thank you for precious information!!

This is intriguing, but is it even better to be type-stable here? Yes, it seems faster (and I saw the other thread, and opened PR on it for printing), but if I understand this then the loop will be unrolled, and does that mean many compiled versions? I.e. a lot more code? For maybe little gain, and could it be worse in the end?

It is type stable to unroll a loop over a heterogeneous tuple. The compiler knows the types of the tuple entries.

I’m not saying it’s not type-stable, or that it’s worse per se, just if unrolled possibly (and assumed the alternative when not type-stable wouldn’t be unrolled).

The question was about if the unrolling might be bad; i.e. using foreach. With for is unstable right, and not unrolled?

1 Like