Skip to content
AgileLogics
7 min read

Type Stability

Julia is fast because the compiler generates specialized machine code for the argument types of each call. That only works well if it can infer the type of every variable and the return value from the argument types alone. Code where it can is type-stable.

unstable(x) = x > 0 ? x : 0.0     # Int or Float64, depending on the value
stable(x) = x > 0 ? x : zero(x)    # always the same type as x

println(Base.return_types(unstable, (Int,)))
println(Base.return_types(stable, (Int,)))
println(typeof(unstable(5)), " ", typeof(unstable(-5)))
Any[Union{Float64, Int64}]
Any[Int64]
Int64 Float64

unstable returns an Int for positive input and a Float64 otherwise, so its inferred type is a Union. Every caller then has to check which one it got. zero(x) returns a zero of the same type as x — the fix.

@code_warntype

In the REPL, @code_warntype f(args...) shows what the compiler inferred. Problems are highlighted in red and the return type appears after Body:

julia> @code_warntype unstable(1)
MethodInstance for unstable(::Int64)
  from unstable(x) @ Main REPL[1]:1
Arguments
  #self#::Core.Const(Main.unstable)
  x::Int64
Body::UNION{FLOAT64, INT64}
...

Look for Any, Union, or red text in the variables and the body. The JET.jl package (@report_opt) finds these problems across a whole call tree, and Cthulhu.jl lets you descend into nested calls.

Testing for stability

using Test

stable(x) = x > 0 ? x : zero(x)
unstable(x) = x > 0 ? x : 0.0

println(@inferred stable(3))
try
    @inferred unstable(3)
catch err
    println(sprint(showerror, err))
end
3
return type Int64 does not match inferred return type Union{Float64, Int64}

@inferred throws if the actual return type differs from the inferred one — use it in your test suite for performance-critical functions.

Common causes of instability

# 1. A variable that changes type inside a loop
function sum_unstable(xs)
    total = 0            # Int...
    for x in xs
        total += x       # ...becomes Float64 for float input
    end
    return total
end

function sum_stable(xs)
    total = zero(eltype(xs))
    for x in xs
        total += x
    end
    return total
end

println(Base.return_types(sum_unstable, (Vector{Float64},)))
println(Base.return_types(sum_stable, (Vector{Float64},)))

# 2. Abstract field types
struct Loose;  v::Real; end
struct Tight{T<:Real}; v::T; end
getv(s) = s.v
println(Base.return_types(getv, (Loose,)), " ", Base.return_types(getv, (Tight{Float64},)))

# 3. Containers with abstract element types
println(eltype(Real[1, 2.0]), " ", eltype([1, 2.0]))
Any[Union{Float64, Int64}]
Any[Float64]
Any[Real] Any[Float64]
Real Float64
  • Initialize accumulators with zero(T) or zero(eltype(xs)).
  • Give struct fields concrete or parametric types.
  • Prefer Vector{Float64} over Vector{Real} or Vector{Any}.
  • Untyped global variables are the fourth big cause — see the lesson on memory and globals.

Function barriers

When a value's type genuinely can't be known (for example, data read from a file), pass it to a separate function. Julia compiles that inner function for the concrete type it actually receives:

function process(data)
    values = data["values"]     # the compiler sees ::Any here
    return kernel(values)       # ...but kernel is specialized
end

function kernel(v)
    s = zero(eltype(v))
    for x in v
        s += x * x
    end
    return s
end

data = Dict{String,Any}("values" => [1.0, 2.0, 3.0], "name" => "demo")
println(process(data))
14.0