pic
Personal
Website

8c. Type Stability with Scalars and Vectors

PhD in Economics

Introduction

The previous section has defined type stability, along with approaches to checking whether the property holds. In this section, we start the analysis of type stability for specific objects. We cover in particular the case of scalars and vectors, providing practical guidance for ensuring type stability with them.

Scalars and Vectors

The definition of type stability indicates that the compiler infers a single concrete type for each expression. The principle applied to scalars is direct, demanding operations be performed on variables with the same concrete type. In the case of vectors, type stability rather requires that the elements have a concrete type, such as Float64, Int64, or Bool.

The following table identifies scalars and vectors satisfying the property.

Objects Whose Elements Have Concrete Types
Scalars Vectors
Int Vector{Int}
Int64 Vector{Int64}
Float64 Vector{Float64}
Bool BitVector

Note: Int defaults to Int64 or Int32, depending on your CPU's architecture.

In the following, we analyze each case separately.

Type Stability with Scalars

To turn the definition of type stability operational for scalars, let's revisit our discussion about types. Recall that only concrete types like Int64 or Float64 can be instantiated, while abstract types like Any or Number can't.

Instantiation simply means that all values ultimately adopt a unique concrete type. For instance, a variable x::Number = 2 shouldn't be interpreted as x having the type Number. Instead, it means that x can only be reassigned to values whose concrete type is a subtype of Number. Ultimately, x must have a concrete type, which in this case is Int64.

Despite that functions always identify a unique concrete type for scalars, some operations that mix Int64 and Float64 could result in type instability. Before providing examples of these cases, we show some scenarios where their mix don't pose a problem.

Type Promotion and Conversion

Julia employs various mechanisms to handle cases combining Int64 and Float64. The first one is type promotion, which converts dissimilar types to a common one, whenever possible. Likewise, Julia also engages in conversions when variables are type-annotated, transforming values to the respective type.

Both mechanisms are illustrated below.

function foo()
  x1::Float64 = 1          # `Int64` will be converted to `Float64`
  x2::Int64   = 2.0        # `Float64` will be converted to `Int64`

  return x1,x2
end

x1,x2 = foo()              # type stable

Output in REPL
julia>
x1
1.0

julia>
x2
2

foo(x,y) = x * y           # if mixing `Int64` and `Float64`, then `Float64`

x        = 2
y        = 0.5

z        = foo(x,y)        # type stable

Output in REPL
julia>
z
1.0

In the first tab, Julia transforms the values of x1 and x2 to make them consistent with the type-annotations. As for the second tab, the output's type will depend on the argument's types, but in all cases they can be predicted. Specifically, the product of Int64 or Float64 will inherit the type, while mixing Int64 and Float64 results in Float64 due to automatic type promotion.

Type Instability with Scalars

While type promotion and conversion can handle certain scenarios, they don't cover all. One of these cases is when a scalar's value is conditional, with each branch returning a value with a different type. Since the compiler only observes types and not values, it can't decide which branch is relevant for the function call. Consequently, it'll generate code that accommodates both possibilities. This can be observed in the following example.

function foo(x,y)
    a = (x > y) ?  x  :  y

    [a * i for i in 1:100_000]
end

foo(1, 2)           # type stable   -> `a * i` is always `Int64`
Output in REPL
julia>
@btime foo(1,2)
  23.800 μs (2 allocations: 781.30 KiB)
function foo(x,y)
    a = (x > y) ?  x  :  y

    [a * i for i in 1:100_000]
end

foo(1, 2.5)         # type UNSTABLE -> `a * i` is either `Int64` or `Float64`
Output in REPL
julia>
@btime foo(1,2.5)
  43.200 μs (2 allocations: 781.30 KiB)

In the example, type instability will inevitably arise when x and y have different types. Note that type promotion is of no help here. The reason is that this mechanism only ensures that a * i will be converted to Float64 if a is Float64, given that i is Int64. Nonetheless, the compiler also needs to consider that a could be Int64, in which case a * i is Int64.

Considering this ambiguity, the method instance created must be capable of handling both scenarios. Then, during runtime, Julia will gather more information to disambiguate the situation, thus selecting the pertinent computation implementation.

Type Stability with Vectors

Vectors in Julia are formally defined as collections of elements with a homogenous type. Since operations based on vectors ultimately work with its individual elements, type stability is contingent on the concrete type of their elements.

In this context, it's important to distinguish between the type of the vector itself and that of its elements. This is because vectors whose elements have a concrete type are themselves concrete, while elements with abstract types can still give rise to vectors with concrete types. This is clearly observed with Vector{Any}, a concrete type comprising elements with abstract type Any.

Before beginning with the analysis of specific scenarios, we consider automatic conversion of types. This mechanism is relevant for the definition of vectors that mix types.

Type Promotion and Conversion

By definition, vectors require all their elements to share the same type. This implies that, for instance, if mix elements with disparate types such as String and Int64, Julia will identify the vector with type Vector{Any}. Nonetheless, there are some instances where elements can be converted to a common type, as when we mix Float64 and Int64.

In particular, this automatic conversion occurs during assignments, where all elements are converted to the most general type that can accommodate them.

x = [1, 2, 2.5]     # automatic conversion to `Vector{Float64}`

Output in REPL
julia>
x
3-element Vector{Float64}: 1.0 2.0 2.5

y = [1, 2.0, 3.0]    # automatic conversion to `Vector{Float64}`

Output in REPL
julia>
y
3-element Vector{Float64}: 1.0 2.0 3.0

In both examples, the assignments are type-unannotated. When assignments are instead declared with type-annotations, Julia will attempt to perform a conversion whenever possible, ensuring that the assigned value conforms to the declared type.

x1                 = [1, 2.0, 3.0]                 # automatic conversion to `Vector{Float64}`  

x2::Vector{Int64}  = y1                            # conversion to `Vector{Int64}`

Output in REPL
julia>
z2
3-element Vector{Number}: 1.0 2.0 2.5

y1                 = [1, 2, 2.5]                   # automatic conversion to `Vector{Float64}`  

y2::Vector{Number} = y1                            # `y2` is still `Vector{Number}`

Output in REPL
julia>
z2
3-element Vector{Number}: 1.0 2.0 2.5

nr_elements  = 3
z            = Vector{Any}(undef, nr_elements)     # `Vector{Any}` always

z           .= 1

Output in REPL
julia>
v
3-element Vector{Any}: 1 1 1

Type Instability

When evaluating type stability in the context of vectors, there are two forms of operations that need to be considered. The first one involves operations that manipulate individual elements, such as x[i]. This scenario parallels the case of scalars, and so type stability adheres to the same rules.

The second scenario involves functions operating on the entire vector. In this case, the design of function commonly ensures type stability if vectors have elements with a concrete type. Note that whether this holds ultimately depends on the package developer's implementation.

For instance, let's consider the example of summing all elements of type-annotated vectors. All the following cases operate on vectors having elements with concrete types and are type stable.

x1::Vector{Int}     = [1, 2, 3]

sum(x1)             # type stable
x2::Vector{Int64}   = [1, 2, 3]

sum(x2)             # type stable
x3::Vector{Float64} = [1, 2, 3]

sum(x3)             # type stable
x4::BitVector       = [true, false, true]

sum(x4)             # type stable

In contrast, the following vectors have elements with abstract types and result in type instability.

x5::Vector{Number} = [1, 2, 3]

sum(x5)             # type UNSTABLE -> `sum` must consider all possible subtypes of `Number`
x6::Vector{Any}    = [1, 2, 3]

sum(x6)             # type UNSTABLE -> `sum` must consider all possible subtypes of `Any`