Rebinding local variable inside function and type stability: does it matter?

Based on my limited understanding of the documentation here, type stability mainly pertains to the return type. However, if I change the type of a local variable (essentially rebinding) inside a function as follows:

g(x) = x + 1.2

function ff()
    x = 1
    x = g(x)
    return x + 2.3
end

and @code_warntype gives
Snipaste_2021-01-29_12-26-41

We see that the return type is of course stable. However, I am not sure about the followng issues.

  • Does the unstable types of the variable x (highlighted in red) hurt performance?
  • Is it recommended to use different variable names instead of reassigning in Julia?
1 Like

The name x doesn’t have a clear type, but Julia’s SSA form has two separate internal variables to split the representation and make it type stable.

4 Likes

Thanks for the explanation. I have little knowledge in compiler theory. The conclusion is that reusing variables for different types inside a function does not compromise the performance, even though the variable is apparently type unstable, right?

In that case it won’t hurt, but not doing that is one of the performance tips: Performance Tips · The Julia Language

4 Likes

I think that’s only true for variables that are clearly separable for the compiler. Chris here can probably tell you a bit more, but I wouldn’t change the type of a variable in a loop when both the old and the new type may be used. And there are probably more conditions where this is hard on the compiler:

julia> function assignseparately(x::Int)
       y = (x+1)^4
       z = π/y
       y = y * ℯ^z
       return y
       end
assignseparately (generic function with 1 method)

julia> assignseparately(4)
628.1495015830967

This is fine:

julia> @code_warntype assignseparately(3)
Variables
  #self#::Core.Const(assignseparately)
  x::Int64
  z::Float64
  y::Union{Float64, Int64}

Body::Float64
1 ─ %1 = (x + 1)::Int64
│   %2 = Core.apply_type(Base.Val, 4)::Core.Const(Val{4})
│   %3 = (%2)()::Core.Const(Val{4}())
│        (y = Base.literal_pow(Main.:^, %1, %3))
│        (z = Main.π / y::Int64)
│   %6 = y::Int64::Int64
│   %7 = (Main.ℯ ^ z)::Float64
│        (y = %6 * %7)
└──      return y::Float64

But this is not:

julia> function assignloop(x::Int)
       y = x÷2 + 5
       z = 1
       for i in 1:x
           z += i<y ? i : i/y
       end
       z
       end
assignloop (generic function with 1 method)

julia> @code_warntype assignloop(10)
Variables
  #self#::Core.Const(assignloop)
  x::Int64
  @_3::Union{Nothing, Tuple{Int64, Int64}}
  z::Union{Float64, Int64}
  y::Int64
  i::Int64
  @_7::Union{Float64, Int64}

Body::Union{Float64, Int64}
1 ─ %1  = (x ÷ 2)::Int64
│         (y = %1 + 5)
│         (z = 1)
│   %4  = (1:x)::Core.PartialStruct(UnitRange{Int64}, Any[Core.Const(1), Int64])
│         (@_3 = Base.iterate(%4))
│   %6  = (@_3 === nothing)::Bool
│   %7  = Base.not_int(%6)::Bool
└──       goto #7 if not %7
2 ┄ %9  = @_3::Tuple{Int64, Int64}::Tuple{Int64, Int64}
│         (i = Core.getfield(%9, 1))
│   %11 = Core.getfield(%9, 2)::Int64
│   %12 = z::Union{Float64, Int64}
│   %13 = (i < y)::Bool
└──       goto #4 if not %13
3 ─       (@_7 = i)
└──       goto #5
4 ─       (@_7 = i / y)
5 ┄ %18 = @_7::Union{Float64, Int64}
│         (z = %12 + %18)
│         (@_3 = Base.iterate(%4, %11))
│   %21 = (@_3 === nothing)::Bool
│   %22 = Base.not_int(%21)::Bool
└──       goto #7 if not %22
6 ─       goto #2
7 ┄       return z

Okay you cant see much with the discourse highlighting, but it’s not stable any more Body::Union{Float64, Int64}. This is of course artificial, but there are more intricate examples where this can go unnoticed easily. Plus, reading the code is much easier when you don’t reuse names for different things in different places.

1 Like

Thanks @FPGro. As stated above:

There are scenarios where reusing a variable name is handy. For example, a function has an argument data, and in its first line, we perform data = preprocess(data) and we will deal with the preprocessed data in the remaining part. Here, the new data may have a different type. Nonetheless, the new type of data is fixed hereafter. Alternatively, it seems verbose to define data_pp = preprocess(data).

1 Like

True, this looks harmless enough, and it is fine for the compiler. Just make sure that preprocess(data) is type stable. If it’s not, you may “inherit” the instability from that function, but that is of course independent of the naming.

1 Like

FWIW, I think this is bad style. I would do either

preprocessed_data = preprocess(data)

or if preprocess results in a different type,

function f(data::PreprocessedData)
    ...
end

f(data::RawData) = f(preprocess(data))
5 Likes

Thanks. I agree with you now. I will follow this recommended style.

1 Like