What this enables
Kernels can now declare runtime-sized storage buffers in Zig:
constbuf=gpu.runtimeArray(u32,0,0,"buf");// ...buf.*.data[i]=x;
The host picks the buffer size at dispatch time. Before this change, the buffer length had to be a compile-time constant (extern struct { data: [N]u32 }), so every input size needed its own .spv. Now one pipeline handles any size.
This unlocks push-constant-driven kernels that dispatch over arbitrary input.
SPIR-V output
Before this change, the emitted struct contained a pointer field, which did not match how the host writes the buffer (raw T[]):
%_ptr_StorageBuffer_uint = OpTypePointer StorageBuffer %uint
%_struct = OpTypeStruct %_ptr_StorageBuffer_uint ; pointer field
After, the struct contains a runtime array, which appears to be what GLSL and HLSL emit for the same source pattern:
%_runtimearr_uint = OpTypeRuntimeArray %uint
%_struct = OpTypeStruct %_runtimearr_uint ; runtime array field
OpDecorate %_runtimearr_uint ArrayStride 4
OpDecorate %_struct Block
Why a side-table?
As far as I can tell, Zig's type system has no "runtime array" type. The gpu.runtimeArray helper returns *addrspace(.storage_buffer) extern struct { data: [*]addrspace(.storage_buffer) T }, so the field is a multi-pointer ([*]T). The frontend appears to lowerbuf.*.data[i] = x to AIR roughly as:
struct_field_ptr to get a pointer to the data field
load to dereference and yield [*]T
ptr_elem_ptr to index that pointer and yield *T
store to write
On the SPIR-V side the field is now a runtime array rather than a pointer, so step 2 has nothing to load (no pointer is stored at offset 0; the array data starts there), and step 3 would emit OpPtrAccessChain on a multi-pointer when OpAccessChain into the runtime array is what's wanted.
I considered two ways to bridge this:
- Change Zig's type system to model runtime arrays directly. This would presumably touch the language, the language spec, and every backend. I am not in a position to take that on.
- Side-table in the SPIR-V backend that records "this SPIR-V id has SPIR-V type
*OpTypeRuntimeArray<T> even though the Zig side sees a *[*]T." Backend-local, scoped to SPIR-V.
This patch takes the second option. I'd appreciate a sanity check from someone who knows the backend better than I do; this may not be the right shape.
How the side-table works
CodeGen.runtime_array_ptrs: AutoHashMapUnmanaged(Id, void). Membership is intended to mean "this id has SPIR-V type *RuntimeArray<T> even though the Zig type system thinks otherwise."
Three call sites cooperate:
structFieldPtr: when the field's Zig type is a multi-pointer in storage_buffer or uniform addrspace, emit one OpAccessChain step into the parent struct (yielding *RuntimeArray<T>), register the id, and return it. The Zig caller still sees a *[*]T.
airLoad: if the source pointer is registered, the load is treated as a no-op. Return the same id and re-register it. The intent is to translate "load [*]T from *[*]T" into "the same id is now treated as [*]T, but its real SPIR-V type stayed *RuntimeArray<T>."
ptrElemPtr: if the input pointer is registered, emit one OpAccessChain step into the runtime array (yielding *T), in place of the OpPtrAccessChain that would otherwise be emitted.
The intended invariant is that an id ends up in the set iff it was produced by structFieldPtr on a buffer-class runtime-array field, possibly propagated through some number of no-op loads. I have not proven this; it's just what the three patches together seem to guarantee on the cases I tried.
Known limitations
As far as I've tested, the side-table only catches the direct access pattern buf.*.data[i]. Anywhere the SPIR-V id can flow without going through airLoad or ptrElemPtr, membership is lost:
- Assigning
const data = buf.*.data; and then indexing data[i] later works in the cases I tried, because the assignment lowers to airLoad, which is patched.
- Passing the
[*]T as a function parameter is not handled. The callee would receive a SPIR-V id of type *RuntimeArray<T> declared as *[*]T, and indexing inside the callee would emit OpPtrAccessChain on a non-pointer. I expect this to fail validation but have not confirmed.
- Taking the address of
buf.*.data is not handled.
- Storing the
[*]T value into a local variable of pointer type is not handled.
There may be other AIR ops that need to propagate side-table membership that I haven't run into yet. Pointers welcome.
Reference/Draft Implementation: ed4b959ddd
## What this enables
Kernels can now declare runtime-sized storage buffers in Zig:
```zig
const buf = gpu.runtimeArray(u32, 0, 0, "buf");
// ...
buf.*.data[i] = x;
```
The host picks the buffer size at dispatch time. Before this change, the buffer length had to be a compile-time constant (`extern struct { data: [N]u32 }`), so every input size needed its own `.spv`. Now one pipeline handles any size.
This unlocks push-constant-driven kernels that dispatch over arbitrary input.
## SPIR-V output
Before this change, the emitted struct contained a pointer field, which did not match how the host writes the buffer (raw `T[]`):
```
%_ptr_StorageBuffer_uint = OpTypePointer StorageBuffer %uint
%_struct = OpTypeStruct %_ptr_StorageBuffer_uint ; pointer field
```
After, the struct contains a runtime array, which appears to be what GLSL and HLSL emit for the same source pattern:
```
%_runtimearr_uint = OpTypeRuntimeArray %uint
%_struct = OpTypeStruct %_runtimearr_uint ; runtime array field
OpDecorate %_runtimearr_uint ArrayStride 4
OpDecorate %_struct Block
```
## Why a side-table?
As far as I can tell, Zig's type system has no "runtime array" type. The `gpu.runtimeArray` helper returns `*addrspace(.storage_buffer) extern struct { data: [*]addrspace(.storage_buffer) T }`, so the field is a multi-pointer (`[*]T`). The frontend appears to lower`buf.*.data[i] = x` to AIR roughly as:
1. `struct_field_ptr` to get a pointer to the `data` field
2. `load` to dereference and yield `[*]T`
3. `ptr_elem_ptr` to index that pointer and yield `*T`
4. `store` to write
On the SPIR-V side the field is now a runtime array rather than a pointer, so step 2 has nothing to load (no pointer is stored at offset 0; the array data starts there), and step 3 would emit `OpPtrAccessChain` on a multi-pointer when `OpAccessChain` into the runtime array is what's wanted.
I considered two ways to bridge this:
- **Change Zig's type system** to model runtime arrays directly. This would presumably touch the language, the language spec, and every backend. I am not in a position to take that on.
- **Side-table in the SPIR-V backend** that records "this SPIR-V id has SPIR-V type `*OpTypeRuntimeArray<T>` even though the Zig side sees a `*[*]T`." Backend-local, scoped to SPIR-V.
This patch takes the second option. I'd appreciate a sanity check from someone who knows the backend better than I do; this may not be the right shape.
## How the side-table works
`CodeGen.runtime_array_ptrs: AutoHashMapUnmanaged(Id, void)`. Membership is intended to mean "this id has SPIR-V type `*RuntimeArray<T>` even though the Zig type system thinks otherwise."
Three call sites cooperate:
- **`structFieldPtr`**: when the field's Zig type is a multi-pointer in `storage_buffer` or `uniform` addrspace, emit one `OpAccessChain` step into the parent struct (yielding `*RuntimeArray<T>`), register the id, and return it. The Zig caller still sees a `*[*]T`.
- **`airLoad`**: if the source pointer is registered, the load is treated as a no-op. Return the same id and re-register it. The intent is to translate "load `[*]T` from `*[*]T`" into "the same id is now treated as `[*]T`, but its real SPIR-V type stayed `*RuntimeArray<T>`."
- **`ptrElemPtr`**: if the input pointer is registered, emit one `OpAccessChain` step into the runtime array (yielding `*T`), in place of the `OpPtrAccessChain` that would otherwise be emitted.
The intended invariant is that an id ends up in the set iff it was produced by `structFieldPtr` on a buffer-class runtime-array field, possibly propagated through some number of no-op loads. I have not proven this; it's just what the three patches together seem to guarantee on the cases I tried.
## Known limitations
As far as I've tested, the side-table only catches the direct access pattern `buf.*.data[i]`. Anywhere the SPIR-V id can flow without going through `airLoad` or `ptrElemPtr`, membership is lost:
- Assigning `const data = buf.*.data;` and then indexing `data[i]` later works in the cases I tried, because the assignment lowers to `airLoad`, which is patched.
- Passing the `[*]T` as a function parameter is not handled. The callee would receive a SPIR-V id of type `*RuntimeArray<T>` declared as `*[*]T`, and indexing inside the callee would emit `OpPtrAccessChain` on a non-pointer. I expect this to fail validation but have not confirmed.
- Taking the address of `buf.*.data` is not handled.
- Storing the `[*]T` value into a local variable of pointer type is not handled.
There may be other AIR ops that need to propagate side-table membership that I haven't run into yet. Pointers welcome.
---
Reference/Draft Implementation: ed4b959ddd