Clarification on passing by value

Hi all, on my journey to learn zig I was a bit confused with all the discussion around the pass struct by reference or value.

My main point of confusion came from reading this discussion: Pass by value semantics.

While learning the language, I tried to follow as closely as possible all the advices on the 0.16 documentation, but from the discussion above it seems that it is not quite updated.

When is it advisable passing a struct via value or reference? Coming from C++, I was prepared to pass everything by reference (apart from basic types), but looking at the documentation and some other threads, it looked like the “Zig Way” was to just default to pass by value and let the compiler decide what to do.

For example if I have a struct that holds the allocator and IO interfaces, should I pass it by value or reference? I ask because each of these interfaces will hold a bunch of pointers, of non-negligible size.

const struct Context = struct {
    io: std.Io,
    allocator: std.mem.Allocator,
};

fn foo(ctx: Context) {...}
// or
fn foo(ctx: *const Context) {...}

As I understand this changed recently, so what is the current advisable way?
Thanks!

zig compiler no longer chooses to pass-by-ref for you as the implementation was flawed. I dont know if the language docs just haven’t been updated, or if they are reserving the right to re-implement it in the future.

if you dont need a pointer for other reasons, default to passing by value. If it is an issue you will notice when benchmarking, if you’re not benchmarking then it wont be an issue.

Thanks. Yes, I was defaulting to pass by value as you suggested. I am just trying to better understand the unwritten rules of the language, since it helps to write better code in general (without necessarily having to put it on godbolt every time).

if you’re not benchmarking then it wont be an issue

I am just hesitant of writing/learning in a reckless manner and having the performance penalty to accumulate over the codebase. Then it’s much harder to profile everything than just a few bottlenecks.

if the type is smaller than a pointer then its probably better to pass by value

if its a bit larger than a pointer it’s a toss up (and probably too small a difference to care)

if its significantly larger than a pointer then it’s worth seriously considering passing it by pointer.

Follow the same rules as C and C++.

I’m not sure if parameter reference optimization will come back, and I’m also concerned about potential performance loss.

So I replaced some pass-by-value cases, which originally relied on parameter reference optimization to express certain semantics, with noalias arg: *const T.

I’m hoping that parameter promotion can optimize them as pass-by-value, and in the future, if parameter reference optimization comes back, it will be easy to grep them and change back to pass-by-value.

I’m trying to think - is there much more to it than this rule?

I’ve always had it in my head that this value vs. reference thing had all kinds of complicated platform specific edge cases, but I can’t think of cache locality arguments or any tricky stuff like that off the top of my head…

The problem really isn’t the specific calling convention of the platform.

The problem is interaction with optimization passes.
Zig “Pass by Value” will copy big struct on the stack and pass a *const noalias T to the function. If the callee get inlined then the memcpy disappears and all is well. But it puts pressure on the inliner and the extra memcpy make the functions harder to inline. Each of those copy is very fast and is unlikely to appear in a profiler, but aggregated they are pesky.

*const T seems like the easy solution, but it disable a lot of LLVM optimizations because compilers can’t reason about memory aliasing. And a lot of the Std generic function like HashMap, only deal with values because Zig used to be more aggressive here.

I’m not happy with the status quo, but there is no indication that a better solution is possible nor that the core team is considering this to be a problem.
If someone cares to open a brainstorming thread, we can gather some ideas

I am also still in doubt.And what about if the call calls another one calls another one calls another one with the same parameter?
Mostly I don’t know what to do with the “a bit larger than a pointer” vars like a rectangle consisting of 4 i32 values.

Measure and see! But typically your optimised build is going to figure it out.

Also remember to use inline when sensible (though also remember Inline Is Not A Hint).

I think a brainstorm thread would be really nice. In any case I think a first idea would be to really improve the documentation in this case. From what I saw up to now I think the zig demographics care enough about these low level details to really benefit from a clear guideline in the docs.

That’s not my experience, because as I tried to explain the only thing LLVM can do is inline the function that takes arguments “by value”. Every inline miss will incure a big performance hit.

And inlining is hard to predict, small changes to a function can move it below or above the threshold.
It’s really not what I want to keep in mind while I’m programming.

Also the notion of small struct in this thread seems to be under evaluated. x86 has big registers and the compiler will pass [8]u64 by registers rather than pointer.

Measuring is too much work. I would have to change all function arguments. Read: rewrite my entire program.

This is absolutely fair, my reply was to the other post mentioning small structs of ints, with an intended emphasis on measuring. I agree that for other scenarios it’s largely an unknown.

It’s really not what I want to keep in mind while I’m programming.

The thing is, I’m not sure why you would need to? If it’s not measurable at all then it doesn’t really matter. It might be too hard to measure on a large codebase because you’d need to A/B test hundreds of functions, but that’s bumping into exact problem space as the compiler or even the language docs have when choosing the correct option - that it’s highly situational.

Any fixed ecommendation in writing risks giving undue importance to a decision that is largely meaningless outside of very specific hot-path situations, and when it does have meaning the answer is complicated and demands measurement. Recommending anything without context would encourage premature optimisation because without a concrete example it’s often a fools errand, as the amswer is sensitive to nearby code, language/compiler version, target arch, and more.

@vulpesx said it well enough already to be honest.

if you dont need a pointer for other reasons, default to passing by value. If it is an issue you will notice when benchmarking, if you’re not benchmarking then it wont be an issue.

This problem is as old as C, at least. Optimizations passes have gone as far as possible with the C semantics of passing parameters. Compilers are relatively ok at transforming parameters between value and pointer, but far below what we’d like.
Zig gave a shot at expanding this optimization to a larger number of cases, with parameter reference optimization. It was an earnest effort, but it failed.
Without a radically new parameter passing semantic, there’s no point in discussing it further.
Just follow the C recommendation: objects larger than 2 pointers are passed by pointer, and below that as values.
Some languages have adopted the in/out/inout system for parameters, we’ll see how they do. There’s also the mutable value semantics, like Hylo, which is based on Swift, but they require eliminating pointers, so I don’t think they’re going to work here.

If this is case and the main choice for the foreseeable future, then I guess at least the documentation should state it clearly. Probably it’s just out of date, but I guess it impacts quite a bit for the newcomers that are trying to understand the language.

My ideas are around some Zig guarantees that extra copies on the stack will be avoided. Generally the caller has some ideas wether or not a given struct needs a copy or not.

For me PTR was the most exciting part of the language, that’s why I’m trying to see what we can salvage.

That was the result location semantics. Also failed experiment.
All of the Zig features that were deliberate optimizations (on top of what LLVM already does) failed. They were the “Killer Features” that SpexGuy referred in his talk.
Currently, Zig defers all optimizations to LLVM. Perhaps when the self-hosted compiler starts doing optimizations, we can try these again, as we’ll have more control about code generation. But given that LLVM is very well-made project, with many years of experience and contributions from very clever people, there’s a good chance they’ve extracted most of what is possible from a C-like language.

No, not entirely. For non-exported functions there’s no real need to follow the platform’s ABI rules for large by-value argument passing, yet LLVM still largely does so in practice. That’s more historical inertia than a fundamental limitation.

A self-hosted backend would give us more direct control over these decisions and could do better.

Addressing your specific example:

const struct Context = struct {
    io: std.Io,
    allocator: std.mem.Allocator,
};

Always pass this struct by value.

If we look at the contents of std.Io and std.mem.Allocator we find:

const Allocator = struct {
    ptr: *anyopaque,
    vtable: *const VTable,
}
const Io = struct {
    userdata: ?*anyopaque,
    vtable: *const VTable,
}

In effect, you are already passing them by reference. Just that it’s 4 pointers, not one. Four pointers = four words of memory. Any target will have enough registers for that.

What about as a general rule?

Pass-by-value when possible, const pointer only when you have to.

Forgot about performance for a second, the better question is:

  • What is the easiest for humans to read and reason about?
  • What will lead to better software with less bugs?

The trouble with const *T is that even though you can’t change the value, something else might. That’s the ‘aliasing’ problem that’s already been mentioned. Sometimes it’s what you want, but it’s more often a source of subtle bugs:

example.zig (858 Bytes)

When you pass by value; a human reading the code can rely on the value to stay the same no matter what. That makes for a simpler mental model when trying to figure out what it’s doing.

If performance is important:

  1. Measure it first.
  2. Pass-by-value when possible, const pointer only when you have to.

In much the same way a human can’t figure out if the pointed-to-value isn’t modified between reads, the compiler may not be able to figure it out either:

Same code as above: Compiler Explorer

Neither the compiler nor LLVM are allowed to second-guess the programmer, so they must assume that the potential aliasing in evil_multiply() is intended and emit instructions that produce the ‘correct’ result in all circumstances.

Your program might never call that function with aliased pointers but you will still pay the performance cost as if it does.

You can fix this with noalias, but that causes new problems.

  1. noalias currently has no documentation.
  2. If you do pass pointers that alias, you get unchecked illegal behaviour.

Pass-by-value when possible. It’s easier for people to understand, and the compiler too.