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
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?
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.
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?
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
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.
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).
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.