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 }