Are there any more-readable alternatives to using many individual variables for performance critical code?

For a package I’ve been developing in which the code performance is critical, many functions utilize a very long list of individual variables, both arguments and internal variables alike. I’ve tried in the past to modify my functions to be able to take mutable structs (for all variables) and named tuples (for the constant variables only) as arguments with the variables as attributes so things are more readable, but that was much slower than just using a bunch of individual variables.

Are there any possible alternatives? Or perhaps certain programming practices/paradigms I’m unaware of?

Hi! Off the top of my head there is no particular reason why structs would be much slower than individual variables (do they have to be mutable though?). Can you maybe give a concrete example?

You should really provide a specific example that we could discuss.

Named tuples shouldn’t be slow, you probably did something wrong. Maybe give a minimal reproducer?

I guess mutable structs is the issue. Immutable structs or namedtuples should have the same performance as separate variables.

1 Like

It’s not clear how you prefer to to write the code, so I’m just throwing a lot of ideas out here.

  1. I’m guessing that you’d still want to keep variables separate for individual reassignment in the function bodies because x += f(y) is less readable and writable as massive_struct.x += f(massive_struct.y). So, packing variables into a single instance only simplifies data being put in function calls, but the function body has to unpack the data at the start and perhaps repack data at the end. You probably already know about iterator destructuring a, b = 1, 2 and splatting f(ab...), but property destructuring (; b, a) = (a=1, b=2, c=3) sounds useful here. You can also splat named tuples for keyword arguments f(;ab...), but it has to have the precise names (no extras), and property destructuring also works for structs (; x, y) = XY(1, 2).
    These can be done in function arguments too, just note that the destructuring stuff doesn’t do multimethods like separate positional arguments. Argument property destructuring is like keyword arguments, which doesn’t do multimethods already, but argument iterator destructuring doesn’t work either e.g. foo((x,y,z))=... has 1 argument formally and will override foo((x,y))=... with 1 argument. That’s not a weird issue with destructuring, it’s just a downside of packing everything together into 1 unannotated argument. Annotating a large tuple or named tuple would be more of a pain than annotating separate arguments, so if multimethods were needed I would lean toward splatting as much as possible.

  2. If you actually want the data packed into 1 instance inside a function, a mutable struct to emulate variable reassignment is overkill, ideally. Mutability is a property of the instance, not the variable, and its utility is for a change to be accessible by multiple variables or other references at the same time, rather than reassigning all of them one by one. That instance must be stored in a separate spot for the multiple variables to point to, which is often an heap allocation (though it could be on the stack if the compiler can determine it has a fixed size and doesn’t escape a local scope).
    This isn’t necessarily a bad tradeoff; multiple variables that can hold different data must store multiple copies, so if they’re supposed to share data, it saves memory for them to point. However, allocating on the heap and garbage collection is slower than stack allocation-deallocation, so it would make sense if your performance was hurt by frequent heap allocations for only packing data going out of and into functions (hence escaping local scopes). That doesn’t mean mutable types are to be avoided at all costs; it just means to save memory and time, you want variables to share a long-lived instance. If you’re instead frequently constructing instances and don’t need sharing, go for immutable types.
    Of course, it’s a pain to reassign a variable with a new immutable instance with a slight change xystruct1 = XY(xystruct1.x, new_y), but there is Accessors.jl to make that easier to write @reset xystruct1.y = new_y.
    I’m not actually sure if this is as performant as separate variables, I’m not really capable of reading LLVM. This comment says that the compiler is able to mutate a field on the stack for reassigning an immutable instance unless the field was holding a mutable instance. I don’t really understand the reason for that exception, I would think it could just mutate a pointer. I would be very interested in an expert looking into this because I have noticed that people often reach for mutable types when they really only need to reassign a variable or a field, possibly due to different mutability definitions in other languages, and it would be fantastic if Accessors.jl could replace that.

1 Like

Thanks for the comments. I’ll give a simple example based on this function in my package. Say one has the following function:

function foo!(a,b,c,d,e,f) # a long list of arguments, some of which remain constant, some mutated
    g,h,i,j,k = 1,2,3,4,5 # more internal variables
    bar!(a,b,c,g,h,i)
    baz!(d,e,f,i,j,k)
end

Where, for readability purposes, there are functions that operate on a bunch of variables. However,usually one hears that when there are lots of variables, one should use some class to make the code cleaner, like the following:

function foo!(input_struct) # input structs in corporates variables a - k
    bar!(input_struct)
    baz!(input_struct)
end

But that, in my specific use case, has slowed the function down quite a bit, therefore I’m looking for alternatives. Of course, it’s all for readability’s sake only. I’ve tried in the past to use something like the following:

function foo!(a,c,f,constants) # constants is some named tuple of variables that don't get mutated at all
    bar!(a,c,f,constants)
    baz!(a,c,f,constants)
end

But not only did I find it to even be a bit more confusing, it slowed the code down slightly. Benchmarking that function on an input used to average at 1 ms, but using the named tuple slowed it down to an average runtime of 1.3 ms (no GC time)

Now, I may have accidentally ommitted other factors which could’ve slowed the code down the second time round, but I just wondered if there’s a more “aesthetically elegant” way to write the code.

That link doesn’t work for me.

Structs (no matter whether they’re mutable or not) are not inherently slower than individual variables, so that’s surprising to hear. How did you measure and what was the observed slowdown? If the effect is real, that should certainly be investigated.

Are you certain this is not a measurement uncertainty?

The first two examples don’t match, input_struct in the 2nd example is replacing 3 different sets of variables in the 1st. If your methods don’t often share some combinations of arguments, it’s not worth refactoring them to structs because you’d need arbitrarily many structs to compile the methods for. In fact your first example is far more readable because you don’t have to look up what variables are in input_struct down the line. The only utility of a composite instance might be for a user to store input data in for reuse. Then whenever they need to rerun a method, they can just splat foo!(input_tuple...).

The most probable reason is incomplete type specifications in the struct definition.

4 Likes