Why Are Statically Sized Arrays Immutable?

I often hear that StaticArrays.SArray is immutable because it is stack allocated. I’m sure this is a misunderstanding on my end, though, because stack allocated types in other languages can be mutated in other languages.

Could anyone explain this a bit further? If I specify a mutable struct with primitive fields, will that automatically be heap allocated? Is there a way to mutate stack allocated types without constructing a fully new type?

2 Likes

The basic problem here is that mutable objects inherently have identity. This means that if 2 different functions mutate the same value, that is observable behavior. Other languages get around this in various ways.

C crashes (or causes nasal deamons) if a stack allocated object outlives it’s lifetime

Rust requires that no to functions have mutable references

Java doesn’t have any values on the stack.

Julia takes a middle ground and will put things on the stack if they aren’t mutable (because then the compiler can duplicate/remove the objects as it sees fit), and is also sometimes able to SROA away mutables if the compiler can prove that no one external can observe that the object exists.

12 Likes

SArray is immutable because it is internally a Tuple, and tuple is immutable! :smile:

6 Likes

Haven’t looked into it, but I think Java now offers “value types” as an experimental feature:

1 Like

Other way arround. SArray was made immutable in order to make it friendly to stack allocation (due to the reasoning provided by @Oscar_Smith above)

No. If the compiler can prove that the mutable struct does not escape the function body, it can be stack allocated. Here’s an example:

julia> using StaticArrays

julia> function f(::Val{N}, x) where {N}
           v = MVector{N, Float64}(undef)
           for i ∈ 1:N
               v[i] = x + i
           end
           SVector(v)
       end;

julia> code_llvm(f, Tuple{Val{4}, Float64}; debuginfo=:none)
; Function Signature: f(Base.Val{4}, Float64)
define void @julia_f_827(ptr noalias nocapture noundef nonnull sret([1 x [4 x double]]) align 8 dereferenceable(32) %sret_return, double %"x::Float64") #0 {
L17.3:
  %"new::MArray" = alloca [32 x i8], align 16
  call void @llvm.lifetime.start.p0(i64 32, ptr nonnull %"new::MArray")
  store <32 x i8> zeroinitializer, ptr %"new::MArray", align 16
  %0 = insertelement <4 x double> poison, double %"x::Float64", i64 0
  %1 = shufflevector <4 x double> %0, <4 x double> poison, <4 x i32> zeroinitializer
  %2 = fadd <4 x double> %1, <double 1.000000e+00, double 2.000000e+00, double 3.000000e+00, double 4.000000e+00>
  store <4 x double> %2, ptr %"new::MArray", align 16
  call void @llvm.memcpy.p0.p0.i64(ptr noundef nonnull align 8 dereferenceable(32) %sret_return, ptr noundef nonnull align 16 dereferenceable(32) %"new::MArray", i64 32, i1 false)
  ret void
}

Notice the alloca at the creation of the MArray. This is allowed because the compiler knows that no references to v will survive the scope of f. So a common trick to get stack allocated mutable values in julia is to only make them temporarily mutable within a function body and then ‘freeze’ them by converting to an immutable type which is then allowed to outlive the function.

Currently, this requires that all intermediate function calls that take v must be inlined, but that requirement will be relaxed somewhat in v1.12 where we’ll have interproceedural escape analysis.

10 Likes

Why are you not using an MArray if you need a mutable, stack allocated array?

In many cases this works well. (Not always, sometimes you need an SArray to avoid allocations).

2 Likes

Those other languages have a different meaning of mutability, which makes comparisons difficult. There mutation usually means a variable, member, or element’s value can be changed, whereever it is. x += 1 would be mutation of the variable x to another value; in languages like Rust that default to immutable variables, the compiler complains if you didn’t declare it to be mutable let mut x = 0.

In Julia, we don’t call reassignable variables, fields, or elements “mutable” in the sense of the other languages. Mutability is about types, and while it involves how you can change something, it’s also about the identity of that value. For immutable types, equal value means the values are one and the same; x = Some(1); y = Some(1) assigned the same instance to 2 variables. We can still make changes by instantiation and reassignment x = Some(2), but then x and y no longer share an instance. This allows the same instance to actually be represented by copies of data in optimizations.

For mutable types, instantiation provides the identity; x = [1]; y = x; z = [1] assigned the same instance to x and y, but a different one (even defined by == to be of equal value) to z. We could make the same kind of change with instantiation and reassignment, but we also have the option of mutating the same instance x[1] = 2 that is reflected by both x and y. In other words, mutability lets multiple references share an instance and its mutations. This needs an instance to be represented by data in a particular location, usually on the heap but could be on the stack under previously described conditions.

It’s not an entirely different way of life, just a different abstraction of data from the copies, moves, references, and pointers built into other languages. The abstraction in Julia (, Python, and Javascript) being simpler isn’t inherently better, it costs some straightforward control. For example, we might mimic data modification on the stack reliably with an immutable struct (and with the syntax of mutation via Accessors.jl), but full instantiation and reassignment is unnecessary work that we’re relying on the compiler to elide when safe. It’s possible to unsafely skip the constructor to new for less to elide, though you probably shouldn’t be skipping any enforcement of invariants in either immutable or mutable types.

2 Likes

It’s not really the bog reality. Static arrays can be mutable but will be less efficient than if they are immutable. Mutable staticArrays are allocated on the heap but the internals datas are on the stack. But with the immutable one, everything is on the stack, no heap, no allocation overhead :hugs:

Well, this is not true, I use them a lot with zero allocations.

Probably because you don’t pile up result
Fir small operations, there is nothing, but I try using them in a big for loop where result of a MArray pile up.
Like


julia> @time for i in 1:10^8
               # A, B and C are 2 MArray of 2 integers elements 
               C = MVector{2, Int}(undef)

               # Some weird operations
               A[1], A[2] = A[1]+B[1], A[2]+B[2]
               B[1], B[2] = B[1]+A[2], B[2]+A[1]

               # We pile it in the new MVector 
               C[1], C[2] = A[1]+B[1], A[2]+B[2]
           end

Try it with A and B initialized, there will be allocations

I really have difficulties in understanding this culture that says stack allocations are zero allocations. As if memory in stack doesn’t have to be managed and, specially, since it doesn’t count as allocated, is infinite.

But, isn’t stack generally automatically managed🤔
Just asking

It’s misleading, but useful, terminology, I think. The issue is that heap allocation is associated with extra costs:

  • Heap allocation requires the GC, for cleaning up.
  • Heap allocation may mean the compiler has a suboptimal understanding of one’s code, so it wasn’t able to optimize it to the desired level. Stack allocation usually only happens when the compiler perfectly understands the programmer’s code, I think.

So, usually, when someone complains “my code allocates”, it’s not really about the allocation itself, rather it’s about those associated costs.

Yes, I understand the virtue of stack allocations. But stack memory is limited (don’t know its limits) and I remember that many years I had a mysterious error in VisualSudio that turned out that I need to change one variable that increased the available stack space. But here stack space seems to be treated as infinite and a program that heap allocates seems to be considered a lower quality code. Ofc I’m wrong, but that is my feeling after several years reading posts in this forum.

1 Like

I work with “small” vectors, like 3 elements or 60 elements. And they do not pile up, I just update and transform them. Then - mostly - MVectors are very fast (like 10 times faster than normal vectors). Sometimes - when transforming vectors - I still need to use SVectors to avoid allocations. Example:

    # extract the data for the winch simulation
    length,  v_reel_out  = y[end-1],  y[end]
    lengthd, v_reel_outd = yd[end-1], yd[end]
    # extract the data of the particles
    y_  = @view y[1:end-2]
    yd_ = @view yd[1:end-2]
    # unpack the vectors y and yd
    part  = reshape(SVector{T}(y_),  Size(3, div(T,6), 2))
    partd = reshape(SVector{T}(yd_), Size(3, div(T,6), 2))
    pos1, vel1 = part[:,:,1], part[:,:,2]
    pos = SVector{div(T,6)+1}(if i==1 SVector(0.0,0,0) else SVector(pos1[:,i-1]) end for i in 1:div(T,6)+1)
    vel = SVector{div(T,6)+1}(if i==1 SVector(0.0,0,0) else SVector(vel1[:,i-1]) end for i in 1:div(T,6)+1)
    posd1, veld1 = partd[:,:,1], partd[:,:,2]
    posd = SVector{div(T,6)+1}(if i==1 SVector(0.0,0,0) else SVector(posd1[:,i-1]) end for i in 1:div(T,6)+1)
    veld = SVector{div(T,6)+1}(if i==1 SVector(0.0,0,0) else SVector(veld1[:,i-1]) end for i in 1:div(T,6)+1)

I don’t think anyone treats the stack as ‘infinite’ (it’s much smaller than the heap), but many small, short-lived stack allocated values are really fast. It’s not the size, it’s the speed.

1 Like

Yeah, the useful thing here isn’t actually stack allocations, but rather allocations with compiler deduced (and strictly nested lifetimes). In general, the compiler should probably acquire a SlabAllocator where it can move bigger heap allocations that have nice lifetimes. One of the reasons the stack is often very fast is that your stack pointer tends to stay at a pretty similar value so all your local variables that are on the stack stay in very hot memory (e.g. L1 cache). Once you are making lots of medium sized vectors (e.g. size 20 or more), you don’t really want to be using the stack, but rather a separate memory region that is expandable (unlike the stack) but which still has pointer increment/decrement for alloc/dealloc.

2 Likes

Also, keep in mind that small statically understood local variables, like the elements of an SVector, might not be stack-allocated at all. They might be stored in registers, or not stored at all if the compiler can fold them into other calculations.

1 Like

Thanks for these further details, which, I think, still fall under my previous “I understand the virtue of stack allocations.” But my point is that the relative importance of this (again, my general perception) has been taken to a point that everything that heap allocates is looked “yes, but it allocates. You should try to avoid that.”

Yes, a symptom of this is people trying to use StaticArrays for everything, even large arrays. The StaticArrays manual recently added a section on “When Static Arrays may be less useful” to try to combat this and de-mystify StaticArrays a bit.

2 Likes