Mutability (and Performance) in julia

A common source of confusion when beginning julia is the idea of mutability and its performance implications. People tend to confuse semantics and implementation. So, because the Docs state

An object with an immutable type is passed around (both in assignment statements and in function calls) by copying, whereas a mutable type is passed around by reference.

they tend to believe that mutables may yield better performance. After all, copying an object must be cheaper than copying a pointer/reference, right? In fact, this is the reasoning that leads C++ programmers to pass by constant reference whenever possible.

Semantics

In fact immutability/mutability is better seen as a semantic alternative to the C++ call-by-value/call-by-reference concept. In C++ we choose for any function call whether to pass our variable by value or by reference. Call-by-value means that any changes to the variable inside the function are not reflected outside the function call, and vice-versa for call-by-reference. (Note: This is pure semantics, so call-by-value doesn’t mean “copy the object and give a copy of the object to the function” – even though most C++ developers have come to read it this way. More on this in the next section.)

In julia we have mutability instead. (Im)mutability is not associated with the function call, but with the type itself. Immutability means that the type cannot change and hence is always called-by-value in the language of a C++ developer. This explains the quote from the Docs above. (The concept of immutability goes even further, as it does not allow any changes on the type whatsoever. This means that the type can’t even change temporarily within the function as a called-by-value variable in C++ could.) On the other hand, any changes to a mutable type inside a function are always reflected outside the function – in resemblance of call-by-reference in C++. While C++ can be seen as more flexible – allowing to choose whether or not changes should be reflected outside the function call – the julia approach enforces a consistency that makes code typically easier to reason about.

Implementations

To understand the performance implications we now need to consider how these semantics can actually be fulfilled by the compiler. Let me stress explicitly, that the compiler only needs to fulfill the semantics and can choose any implementation that complies with them. In C++ the compiler typically treats call-by-value as “copy the object and give a copy of the object to the function” (as stated above) unless it optimizes the copy out. Call-by-reference is treated as “copy not the object itself, but the pointer to the object, give it to the function, and interpret any use of the variable inside the function as following that pointer and working with the object pointed to”. When you think about it long enough, it becomes clear why these implementations fulfill the semantics specified above.

In julia we might be tempted now to equate “immutable” with “always copy the object”. And yes, copying may happen at times. However, the compiler may also choose to call-by-reference after verifying that no changes to the type are made. You should generally assume the compiler to make a near-optimal choice for you (given the size of the object, the memory layout, etc.) and not worry about it. This means that I find it simpler to always think the compiler will make the call by constant reference, as this is typically the more performant version and I can’t discriminate which one the compiler chooses anyways. With mutable types on the other hand, it is quite clear that the fastest solution is to always choose what compares to (non-constant!) call-by-reference in C++. Just to consider an alternative: The compiler could also make a copy, keep a register of copies, and propagate any change made to any one copy to all the others – but this obviously creates unnecessary overhead.

Performance

So, with the reasonable assumption of the compiler making efficient choices about the implementation within the limits enforced by semantics, it should be clear that immutability is a semantic restriction and should therefore never lead to decreased performance. But let us consider an example where this semantic restriction can lead to an actual gain in performance: A Vector{Position} where Position is a type of three Float64 fields: x,y,z. If Position is

  • mutable
    • it is essentially (*double, *double, *double)
    • and the Vector therefore **double
  • immutable
    • it is essentially (double, double, double)
    • and the Vector therefore *double
    • Looping over the elements of the Vector then becomes much cheaper, as less pointer-lookups are required to arrive at any element and the memory can be layed out contiguously.

Conclusion

DO NOT think

  • immutable == call-by-value

INSTEAD think

  • immutable == call-by-constreference
  • mutable == call-by-reference