Constant propagation and inference of constructed types

Context: I had a parameterized struct type with convenience constructors that input some of the parameters as arguments. Based on previous experience, I expected that if such constructors are provided with constant values for those arguments, the constants will be propagated and the type of the constructed object will be inferred. To my surprise it was not, even though that approach had generally worked for me previously.

Here’s an illustration of how constant propagation sometimes works and sometimes doesn’t, even for similar-looking code. First, a couple of cases for which constant propagation does work:

struct F1{T} end
f1(T) = F1{T}()
g1_float() = f1(Float64)
g1_tuple() = f1((1,2,3))

julia> @code_warntype g1_float()
Variables
  #self#::Core.Compiler.Const(g1_float, false)
Body::F1{Float64}
1 ─ %1 = Main.f1(Main.Float64)::Core.Compiler.Const(F1{Float64}(), false)
└──      return %1

julia> @code_warntype g1_tuple()
Variables
  #self#::Core.Compiler.Const(g1_tuple, false)
Body::F1{(1, 2, 3)}
1 ─ %1 = Core.tuple(1, 2, 3)::Core.Compiler.Const((1, 2, 3), false)
│   %2 = Main.f1(%1)::Core.Compiler.Const(F1{(1, 2, 3)}(), false)
└──      return %2

So far so good – constant types and tuples can be propagated to form types. Now, we add a second type parameter:

struct F2{T,X} end
f2(T, x) = F2{T,typeof(x)}()
g2_float(x) = f2(Float64, x)
g2_tuple(x) = f2((1,2,3), x)

julia> @code_warntype g2_float('a')
Variables
  #self#::Core.Compiler.Const(g2_float, false)
  x::Char
Body::F2{Float64,Char}
1 ─ %1 = Main.f2(Main.Float64, x)::Core.Compiler.Const(F2{Float64,Char}(), false)
└──      return %1

julia> @code_warntype g2_tuple('a')
Variables
  #self#::Core.Compiler.Const(g2_tuple, false)
  x::Char
Body::F2{_A,Char} where _A
1 ─ %1 = Core.tuple(1, 2, 3)::Core.Compiler.Const((1, 2, 3), false)
│   %2 = Main.f2(%1, x)::F2{_A,Char} where _A
└──      return %2

For some reason, adding a second argument (or type parameter) made the compiler forget how to propagate the tuple to the output type, but not the type parameter. (Note that the type of char argument was also propagated.)

It gets stranger. Suppose we don’t ask for an instance, just for the type of object that would’ve been created:

f2_type(T, x) = F2{T,typeof(x)}
g2_type_tuple(x) = f2_type((1,2,3), x)

julia> @code_warntype g2_type_tuple('a')
Variables
  #self#::Core.Compiler.Const(g2_type_tuple, false)
  x::Char
Body::Type{F2{(1, 2, 3),Char}}
1 ─ %1 = Core.tuple(1, 2, 3)::Core.Compiler.Const((1, 2, 3), false)
│   %2 = Main.f2_type(%1, x)::Core.Compiler.Const(F2{(1, 2, 3),Char}, false)
└──      return %2

The compiler remembered how to propagate the tuple again! This shows that the compiler actually has the mechanism to infer the output type of f2, but for some reason just doesn’t do so in the other case.

Finally, we can obtain the desired behavior by wrapping the tuple in a Val:

f2_val(::Val{T}, x) where {T} = F2{T, typeof(x)}()
g2_val_tuple(x) = f2_val(Val((1,2,3)), x)

julia> @code_warntype g2_val_tuple('a')
Variables
  #self#::Core.Compiler.Const(g2_val_tuple, false)
  x::Char

Body::F2{(1, 2, 3),Char}
1 ─ %1 = Core.tuple(1, 2, 3)::Core.Compiler.Const((1, 2, 3), false)
│   %2 = Main.Val(%1)::Core.Compiler.Const(Val{(1, 2, 3)}(), true)
│   %3 = Main.f2_val(%2, x)::Core.Compiler.Const(F2{(1, 2, 3),Char}(), false)
└──      return %3

The heuristics regarding what will and what will not be inferred by the compiler seem to me quite complex, almost haphazard. I realize that the compiler has the prerogative to infer as much or as little as it wants, but it is helpful when writing code to have some intuition about what is likely to be performant and what is not. With behavior like this, I seem to spend a significant amount of time rewriting code trying different approaches until I stumble upon the “magic” way of writing things so that the compiler will propagate constants and infer types.

Is there a rational explanation for the behavior above? Could compiler type inference be made to work more consistently/predictably? (BTW, this is not meant to be a criticism of the developers, since Julia is overall quite impressive and I know a lot of effort has gone into the compiler.)

6 Likes

I’ve run into the same problem and have the same criticism. The OP was 3 years ago. Is there progress on this in the compiler?

1 Like

Did you try the previously posted code? Everything seems to const prop now:

julia> @code_warntype g2_tuple('a')
MethodInstance for g2_tuple(::Char)
  from g2_tuple(x) @ Main REPL[26]:1
Arguments
  #self#::Core.Const(g2_tuple)
  x::Char
Body::F2{(1, 2, 3), Char}
1 ─ %1 = Core.tuple(1, 2, 3)::Core.Const((1, 2, 3))
│   %2 = Main.f2(%1, x)::Core.Const(F2{(1, 2, 3), Char}())
└──      return %2

There has been a great deal of work on constant propagation.

1 Like

Hi. Thanks for the response. My case follows:

using StaticArrays, StructArrays

const PointRp2{T <: AbstractFloat} = SVector{3,T}
const MinRp2Sample{N,T <: AbstractFloat} = MVector{N,PointRp2{T}}
const CspondFields = (:first,:second)
const PointCspond{T <: AbstractFloat} = NamedTuple{CspondFields, Tuple{PointRp2{T},PointRp2{T}}}
const PointCsponds{T <: AbstractFloat} = StructArrays.StructArray{PointCspond{T}}

struct SamplePc{N, T <: AbstractFloat}
    pc::PointCsponds{T}
    u₁::MinRp2Sample{N,T}
    u₂::MinRp2Sample{N,T}
    data_idx::MVector{N,UInt32}
end

SamplePc(pc::PointCsponds{T}, N) where {T} = SamplePc(pc,MinRp2Sample{N,T}(undef),MinRp2Sample{N,T}(undef),MVector{N,UInt32}(undef))

u1 = [SVector{3}(rand(3)) for k in 1:7]
u2 = [SVector{3}(rand(3)) for k in 1:7]
pc =  PointCsponds{Float64}((u1,u2))

tst = SamplePc(pc,7)

produces

 @code_warntype SamplePc(pc,7)
MethodInstance for SamplePc(::StructArrays.StructVector{NamedTuple{(:first, :second), Tuple{StaticArraysCore.SVector{3, Float64}, StaticArraysCore.SVector{3, Float64}}}, NamedTuple{(:first, :second), Tuple{Vector{StaticArraysCore.SVector{3, Float64}}, Vector{StaticArraysCore.SVector{3, Float64}}}}, Int64}, ::Int64)
  from SamplePc(pc::StructArrays.StructArray{NamedTuple{(:first, :second), Tuple{StaticArraysCore.SVector{3, T}, StaticArraysCore.SVector{3, T}}}}, N) where T in VisualGeometryToolkit at /home/jbpritts/.julia/dev/VisualGeometryToolkit/src/estimators/ransac_helpers.jl:10
Static Parameters
  T = Float64
Arguments
  #self#::Type{SamplePc}
  pc::StructArrays.StructVector{NamedTuple{(:first, :second), Tuple{StaticArraysCore.SVector{3, Float64}, StaticArraysCore.SVector{3, Float64}}}, NamedTuple{(:first, :second), Tuple{Vector{StaticArraysCore.SVector{3, Float64}}, Vector{StaticArraysCore.SVector{3, Float64}}}}, Int64}
  N::Int64
Body::SamplePc{_A, Float64} where _A
1 ─ %1 = Core.apply_type(VisualGeometryToolkit.MinRp2Sample, N, $(Expr(:static_parameter, 1)))::Type{StaticArraysCore.MVector{_A, StaticArraysCore.SVector{3, Float64}}} where _A
│   %2 = (%1)(VisualGeometryToolkit.undef)::StaticArraysCore.MArray{_A, StaticArraysCore.SVector{3, Float64}, 1} where _A<:Tuple
│   %3 = Core.apply_type(VisualGeometryToolkit.MinRp2Sample, N, $(Expr(:static_parameter, 1)))::Type{StaticArraysCore.MVector{_A, StaticArraysCore.SVector{3, Float64}}} where _A
│   %4 = (%3)(VisualGeometryToolkit.undef)::StaticArraysCore.MArray{_A, StaticArraysCore.SVector{3, Float64}, 1} where _A<:Tuple
│   %5 = Core.apply_type(VisualGeometryToolkit.MVector, N, VisualGeometryToolkit.UInt32)::Type{StaticArraysCore.MVector{_A, UInt32}} where _A
│   %6 = (%5)(VisualGeometryToolkit.undef)::StaticArraysCore.MArray{_A, UInt32, 1} where _A<:Tuple
│   %7 = VisualGeometryToolkit.SamplePc(pc, %2, %4, %6)::SamplePc{_A, Float64} where _A
└──      return %7

under is supposed to be undef ?

yes, were you able to reproduce the code_warntype message? I fixed a problem with the PointCsponds constructor as well. Should be an MWE now.

1 Like

@StefanKarpinski , the code example is now an MWE where I think constant propagation fails. Can you confirm, or am I making some coding error?

You are likely misunderstanding how @code_warntype works. As you have used it, there is no constant to propagate. You are asking Julia to do type inference on SamplePc(..., ::Int).

If you want the 7 to propagate, try somethingvlkke below.

julia> @inline SamplePc(pc::PointCsponds{T}, N) where {T} = SamplePc(pc,MinRp2Sample{N,T}(undef),MinRp2Sample{N,T}(undef),MVector{N,UInt32}(undef))
SamplePc

julia> @code_warntype (pc->SamplePc(pc,7))(pc)                      MethodInstance for (::var"#19#20")(::StructVector{NamedTuple{(:first, :second), Tuple{SVector{3, Float64}, SVector{3, Float64}}}, NamedTuple{(:first, :second), Tuple{Vector{SVector{3, Float64}}, Vector{SVector{3, Float64}}}}, Int64})
  from (::var"#19#20")(pc) @ Main REPL[23]:1
Arguments
  #self#::Core.Const(var"#19#20"())
  pc::StructVector{NamedTuple{(:first, :second), Tuple{SVector{3, Float64}, SVector{3, Float64}}}, NamedTuple{(:first, :second), Tuple{Vector{SVector{3, Float64}}, Vector{SVector{3, Float64}}}}, Int64}
Body::SamplePc{7, Float64}
1 ─ %1 = Main.SamplePc(pc, 7)::Core.PartialStruct(SamplePc{7, Float64}, Any[StructVector{NamedTuple{(:first, :second), Tuple{SVector{3, Float64}, SVector{3, Float64}}}, NamedTuple{(:first, :second), Tuple{Vector{SVector{3, Float64}}, Vector{SVector{3, Float64}}}}, Int64}, MVector{7, SVector{3, Float64}}, MVector{7, SVector{3, Float64}}, MVector{7, UInt32}])
└──      return %1
4 Likes

Ok, I don’t why there is a type instability (or ambiguity) if I specify 7 as a constant for N as I did in the original code.

Look at the macro expansion of @code_warntype:

julia> @macroexpand @code_warntype SamplePc(pc,7)                   :(InteractiveUtils.code_warntype(SamplePc, (Base.typesof)(pc, 7)))

The value 7 is never analyzed. Only its type is used.

1 Like

Oh wow. ok, I thought it was a result of compilation, not how it was called by code_warntype. That’s a gotcha for sure, and it seems non-obvious. Should it be documented?

That’s exactly what the documentation says.

help?> @code_warntype
  @code_warntype

  Evaluates the arguments to the
  function or macro call,
  determines their types, and
  calls code_warntype on the
  resulting expression.

I understand now the problem, but I think the unanointed (e.g., me) might not catch this issue and think there is a type instability problem with constant propagation.

Any valid function call has specific concrete arguments; the fact that an argument is passed as a literal or a variable doesn’t affect the call itself, which is what is shown.

2 Likes

This is why I really am not a fan of @code_warntype (and all the other @code_ macros). The way it’s presented is very misleading.

It’s quite convenient for quick interactive use, but typically if I’m displaying it anywhere I prefer to use the function where the types are specified.

julia> code_warntype(x -> x + 1, (Float64,))
MethodInstance for (::var"#7#8")(::Float64)
  from (::var"#7#8")(x) @ Main REPL[56]:1
Arguments
  #self#::Core.Const(var"#7#8"())
  x::Float64
Body::Float64
1 ─ %1 = (x + 1)::Float64
└──      return %1

Simply saying that the behaviour is documented isn’t a good excuse for non-intuitive behaviour since people won’t read the docs if they think they intuitively understand what it does without reading the docs.

2 Likes