Type Stability
While Julia is a dynamic language, it can be very important for the compiler to know the type of the variables being used, so that it can specialise on it. We have been alluding to this throughout this course so far, but getting this wrong may have huge performance impacts. Let’s take a concrete example:
function get_fibonacci(n)
a = 1
b = 1
fibonacci_nums = []
push!(fibonacci_nums, a)
push!(fibonacci_nums, b)
for i = 1:n-2
a, b = b, a+b
push!(fibonacci_nums, b)
end
return fibonacci_nums
endThis calculates the first Fibonacci numbers and stores the results in an array. Note that [] is shorthand for Vector{Any}() — an array that can hold anything — so even though we only ever push integers into it, the result is a Vector{Any}. Let’s confirm that, and then see what effect it has on performance:
fibonacci_nums = get_fibonacci(50);
@show typeof(fibonacci_nums);typeof(fibonacci_nums) = Vector{Any}import BenchmarkTools: @benchmark, @btime, @belapsed
display(@benchmark sum($fibonacci_nums))BenchmarkTools.Trial: 10000 samples with 59 evaluations per sample.
Range (min … max): 849.153 ns … 69.536 μs ┊ GC (min … max): 0.00% … 97.30%
Time (median): 902.034 ns ┊ GC (median): 0.00%
Time (mean ± σ): 978.995 ns ± 984.329 ns ┊ GC (mean ± σ): 1.91% ± 2.87%
▅██▅▅▂▂▃▂▄▆▅▁ ▁ ▁ ▂
█████████████████▇██████▇▅▆▆▆▆███▆▇▅▅▆▆▆▇▅▅▆▆▇▅▅▆▃▅▄▄▃▆▅▄▃▁▄▆ █
849 ns Histogram: log(frequency) by time 1.77 μs <
Memory estimate: 608 bytes, allocs estimate: 38.We can see that this sum had many allocations, and took a significant amount of time. If we simply make another array with the correct type, let us see the difference in performance:
fibonacci_nums_with_type = [f for f in fibonacci_nums];
@show typeof(fibonacci_nums_with_type);typeof(fibonacci_nums_with_type) = Vector{Int64}display(@benchmark sum($fibonacci_nums_with_type))BenchmarkTools.Trial: 10000 samples with 999 evaluations per sample.
Range (min … max): 7.818 ns … 45.265 ns ┊ GC (min … max): 0.00% … 0.00%
Time (median): 8.268 ns ┊ GC (median): 0.00%
Time (mean ± σ): 8.794 ns ± 1.540 ns ┊ GC (mean ± σ): 0.00% ± 0.00%
█▆█▅ ▆▇▇▄▄▂ ▂▂ ▂
████▄▄▇██████▆▆▆▆██▅█▅▅▅▆▄▆▅▅▅▄▄▅▅▄▅▄▂▅▅▄▅▇▅█▇▇▇▇▆▇▇▅▆▆▆▅▅ █
7.82 ns Histogram: log(frequency) by time 15.6 ns <
Memory estimate: 0 bytes, allocs estimate: 0.Notice that the allocations were reduced to zero. Additionally, the runtime of these algorithms improved dramatically. In this example, when Julia knew the type of the variables, the performance increased by around two orders of magnitude. The reason why performance was so low for the first method, is that Julia had to check the type of each individual element in the array to make sure that it was the right type.
Understanding Type Instability
If the compiler cannot trace the types of the variables used throughout a function (each variable having a known concrete type at compile time), then the function is said to be type unstable. This means that the compiler will have to perform additional checks on variables with unknown types at runtime, which costs performance.
Let’s take a look at an example of a type unstable function:
function example_type_unstable_fn(x)
s = 0
for x_i in x
s += x_i
end
s
endThis looks like a normal implementation. We can even run it and benchmark to see if it works:
x = collect(1:100);
display(@benchmark example_type_unstable_fn($x))BenchmarkTools.Trial: 10000 samples with 999 evaluations per sample.
Range (min … max): 10.741 ns … 74.434 ns ┊ GC (min … max): 0.00% … 0.00%
Time (median): 11.271 ns ┊ GC (median): 0.00%
Time (mean ± σ): 11.804 ns ± 2.403 ns ┊ GC (mean ± σ): 0.00% ± 0.00%
▆▇█▇▅▁▂ ▁ ▁ ▂
███████▇▅▆▇▆████▆▆▇██▇▇▆▇▇▇▇▇▇▇▅▅▅▆▅▆▆▅▅▄▅▆▇█▃▇▅▇▁▄▁▃▃▃▆██▆ █
10.7 ns Histogram: log(frequency) by time 22.8 ns <
Memory estimate: 0 bytes, allocs estimate: 0.There are actually no issues with this code so far that we can see, but let’s show an example where some performance issues come out:
function calc_summary(rows)
return sum(example_type_unstable_fn, rows)
end
rows = [rand(rand(0:10)) for _ in 1:100];
display(@benchmark calc_summary($rows))BenchmarkTools.Trial: 10000 samples with 276 evaluations per sample.
Range (min … max): 283.478 ns … 1.836 μs ┊ GC (min … max): 0.00% … 0.00%
Time (median): 294.783 ns ┊ GC (median): 0.00%
Time (mean ± σ): 302.076 ns ± 34.686 ns ┊ GC (mean ± σ): 0.00% ± 0.00%
▅█▅▂
▄██████▅▄▃▃▃▃▃▃▃▃▃▃▃▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▁▂▂▂▂▂▂▂ ▃
283 ns Histogram: frequency by time 439 ns <
Memory estimate: 0 bytes, allocs estimate: 0.We can use the @code_warntype macro to identify type instabilities. This macro lives in the InteractiveUtils standard library, which the REPL loads for you automatically, but which you must import explicitly inside a script:
using InteractiveUtils
@code_warntype example_type_unstable_fn(rows[1])MethodInstance for example_type_unstable_fn(::Vector{Float64})
from example_type_unstable_fn(x) @ Main ~/dev/website-dev/personal-website/.code-exec/_53f867569c9c8c6a2fbfb8e5cae2618489feecdf3ce20c5bc2d9af61eee5150b.jl:25
Arguments
#self#::Core.Const(Main.example_type_unstable_fn)
x::Vector{Float64}
Locals
@_3::UNION{NOTHING, TUPLE{FLOAT64, INT64}}
s::UNION{FLOAT64, INT64}
x_i::Float64
Body::UNION{FLOAT64, INT64}
1 ─ (s = 0)
│ %2 = x::Vector{Float64}
│ (@_3 = Base.iterate(%2))
│ %4 = @_3::UNION{NOTHING, TUPLE{FLOAT64, INT64}}
│ %5 = (%4 === nothing)::Bool
│ %6 = Base.not_int(%5)::Bool
└── goto #4 if not %6
2 ┄ %8 = @_3::Tuple{Float64, Int64}
│ (x_i = Core.getfield(%8, 1))
│ %10 = Core.getfield(%8, 2)::Int64
│ %11 = Main.:+::Core.Const(+)
│ %12 = s::UNION{FLOAT64, INT64}
│ %13 = x_i::Float64
│ (s = (%11)(%12, %13))
│ (@_3 = Base.iterate(%2, %10))
│ %16 = @_3::UNION{NOTHING, TUPLE{FLOAT64, INT64}}
│ %17 = (%16 === nothing)::Bool
│ %18 = Base.not_int(%17)::Bool
└── goto #4 if not %18
3 ─ goto #2
4 ┄ %21 = s::UNION{FLOAT64, INT64}
└── return %21We can see that the variable s is type unstable (i.e. has a union type). This is because the input array has the possibility of being empty. If this array is empty then the loop gets skipped and the function will return 0, which is an integer. If the array is not empty, then the first assignment to s will promote the type to a floating point number.
Note: This particular type instability is not catastrophic in recent versions of Julia. When the instability covers a small union of concrete types the compiler can union split, generating a branch for each possibility, so you pay a modest cost rather than a full dynamic dispatch. It is still a real cost, as the benchmarks below show. More importantly, if this code is nested within a larger algorithm, the instability can propagate outwards and cause much larger issues.
We can fix this type instability by using generic functions, like zero and eltype:
function example_type_stable_fn(x)
s = zero(eltype(x))
for x_i in x
s += x_i
end
s
end
function calc_summary_stable(rows)
return sum(example_type_stable_fn, rows)
endNow let’s benchmark both versions:
display(@benchmark calc_summary($rows))BenchmarkTools.Trial: 10000 samples with 269 evaluations per sample.
Range (min … max): 283.978 ns … 1.993 μs ┊ GC (min … max): 0.00% … 0.00%
Time (median): 303.755 ns ┊ GC (median): 0.00%
Time (mean ± σ): 321.343 ns ± 51.260 ns ┊ GC (mean ± σ): 0.00% ± 0.00%
▃█▅ ▁
▂▅████▃▂▂▂▂▄█▆▆▂▁▁▁▂▂▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ ▂
284 ns Histogram: frequency by time 521 ns <
Memory estimate: 0 bytes, allocs estimate: 0.display(@benchmark calc_summary_stable($rows))BenchmarkTools.Trial: 10000 samples with 240 evaluations per sample.
Range (min … max): 311.333 ns … 1.546 μs ┊ GC (min … max): 0.00% … 0.00%
Time (median): 326.875 ns ┊ GC (median): 0.00%
Time (mean ± σ): 339.818 ns ± 52.038 ns ┊ GC (mean ± σ): 0.00% ± 0.00%
▆███▇▇▆▅▄▃▃▂▂▂▂▁ ▂
███████████████████▇▆▇▇▇▇▆▆▇▆▆▆▆▇▆▆▅▅▅▇▇█▇█▇▅▅▅▆▅▅▆▅▄▅▄▂▅▅▄▃ █
311 ns Histogram: log(frequency) by time 562 ns <
Memory estimate: 0 bytes, allocs estimate: 0.You can see a solid performance improvement when switching to the type stable version — roughly a factor of two here, with neither version allocating. Making our code type stable also ensures that the code is very generic and reusable across several different datatypes. In general, it is a good idea to avoid hard-coding numeric literals such as 0 and 1 in generic code, and instead build them from the input with the zero, one, eltype and typeof functions, so that they take the same type as the data you are working with.
Barriers
There are some occasions when you cannot predict the type being returned from a function, which leads to type instability. There are still some tools one can use to mitigate the performance hits of this. The antidote to this problem is to break up larger functions into several smaller functions, which act as type barriers so that performance critical parts of your code are not affected.
Let’s take the example of using an array to store parameters which are used within a function:
function params_in_array_test(x)
a = 0
b = 1
for i = 1:100
c = x[1] + a
d = x[2] / b
a, b = c, d
end
return a, b
end
x = [2.0, 1]
display(@benchmark params_in_array_test($x))BenchmarkTools.Trial: 10000 samples with 846 evaluations per sample.
Range (min … max): 138.570 ns … 399.846 ns ┊ GC (min … max): 0.00% … 0.00%
Time (median): 146.974 ns ┊ GC (median): 0.00%
Time (mean ± σ): 151.188 ns ± 14.794 ns ┊ GC (mean ± σ): 0.00% ± 0.00%
▁ ▆█▇▇▆▇▇▄▄▄▄▄▆▂▁ ▁ ▁▂▅▂▁▂▁▁ ▂
█▇▅▇█████████████████████▇▆▇▅▅▆▆▇▆▆▅██████████▇▆▆▅▆▅▇█▆▇▆▅▆▇▆ █
139 ns Histogram: log(frequency) by time 193 ns <
Memory estimate: 0 bytes, allocs estimate: 0.Note that the array constructor has “promoted” the integer 1 to a floating point number, so that every element shares a single concrete type: x here is a Vector{Float64}, not a mixed array. Now, let’s imagine that we add another parameter to this array, which is of a different type:
x = [2.0, 1, "third"]
display(@benchmark params_in_array_test($x))BenchmarkTools.Trial: 10000 samples with 7 evaluations per sample.
Range (min … max): 4.453 μs … 92.449 μs ┊ GC (min … max): 0.00% … 86.79%
Time (median): 5.069 μs ┊ GC (median): 0.00%
Time (mean ± σ): 5.263 μs ± 3.275 μs ┊ GC (mean ± σ): 2.71% ± 4.09%
▂▃▅█▃▃
▁▂▂▁▂▂▂▄▆▇▆████████▅▄▂▂▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▂▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ ▂
4.45 μs Histogram: frequency by time 6.85 μs <
Memory estimate: 3.16 KiB, allocs estimate: 201.Now, even though the first two entries of x are unchanged and all we did was add a third parameter, the function has become more than an order of magnitude slower, and it now allocates on every iteration of the loop where before it allocated nothing at all. This is because x is now a Vector{Any}, so the compiler no longer knows the types of x[1] and x[2], and every operation in the loop has to be dispatched dynamically and boxed on the heap.
We can remedy this hit, without changing the input types, by using a barrier function:
function _params_in_array_test_loop(x1, x2, a, b)
for i = 1:100
c = x1+a
d = x2 / b
a, b = c, d
end
return a, b
end
function params_in_array_test_with_barrier(x)
a = 0
b = 1
return _params_in_array_test_loop(x[1], x[2], a, b)
end
display(@benchmark params_in_array_test_with_barrier($x))BenchmarkTools.Trial: 10000 samples with 575 evaluations per sample.
Range (min … max): 200.522 ns … 2.791 μs ┊ GC (min … max): 0.00% … 88.86%
Time (median): 212.348 ns ┊ GC (median): 0.00%
Time (mean ± σ): 215.386 ns ± 53.508 ns ┊ GC (mean ± σ): 0.52% ± 1.97%
█ ▁ ▃
▁▆▆█▅▃▃▇█▅▅█▅▅▆▇█▆▄▄▄▄▅▄▆▄▄▃▃▄▃▃▃▂▂▃▃▂▂▂▂▂▂▂▂▂▂▂▁▁▁▁▁▁▁▁▁▁▁▁ ▃
201 ns Histogram: frequency by time 246 ns <
Memory estimate: 32 bytes, allocs estimate: 1.This recovers almost all of the lost performance: the loop now runs in a type stable manner and only a single dynamic dispatch remains, at the point where the untyped values are pulled out of x and handed to the barrier function. This is the tool to reach for when it is not possible to remove the type instability entirely.
Using Composite Types
In Julia, we can define our own composite types using struct. By default, structs are immutable. Structs are a common place that people introduce type instability. Take the example of defining one’s own complex number:
struct MyComplex
real
imag
endHowever, this type is a source of type instability. By not annotating them, we have implicitly declared both the real and imag fields to have type Any, so the compiler cannot know what they hold, and an instance must store pointers to boxed values on the heap. We can fix this by specifying a type:
struct MyComplexFixed
real::Float64
imag::Float64
endHowever now, this type will only work with type Float64. Instead, we can use a feature of Julia called generics, which lets the field types be a parameter of the struct itself:
struct MyComplexGeneric{T<:Number}
real::T
imag::T
end
# Example usage
c1 = MyComplexGeneric(1.0, 2.0)
c2 = MyComplexGeneric(1, 2)
@show typeof(c1) typeof(c2) isbitstype(typeof(c1));typeof(c1) = MyComplexGeneric{Float64}
typeof(c2) = MyComplexGeneric{Int64}
isbitstype(typeof(c1)) = trueIf you have a performance critical struct, make sure that the types are well-defined. If every field of a struct has a concrete, fixed-size type (for example a collection of numeric fields), then the struct itself is an isbits type, and Julia is free to keep it in registers or on the stack rather than allocating it on the heap.