Type stability of indexing a tuple in local scope vs global scope

I know that getproperty is normally not type stable. But I had the mental model that getproperty on a named tuple is magically type stable somehow. And I had the same belief for getindex on a tuple or named tuple. However, my mental model appears to be wrong, at least in the global scope:

julia> t = (1, 2.0)
(1, 2.0)

julia> @code_warntype t[1]
Variables
  #self#::Core.Const(getindex)
  t::Tuple{Int64, Float64}
  i::Int64

Body::Union{Float64, Int64}
1 ─      nothing
│   %2 = Base.getfield(t, i, $(Expr(:boundscheck)))::Union{Float64, Int64}
└──      return %2

Taken by itself, that seems understandable to me. What I don’t quite get is that if I merely wrap t[1] in a function, the type instability goes away:

julia> foo(t) = t[1];

julia> @code_warntype foo(t)
Variables
  #self#::Core.Const(foo)
  t::Tuple{Int64, Float64}

Body::Int64
1 ─ %1 = Base.getindex(t, 1)::Int64
└──      return %1

Can anyone explain why t[1] in global scope is not type stable but foo(t) is type stable? My only guess is that when t[1] is in a local scope instead of global scope, the compiler can make more guarantees about the type of t[1], but that’s a hand-wavy argument.

1 Like

The behavior of @code_warntype makes a bit more sense if you look at what it’s actually doing:

julia> @macroexpand @code_warntype t[1]
:(InteractiveUtils.code_warntype(getindex, (Base.typesof)(t, 1)))

julia> Base.typesof(t, 1)
Tuple{Tuple{Int64, Float64}, Int64}

So @code_warntype is just calling the regular function code_warntype with the types of t and 1, which are Tuple{Int64, Float64} and Int64, respectively.

That means code_warntype has only access to the type of 1, not the constant value. There’s no way it can possibly return anything more specific than a Union because, just from that information, there’s no way to know if you asked for t[1] or t[2].

Once you’ve hard-coded the index 1 in a function, though, the compiler can be even smarter and do some constant propagation to figure out the result type. When you do: @code_warntype foo(t), there actually is enough information from just the type of t to infer the result of foo because of the literal t[1] in the definition of foo.

Being in a function doesn’t really matter–we can show that even in a function @code_warntype's behavior doesn’t change at all:

julia> function bar(t)
         @code_warntype t[1]
         t[1]
       end
bar (generic function with 1 method)

julia> bar(t)
Variables
  #self#::Core.Const(getindex)
  t::Tuple{Int64, Float64}
  i::Int64

Body::Union{Float64, Int64}

There’s no difference because @code_warntype is just calling code_warntype(getindex, typesof(t, 1)) just as before.

6 Likes

That makes sense. Thanks for clearing that up!