(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.