gwenzek | 2026-05-25 10:05:11 UTC | #1 I'm currently a bit confused about pass by value semantics in Zig 0.16.0. From docs: https://ziglang.org/documentation/master/#Pass-by-value-Parameters > Primitive types such as [Integers](https://ziglang.org/documentation/master/#Integers) and [Floats](https://ziglang.org/documentation/master/#Floats) passed as parameters are copied, and then the copy is available in the function body. This is called "passing by value". Copying a primitive type is essentially free and typically involves nothing more than setting a register. > Structs, unions, and arrays can sometimes be more efficiently passed as a reference, since a copy could be arbitrarily expensive depending on the size. When these types are passed as parameters, Zig may choose to copy and pass by value, or pass by reference, whichever way Zig decides will be faster. This is made possible, in part, by the fact that parameters are immutable. Immutable has a very strong meaning in compiler world and it particular it means that nothing can change it. So calling an external function `magic()` can't modify the argument. This is not true for `a: *const A` argument because `magic()` may have a mutable version of the pointer and modify `a`. This is quite important for optimization to happen properly in face of `extern` calls. You can see this in the following snippet: https://godbolt.org/z/G1KvYKhn4 Despite the extern call, the check is moved outside of the loop and the loop is unrolled twice. **This is great: optimization happens despite the extern call**. But as soon as you introduce the `len` parameter in the `math` function, the optimization is lost, and on top of that A is copied with a `memcpy`. ## Given that pass by value arguments are immutable, why is Zig introducing a `memcpy` ? My understanding was that the `memcpy` in 0.15.2 was a stopgap for the PRO footgun, but this was apparently removed in 0.16 according to @mlugg https://github.com/ziglang/zig/issues/5973#issuecomment-4334852241 ------------------------- LucasSantos91 | 2026-05-25 09:38:44 UTC | #2 [quote="gwenzek, post:1, topic:15710"] Zig may choose to copy and pass by value, or pass by reference, whichever way Zig decides will be faster. [/quote] This was PRO, which was removed. The doc is outdated. I don't think the `len` parameter had anything to do with the change in optimizations. ------------------------- matklad | 2026-05-25 09:49:42 UTC | #3 [quote="gwenzek, post:1, topic:15710"] But as soon as you introduce the `len` parameter in the `math` function, the optimization is lost, and on top of that A is copied with a `memcpy`. [/quote] Maybe it's me, but it looks like A is copied in both versions? ``` mov edx, 8200 call memcpy@PLT ``` This snippet is present regardless of which two lines is commented out. For the docs, I think it is sub optimally worded. First, it confuses language semantics with implementation strategy. Second, it paints a misleading picture of the actual implementation strategy. Opening up with a difference between primitives and structs is a sure way to confuse the user. I'd phrase it like this: > Parameters are passed by value: a function gets an independent copy of a parameter that can't be modified externally: > ```text > litmus test example with a global var. > ``` > > Of course, the implementation is free to follow the "as if" rule and eliminate the copy when that doesn't change program's semantics and leads to faster code. Notably, most function calls are inlined completely, and there's no parameter passing to speak of. This phrasing omits the "`const` parameters enable optimizations" part, but I think that logical inference is incorrect. You could imagine Zig where the semantics is mutable `var`, but the implementation is the same as today. Basically, you write `fn f([var] x: u32) void`, and compiler internally thinks about it as ```zig fn ([const] x_original: u32) void { var x: u32 = x_original; } ``` and that extra copy inside the function can be eliminated by the same logic which today checks for "useless var". That is, on the implementation level, keeping parameter ABI `const` is helpful when passing arguments through multiple level of the call, and, arguably, this is the right ABI for `extern` functions. So, this is a performance bug in the C ABI that parameters are passed by references, _and the caller is allowed to modify their memory, such that modifications remain visible to callee_. But, again, this is implementation strategy concern, not language semantics concern, even if aligning the two leads to a simpler compiler. ------------------------- LucasSantos91 | 2026-05-25 10:02:49 UTC | #4 [quote="matklad, post:3, topic:15710"] For the docs, I think it is sub optimally worded. First, it confuses language semantics with implementation strategy. Second, it paints a misleading picture of the actual implementation strategy. Opening up with a difference between primitives and structs is a sure way to confuse the user. [/quote] I think it was there because PRO was a big selling point for Zig. Describing it like you did, with the "as-if" rule, would undersell it, because that's what every other compiler does. And it mentions `const` because it was also trying to sell `const` parameters. ------------------------- gwenzek | 2026-05-25 10:05:44 UTC | #5 My bad I posted the wrong snippet. The size of the data array seems to matter wrt inlining. ------------------------- gwenzek | 2026-05-25 10:17:38 UTC | #6 My bad, I posted the wrong snippet. When using `[32]u64` the memcpy is ellided when len is not used. (I updated OG post with link) [quote="matklad, post:3, topic:15710"] Parameters are passed by value: a function gets an independent copy of a parameter [/quote] Ok I've tracked it back. The change happened between 0.9 and 0.10. Before 0.9 big structs are passed by immutable reference, but since 0.10 they are passed by copy and hoping LLVM does magic. Which is very brittle as in my example where increasing the struct size of adding a parameter breaks the optimization. My mental model seems quite outdated. This seems to actively discourage passing by value in favor of passing by pointer. Makes me a bit sad. For me this was the power of Zig having the advantage of Rust immutable references without paying the cost. Are there plans to bring this back now that it's harder to accidentally alias argument and return values ? ------------------------- matklad | 2026-05-25 10:22:26 UTC | #7 [quote="gwenzek, post:6, topic:15710"] When using `[32]u64` the memcpy is ellided [/quote] It's more like `memcpy` is inlined, rather than elided: ``` movups xmm0, xmmword ptr [rdi] movaps xmmword ptr [rbp - 176], xmm0 movups xmm0, xmmword ptr [rdi + 16] movaps xmmword ptr [rbp - 240], xmm0 movups xmm0, xmmword ptr [rdi + 32] movaps xmmword ptr [rbp - 112], xmm0 ``` This is doing `memcpy`, one SSE register at a time. ------------------------- gwenzek | 2026-05-25 10:45:29 UTC | #8 Thanks. I've confirmed with another example: ``` pub const A = struct { data: [1024]u64, noinline fn wrap2(a: A) u64 { return a.wrap1(); } noinline fn wrap1(a: A) u64 { return a.math(); } noinline fn math(a: A) u64 { var res: u64 = 0; for (a.data[0..]) |x| { res += x; } return res; } }; pub export fn math(a: *const A) u64 { return a.wrap2(); } ``` Every non-inlined call result in a memcpy. I find this pretty bad TBH. Are there plans to improve this in Zig ? or are we stuck in the C world ? ------------------------- matklad | 2026-05-25 11:00:34 UTC | #9 Parameter Reference Optimization (PRO) and Result Location Semantics (RLS) were intended to address this, but they didn't work out. It turned out to be hard to find a set of rules that: * enables the compiler to pick by-reference or by-value on the case-by-case basis * is easy for human to get right * is easy for compiler to safety-check when the human gets it wrong * doesn't require "borrow checker" (complex flow sensitive local type inference + verbose non-local annotations) Note that "borrow checker" might be necessary, but is not sufficient here. Rust _doesn't_ do the optimal thing, because they way you specify "don't care" parameter passing is via `&T`, but that actually guarantees, at the level of language semantics, that the address of `T` is observable and meaningful. The compiler _can't_ just turn this into by value, in general. For me, this is also the case where I feel Zig's not quite perfect: I want all "memcpy to avoid aliasing" to be explicit in the code, both for aesthetic and for practical reasons (there were numerous bugs in TigerBeetle where we accidentally `memcpy`ed killobytes of data via implicit copies added by the compiler). Carbon and Hylo are two languages which I think try to make a smarter choice here, but I don't know the current state. Would appreciate a "Passing Arguments" PLT/compiler design post with a deep dive about what we know and what we don't know about the topic! ------------------------- npc1054657282 | 2026-05-26 13:31:35 UTC | #10 (LLM translation warning: My posts on the Ziggit forum are basically based on machine translation, but this time the machine translation really feels somewhat terrible.) I've given this issue a lot of thought, and I believe PRO is theoretically feasible. The current hurdles seem to stem primarily from LLVM's limitations and the lack of a true "immutable pointer" concept in Zig, similar to what D has. Under different targets, the parameter passing ABI for large structs varies. Windows, for instance, passes all large structs by reference. This forces the caller to temporarily copy the value to the stack and pass a pointer to that location. Linux takes a different approach, pushing large structs directly onto the stack. While the Linux convention feels more elegant since it avoids redundant indirection, Windows' approach is actually more flexible for optimization. The caller has much more context about whether the arguments themselves are mutable or aliased than the callee does. Therefore, leaving the decision of whether an extra stack copy is needed up to the caller makes sense. If we ignore the target's default ABI and look purely at `callconv(.auto)` for internal functions, we could theoretically adopt a Windows-like pass-by-reference convention for large structs. The caller could naturally apply aggressive optimizations, and the ideal semantic of "const parameters don't require separate copies" could be fully realized. However, I quickly hit a roadblock. If our argument originates from a `*const T`, the compiler cannot safely optimize it. It has to defensively assume the underlying value might be mutated due to aliasing. In practice, we frequently pass around `*const T` to indirectly reference const parameters, which immediately defeats the PRO optimization. This happens because semantic information is lost when taking a pointer. What starts as a strictly immutable value within a lifetime degrades into a read-only view that might change under the hood. I understand the value of `*const T` for expressing read-only views, but true immutable semantics are lost here. This led me to D's concept of immutable pointers, and I think officially introducing a similar concept to Zig would be helpful. The approach above was my initial thought. Optimizations in that direction are purely semantic-driven and don't rely on LLVM's backend machinery. The benefit of semantics-driven development is that developers can predict whether a certain optimization will definitely occur. E.g., if we pass a const position as a parameter, developers can predict that there will definitely be no extra expensive copies here. However, when I considered how to actually implement this within the LLVM pipeline, I hit a wall again. LLVM is perfectly equipped to decide exactly "which parameters are better passed by reference and which should be passed by value" However, if we leave the final ABI decision entirely to the backend, the frontend loses the ability to know the actual function signature, making semantic-driven optimizations impossible. This led me to a second approach for the `callconv(.auto)` convention. What if the frontend lowers all internal function parameters to pointers, decorates them with `readonly noalias`? The caller then decides, based on the parameter's mutability, whether to pass a pointer to the original data or a pointer to a temporary stack copy. From there, we let LLVM's ArgumentPromotion pass do the heavy lifting. Because the function is internal, LLVM can safely rewrite its signature. If the optimizer determines that a parameter is better passed by value, it will automatically promote the pointer to a value and update all local call sites. This is what I can think of at the moment. I'm stiil curious about whether this is an optimal path forward or if there are hidden pitfalls. ------------------------- matklad | 2026-05-26 16:14:55 UTC | #11 [quote="npc1054657282, post:10, topic:15710"] This led me to D’s concept of immutable pointers, and I think officially introducing a similar concept to Zig would be helpful. [/quote] Having used D in the past, I am pretty skeptical about the usefulness of true immutability: relatively few things are immutable (nobody can write) as opposed to read-only (_I_ can't write), and of those which are, _most_ are `comptime`. It also feels like we need something else here? We don't need immutability, just a guarantee that the pointed to data won't change _while we are looking_. That's `noalias`, I think. ------------------------- npc1054657282 | 2026-05-26 17:38:25 UTC | #12 I think our usage of data types can generally be categorized into stateful and stateless. For stateful data, it is usually sufficient to use `*T`, and `*const T` represents the read-only nature of stateful data in a specific context. If a large amount of stateful type data is used, I can understand there might be doubts about the use of immutable pointers. Our expectations for PRO activation mostly pertain to stateless data types. I believe such data should be `const` as much as possible and avoid using `var` whenever possible. One counterargument might be that when such stateless type data is used in loops, it is difficult to avoid declaring it as mutable. However, I now use a paradigm in which by rewriting the loop as a state machine for its loop variable, I ensure that such data types always exist in `const` form: ``` const loop_start: Stateless = foo(); sw: switch(loop_start) { else => |bar| { if (is_loop_end(bar)) break :sw; ... continue :sw baz(bar); }, } ``` ------------------------- jmctagger | 2026-05-28 16:07:34 UTC | #13 [quote="gwenzek, post:6, topic:15710"] but since 0.10 they are passed by copy and hoping LLVM does magic. [/quote] Out of curiosity, what about without LLVM, with the native (x86) backend - is there any known characteristic difference? I second @matklad's invitation to a "'Passing arguments' PLT/compiler design ... deep dive" post by somebody in the core team. ------------------------- jmctagger | 2026-05-28 16:25:41 UTC | #14 [quote="npc1054657282, post:10, topic:15710"] My posts on the Ziggit forum are basically based on machine translation, but this time the machine translation really feels somewhat terrible. [/quote] It actually reads very well. [quote="npc1054657282, post:10, topic:15710"] What if the frontend lowers all internal function parameters to pointers, decorates them with `readonly noalias`? [/quote] I'm intrigued by this, and look forward to more about it. Agreed, caller clarity on whether a temporary stack copy is required is in the spotlight, but callee clarity, for the purpose of *designing* a function signature that doesn't result in surprises (copies or aliasing) is also essential. Am I stating the overly-obvious? ------------------------- pzittlau | 2026-05-28 18:47:17 UTC | #15 [quote="jmctagger, post:13, topic:15710"] Out of curiosity, what about without LLVM, with the native (x86) backend - is there any known characteristic difference? [/quote] AFAIK based on the last time I looked at the output generated by the native backend it doesn't have any (or only very few) optimizations implemented. So every argument was copied naively and the prologue stored all callee saved registers regardless if used or not. Of course there also wasn't any function inlining that could reduce the overhead introduced by that naive saving/copying. Later when optimizations are implemented there likely won't be a big difference for such things compared to llvm. [quote="npc1054657282, post:10, topic:15710"] What if the frontend lowers all internal function parameters to pointers, decorates them with `readonly noalias`? [/quote] I find that very naive because what then happens when you don't have optimizations enabled in debug builds? This would introduce a lot of unnecessary overhead and, so I think, lead to exact state C++ and Rust are in, that they are basically unusably slow without optimizations because they rely so much on pervasive inlining (especially because of the use of tiny getters/setters in things like `vector`) ------------------------- npc1054657282 | 2026-05-28 19:26:39 UTC | #16 [quote="pzittlau, post:15, topic:15710"] I find that very naive because what then happens when you don’t have optimizations enabled in debug builds? [/quote] This makes sense. The reason I advocate for "optimization guaranteed by semantics" is precisely to ensure that even if optimizations are not enabled, certain large object copies are not expected to happen. However, always using `noalias readonly` pointers undermines the performance of non-optimized scenarios from another perspective. To address this, a possible compromise could be for the compiler frontend to only pass by value for the most conservative cases, such as parameters smaller than `usize`, while for larger ones, use `readonly noalias` pointer passing and rely on further optimizations from the backend. ------------------------- pzittlau | 2026-05-28 19:52:37 UTC | #17 [quote="npc1054657282, post:16, topic:15710"] The reason I advocate for “optimization guaranteed by semantics” is precisely to ensure that even if optimizations are not enabled, certain large object copies are not expected to happen. [/quote] This also makes sense. [quote="npc1054657282, post:16, topic:15710"] To address this, a possible compromise could be for the compiler frontend to only pass by value for the most conservative cases, such as parameters smaller than `usize`, while for larger ones, use `readonly noalias` pointer passing and rely on further optimizations from the backend. [/quote] As far as I understand this was basically what Zig did in some earlier versions and it seemed to cause some problems, which is why it isn't done anymore. Other cases in which this might be problematic are return value optimization and copy-ellision passes when something like this is in the code: ``` process(calculate_matrix()) or process(a, bunch).do(of, random).something(parameters).else() ``` This just makes the optimizers job harder. The programmer in my mind should be responsible to accurately annotate where something should be passed by value or const pointer. ------------------------- LucasSantos91 | 2026-05-28 19:55:08 UTC | #18 [quote="npc1054657282, post:10, topic:15710"] What if the frontend lowers all internal function parameters to pointers, decorates them with `readonly noalias`? The caller then decides, based on the parameter’s mutability, whether to pass a pointer to the original data or a pointer to a temporary stack copy. [/quote] The core team tried for years to make it work, before giving up. If you want to try tackling this, you should start by looking at what they've tried. Remember that Zig uses the same parameters semantics as C. If this optimization were simple, LLVM would have it already. With any idea regarding this, the first step is always to put it against the ArrayList test, which your idea would fail. The test is to append to the list an object which is already on the list: ``` pub fn append(list: *List, item: Item, allocator: Allocator) Allocator.Error!void{ list.items = try allocator.realloc(list.items + 1); list.items[list.items.len - 1] = item; } // Somewhere else try list.append(list.items[0], allocator); ``` Semantic-wise, this is correct, since the parameter is a copy of the item. But the optimization breaks it. If the `realloc` changes the location of the `list.items`, the pointer that you secretly passed to function is now dangling. Note that copying the list to stack would not fix it, because the copy would not be deep. ------------------------- npc1054657282 | 2026-05-28 20:03:38 UTC | #19 [quote="LucasSantos91, post:18, topic:15710"] Note that copying the list to stack would not fix it, because the copy would not be deep [/quote] I didn't fully understand. If `item` itself is a type of data that requires a deep copy, this should be a logical issue; semantically, it is an error, not a parameter-passing optimization problem. In the semantic-driven optimization I envision, since `list` itself is mutable, `list.item` is also mutable. Therefore, it certainly won't be optimized and will instead be copied to the stack during the frontend phase. [quote="pzittlau, post:17, topic:15710"] As far as I understand this was basically what Zig did in some earlier versions and it seemed to cause some problems, which is why it isn’t done anymore. [/quote] Based on the problem that occurred, I think Zig does not by default copy parameters to the stack before passing by reference, but passes by reference directly. And I thought only immutable data could be passed by reference directly; otherwise, it should be copied to the stack before passing by reference. ------------------------- pzittlau | 2026-05-28 20:13:08 UTC | #20 [quote="npc1054657282, post:19, topic:15710"] I didn’t fully understand. If `item` itself is a type of data that requires a deep copy, this should be a logical issue; semantically, it is an error, not a parameter-passing optimization problem. [/quote] I think what they mean is something like this: ``` const std = @import("std"); const mem = std.mem; const LargeItem = struct { data: [128]u8, // Large enough that a compiler might want to pass it by pointer }; fn ownAppend(list: *std.ArrayList(LargeItem), gpa: mem.Allocator, item: LargeItem) !void { // This call might force the ArrayList to grow, freeing the old memory block try list.ensureUnusedCapacity(gpa, 1); // If the memory moved, 'item' is now a dangling pointer list.appendAssumeCapacity(item); } pub fn main(init: std.process.Init) !void { var list: std.ArrayList(LargeItem) = .empty; defer list.deinit(init.gpa); try list.append(init.gpa, LargeItem{ .data = [_]u8{42} ** 128 }); // Value semantics guarantee this should be a safe snapshot copy. try ownAppend(&list, init.gpa, list.items[0]); } ``` [quote="npc1054657282, post:19, topic:15710"] Based on the problem that occurred, I think Zig does not by default copy parameters to the stack before passing by reference, but passes by reference directly. [/quote] If you pass by pointer(reference) nothing will be copied onto the stack(except if you dereference it later onto a stack variable). [quote="npc1054657282, post:19, topic:15710"] And I thought only immutable data could be passed by reference directly; otherwise, it should be copied to the stack before passing by reference. [/quote] Honestly I don't understand what you mean by that. ------------------------- jumpnbrownweasel | 2026-05-28 20:13:32 UTC | #21 Just for completeness, I think you're referring to this issue: https://github.com/ziglang/zig/issues/5973 ------------------------- LucasSantos91 | 2026-05-28 20:19:00 UTC | #22 [quote="npc1054657282, post:19, topic:15710"] I didn’t fully understand. If `item` itself is a type of data that requires a deep copy, this should be a logical issue; semantically, it is an error, not a parameter-passing optimization problem. [/quote] Look again. `Item` can be a `u8`, the issue still happens. `item` is semantically copied into the function stack. It should be semantically safe to move the data inside the list somewhere else. But if you surreptitiously changed the value to a pointer, that pointer will be dangling after the `realloc`, before you could copy it. Search for "Attack of the Killer Features" on youtube, by SpexGuy. ------------------------- LucasSantos91 | 2026-05-28 20:36:46 UTC | #23 Worst title ever for an issue. It should have been called "Hidden pass by reference miscompilation". A footgun is when a feature is easy to misuse. This is correct code being broken by an optimization, that's a miscompilation. ------------------------- npc1054657282 | 2026-05-28 20:28:16 UTC | #24 [quote="pzittlau, post:20, topic:15710"] If you pass by pointer(reference) nothing will be copied onto the stack(except if you dereference it later onto a stack variable). [/quote] It may need to be reiterated that here, the 'pass-by-reference' is designed based on [the Windows calling convention](https://learn.microsoft.com/en-us/cpp/build/x64-calling-convention?view=msvc-170#parameter-passing). > For these aggregate types passed as a pointer, including **`__m128`** , the caller-allocated temporary memory must be 16-byte aligned. Simply put, for passing larger structures, the Windows calling convention is: allocate a temporary copy on the stack, while passing the parameter by reference. This allows the caller to optimize based on known information: the caller knows the mutability and aliasing of the parameter, and if the caller knows the parameter is immutable, the temporary copy can be omitted. And based on Zig's previous issues, I believe that Zig does not make a temporary copy for parameters that are not immutable either. ------------------------- pzittlau | 2026-05-28 20:34:46 UTC | #25 Oh my bad. I overread the windows part beforehand. [quote="npc1054657282, post:24, topic:15710"] This allows the caller to optimize based on known information: the caller knows the mutability and aliasing of the parameter, and **if the caller knows the parameter is immutable, the temporary copy can be omitted**. [/quote] It's late for me so excuse me if I'm wrong, but isn't this still susceptible to [this](https://ziggit.dev/t/pass-by-value-semantics/15710/20?u=pzittlau)? ------------------------- npc1054657282 | 2026-05-28 20:41:45 UTC | #26 [quote="pzittlau, post:25, topic:15710"] It’s late for me so excuse me if I’m wrong, but isn’t this still susceptible to [this](https://ziggit.dev/t/pass-by-value-semantics/15710/20)? [/quote] [quote="pzittlau, post:20, topic:15710"] ``` pub fn main(init: std.process.Init) !void { var list: std.ArrayList(LargeItem) = .empty; defer list.deinit(init.gpa); try list.append(init.gpa, LargeItem{ .data = [_]u8{42} ** 128 }); // Value semantics guarantee this should be a safe snapshot copy. try ownAppend(&list, init.gpa, list.items[0]); } ``` [/quote] My understanding is as follows: `list` is mutable, and `list.items[0]` is mutable. Therefore, semantic analysis cannot conclude that the temporary copy here should be eliminated, so it will still be temporary copied according to the usual pass-by-value semantics. [quote="LucasSantos91, post:22, topic:15710"] Look again. `Item` can be a `u8`, the issue still happens. `item` is semantically copied into the function stack. It should be semantically safe to move the data inside the list somewhere else. But if you surreptitiously changed the value to a pointer, that pointer will be dangling after the `realloc`, before you could copy it. [/quote] Can this explaination helps? ------------------------- LucasSantos91 | 2026-05-28 20:43:48 UTC | #27 [quote="npc1054657282, post:26, topic:15710"] `list` is mutable, and `list.items[0]` is mutable. Therefore, semantic analysis cannot conclude that the temporary copy here should be eliminated, so it will still be temporary copied according to the usual pass-by-value semantics. [/quote] Yes, now find any example where this is safe, guaranteed, and can be proven programatically. There will be a minuscule amount of trivial cases, which are already optimized in every every compiler. Zig' PRO was supposed to be the next step forward. ------------------------- npc1054657282 | 2026-05-28 20:48:34 UTC | #28 [quote="LucasSantos91, post:27, topic:15710"] which are already optimized in every every compiler. Zig’ PRO was supposed to be the next step forward. [/quote] [quote="npc1054657282, post:16, topic:15710"] The reason I advocate for “optimization guaranteed by semantics” is precisely to ensure that even if optimizations are not enabled, certain large object copies are not expected to happen. However, always using `noalias readonly` pointers undermines the performance of non-optimized scenarios from another perspective. [/quote] My ambition is not that great; my idea is simply this: for those stateless immutable data, copy optimization for pass-by-value can be determined semantically, so that even users in scenarios where the optimization is not enabled can be assured that there is no additional overhead here. This is especially useful for generics. ------------------------- matklad | 2026-05-28 20:52:30 UTC | #29 [quote="LucasSantos91, post:23, topic:15710"] This is correct code [/quote] The thing is, whether the code is correct depends on definition of the language. You _could_ define a language such that this sort of aliasing is illegal behavior, ad we might still get something like that eventually: https://github.com/ziglang/zig/issues/1108. My understanding is that, at the time the issue was written, this was literally undefined behavior, in a sense that the language didn't conclusively rule one way or another whether this sort of code is valid. ------------------------- gwenzek | 2026-05-28 20:53:00 UTC | #30 > copy optimization for pass-by-value can be determined semantically I like the idea. Though I always hated the "will copy elision trigger" C++ game. But maybe that's a UX problem. Like if there is a way to ask ZIg compiler, is there a copy ellision here ? it's way better. ------------------------- LucasSantos91 | 2026-05-28 21:10:59 UTC | #31 With PRO, some types [quote="matklad, post:29, topic:15710"] [quote="LucasSantos91, post:23, topic:15710"] This is correct code [/quote] The thing is, whether the code is correct depends on definition of the language. [/quote] Defining it this way would make some kinds of code impossible (literally) to write correctly. Which was exactly the case when PRO existed. Take a look at this [beautiful example](https://ziggit.dev/t/copy-or-reference/5088?u=lucassantos91). I doubt anyone will disagree with PRO being removed after seeing it. In the example, the functions involved had no pointer arguments, and in fact, there's not a single pointer to be seen, other than a discarded one (`_ = &b`), which happens *after* the function call. That user even explicitly created an unnecessary copy on the stack, even though the argument itself was passed by value and should be, semantically, copied. The copy explicitly eliminates aliasing. PRO attacked anyways. The compiler eliminated the explicit copy and passed a one-byte struct by reference, creating aliasing, even though semantically the user did everything to avoid aliasing. ------------------------- LucasSantos91 | 2026-05-28 21:13:33 UTC | #32 [quote="npc1054657282, post:28, topic:15710"] My ambition is not that great; my idea is simply this: for those stateless immutable data, copy optimization for pass-by-value can be determined semantically, so that even users in scenarios where the optimization is not enabled can be assured that there is no additional overhead here. [/quote] I don't think it's possible to determine these conditions hold without analyzing the code, that is, enabling optimizations. And I'm pretty sure your small ambition is what LLVM already does, with optimizations enabled. ------------------------- gwenzek | 2026-05-28 21:35:39 UTC | #33 I feel the discussion is a bit too much all or nothing. I would love some reliable optimization that currently LLVM don't do. The main issue with PRO is a pointer may alias with one of the argument magic pointer. But there can be no alias if nobody knows the magic pointer. Which is true if you just created the value on the stack and you didn't give the pointer to anyone. It means taking a pointer to an argument or a stack variable requires making a new copy for further pass by reference call. * in the array list example, `items[0]` isn't on the stack, so it need to be copied to it before being passed by reference The other big footgun with naive PRO ``` fn merge(a: *A, b: A) { ... } fn shootfoot() void { var a: A = .init; merge(&a, a); } ``` * here `shootfoot` needs two copies of `a` because of the reference. ------------------------- gwenzek | 2026-05-28 21:37:05 UTC | #34 This example is baffling, but it seems to be due to a poorly implemented PRO, rather than PRO in general. Why would `const b = a;` not materialize a copy of the global variable `a` on the stack ? ------------------------- npc1054657282 | 2026-05-28 21:38:36 UTC | #35 [quote="LucasSantos91, post:32, topic:15710"] I don’t think it’s possible to determine these conditions hold without analyzing the code, that is, enabling optimizations. And I’m pretty sure your small ambition is what LLVM already does, with optimizations enabled. [/quote] If we want to infer aliasing situations, I think it requires relatively complex semantic analysis. However, I believe that in most cases the demands we face are very simple: when we pass stateless data through the abstraction of a function, we do not want the parameter passing to incur an extra meaningless expensive copy in an unoptimized scenario. To explicitly ensure this, one would have to manually write `noalias *const T` in the function signature, which is usually not what we want. The cost for users to achieve this guarantee is very simple: cultivate the good habit of avoiding `var` when using stateless data. Doing so makes it easy to determine through semantics that optimization is possible. ------------------------- matklad | 2026-05-28 21:41:08 UTC | #36 [quote="LucasSantos91, post:31, topic:15710"] Defining it this way would make some kinds of code impossible (literally) to write correctly. [/quote] I wouldn’t be so categorical! I think there is a coherent language where passing parameters and `const` bindings in general is specified as creating an aliases for data, where the aliased data is considered “borrowed” (so, mutating it while such a binding is active is illegal behavior), and where there’s an explicit operator to force a copy. I can clearly see two ways to look at this whole design space: You can see it as extremely weird that function parameter isn’t an independent copy of memory, and can alias something else. But, equally, you could see it as extremely weird that compiler silently duplicates or moves values behind your back. ------------------------- gwenzek | 2026-05-28 21:48:37 UTC | #37 I feel one thing that would help the compiler detect aliases is to prevent pointers from being copied by default. Like `fn (x: *Foo) void` is not allowed to store x pointer anywhere. Only `fn (x: *free-for-all Foo)` can. ------------------------- gwenzek | 2026-05-28 21:55:08 UTC | #38 [quote="LucasSantos91, post:32, topic:15710"] I’m pretty sure your small ambition is what LLVM already does, with optimizations enabled. [/quote] LLVM is actually quite bad at removing copies, unless it inlines. https://godbolt.org/z/9479cxvjP In this examples I would expect exactly one copy, when receiving data from outer world. ------------------------- LucasSantos91 | 2026-05-28 22:11:49 UTC | #39 Maybe. I would hope so. My biggest wish for Zig is for PRO to come back correctly. I don't know if it's impossible, but the evidence does point in that direction. Like I said earlier, if we use C's semantics for parameter passing, then we benefit from C's optimizations in LLVM. And those guys have been optimizing C for a very long time, and they've done amazing things, but they have failed to implement this. Then Andrew and the core team came, stubbornly insisted on it for so long, and also failed. If it is possible, it will certainly take a genius with some kind of breakthrough. We'd have a better shot at this if we changed the semantics of parameter passing to something different from C, like the in/out parameters that some languages are trying. ------------------------- LucasSantos91 | 2026-05-28 22:26:59 UTC | #40 That code is unoptimized. On ReleaseFast the behavior is what you expected. ------------------------- hvbargen | 2026-05-29 03:11:43 UTC | #41 [quote="LucasSantos91, post:39, topic:15710"] We’d have a better shot at this if we changed the semantics of parameter passing to something different from C, like the in/out parameters that some languages are trying. [/quote] One of these languages is PL/SQL. But aliasing is possible there, too. At least if you use the `NOCOPY` “hint” for `IN OUT` parameters, which the language itself advocates (you get a compiler warning without it). Just happened at work a few weeks ago, when a colleague wondered “WTF, this function actually changes an input parameter!?” ------------------------- gwenzek | 2026-05-29 05:25:50 UTC | #42 I find it weird to call ReleaseSafe "unoptimized" ------------------------- gwenzek | 2026-05-29 08:02:43 UTC | #43 > Then Andrew and the core team came, stubbornly insisted on it for so long, and also failed I would love a write up on what they tried. From the outside it looks like the first implementation was really greedy, and memcpy everywhere got added when the issue blew up on Hacker News and Zig got a bad rap. I'm sure there is more to it ------------------------- LucasSantos91 | 2026-05-29 14:36:26 UTC | #44 Sadly, ReleaseSafe just means SlightlyLessWorseDebug. ------------------------- jumpnbrownweasel | 2026-05-29 15:51:55 UTC | #45 ReleaseSafe gives me at least 10x the performance of debug. So not just slightly less worse. It is slightly better than Rust release mode. ------------------------- jumpnbrownweasel | 2026-05-29 19:10:17 UTC | #46 [quote="LucasSantos91, post:31, topic:15710"] Defining it this way would make some kinds of code impossible (literally) to write correctly. Which was exactly the case when PRO existed. [/quote] I wonder how Odin gets away with it. > By default, Odin procedures use the "odin" calling convention. This calling convention is the same as C, however it differs in a couple of ways: > > It promotes values to a pointer if that’s more efficient on the target system, and > It includes a pointer to the current context as an implicit additional argument. > > The promotion is enabled by the fact that all parameters are immutable in Odin, and its rules are consistent for a given type and platform and can be relied on since they are part of the calling convention. ------------------------- LucasSantos91 | 2026-05-29 21:54:05 UTC | #47 [quote="jumpnbrownweasel, post:46, topic:15710"] I wonder how Odin gets away with it. [/quote] I don't know Odin, but I'm guessing it doesn't. Had anyone tried the ArrayList test in it? ------------------------- gwenzek | 2026-05-29 22:15:30 UTC | #48 I have tried with the "merge" footgun. Odin is basically at Zig 0.9 level, alias all the things ! https://godbolt.org/z/fK6b8an3z ``` const A = struct {x: u32, y: u32, z: [100]u32}; noinline fn merge(a: *A, b: A) void { a.x += b.y; a.y += b.x; var i: u32 = 0; while (i < 100) : (i+=1) {a.z[i] += b.z[i];} } export fn shootfoot(i: u32) u32 { var a: A = .{.x = i, .y = i+1, .z = undefined}; merge(&a, a); return a.y; } ``` emits `call fastcc void @merge(%A* %a, %A* %a)` which is incorrect ------------------------- jumpnbrownweasel | 2026-05-29 22:29:11 UTC | #49 Interesting! Thanks. ------------------------- matklad | 2026-05-29 23:09:59 UTC | #50 Didn't know that, thanks! Some more context: https://github.com/odin-lang/Odin/issues/2971#issuecomment-1824961908 ------------------------- gwenzek | 2026-05-30 06:19:24 UTC | #51 https://github.com/odin-lang/Odin/issues/4604 They also have the array list bug ------------------------- zigster | 2026-05-31 02:54:36 UTC | #52 I suspect that the semantic solution that Pony uses might fit this pretty well ? Adding iso/ref/val annotations to declare the capability restrictions up front seems to do the job, and solves some tricky problems. It’s a bit of an all-or-nothing solution though. Not sure how well that might work out with a non GC language like zig - like, can you still provide similar guarantees without GC ? ------------------------- jumpnbrownweasel | 2026-05-31 04:52:23 UTC | #53 [quote="zigster, post:52, topic:15710"] can you still provide similar guarantees without GC ? [/quote] I assume you need RAII plus ownership tracking in the compiler, just like Rust. ------------------------- hvbargen | 2026-05-31 07:46:06 UTC | #54 Some Ada users here? Looking at the docs, it seems to me that even Ada Spark cannot catch all of the possible issues. ------------------------- WeeBull | 2026-06-01 19:36:19 UTC | #55 So they're taking the position that the programmer should know that the semantics change if the item being passed is greater than 16 bytes in size. There's no way anybody is getting caught out by that one. Oooof! I'm glad PRO got reverted in Zig. ------------------------- jumpnbrownweasel | 2026-06-01 20:35:10 UTC | #56 [quote="WeeBull, post:55, topic:15710"] So they’re taking the position that the programmer should know that the semantics change if the item being passed is greater than 16 bytes in size. [/quote] I think it's more likely they would document that when you pass a variable by value, you shouldn't also pass it by pointer. And if you do, expect that mutating through the pointer may change the value argument. ------------------------- npc1054657282 | 2026-06-01 21:34:13 UTC | #57 [quote="WeeBull, post:55, topic:15710"] So they’re taking the position that the programmer should know that the semantics change if the item being passed is greater than 16 bytes in size. There’s no way anybody is getting caught out by that one. [/quote] I'm not very sure about other people's concepts. But my expectation for PRO is as follows: For the passing of immutable values of stateless data, the coder should not need to deliberately write parameters like `noalias *const T` for the sake of performance optimization to reduce copying (otherwise the consistency of generics would easily be broken depending on the size of the type). The coder should assume that the language can perform such optimization during parameter passing, and the coder should not have to design obscure function signatures for performance. Therefore, the function signature only expresses the intended semantics and will not break the generic parameter forms because of that. Regarding the aliasing issue, the language should be able to detect it and fall back to copying. Since we usually only expect passing by value for immutable stateless data, ideal code should not have such aliasing. Similarly, if a coder deliberately writes `noalias *const T` parameters for performance reasons, the aliasing issue was already present. The problem never disappears; it just manifests in a different form. For an ideal PRO, it's a performance fallback; for an interface deliberately written by the coder, it's ib. The earlier version of Zig's PRO had issues because it basically did not provide checks or fallbacks for aliasing. This is also why, in my philosophy, PRO should be based on the Windows calling convention. ------------------------- ericlang | 2026-06-02 07:13:52 UTC | #58 [quote="npc1054657282, post:57, topic:15710"] For the passing of immutable values of stateless data, the coder should not need to deliberately write parameters like `noalias *const T` for the sake of performance optimization [/quote] Agree! But can we trust the compiler for 100%? ------------------------- pzittlau | 2026-06-02 07:26:22 UTC | #59 [quote="npc1054657282, post:57, topic:15710"] Regarding the aliasing issue, the language should be able to detect it and fall back to copying. [/quote] That is very handwavy. Falling back to copying implies either relying on compile-time escape analysis (which is often pessimistic and fails across boundaries) or introducing unexpected pointer comparisons at runtime. Neither of those are desirable or expected in systems code. Also an additional problem that, as far as I've seen, wasn't talked about at all in this thread is multithreaded code. It's not unreasonable to assume that code exists that operates on things as values where concurrent threads are updating the "original". With a normal mutex operation this will likely rarely happen, but this is Zig and you can (and should be able to) do anything you please. And other synchronization methods where this would lead to issues are easily possible to create and also widely used. I haven't looked into it but intuitively lock free stuff and also something like a seqlock could suffer from this. Other problems I can think of are (1) the then missing consistency of generics, where you might need to explicitely handle cases for types larger than some processor dependent threshold, for which the reason is entirely hidden and can easily be forgotten because, at least I, wouldn't think about how this thing I'm programming could be used in all possible ways while writing some code. And (2) the already non-stable ABI which Zig has is then even more brittle and making it even harder to allow things like [this](https://blog.s-schoener.com/2026-04-16-zig-abi/). (3) The ability for any code to write assembly, which is largely a black box to the compiler and with that especially to some part of the frontend, and expect something to be passed by value. (4) Being able to patch the code that is running at runtime - I don't need to say more about this one I think. It also violates the key thing that for me Zig stands for is that the amount of implicit things is reduced to the necessary minimum[^1]. [^1]: But not the absolute minimum as this would be very awkward. ``` $ zig zen | sed -n '2p;4p;10p' * Communicate intent precisely. * Favor reading code over writing code. * Reduce the amount one must remember. ``` I believe something like this just doesn't belong at all into a close to the hardware language that allows complete control over the executed code. ------------------------- npc1054657282 | 2026-06-02 07:48:12 UTC | #60 [quote="pzittlau, post:59, topic:15710"] That is very handwavy. Falling back to copying implies either relying on compile-time escape analysis (which is often pessimistic and fails across boundaries) or introducing unexpected pointer comparisons at runtime. Neither of those are desirable or expected in systems code. [/quote] This is the reason I advocate treating it as a 'semantics' rather than an 'optimization,' which means that in practice, the code writer can predict whether it actually occurs. The semantics I envisage are very pessimistic, and copy elision can only be guaranteed when the parameters are immutable; in other scenarios, there will be no copy elision. Immutable parameters are also the rare cases where I believe such optimizations should be adopted. [quote="pzittlau, post:59, topic:15710"] It also violates the key thing that for me Zig stands for is that the amount of implicit things is reduced to the necessary minimum [/quote] When PRO was removed, my initial thought was the same: I thought it was 'nicely deleted.' At that time, I confidently believed that I could completely implement a similar behavior in user space with a more explicit semantics. Until I actually started attempting to do it in user space, only to be completely defeated. I now admit that without language support, it is currently difficult to implement generics with equivalent usability in user space. What particularly blocked me was the available use of `noalias`; because it only applies to modifier parameters rather than types, and it only applies to pointer types, I could hardly have a design in user space with equivalent capability. ------------------------- pzittlau | 2026-06-02 08:13:33 UTC | #61 I just don't understand why you need something like this. Where is the problem of typing `*const T` instead `T` where needed? For me I mostly just follow the convention of pass by value or pointer already established for the type except for specific cases where something different is needed. Of course this only works if such a convention is already established. But for most types I know beforehand if they are expected to get large or not and can decide on that. If they are passed by value and then, against my expectations get a bunch of fields I need to do one of two things: Rethink the type, API and maybe even subsystem around it, and split it up and structure it in a better way; or change functions to pass the pointer instead when the type can't be split reasonably. That's it. This changing every function to a pointer is sometimes grudge work but grep and quickfix make it okay and teach me to next time think more clearly beforehand. ------------------------- npc1054657282 | 2026-06-02 08:40:08 UTC | #62 [quote="pzittlau, post:61, topic:15710"] I just don’t understand why you need something like this. Where is the problem of typing `*const T` instead `T` where needed? [/quote] As long as you need to implement generics, you cannot anticipate the size of the target type. For the same data structure, which is better, passing by value or by reference, also differs on different architectures. Of course, I know of a possible approach, which is to use `noalias *const T` everywhere and then hope for LLVM's ArgPromotion. But as far as I know, extensive use of `noalias *const T` is not recommended in Zig. If extensive use of `noalias *const T` is considered good practice, I don't mind not having PRO. ------------------------- gwenzek | 2026-06-02 10:09:39 UTC | #63 [quote="pzittlau, post:61, topic:15710"] I just don’t understand why you need something like this. Where is the problem of typing `*const T` instead `T` where needed? [/quote] I tried to illustrate this in the opening post. The problem is that any extern call becomes an optimization barrier in the presence of `*const T`. Most of the std also works by value, eg std.ArrayList or std.HashMap. And I think it should because the right call for `*const T` vs `T` is also architecture specific. I think we could improve pass-by-value implementation by removing a few unneeded memcpy with a few simple heuristics. ------------------------- Dok8tavo | 2026-06-02 10:15:30 UTC | #64 Would implementing a `Pro(comptime T: type) type` that returns either `*const T` or `T` based on the size of `T` and the target be a good temporary solution for generic code? With it's counterpart `inline fn pro(ptr: anytype) Pro(@TypeOf(T))` that either returns the pointer or dereference it, hoping it's rendered trivial enough for the compiler to optimize. ------------------------- matklad | 2026-06-02 10:26:39 UTC | #65 [quote="pzittlau, post:61, topic:15710"] Where is the problem of typing `*const T` instead `T` where needed? [/quote] "where needed" is the problem here, due to two cases: Case 1: ``` fn frobnicate(T: type, value: T) void { ... } ``` This is a generic function, the caller picks `T` and there might be multiple of those. Eg, something like `array_list.push(value)` wants `T` if value is `i32`, but it wants `*const T` if the value is something much larger. Case 2: ``` const Foo = struct { // ... }; fn frobnicate(value: Foo) void { ... } ``` ``` $ zig build-exe -target x86_64-linux frobnicate.zig $ zig build-exe -target x86-linux frobnicate.zig ``` Even for concrete code, the "cutoff point" where you should switch from `T` to `*const T` depends on the target CPU architecture. x86 has less architectural registers than x86_64, so there are some types that want to be `*const T` on `x86` and `T` on `x86_64`. ------------------------- pzittlau | 2026-06-02 11:56:44 UTC | #66 [quote="matklad, post:65, topic:15710"] Case 1: ``` fn frobnicate(T: type, value: T) void { ... } ``` This is a generic function, the caller picks `T` and there might be multiple of those. Eg, something like `array_list.push(value)` wants `T` if value is `i32`, but it wants `*const T` if the value is something much larger. [/quote] Wouldn't it then make more sense to just have another function like ``` fn frobnicateConst(T: type, value: *const T) void { ... } ``` This is some extra typing but communicates everything clearly and let's the caller decide instead of doing it behind my back. [quote="matklad, post:65, topic:15710"] Even for concrete code, the “cutoff point” where you should switch from `T` to `*const T` depends on the target CPU architecture. [/quote] That's true but at least for me it's more about the size class. Copying 8 or 16 bytes by value is almost always fine re. Doing it with 32 or more bytes is more questionable so I would default to pointer. Of course it depends on the target but we likely all have some general assumptions of where the code will run and can adjust roughly to that. And if the last percentages of performance are needed you really can't do anything general anyway. --- Maybe my context is too multithreaded or something, but I wouldn't like it if things I've said to be passed by value aren't actually copied and because of that I get some issues in optimistic concurrency contexts. Having PRO would then need an extra annotation to force the compiler to really pass it by value and disallow it. But this, at least to me, seems backwards. ------------------------- glfmn | 2026-06-02 12:28:16 UTC | #67 [quote="LucasSantos91, post:31, topic:15710, full:true"] That user even explicitly created an unnecessary copy on the stack [/quote] Technically, language concepts/abstractions like variables do not equate to data specifically existing on the stack or in any particular location, right? The compiler could load the value as an immediate into a register, and ultimately never need to copy it into the stack, or observe the copy is unnecessary and elide it. ------------------------- npc1054657282 | 2026-06-02 22:59:14 UTC | #68 [quote="pzittlau, post:66, topic:15710"] Maybe my context is too multithreaded or something, but I wouldn’t like it if things I’ve said to be passed by value aren’t actually copied and because of that I get some issues in optimistic concurrency contexts. Having PRO would then need an extra annotation to force the compiler to really pass it by value and disallow it. But this, at least to me, seems backwards. [/quote] PRO only applies to copy elision at function call boundaries. In any case, there will always be an explicit copy across thread boundaries, and this copy cannot be omitted under any circumstances. The copy at the parameter passing is generally redundant, meaning that without PRO, you would make a copy when crossing the thread boundary, and then make an additional redundant copy when passing parameters to a function. PRO only concerns itself with redundant copies during parameter passing; it does nothing about copies across threads. ------------------------- pzittlau | 2026-06-02 23:32:23 UTC | #69 [quote="npc1054657282, post:68, topic:15710"] PRO only applies to copy elision at function call boundaries. In any case, there will always be an explicit copy across thread boundaries [/quote] I think we might be talking past each other here. I'm not talking about passing arguments to a thread during creation, which, as you noted, involves manually copying them to the new stack. I'm talking about the shared data structures that are accessed during execution. Consider something like a [Seqlock](https://en.wikipedia.org/wiki/Seqlock), where the entire premise relies on taking a local snapshot by value, verifying it, and then working on that snapshot. ``` const LargeState = struct { data: [1024]u8 }; var shared_state: LargeState = undefined; var seqlock: atomic.Value(usize) = .init(0); fn doWork() void { var local_snapshot: LargeState = undefined; while (true) { const seq1 = seqlock.load(.acquire); if (seq1 % 2 != 0) continue; local_snapshot = shared_state; const seq2 = seqlock.load(.acquire); if (seq1 == seq2) break; } process(local_snapshot); } fn process(state: LargeState) void { ... } ``` In this context, the copy isn't redundant, as you said - it's load bearing for thread safety. With PRO the optimizer might look at this and decide to make the `local_snapshot` a `*const LargeState`. If PRO does this copy elision, `process` is now operating directly on the live `shared_state` behind the scenes. Another thread can easily modify that `shared_state` while `process` is running, leading to data races and other things even after the seqlock validation was already finished. To prevent this under PRO, you would have to forcefully defeat the optimizer using compiler barriers just to guarantee that pass by value actually does pass by value. Another example could be just a simple SPSC ring buffer where some thread writes to it and another reads from it. ``` var ring: [8]LargeMessage = undefined; fn pop(self: *RingBuffer) LargeMessage { self.mutex.lock(); defer self.mutex.unlock(); const msg = self.ring[tail]; self.tail = (tail + 1) % self.ring.len; return msg; } fn worker() void { while (true) { // We pop the message and pass it directly. // Semantically, `pop` returns an independent copy by value, // so we don't need to hold the lock during processing. process(shared_ring.pop()); } } fn process(msg: LargeMessage) void { ... } ``` Again PRO would easily break this because the writing thread can just overwrite the value we've just "freed" within the `pop`. And these are just simple cases in largely self containing datastructures. Imagining this to large and complex codebases explains, at least to me, why the Zig devs scrapped this. ------------------------- npc1054657282 | 2026-06-02 23:44:14 UTC | #70 [quote="pzittlau, post:69, topic:15710"] ``` const LargeState = struct { data: [1024]u8 }; var shared_state: LargeState = undefined; var seqlock: atomic.Value(usize) = .init(0); fn doWork() void { var local_snapshot: LargeState = undefined; while (true) { const seq1 = seqlock.load(.acquire); if (seq1 % 2 != 0) continue; local_snapshot = shared_state; const seq2 = seqlock.load(.acquire); if (seq1 == seq2) break; } process(local_snapshot); } fn process(state: LargeState) void { ... } ``` In this context, the copy isn’t redundant, as you said - it’s load bearing for thread safety. With PRO the optimizer might look at this and decide to make the `local_snapshot` a `*const LargeState`. If PRO does this copy elision, `process` is now operating directly on the live `shared_state` behind the scenes. Another thread can easily modify that `shared_state` while `process` is running, leading to data races and other things even after the seqlock validation was already finished. [/quote] This is impossible; you have a fundamental misunderstanding of PRO. PRO only acts on parameter passing, and `local_snapshot = shared_state;` will definitely perform a full copy. It cannot make the parameter passed into `local_snapshot` become a reference to `shared_state`. [quote="pzittlau, post:69, topic:15710"] ``` var ring: [8]LargeMessage = undefined; fn pop(self: *RingBuffer) LargeMessage { self.mutex.lock(); defer self.mutex.unlock(); const msg = self.ring[tail]; self.tail = (tail + 1) % self.ring.len; return msg; } fn worker() void { while (true) { // We pop the message and pass it directly. // Semantically, `pop` returns an independent copy by value, // so we don't need to hold the lock during processing. process(shared_ring.pop()); } } fn process(msg: LargeMessage) void { ... } ``` [/quote] `const msg = self.ring[tail]` It is impossible to be omitted by PRO (because the scenario here is not about parameter passing at all). If a problem occurs here, it is considered a compilation error. Of course, zig’s PRO used to be quite bad and completely ignored the aliasing issue. Even so, it will not have an impact here. ------------------------- gwenzek | 2026-06-03 07:14:19 UTC | #71 Is there a very kind someone that could help me figure out where the "copy function argument on the stack" is implemented in Zig ? I was thinking it would be in AIR, but my current understanding is that it's actually delegated to each backend, eg in LLVM backend: https://codeberg.org/ziglang/zig/src/commit/b3747dd707eeaf06e0bacbde259bf71ef525a961/src/codegen/llvm.zig#L1303-L1308 This makes it harder to implement a copy-ellision pass at the AIR level. ------------------------- alanza | 2026-06-03 19:41:04 UTC | #72 This is a question better-suited for the Zulip, which is more likely to have people with a knowledge of AIR around to answer. ------------------------- klpomlp | 2026-06-04 13:11:26 UTC | #73 [quote="npc1054657282, post:28, topic:15710"] My ambition is not that great; my idea is simply this: for those stateless immutable data, copy optimization for pass-by-value can be determined semantically, so that even users in scenarios where the optimization is not enabled can be assured that there is no additional overhead here. This is especially useful for generics. [/quote] I think the key advantage is predictability. When the language semantics guarantee that immutable, stateless values can be passed without extra overhead, users don't have to depend on whether a particular optimization pass is enabled. That becomes especially valuable in generic code, where performance characteristics can otherwise be harder to reason about. ------------------------- pzittlau | 2026-06-05 09:00:55 UTC | #74 I've read a bit more on the topic and now understand the confusion I had, though I still have doubts if PRO is really a good idea, both in general and in the context of Zig. [quote="npc1054657282, post:70, topic:15710"] PRO only acts on parameter passing, and `local_snapshot = shared_state;` will definitely perform a full copy. It cannot make the parameter passed into `local_snapshot` become a reference to `shared_state`. [/quote] Yes I now understand, thank you. However I think, the hazard still remains if we pass a shared or global variable directly to a function. If a developer writes a call like `process(shared_state)` expecting the value to be copied at the function boundary, PRO could silently optimize this to pass a pointer instead of copying it. And since the value a pointer points to can be overwritten at any time in zig this could get problematic. Yes this could be a race condition, so anything goes, but also could not be because of some more complex multithreading schemes. I think that to write thread-safe code under a language with PRO, developers would have to defensively assign every shared or global variable to a local stack variable before passing it to any function by value, which would make that language for me basically unusable. --- Another thing, which is a bit more meta, is, that I don't like the distinction between value and pointer[^1] passing. I prefer the mental model that everything is passed by value, be that a "literal value" or an address. When a language attempts to blur this distinction by dynamically switching between copying a struct and passing its address under the hood, it breaks the direct mapping to the hardware. A systems language is much easier to reason about when pass-by-value guarantees a physical copy, and passing a pointer guarantees we are operating on an address. [^1]: I dislike the term reference for this even more. At least for me that's a higher abstraction level than a pointer, which is just an address(and provenance,...). ------------------------- jumpnbrownweasel | 2026-06-05 15:40:12 UTC | #75 [quote="pzittlau, post:74, topic:15710"] If a developer writes a call like `process(shared_state)` expecting the value to be copied at the function boundary [/quote] Even without PRO, this is very unsafe if there is no protection against mutation in one thread while accessing it in another thread. The copy could be made in one thread while being mutated in another thread, because copying is not atomic. Shared mutable state must always be protected in some way (a mutex, etc). If the copy is done before the call using a mutex, for example, then there is no problem with or without PRO. The same is true if a mutex is held during the call. ------------------------- npc1054657282 | 2026-06-06 09:54:30 UTC | #76 [quote="pzittlau, post:74, topic:15710"] However I think, the hazard still remains if we pass a shared or global variable directly to a function. If a developer writes a call like `process(shared_state)` expecting the value to be copied at the function boundary, PRO could silently optimize this to pass a pointer instead of copying it. And since the value a pointer points to can be overwritten at any time in zig this could get problematic. Yes this could be a race condition, so anything goes, but also could not be because of some more complex multithreading schemes. [/quote] Any real examples? I suspect that the issue you're discussing would exist at the function call boundary Of course, even if they do exist, under the 'PRO determined by parameter variability' that I envision, this cannot be a problem, because any shared variables are mutable rather than immutable. For me, it is already sufficient that PRO applies to constants. ------------------------- pzittlau | 2026-06-07 14:58:02 UTC | #77 I think the core realization is that `const` in Zig is an access control and not a physical property of the underlying memory. A `const` pointer only tells the compiler, that *this* specific path cannot mutate the data. And even that assumption is wrong in light of things like `@constCast` and inline assembly or just calling to some opaque function. Of course the underlying memory isn't immutable even if we disallow those things. For that we can have concurrent threads, OS operations, like another thread/process having a writable map to our read-only pages or us just remapping the thing as `r|w`. This makes any pointer-based data sharing in concurrent environments highly susceptible to hidden compiler transformations. Take this example: ```zig fn foo(state: LargeShared) void { if (!state.is_done) { std.debug.print("State isn't done\n", .{}); } state.is_done = true; // A concurrent thread resets the global state back to false here. if (!state.is_done) { std.debug.print("State isn't done\n", .{}); } } ``` If the compiler guarantees physical pass-by-value, `state` is a private, isolated stack copy. The second check is guaranteed to see `local.is_done == true`, and the message can never print twice. But with PRO and copy elision, the compiler might optimize state to be a direct alias of the shared global memory. If a concurrent thread resets the global state back to false mid-execution, the second check reads the live global memory. The function prints "State isn't done" twice, violating basic single-threaded control flow expectations. A basic [non-repeatable read](https://en.wikipedia.org/wiki/Read%E2%80%93write_conflict)[^1]. [^1]: For anyone who hasn't done so: It can be really worthwhile looking into the database literature for ideas about concurrency control and problems with transactions. Of course in a typical program this wouldn't be written as clearly as here. But nested into functions across the entire system. I think it's easy to imagine that finding such a thing when compiling can be very hard and the outcome can be very bad. So the compiler has to be pessimistic and will likely disallow most things. Then the question is: Why even have such an "optimization" if it can't be done reliably? ------------------------- gwenzek | 2026-06-07 18:09:11 UTC | #78 At this point you have invented your own version of PRO and then complain about it. The main thing I want to get optimize is chaining function that passes the same struct three or four times down the callstack without having to worry if any of those function will fail to inline, and triggering a copy. Yes the first call of the chain may need a copy but not after that. This is a common pattern in eg std library. Look at ArrayHashMap.get it passes the key and the map by value down 4 functions. I don't want this resulting in 4 copies. This mostly don't happen, but I have seen fail because of unecessary complicated code in hashmap leading to a failed inlining. (I have a PR open, but I feel there is a tension between idiomatic Zig and what the compiler can optimize correctly) ------------------------- npc1054657282 | 2026-06-08 01:59:41 UTC | #79 [quote="pzittlau, post:77, topic:15710"] ``` fn foo(state: LargeShared) void { if (!state.is_done) { std.debug.print("State isn't done\n", .{}); } state.is_done = true; // A concurrent thread resets the global state back to false here. if (!state.is_done) { std.debug.print("State isn't done\n", .{}); } } ``` [/quote] Please note: Unlike in C, in Zig, function parameters themselves are `const` and cannot be modified, so this code cannot compile because it modifies the parameter itself. If you want to modify the parameter itself, in Zig, the only way is to create a copy of the parameter as a `var` inside the function and then modify that copy. -------------------------