<function>
or <operator>
).
This is just notation, and the symbols <
and >
should not be misconstrued as Julia's syntax.
Action | Keyboard Shortcut |
---|---|
Previous Section | Ctrl + 🠘 |
Next Section | Ctrl + 🠚 |
List of Sections | Ctrl + z |
List of Subsections | Ctrl + x |
Close Any Popped Up Window (like this one) | Esc |
Open All Codes and Outputs in a Post | Alt + 🠛 |
Close All Codes and Outputs in a Post | Alt + 🠙 |
Unit | Acronym | Measure in Seconds |
---|---|---|
Seconds | s | 1 |
Milliseconds | ms | 10-3 |
Microseconds | μs | 10-6 |
Nanoseconds | ns | 10-9 |
This section will formally define type stability, additionally reviewing tools for verifying the property. In the next section, we'll start the coverage of type stability applied to specific objects.
Previously, we've described the process that unfolds when a function is called. To recap, consider a function foo(x) = x + 2
and a variable a
with a specific value assigned. To emphasize that the process depends on types rather than values, we won't explicitly state a
's value.
When we call foo(a)
, Julia must compile a method instance to compute a + 2
. This process involves creating machine code to calculate a + 2
, given the concrete types inferred of a
and 2
. The resulting instructions are then stored, making them readily available for any subsequent call foo(b)
where b
has the same concrete type as a
.
To understand the factors influencing performance, it's essential to distinguish between two distinct phases in a function call: compilation time and runtime. Compilation time takes place when a method instance is created. This entails the generation of machine code, based on the concrete types inferred. Importantly, this process involves no computations, and only takes place during initial function calls. For its part, runtime refers to the moment when the code instructions are actually run. Unlike compilation time, this involving the execution of the compiled code, and occurs every time an operation is computed.
The key to generating fast code lies in the information available to the compiler during the compilation stage. This is primarily gathered through type inference, where Julia determines the specific type of each variable and expression involved. When the compiler can accurately predict a single concrete type for the function's output, we say that the function call is type stable.
Operationally, the definition implies that the compiler must infer unique concrete types for each expression involved. If the condition is met, the compiler will be able to specialize the computation approach, resulting in fast execution. Essentially, the compiler has sufficient information to determine a straight execution path, thus avoiding unnecessary type checks and dispatches at runtime.
In contrast, type-unstable functions generate code that must accommodate multiple possibilities, with instructions for each possible combination of unique concrete types. This results in additional overhead during runtime, where Julia dynamically gathers type information and perform extra calculations based on this new information. The consequence is a pronounced deterioration in performance.
To observe type stability in action, let's consider the following example.
x = [1, 2, 3] # `x` has type `Vector{Int64}`
@btime sum($x[1:2]) # type stable
22.406 ns (1 allocation: 80 bytes)
x = [1, 2, "hello"] # `x` has type `Vector{Any}`
@btime sum($x[1:2]) # type UNSTABLE
31.938 ns (1 allocation: 64 bytes)
Although both operations are ultimately reduced to computing 1 + 2
, the method implemented in each case differs. The first approach in particular is faster, because its computations are based on a type-stable function call.
Specifically, the output x[1] + x[2]
in the first can be deduced to be Int64
, thus satisfying the definition of type stability. The feature reflects that x[1]
and x[2]
are identified as Int64
, and so the compiler can generate code specialized to this type. This fast isn't only relevant for the example considered, as the code will apply to any call sum(y)
with y
having type Vector{Float64}
.
In contrast, the second tab defines a type-unstable function call: x
has type Vector{Any}
, making it impossible to predict a unique concrete type for x[1] + x[2]
, given the information on x
's type. This reflects that x[1]
and x[2]
may embody any concrete type that is a subtype of Any
. Consequently, the compiler creates code with numerous conditional statements, where each branch states how to compute x[1] + x[2]
for each possible type (e.g. Int64
, Float64
, Float32
, etc.). This results in compiled code that is slow, as it'll require extra work during runtime. Moreover, the degraded performance will be incurred for every call sum(y)
where y
has type Vector{Any}
.
While slower execution can serve as an indication of type instability, there are more formal and reliable mechanisms to determine this. One such mechanism is the @code_warntype
macro, which reports all the types inferred during a function call.
To illustrate its usage, consider a function that defines y
as a transformation of x
, and then uses y
to perform some operation.
function foo(x)
y = (x < 0) ? 0 : x
return [y * i for i in 1:100]
end
@code_warntype foo(1.)
function foo(x)
y = (x < 0) ? 0 : x
return [y * i for i in 1:100]
end
@code_warntype foo(1)
The output of @code_warntype
can be difficult to interpret. Nonetheless, the addition of colors facilitates its understanding:
If all lines are blue, the function is type stable. This means that Julia can identify a unique concrete type for each variable.
If at least one line is red, the function is type unstable. It reflects that one variable or more could potentially adopt multiple possible types.
Yellow lines indicate type instabilities that the compiler can handle effectively, in the sense that they have a reduced impact on performance. As a rule of thumb, you can safely ignore them.
In the provided example, the compiler attempts to infer concrete types. This is by identifying two pieces of information based on x
's concrete type:
the type of y
,
the type of y * i
with i
having type Int64
, which implicitly defines the type of [y * i for i in 1:100]
.
The example clearly demonstrates that the same function can be type stable or unstable depending on the types of its inputs: foo
is type stable when x
has type Int64
, but type unstable when x
is Float64
.
In the scenario where x = 1
, the compiler infers for i) that y
can be equal to either 0
or x
. Since both 0
and 1
are of type Int64
, the compiler identifies a unique type for y
, which is Int64
. Regarding ii), y * i
also yields an Int64
, as both i
and y
have type Int64
. This establishes that [y * i for i in 1:100]
is of type Vector{Int64}
. Consequently, foo(1)
is type stable, enabling Julia to invoke a method specialized to integers.
As for x = 1.0
, the information for i) is that y
could be either 0
or 1.0
. As a result, the compiler is unable to infer a unique type for y
, which can possibly adopt a value with type Int64
or Float64
. The macro @code_warntype
reflects this by identifying y
as having type Union{Float64, Int64}
. This ambiguity impacts ii), with the compiler unable to specialize and forced to consider an approach that handles Union{Float64, Int64}
. Overall, foo(1.0)
is type unstable, which has a detrimental impact on performance.
foo(-2)
or foo(-2.0)
. This is because the compilation process relies on information about types, not values. This means that the type stability depends on whether x
has type Int64
or Float64
, regardless of its actual value.
Not all type instabilities have the same impact on performance, and their severity is ultimately indicated through a yellow or red warning. Yellow warnings, which are relatively minor, typically result from isolated computations that Julia can handle effectively. However, if these operations are repeated within a for-loop, they may escalate into more serious performance issues, thus triggering a red warning. The following example demonstrates a scenario like this.
function foo(x)
y = (x < 0) ? 0 : x
y * 2
end
@code_warntype foo(1.)
function foo(x)
y = (x < 0) ? 0 : x
[y * i for i in 1:100]
end
@code_warntype foo(1.)
function foo(x)
y = (x < 0) ? 0 : x
for i in 1:100
y = y + i
end
return y
end
@code_warntype foo(1.)
The yellow warning comes from the possibility of y * 2
returning a Float64
or Int64
value. However, this operation is computed only once and consists of two types that the compiler can manage well. Instead, the second tab needs to handle a vector with multiple elements created through y * i
, without knowledge of y
's type. This results in a red warning.
It's worth remarking, though, that not all yellow warnings necessarily lead to a red one when incorporated into a for-loop. In fact, this doesn't occur in the third tab, where the for-loop doesn't involve a vector. This reinforces the idea that not all type instabilities are equally detrimental.
nothing
with type Nothing
when the sequence is exhausted.
function foo()
for i in 1:100
i
end
end
@code_warntype foo()