Mutating a StructArray without allocation

Hello,

I’m trying to use StructArrays.jl as a fast, convenient solution for working with coordinate data. But I’m having difficulty operating on StructArrays in the way I had hoped I could.

Here is a MWE of what I’m trying to do:

# a struct to hold individual coordinates
mutable struct Coordinate{T<:Real}
    x::T
    y::T
end
Coordinate() = Coordinate(0.0, 0.0)
# a StructArray of  coordinates
s = StructArray([Coordinate() for i = 1:10])
10-element StructArray(::Vector{Float64}, ::Vector{Float64}) with eltype Coordinate{Float64}:
 Coordinate{Float64}(0.0, 0.0)
 Coordinate{Float64}(0.0, 0.0)
 Coordinate{Float64}(0.0, 0.0)
 Coordinate{Float64}(0.0, 0.0)
 Coordinate{Float64}(0.0, 0.0)
 Coordinate{Float64}(0.0, 0.0)
 Coordinate{Float64}(0.0, 0.0)
 Coordinate{Float64}(0.0, 0.0)
 Coordinate{Float64}(0.0, 0.0)
 Coordinate{Float64}(0.0, 0.0)

On the surface, this gives me exactly what I want - I can easily access individual coordinates and I can rapidly iterate over the whole container. But I’m struggling to mutate the elements of this container without allocation.

While I can easily mutate the component arrays of a StructArray, there are many instances where I would like to operate on a single instance of Coordinate within the larger StructArray. The former is always non-allocating, but the latter is what’s causing me difficulty:

# mutating an element of the component array
@allocated s.x[1] = 1.0
0
# attempting to mutate both elements of a single coordinate
@allocated s[1] = Coordinate(1.0, 1.0)
32

The second result seems to indicate a new instance of Coordinate was allocated, and then copied into the elements of s[1]. If I choose to define Coordinate as an immutable struct instead of a mutable one, this behavior changes and the assignment above no longer allocates. However, using the immutable form prohibits me from writing any functions that operate on single instances of Coordinate. Here is a toy example of such a function:

# randomly increment a value
function step!(coord::Coordinate)
    coord.x += rand(-1:1)
    coord.y += rand(-1:1)
    return nothing
end
@allocated step!(s[1])
32

My intent is to mutate in place, but because the function is written to operate on the struct, a new struct is allocated and the values copied. It’s unclear to me how to write subroutines for StructArrays which are non-allocating.

I’ve developed an alternative solution through the use of StaticArrays.jl, which simply uses MVectors to house the coordinates instead of a custom struct. I can freely pass MVectors to and from functions without allocation, but I lose the convenient syntax of the StructArray and the ability to rapidly iterate over columns.

My goal is to be able to write functions which operate on StructArrays, or individual elements thereof, without allocation. Any suggestions would be greatly appreciated.

Thanks in advance.

Hi!

I haven’t used the package before, but it sounds like you are running into this issue?
https://juliaarrays.github.io/StructArrays.jl/stable/counterintuitive/

Considering this statement from the page,

A StructArray with immutable elements will in many cases behave identically to (but be more efficient than) a StructArray with mutable elements.

it does sound like using the immutable structs would avoid the issues. You could for example rewrite the method(s) operating on the structs to return a new instance:

function step(coord::Coordinate)
    return Coordinate(coord.x + rand(-1:1), coord.y + rand(-1:1))
end

function step_array!(s)
    for i in eachindex(s)
        s[i] = step(s[i])
    end
    return s
end

(Actually, the step! function in this example also doesn’t allocate with the mutable version of the struct either.)

Just an aside: Have you checked if you actually have a benefit of StructArrays.jl compared to a normal Array of your structs? I’m not sure if you will see much performance benefit when operating on single elements – it sounds like you want to rewrite your operations to operate on every field of each struct at once, since the fields are just stored as vectors internally.

Fully support the advice to use immutable structs and functions like step(coord::Coordinate)::Coordinate instead of mutables. Tend to be faster even outside of the StructArrays context, and can be cleaner as well.

1 Like

Thanks for the input.
Using immutable structs allows me to directly operate on the StructArray without allocation, but doesn’t solve the problem with functions.
Adjusting my definitions like so:

# the struct is now immutable
struct Coordinate{T<:Real}
    x::T
    y::T
end
Coordinate() = Coordinate(0.0, 0.0)
# step function now returns values instead of mutating in place
function step(coord::Coordinate)
    return Coordinate(coord.x + rand(-1:1), coord.y + rand(-1:1))
end

I then define the StructArray s as I did in the original post.

# Assignment mutates the component arrays (good)
@allocated s[1] = Coordinate(1.0, 1.0)
0
# functions still allocate and copy values (bad)
@allocated s[1] = step(s[1])
64

I also can’t define functions designed to mutate the struct itself, as the struct is no longer mutable. I suspect the solution has to involve operating on the component arrays of the StructArray. But I can’t figure out how to adjust my functions to make that happen.

Also, regarding your aside @Sevi:
I haven’t gotten to the point of benchmarking the performance of a StructArray yet, but they allow me to vectorize operations over columns should I choose to do so. This is not possible with an Array of structs. I think the problem may be that I want to have my cake and eat it, too - the ability to operate on single instance of coord, and also rapidly iterate over columns. My solution using StaticVectors is completely allocation-free, but forces me to give up one of those two operations.

Looks like the classical “benchmarking in the global scope” issue (:
I don’t see any allocations when putting this code into a function:

julia> function f(s)
           s[1] = step(s[1])
       end

julia> @btime f($s)
  5.916 ns (0 allocations: 0 bytes)

Try viewing this as a positive and write code without struct mutations :slight_smile:

4 Likes

You’re exactly right.

I probably could have saved us all some time if I’d been more careful about how I use @allocated.
So it seems the solution is simply to switch to immutable structs.

Thanks again

3 Likes