Tuples, iterators, splats, and type stability

I’m trying understand type stability of functions like

f(t::Tuple) = tuple(t...)
g(t::Tuple) = tuple((identity(l) for l in t)...)

identity here is meant to be a place-holder for any type stable function. Of the two, f is stable, g is not:

julia> t = (:a, 1, 0.1);
julia> @code_warntype f(t)
Variables
  #self#::Core.Compiler.Const(f, false)
  t::Tuple{Symbol,Int64,Float64}

Body::Tuple{Symbol,Int64,Float64}
1 ─ %1 = Core._apply_iterate(Base.iterate, Main.tuple, t)::Tuple{Symbol,Int64,Float64}
└──      return %1

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

Body::Tuple{Vararg{Union{Float64, Int64, Symbol},N} where N}
1 ─ %1 = Base.Generator(Main.identity, t)::Base.Generator{Tuple{Symbol,Int64,Float64},typeof(identity)}
│   %2 = Core._apply_iterate(Base.iterate, Main.tuple, %1)::Tuple{Vararg{Union{Float64, Int64, Symbol},N} where N}
└──      return %2

There are two, possibly related, things I don’t understand here. First, what exactly does tuple(t...) do? I would have expected this to iterate through t, and pass the results onto tuple. But I guess that’s not what’s happening, since iterate doesn’t seem to be type stable:

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

Body::Union{Nothing, Tuple{Union{Float64, Int64, Symbol},Int64}}
1 ─      nothing
│        nothing
│   %3 = (#self#)(t, 1)::Union{Nothing, Tuple{Union{Float64, Int64, Symbol},Int64}}
└──      return %3

So what exactly does splatting do then, and is this explained somewhere in the manual?

Second, for g, I’ve come to the conclusion that its type instability is probably caused by the compiler not being able to infer the length of a Base.Generator{NTuple{N,T}} from its type. Is there a reason why this is so? Could there be something like a value for IteratorSize called TypeHasLength() that would be a guarantee that length(::T) = length(T) for this type T, i.e. the length is inferable from the type? This seems to me like it would make a lot of sense for Tuples, and probably also for a lot of user-defined composite types that have a number of fields, and iterate just goes through them. This is actually the original use case that got me thinking about all this: A user-defined, iterable type for which I would like a function like g to be type stable.

2 Likes

PS. Derailing my own thread, but in trying to figure this out I’ve really come to think that map on generic iterators should return generators. I find it very unintuitive that map(h, gen) and (h(i) for i in gen) behave so very differently even when gen is a generator.

1 Like

From the FAQ:

... splits one argument into many different arguments in function calls

That’s really all there is to it: f(t...) is simply equivalent to f(first(t), second(t), ..., last(t)).

You are correct that one of the issues in your function g is that using a generator hides the length of t from the compiler, and so Julia must figure out a runtime which method of tuple to call and hence it cannot infer the return type of g. The other issue is that generators can only have a single eltype, so if the elements of t have different types then the eltype of the generator must be an abstract type which is a supertype of all of the element types.

2 Likes

BTW, Add Iterators.map by tkf · Pull Request #34352 · JuliaLang/julia · GitHub added an uncollected map to go with filter etc:

julia> Iterators.map(sqrt, ntuple(+,5))
Base.Generator{NTuple{5,Int64},typeof(sqrt)}(sqrt, (1, 2, 3, 4, 5))
5 Likes

And what if t isn’t a Tuple, does it first gets converted to a Tuple with something like tuple(t...)?

It is iterated. See, among other pages,

https://docs.julialang.org/en/v1/base/base/#

https://docs.julialang.org/en/v1/manual/faq/#The-two-uses-of-the-…-operator:-slurping-and-splatting-1

Thanks. Whether it’s iterated is what I meant to ask above as well, but my brain-finger communication failed. I’ve read the pages you link though, and neither one of them really answers the question, e.g. the fact that general iterables go through iterate when splatting, but Tuples don’t, is not specified.

Thanks, I wasn’t aware of this. Seems perfect, a reason to look forward to 1.6.

All this seems to point towards a rule of thumb though, that generators are a bad idea when dealing with tuples and tuple-like iterables (e.g. user types that iterate over their fields of mixed types), if one wants to maintain type stability.

1 Like

AFAIK Tuples are technically iterated too, it’s just that then the compiler can expand that efficiently.

If you want to find out these things interactively while learning Julia, constructs like

julia> Meta.@lower f(x...)
:($(Expr(:thunk, CodeInfo(
    @ none within `top-level scope'
1 ─ %1 = Core._apply_iterate(Base.iterate, f, x)
└──      return %1
))))

are useful.

Fix for the inference imprecision here (together with some other recent work on master):

https://github.com/JuliaLang/julia/pull/36622

13 Likes

I am officially impressed with the Julia community’s responsivity to feedback.

4 Likes

Should be fixed on master.

10 Likes