← Back

Zero-cost GPU bounds elision with Lean 4 and Zig

WebGPU has to be conservative around runtime-sized buffers. If the compiler cannot prove an index is safe, it keeps clamp machinery in the shader. Doe asks a narrower question: when a proof covers the index family, can the runtime move that safety check to dispatch time and delete the per-access guard?

The split is simple: Lean proves the whole bounds family; Zig refuses to build unless the checked-out artifact still matches that proof. If the implementation can match a shader access to a proof-covered pattern in the artifact, the WebGPU implementation can apply a simpler rewrite. Doe is a concrete WebGPU implementation that shows that path end to end.

Lead image from the Substack version of Zero cost GPU bounds elision with Lean 4 and Zig
Lead image from the Substack version of this post.

The evidence is in the repo, not in a diagram. These are the exact files that carry the proof from theorem to build gate to shader rewrite:

What The Default Path Keeps Alive

Consider this shader:

@group(0) @binding(0) var<storage, read_write> data: array<u32>;

@compute @workgroup_size(8)
fn main(@builtin(global_invocation_id) gid: vec3u) {
    for (var i: u32 = 1u; i <= 4u; i = i + 2u) {
        data[gid.x * 2u + i * 3u + 1u] = 1u;
    }
}

In the default robust path, the compiler cannot assume that

gid.x * 2u + i * 3u + 1u

is in bounds for every legal dispatch. So it lowers the access through a runtime clamp:

if (config.elide_proven_bounds) {
    if (dispatch_proof_match.try_elide_storage_index(module, function, function_id, i, index_data)) |precondition| {
        module.dispatch_preconditions.append(module.allocator, precondition) catch return error.OutOfMemory;
        continue;
    }
}

try clamp_runtime_sized(allocator, module, function, i);

In Zig, the fallback builds the clamp path explicitly:

const al_args = try function.append_expr_args(allocator, &.{base_ref});
const array_length_id = try function.append_expr(allocator, .{
    .ty = u32_ty,
    .category = .value,
    .data = .{ .call = .{
        .name = try ir.dup_string(allocator, "arrayLength"),
        .kind = .builtin,
        .args = al_args,
    } },
});

const one_id = try function.append_expr(allocator, .{
    .ty = u32_ty,
    .category = .value,
    .data = .{ .int_lit = 1 },
});

const sub_id = try function.append_expr(allocator, .{
    .ty = u32_ty,
    .category = .value,
    .data = .{ .binary = .{
        .op = .sub,
        .lhs = array_length_id,
        .rhs = one_id,
    } },
});

const min_args = try function.append_expr_args(allocator, &.{ original_index, sub_id });
const min_id = try function.append_expr(allocator, .{
    .ty = u32_ty,
    .category = .value,
    .data = .{ .call = .{
        .name = try ir.dup_string(allocator, "min"),
        .kind = .builtin,
        .args = min_args,
    } },
});

That is the baseline. min(...) stays in the kernel. Runtime size plumbing stays alive with it.

What Lean Proves

Lean proves the affine family with one inequality. In ComputeBounds.lean, the affine loop case is stated for arbitrary naturals:

theorem gid_affine_plus_scaled_loop_index_inbounds_when_dispatch_fits
    (workgroup_id_x local_id_x workgroup_size_x num_workgroups_x array_length : Nat)
    (gid_stride limit i loop_stride offset : Nat)
    (h_gid_stride : 0 < gid_stride)
    (h_loop_stride : 0 < loop_stride)
    (h_i : i < limit)
    (h_wid : workgroup_id_x < num_workgroups_x)
    (h_lid : local_id_x < workgroup_size_x)
    (h_fit : workgroup_size_x * num_workgroups_x * gid_stride + limit * loop_stride + offset ≤ array_length) :
    globalInvocationId workgroup_id_x local_id_x workgroup_size_x * gid_stride + i * loop_stride + offset < array_length := by
  let gid := globalInvocationId workgroup_id_x local_id_x workgroup_size_x
  let total := workgroup_size_x * num_workgroups_x
  have h_gid_lt_total : gid < total := by
    dsimp [gid, total]
    exact gid_component_lt_total
      workgroup_id_x local_id_x workgroup_size_x num_workgroups_x
      (workgroup_size_x * num_workgroups_x)
      h_wid h_lid (Nat.le_refl _)
  have h_gid_scaled_lt :
      gid * gid_stride < total * gid_stride := by
    exact Nat.mul_lt_mul_of_pos_right h_gid_lt_total h_gid_stride
  have h_loop_scaled_lt :
      i * loop_stride < limit * loop_stride := by
    exact Nat.mul_lt_mul_of_pos_right h_i h_loop_stride
  have h_sum_lt :
      gid * gid_stride + i * loop_stride <
      total * gid_stride + limit * loop_stride := by
    exact Nat.add_lt_add h_gid_scaled_lt h_loop_scaled_lt
  have h_offset_lt :
      gid * gid_stride + i * loop_stride + offset <
      (total * gid_stride + limit * loop_stride) + offset := by
    exact Nat.add_lt_add_right h_sum_lt offset
  calc
    gid * gid_stride + i * loop_stride + offset <
        (total * gid_stride + limit * loop_stride) + offset := h_offset_lt
    _ ≤ array_length := h_fit

Zig comptime can check one concrete tuple, but not the whole quantified family. Lean proves the family. Zig makes it operational.

What Zig Matches

On the implementation side, the affine-loop matcher path is real code, not a schematic:

if (match_gid_component_loop_affine_plus_offset(function, index_data.index, .global_invocation_id)) |match| {
    if (loop_match.find_bounded_loop_limit(function, expr_id, match.local_idx)) |loop_limit| {
        return .{
            .kind = .gid_component,
            .gid_axis = match.axis,
            .storage_binding = binding,
            .element_multiplier = match.gid_multiplier,
            .loop_limit = loop_limit,
            .loop_limit_multiplier = match.loop_multiplier,
            .element_stride_bytes = element_stride_bytes,
            .element_offset = match.offset,
        };
    }
}

For the motivating shader, those matched fields resolve to axis 0, binding (0, 0), gid multiplier 2, loop limit 5, loop multiplier 3, 4-byte elements, and offset 1. The 5 is the exclusive upper bound after normalizing i <= 4 to < 5. Once that match succeeds, the shader access stays in its original form and the guard machinery disappears. There is no per-access min(...), no arrayLength(&data), and no _doe_sizes buffer kept alive just to answer a runtime query inside the kernel. The safety check moves to dispatch time instead. The byte-count path is also straight from the runtime:

.gid_component => blk: {
    const axis = precondition.gid_axis;
    if (axis >= dispatch_workgroups.len or axis >= workgroup_size.len) {
        return error.DispatchPreconditionFailed;
    }
    const total_invocations = try invocation_extent(dispatch_workgroups[axis], workgroup_size[axis]);
    const scaled_invocations = try std.math.mul(u64, total_invocations, precondition.element_multiplier);
    const loop_elements = try std.math.mul(u64, precondition.loop_limit, precondition.loop_limit_multiplier);
    const affine_elements = try std.math.add(u64, scaled_invocations, loop_elements);
    const total_elements = try std.math.add(u64, affine_elements, precondition.element_offset);
    break :blk try std.math.mul(u64, total_elements, precondition.element_stride_bytes);
},

The exchange is:

  1. GPU-side clamp removed
  2. size-buffer machinery removed with it
  3. one host-side bound check added before launch
Side-by-side flow diagram comparing the default robust path and the proof-backed path for bounds elision
The safety check moves from repeated in-kernel clamps to one proof-backed dispatch precondition.
Shader Size min(...) _doe_sizes needs_sizes_buf Dispatch preconditions Compile p50 Native Vulkan p50/dispatch GPU timestamp
affine_loop_storage 356 B -> 267 B 1 -> 0 true -> false true -> false 0 -> 1 77,997 -> 56,967 ns (-27.0%) 31,666.7 -> 28,135.4 ns (-11.2%) 515.9 -> 507.5 ns (-1.6%)
tiled_storage 322 B -> 233 B 1 -> 0 true -> false true -> false 0 -> 1 61,306 -> 46,959 ns (-23.4%) 28,000.6 -> 27,571.3 ns (-1.5%) 510.3 -> 502.3 ns (-1.6%)
flat_2d_storage 387 B -> 298 B 1 -> 0 true -> false true -> false 0 -> 1 69,471 -> 51,657 ns (-25.6%) 28,465.2 -> 28,248.2 ns (-0.8%) 547.6 -> 531.4 ns (-3.0%)

Compile p50 drops by about a quarter. Steady-state dispatch gets a smaller but measurable lift. GPU timestamps move only slightly.

Grouped bar chart showing compile, native dispatch, and GPU timestamp reductions across three proof-backed shader cases
The measured benefit is front-loaded into compile time, with smaller but still real steady-state wins once the shader no longer carries clamp and size-buffer machinery.

Why This Matters

Performance is a benefit, but the longer-term appeal is simpler invariants, fewer ad hoc guards, and a tighter proof-to-build contract. The proofs are not treated like a loose JSON hint. The extraction step writes a fixed schema with provenance and theorem inventory. The Zig build embeds the artifact and the expected hashes. lean_proof.zig recomputes the same provenance from the current checkout and rejects drift with @compileError. The artifact is trusted only when the local tree can re-derive it.

The point is not that Lean makes GPU code magically faster. The point is that a proof can become an enforceable build artifact, then a matcher, then a specific IR rewrite. The clamp disappears only when that chain still holds.

Reproduction note: Build with -Dlean-verified=false and -Dlean-verified=true. See proof_metrics_summary.json.