lib/choir/src/backends/gpu/cpu/test.zig

daab053ee43316e1809a84551d573ddd1e5bf3d2

  1 const std = @import("std");
  2 const abi = @import("choir_abi");
  3 const choir = @import("../../../root.zig");
  4 const fixture = @import("../fixture/root.zig");
  5 const registration = @import("../registration.zig");
  6 const cpu = @import("root.zig");
  7 
  8 const ir = choir.ir;
  9 const testing = std.testing;
 10 const countOperationsNamed = ir.inspection.countOperationsNamed;
 11 const Arith = choir.dialects.ArithDialect;
 12 const Gpu = choir.dialects.gpu.GpuDialect;
 13 const Memref = choir.dialects.MemrefDialect;
 14 const Scf = choir.dialects.ScfDialect;
 15 
 16 test "cpu declaration coverage" {
 17     std.testing.refAllDecls(cpu.lowering);
 18     std.testing.refAllDecls(cpu);
 19 }
 20 
 21 /// A parsed kernel module and the host loop lowered from it, both owned by
 22 /// `ctx`.
 23 const Lowered = struct {
 24     ctx: ir.Context,
 25     kernel: *ir.Operation,
 26     host: *ir.Operation,
 27     text: []u8,
 28 
 29     fn init(
 30         self: *Lowered,
 31         source: []const u8,
 32         options: cpu.LowerOptions,
 33     ) !void {
 34         self.ctx = try ir.Context.init(testing.allocator, ir.Context.Limits.testing);
 35         errdefer self.ctx.deinit(testing.allocator);
 36         try registration.prepareCompilationDialects(&self.ctx);
 37         self.kernel = try fixture.parse(&self.ctx, source);
 38         errdefer self.kernel.erase();
 39         self.host = try cpu.lowerKernelToHostLoop(testing.allocator, self.kernel, options);
 40         errdefer self.host.erase();
 41         try ir.verifyOperation(self.host, ir.verify.default_options);
 42         self.text = try ir.dump.operationAlloc(testing.allocator, self.host);
 43     }
 44 
 45     fn deinit(self: *Lowered) void {
 46         testing.allocator.free(self.text);
 47         self.host.erase();
 48         self.kernel.erase();
 49         self.ctx.deinit(testing.allocator);
 50     }
 51 
 52     fn function(self: *const Lowered) choir.dialects.FuncDialect.FuncOp {
 53         const body = ir.inspection.moduleBodyBlock(self.host).?;
 54         var operations = body.getOperations();
 55         const op = operations.next().?;
 56         std.debug.assert(ir.inspection.isFunctionDefinition(op));
 57         return .{ .op = op };
 58     }
 59 
 60     fn count(self: *const Lowered, comptime Op: type) usize {
 61         return countOperationsNamed(self.host, Op.operation_name);
 62     }
 63 
 64     /// Reports whether one printed line holds both `operation` and `type_name`.
 65     fn hasTypedLine(self: *const Lowered, operation: []const u8, type_name: []const u8) bool {
 66         var lines = std.mem.splitScalar(u8, self.text, '\n');
 67         while (lines.next()) |line| {
 68             const has_operation = std.mem.indexOf(u8, line, operation) != null;
 69             if (has_operation and std.mem.indexOf(u8, line, type_name) != null) return true;
 70         }
 71         return false;
 72     }
 73 };
 74 
 75 test "host loop lowering wraps a parsed global x kernel in an scf loop" {
 76     var lowered: Lowered = undefined;
 77     try lowered.init(@embedFile("fixture/copy.txt"), .{ .entry_name = "test_copy" });
 78     defer lowered.deinit();
 79     const host = lowered.function();
 80     try testing.expect(!host.isKernel());
 81     try testing.expectEqual(@as(usize, 9), host.getNumArguments());
 82     try testing.expect(lowered.count(Scf.ForOp) > 0);
 83     try testing.expectEqual(@as(usize, 0), lowered.count(Gpu.GlobalIdxOp));
 84     try testing.expect(lowered.count(Arith.RemOp) > 0);
 85     try testing.expect(lowered.count(Memref.LoadOp) > 0);
 86     try testing.expect(lowered.count(Memref.StoreOp) > 0);
 87 }
 88 
 89 test "host loop lowering vectorizes a parsed straight line add" {
 90     var lowered: Lowered = undefined;
 91     try lowered.init(@embedFile("fixture/vector.txt"), .{
 92         .entry_name = "test_vector",
 93         .vector_width = 4,
 94     });
 95     defer lowered.deinit();
 96     try testing.expectEqual(@as(usize, 0), lowered.count(Gpu.GlobalIdxOp));
 97     try testing.expect(lowered.hasTypedLine("memref.load(", "!arith.vec4xf32"));
 98     try testing.expect(lowered.hasTypedLine("arith.add(", "!arith.vec4xf32"));
 99 }
100 
101 test "host loop lowering splats a parsed scalar argument and keeps a scalar tail" {
102     var lowered: Lowered = undefined;
103     try lowered.init(@embedFile("fixture/splat.txt"), .{
104         .entry_name = "test_splat",
105         .vector_width = 4,
106     });
107     defer lowered.deinit();
108     try testing.expectEqual(@as(usize, 2), lowered.count(Scf.ForOp));
109     try testing.expect(lowered.hasTypedLine("arith.splat(", "!arith.vec4xf32"));
110     try testing.expect(lowered.hasTypedLine("arith.add(", "!arith.vec4xf32"));
111 }
112 
113 test "host loop lowering derives parsed global y and z from the flat launch shape" {
114     var lowered: Lowered = undefined;
115     try lowered.init(@embedFile("fixture/axes.txt"), .{ .entry_name = "test_axes" });
116     defer lowered.deinit();
117     try testing.expectEqual(@as(usize, 7), lowered.function().getNumArguments());
118     try testing.expectEqual(@as(usize, 0), lowered.count(Gpu.GlobalIdxOp));
119     try testing.expect(lowered.count(Arith.DivOp) > 0);
120     try testing.expect(lowered.count(Arith.RemOp) > 0);
121     try testing.expect(lowered.count(Arith.MulOp) > 0);
122 }
123 
124 test "host loop lowering derives parsed thread, block and launch dimensions" {
125     var lowered: Lowered = undefined;
126     try lowered.init(@embedFile("fixture/dims.txt"), .{ .entry_name = "test_dims" });
127     defer lowered.deinit();
128     try testing.expectEqual(@as(usize, 7), lowered.function().getNumArguments());
129     inline for (.{ Gpu.ThreadIdxOp, Gpu.BlockIdxOp, Gpu.BlockDimOp, Gpu.GridDimOp }) |Op| {
130         try testing.expectEqual(@as(usize, 0), lowered.count(Op));
131     }
132     try testing.expect(lowered.count(Arith.DivOp) > 0);
133     try testing.expect(lowered.count(Arith.RemOp) > 0);
134 }
135 
136 test "host loop lowering rejects a parsed lane id" {
137     var ctx = try ir.Context.init(testing.allocator, ir.Context.Limits.testing);
138     defer ctx.deinit(testing.allocator);
139     try registration.prepareCompilationDialects(&ctx);
140     const kernel = try fixture.parse(&ctx, @embedFile("fixture/lane.txt"));
141     defer kernel.erase();
142     try testing.expectError(
143         error.UnsupportedOperation,
144         cpu.lowerKernelToHostLoop(testing.allocator, kernel, .{ .entry_name = "test_lane" }),
145     );
146 }
147 
148 /// A stage module and the host functions lowered from it, both owned by `session`.
149 const LoweredStages = struct {
150     session: fixture.stages.Session,
151     host: *ir.Operation,
152 
153     fn init(self: *LoweredStages, source: []const u8) !void {
154         try self.session.init(testing.allocator, .testing, source, false);
155         errdefer self.session.deinit(testing.allocator);
156         self.host = try cpu.lowerStagesToHost(testing.allocator, self.session.module, null);
157     }
158 
159     fn deinit(self: *LoweredStages) void {
160         self.host.erase();
161         self.session.deinit(testing.allocator);
162     }
163 };
164 
165 test "stage lowering turns each stage into a host function over the stage buffers" {
166     var lowered: LoweredStages = undefined;
167     try lowered.init(fixture.stages.cases[0].source);
168     defer lowered.deinit();
169     for ([_][]const u8{ "textured_vertex", "textured_fragment" }) |name| {
170         const function = choir.dialects.FuncDialect.FuncOp{ .op = ir.inspection.functionDefinitionByName(lowered.host, name).? };
171         try testing.expectEqual(@as(usize, 3), function.getNumArguments());
172     }
173     try testing.expectEqual(@as(usize, 0), countOperationsNamed(lowered.host, Gpu.StageInputOp.operation_name));
174     try testing.expectEqual(@as(usize, 0), countOperationsNamed(lowered.host, Gpu.SampleOp.operation_name));
175     try testing.expectEqual(@as(usize, 1), countOperationsNamed(lowered.host, choir.dialects.FuncDialect.CallOp.operation_name));
176 }
177 
178 test "stage local array survives CPU lowering and runs from host stack" {
179     if (comptime !@hasDecl(choir.backends.x86_64, "backend")) return error.SkipZigTest;
180     var lowered: LoweredStages = undefined;
181     try lowered.init(fixture.stages.alloca_case.source);
182     defer lowered.deinit();
183     try testing.expectEqual(@as(usize, 2), countOperationsNamed(lowered.host, Memref.AllocaOp.operation_name));
184 
185     var backend = try choir.backends.x86_64.backend.Backend.init(testing.allocator, &lowered.session.ctx, .testing);
186     defer backend.deinit();
187     const handle = try backend.compile(lowered.host);
188     const Stage = *const fn ([*]const f32, [*]f32, *anyopaque) callconv(.c) void;
189     const fragment: Stage = @ptrFromInt(try backend.runtime.functionAddress(handle, "alloca_fragment"));
190     var inputs: [abi.stage.io_words]f32 = @splat(0);
191     var outputs: [abi.stage.io_words]f32 = @splat(0);
192     var context: [abi.stage.context_words]f32 = @splat(0);
193     const color = [_]f32{ 0.125, 0.375, 0.625, 0.875 };
194     inputs[abi.stage.slot(abi.stage.smooth, 0, 0)..][0..4].* = color;
195     fragment(&inputs, &outputs, &context);
196     try testing.expectEqualSlices(f32, &color, outputs[abi.stage.slot(abi.stage.smooth, 0, 0)..][0..4]);
197 }
198 
199 test "stage calls retain shared and nested helpers in the CPU twin" {
200     if (comptime !@hasDecl(choir.backends.x86_64, "backend")) return error.SkipZigTest;
201     var lowered: LoweredStages = undefined;
202     try lowered.init(fixture.stages.calls_case.source);
203     defer lowered.deinit();
204     try testing.expectEqual(@as(usize, 4), countOperationsNamed(lowered.host, choir.dialects.FuncDialect.FuncOp.operation_name));
205     try testing.expectEqual(@as(usize, 4), countOperationsNamed(lowered.host, choir.dialects.FuncDialect.CallOp.operation_name));
206     try testing.expectEqual(@as(usize, 1), countOperationsNamed(lowered.host, Memref.AllocaOp.operation_name));
207 
208     var backend = try choir.backends.x86_64.backend.Backend.init(testing.allocator, &lowered.session.ctx, .testing);
209     defer backend.deinit();
210     const handle = try backend.compile(lowered.host);
211     const Stage = *const fn ([*]const f32, [*]f32, *anyopaque) callconv(.c) void;
212     const fragment: Stage = @ptrFromInt(try backend.runtime.functionAddress(handle, "calls_fragment"));
213     var inputs: [abi.stage.io_words]f32 = @splat(0);
214     var outputs: [abi.stage.io_words]f32 = @splat(0);
215     var context: [abi.stage.context_words]f32 = @splat(0);
216     inputs[abi.stage.slot(abi.stage.smooth, 0, 0)..][0..4].* = .{ 0, 1, 0, 1 };
217     fragment(&inputs, &outputs, &context);
218     try testing.expectEqualSlices(f32, &.{ 1, 0, 1, 1 }, outputs[abi.stage.slot(abi.stage.smooth, 0, 0)..][0..4]);
219 }
220 
221 test "stage CPU lowering refuses recursive calls by name" {
222     const source = try std.mem.replaceOwned(u8, testing.allocator, fixture.stages.calls_case.source, "%9 = func.call(%8) {callee = @invert}", "%9 = func.call(%8) {callee = @nested_invert}");
223     defer testing.allocator.free(source);
224     var session: fixture.stages.Session = undefined;
225     try session.init(testing.allocator, .testing, source, false);
226     defer session.deinit(testing.allocator);
227     var refusal: cpu.StageRefusal = .{};
228     try testing.expectError(error.UnsupportedOperation, cpu.lowerStagesToHost(testing.allocator, session.module, &refusal));
229     try testing.expectEqualStrings(choir.dialects.FuncDialect.CallOp.operation_name, refusal.operation);
230 }
231 
232 test "stage CPU lowering refuses dynamic local array by name" {
233     const source = try std.mem.replaceOwned(
234         u8,
235         testing.allocator,
236         fixture.stages.alloca_case.source,
237         "%16 = memref.alloca() : !memref<4,arith.f32,local>",
238         "%16 = memref.alloca(%12) : !memref<?,arith.f32,local>",
239     );
240     defer testing.allocator.free(source);
241     var session: fixture.stages.Session = undefined;
242     try session.init(testing.allocator, .testing, source, false);
243     defer session.deinit(testing.allocator);
244     var refusal: cpu.StageRefusal = .{};
245     try testing.expectError(error.UnsupportedStageMemory, cpu.lowerStagesToHost(testing.allocator, session.module, &refusal));
246     try testing.expectEqualStrings(Memref.AllocaOp.operation_name, refusal.operation);
247 }
248 
249 test "a fragment that takes derivatives lowers to a quad function sharing each partner slice" {
250     var lowered: LoweredStages = undefined;
251     try lowered.init(fixture.stages.gradient_case.source);
252     defer lowered.deinit();
253     try testing.expect(ir.inspection.functionDefinitionByName(lowered.host, "gradient_fragment") == null);
254     const quad_op = ir.inspection.functionDefinitionByName(lowered.host, "gradient_fragment" ++ abi.stage.quad_suffix).?;
255     try testing.expectEqual(@as(usize, 4), (choir.dialects.FuncDialect.FuncOp{ .op = quad_op }).getNumArguments());
256     inline for (.{ Gpu.DpdxOp, Gpu.DpdyOp, Gpu.FwidthOp }) |Op| {
257         try testing.expectEqual(@as(usize, 0), countOperationsNamed(lowered.host, Op.operation_name));
258     }
259     try testing.expectEqual(@as(usize, 3 * 5), countOperationsNamed(quad_op, Memref.LoadOp.operation_name));
260 }
261 
262 test "a derivative of a helper's result calls the shared helper again at each partner" {
263     var lowered: LoweredStages = undefined;
264     try lowered.init(fixture.stages.gradient_case.source);
265     defer lowered.deinit();
266     const quad_op = ir.inspection.functionDefinitionByName(lowered.host, "gradient_call_fragment" ++ abi.stage.quad_suffix).?;
267     try testing.expectEqual(@as(usize, 3 * 2), countOperationsNamed(quad_op, choir.dialects.FuncDialect.CallOp.operation_name));
268     for ([_][]const u8{ "gradient_scaled", "gradient_twice" }) |name| {
269         try testing.expect(ir.inspection.functionDefinitionByName(lowered.host, name) != null);
270     }
271     try testing.expectEqual(@as(usize, 5), countOperationsNamed(lowered.host, choir.dialects.FuncDialect.FuncOp.operation_name));
272 }
273 
274 test "quad derivatives run through the x86_64 JIT as the odd lane minus the even lane" {
275     if (comptime !@hasDecl(choir.backends.x86_64, "backend")) return error.SkipZigTest;
276     const layout = abi.stage;
277     var lowered: LoweredStages = undefined;
278     try lowered.init(fixture.stages.gradient_case.source);
279     defer lowered.deinit();
280     var backend = try choir.backends.x86_64.backend.Backend.init(testing.allocator, &lowered.session.ctx, .testing);
281     defer backend.deinit();
282     const handle = try backend.compile(lowered.host);
283     const Quad = *const fn ([*]const f32, usize, [*]f32, *anyopaque) callconv(.c) void;
284     const fragment: Quad = @ptrFromInt(try backend.runtime.functionAddress(handle, "gradient_fragment" ++ layout.quad_suffix));
285     var context: [layout.context_words]f32 = @splat(0);
286     var quad: [layout.quad_lanes][layout.io_words]f32 = @splat(@splat(0));
287     var q: [layout.quad_lanes]f32 = undefined;
288     const u = [layout.quad_lanes]f32{ 0.25, 1, 0.5, 3 };
289     for (&quad, &q, u, 0..) |*block, *product, value, lane| {
290         const x: f32 = @floatFromInt(2 + (lane & 1));
291         const y: f32 = @floatFromInt(4 + (lane >> 1));
292         block[layout.frag_coord..][0..4].* = .{ x + 0.5, y + 0.5, 0, 1 };
293         block[layout.slot(layout.smooth, 0, 0)] = value;
294         product.* = (x + 0.5) * (y + 0.5) * 0.015625;
295     }
296     const call: Quad = @ptrFromInt(try backend.runtime.functionAddress(handle, "gradient_call_fragment" ++ layout.quad_suffix));
297     var outputs: [layout.io_words]f32 = @splat(0);
298     for (0..layout.quad_lanes) |lane| {
299         const row = lane & 2;
300         const column = lane & 1;
301         fragment(@ptrCast(&quad), lane, &outputs, &context);
302         const dx = u[row | 1] - u[row];
303         const dy = u[2 | column] - u[column];
304         const expected = [4]f32{ q[row | 1] - q[row], q[2 | column] - q[column], dx, @abs(dx) + @abs(dy) };
305         try testing.expectEqualSlices(f32, &expected, outputs[layout.slot(layout.smooth, 0, 0)..][0..4]);
306         call(@ptrCast(&quad), lane, &outputs, &context);
307         const twice_dx = (u[row | 1] + u[row | 1]) - (u[row] + u[row]);
308         const twice_dy = (u[2 | column] + u[2 | column]) - (u[column] + u[column]);
309         const through_helpers = [4]f32{ expected[0], expected[1], twice_dx, @abs(twice_dx) + @abs(twice_dy) };
310         try testing.expectEqualSlices(f32, &through_helpers, outputs[layout.slot(layout.smooth, 0, 0)..][0..4]);
311     }
312     for (&quad) |*block| block[layout.slot(layout.smooth, 0, 0)] = 0.5;
313     for (0..layout.quad_lanes) |lane| {
314         fragment(@ptrCast(&quad), lane, &outputs, &context);
315         try testing.expectEqual(@as(u32, 0), @as(u32, @bitCast(outputs[layout.slot(layout.smooth, 0, 2)])));
316     }
317 }
318 
319 test "quad lowering refuses by name what a partner cannot recompute" {
320     const gradient = fixture.stages.gradient_case.source;
321     const needle = "        %18 = gpu.dpdx(%8) : !arith.f32\n";
322     const Case = struct { replacement: []const u8, refused: []const u8 };
323     const cases = [_]Case{
324         .{
325             .replacement = "        %18 = gpu.dpdx(%16) : !arith.f32\n",
326             .refused = Gpu.DpdxOp.operation_name,
327         },
328         .{
329             .replacement =
330             \\        %50 = arith.constant() properties(0:i64) : !arith.index
331             \\        %51 = memref.alloca() : !memref<1,arith.f32,local>
332             \\        memref.store(%8, %51, %50)
333             \\        %52 = memref.load(%51, %50) : !arith.f32
334             \\        %18 = gpu.dpdx(%52) : !arith.f32
335             \\
336             ,
337             .refused = Memref.AllocaOp.operation_name,
338         },
339         .{
340             .replacement =
341             \\        %18 = gpu.dpdx(%8) : !arith.f32
342             \\        %50 = gpu.front_facing() : !arith.bool
343             \\        scf.if(%50) {
344             \\          ^bb0:
345             \\            %51 = gpu.dpdy(%8) : !arith.f32
346             \\            gpu.stage_output(%51) {location = 1:i64}
347             \\            scf.yield()
348             \\        } {
349             \\          ^bb0:
350             \\            scf.yield()
351             \\        }
352             \\
353             ,
354             .refused = Gpu.DpdyOp.operation_name,
355         },
356     };
357     try testing.expectEqual(@as(usize, 1), std.mem.count(u8, gradient, needle));
358     for (cases) |case| {
359         const source = try std.mem.replaceOwned(u8, testing.allocator, gradient, needle, case.replacement);
360         defer testing.allocator.free(source);
361         var session: fixture.stages.Session = undefined;
362         try session.init(testing.allocator, .testing, source, false);
363         defer session.deinit(testing.allocator);
364         var refusal: cpu.StageRefusal = .{};
365         try testing.expectError(error.UnsupportedOperation, cpu.lowerStagesToHost(testing.allocator, session.module, &refusal));
366         try testing.expectEqualStrings(case.refused, refusal.operation);
367     }
368 }
369 
370 /// The texel `sampleStub` returns: the coordinates it was given, then the key and the level.
371 fn sampleStub(context: *anyopaque, key: i32, outputs: [*]f32) callconv(.c) void {
372     const calls: *u32 = @ptrCast(@alignCast(context));
373     calls.* += 1;
374     const layout = abi.stage;
375     const u = outputs[layout.sample];
376     const v = outputs[layout.sample + 1];
377     const lod = outputs[layout.sample + 2];
378     outputs[layout.sample..][0..4].* = .{ u, v, @floatFromInt(key), lod };
379 }
380 
381 test "lowered textured stages run through the x86_64 JIT" {
382     if (comptime !@hasDecl(choir.backends.x86_64, "backend")) return error.SkipZigTest;
383     const layout = abi.stage;
384     var lowered: LoweredStages = undefined;
385     try lowered.init(fixture.stages.cases[0].source);
386     defer lowered.deinit();
387     var backend = try choir.backends.x86_64.backend.Backend.init(testing.allocator, &lowered.session.ctx, .testing);
388     defer backend.deinit();
389     try backend.runtime.registerExternalSymbol(layout.sample_symbol, @intFromPtr(&sampleStub));
390     const handle = try backend.compile(lowered.host);
391     const Stage = *const fn ([*]const f32, [*]f32, *anyopaque) callconv(.c) void;
392     const vertex: Stage = @ptrFromInt(try backend.runtime.functionAddress(handle, "textured_vertex"));
393     const fragment: Stage = @ptrFromInt(try backend.runtime.functionAddress(handle, "textured_fragment"));
394 
395     var calls: u32 = 0;
396     var inputs: [layout.io_words]f32 = @splat(0);
397     var outputs: [layout.io_words]f32 = @splat(0);
398     inputs[layout.slot(layout.smooth, 0, 0)..][0..3].* = .{ 0.25, -0.5, 0.75 };
399     inputs[layout.slot(layout.smooth, 1, 0)..][0..2].* = .{ 0.125, 0.875 };
400     inputs[layout.slot(layout.smooth, 2, 0)..][0..4].* = .{ 1, 0.5, 0.25, 0.75 };
401     vertex(&inputs, &outputs, &calls);
402     try testing.expectEqualSlices(f32, &.{ 0.25, -0.5, 0.75, 1 }, outputs[layout.position..][0..4]);
403     try testing.expectEqualSlices(f32, &.{ 0.125, 0.875 }, outputs[layout.slot(layout.smooth, 0, 0)..][0..2]);
404     try testing.expectEqualSlices(f32, &.{ 1, 0.5, 0.25, 0.75 }, outputs[layout.slot(layout.smooth, 1, 0)..][0..4]);
405     try testing.expectEqual(@as(u32, 0xf3), @as(u32, @bitCast(outputs[layout.smooth_mask_low])));
406     try testing.expectEqual(@as(u32, 0), @as(u32, @bitCast(outputs[layout.flat_mask_low])));
407 
408     var fragment_inputs: [layout.io_words]f32 = @splat(0);
409     var fragment_outputs: [layout.io_words]f32 = @splat(0);
410     fragment_inputs[layout.slot(layout.smooth, 0, 0)..][0..2].* = .{ 0.5, 0.25 };
411     fragment_inputs[layout.slot(layout.smooth, 1, 0)..][0..4].* = .{ 2, 4, 0.5, 1 };
412     fragment(&fragment_inputs, &fragment_outputs, &calls);
413     try testing.expectEqual(@as(u32, 1), calls);
414     try testing.expectEqualSlices(f32, &.{ 1, 1, 0, 0 }, fragment_outputs[layout.slot(layout.smooth, 0, 0)..][0..4]);
415 }
416 
417 test "lowered block reads run through the x86_64 JIT over the context words" {
418     if (comptime !@hasDecl(choir.backends.x86_64, "backend")) return error.SkipZigTest;
419     const layout = abi.stage;
420     var lowered: LoweredStages = undefined;
421     try lowered.init(fixture.stages.cases[2].source);
422     defer lowered.deinit();
423     var backend = try choir.backends.x86_64.backend.Backend.init(testing.allocator, &lowered.session.ctx, .testing);
424     defer backend.deinit();
425     try backend.runtime.registerExternalSymbol(layout.sample_symbol, @intFromPtr(&sampleStub));
426     const handle = try backend.compile(lowered.host);
427     const Stage = *const fn ([*]const f32, [*]f32, *anyopaque) callconv(.c) void;
428     const vertex: Stage = @ptrFromInt(try backend.runtime.functionAddress(handle, "pushed_vertex"));
429     const fragment: Stage = @ptrFromInt(try backend.runtime.functionAddress(handle, "pushed_fragment"));
430 
431     var context: [layout.context_words]f32 = @splat(0);
432     context[layout.push..][0..4].* = .{ 0.5, -0.25, 2, 0.5 };
433     const uniform = layout.uniformWord(1, 0);
434     context[uniform..][0..8].* = .{ 1, 0.5, 0.25, 0.5, 0.5, 1, 2, 1 };
435     var inputs: [layout.io_words]f32 = @splat(0);
436     var outputs: [layout.io_words]f32 = @splat(0);
437     inputs[layout.slot(layout.smooth, 0, 0)..][0..3].* = .{ 0.25, 0.5, 0.75 };
438     inputs[layout.slot(layout.smooth, 2, 0)..][0..4].* = .{ 1, 1, 1, 1 };
439     vertex(&inputs, &outputs, &context);
440     try testing.expectEqualSlices(f32, &.{ 1, 0.75, 0.75, 1 }, outputs[layout.position..][0..4]);
441 
442     var fragment_inputs: [layout.io_words]f32 = @splat(0);
443     var fragment_outputs: [layout.io_words]f32 = @splat(0);
444     fragment_inputs[layout.slot(layout.smooth, 0, 0)..][0..4].* = .{ 1, 0.5, 1, 0.5 };
445     fragment(&fragment_inputs, &fragment_outputs, &context);
446     const color = fragment_outputs[layout.slot(layout.smooth, 0, 0)..][0..4];
447     try testing.expectEqualSlices(f32, &.{ 0.25, 0.125, 0.25, 0.25 }, color);
448 }
449 
450 test "stage lowering refuses block reads its context cannot place" {
451     const pushed = fixture.stages.cases[2];
452     const needle = "{binding = 1:i64, group = 0:i64, offset = 12:i64}";
453     const refusals = [_][]const u8{
454         "{binding = 1:i64, group = 1:i64, offset = 12:i64}",
455         "{binding = 8:i64, group = 0:i64, offset = 12:i64}",
456         "{binding = 1:i64, group = 0:i64, offset = 1024:i64}",
457         "{binding = 1:i64, group = 0:i64, offset = 10:i64}",
458     };
459     try testing.expectEqual(@as(usize, 1), std.mem.count(u8, pushed.source, needle));
460     for (refusals) |replacement| {
461         const source = try std.mem.replaceOwned(u8, testing.allocator, pushed.source, needle, replacement);
462         defer testing.allocator.free(source);
463         var session: fixture.stages.Session = undefined;
464         try session.init(testing.allocator, .testing, source, false);
465         defer session.deinit(testing.allocator);
466         var refusal: cpu.StageRefusal = .{};
467         const lowered = cpu.lowerStagesToHost(testing.allocator, session.module, &refusal);
468         try testing.expectError(error.UnsupportedOperation, lowered);
469         try testing.expectEqualStrings(Gpu.UniformOp.operation_name, refusal.operation);
470     }
471 }