From b7333f96d08a81ba9035a643efd6ce549454e9f3 Mon Sep 17 00:00:00 2001 From: Tristan Ross Date: Tue, 6 Oct 2026 08:59:58 -0700 Subject: [PATCH 1/7] feat(glacier): assemble the mandelbrot kernel against glacier's word stream --- build.zig | 15 + libs/vulcan-target.zig | 1 + libs/vulcan-target/glacier.zig | 19 ++ libs/vulcan-target/glacier/encode.zig | 97 ++++++ .../glacier/tests/mandelbrot.zig | 292 ++++++++++++++++++ 5 files changed, 424 insertions(+) create mode 100644 libs/vulcan-target/glacier.zig create mode 100644 libs/vulcan-target/glacier/encode.zig create mode 100644 libs/vulcan-target/glacier/tests/mandelbrot.zig diff --git a/build.zig b/build.zig index 14ee37b..fe2f8bc 100644 --- a/build.zig +++ b/build.zig @@ -717,6 +717,21 @@ pub fn build(b: *std.Build) void { }) }); test_step.dependOn(&b.addRunArtifact(nvidia_addressing).step); + // The Glacier GPU target. A Glacier core's instruction words ARE RISC-V words, so the only + // thing this proves about encoding is that the shared riscv64 encoders emit them: the kernel + // below is diffed against the word stream Glacier's own assembler produces for a program that + // runs on an ECP5 FPGA today. Codegen from IR is checked against the same oracle as it lands. + const glacier_mandelbrot = b.addTest(.{ .root_module = b.createModule(.{ + .root_source_file = b.path("libs/vulcan-target/glacier/tests/mandelbrot.zig"), + .target = target, + .optimize = optimize, + .imports = &.{ + .{ .name = "vulcan-ir", .module = vulcan_ir }, + .{ .name = "vulcan-target", .module = vulcan_target }, + }, + }) }); + test_step.dependOn(&b.addRunArtifact(glacier_mandelbrot).step); + // Scalar-expanded matmul over deliberately odd global pointers. The expansion is test-owned: // NVIDIA production codegen receives the ordinary scalar IR, then real hardware proves its // bytewise memory lowering preserves the exact footprint and fp32 bits. diff --git a/libs/vulcan-target.zig b/libs/vulcan-target.zig index 52aa525..376fd15 100644 --- a/libs/vulcan-target.zig +++ b/libs/vulcan-target.zig @@ -9,6 +9,7 @@ pub const aarch64 = @import("vulcan-target/aarch64.zig"); pub const x86 = @import("vulcan-target/x86.zig"); pub const x86_64 = @import("vulcan-target/x86_64.zig"); pub const nvidia = @import("vulcan-target/nvidia.zig"); +pub const glacier = @import("vulcan-target/glacier.zig"); pub const wasm = @import("vulcan-target/wasm.zig"); /// Portable C99 source backend: lowers an IR function to equivalent C. diff --git a/libs/vulcan-target/glacier.zig b/libs/vulcan-target/glacier.zig new file mode 100644 index 0000000..ebe052c --- /dev/null +++ b/libs/vulcan-target/glacier.zig @@ -0,0 +1,19 @@ +//! The Glacier GPU target: the LilithSemi GPU core generation inside the Chenega platform. +//! +//! A Glacier core is a device, not a CPU. It comes out of reset HALTED and runs only when a host +//! writes its CSR window over a transport, so generated code is a kernel launched into an +//! instruction aperture rather than a function something calls. +//! +//! Its instruction words ARE RISC-V words, RV32I plus RV32M, the A extension atomics, a scalar FP +//! divide and an RVV v1.0 subset, so this target reuses `riscv64/encode.zig` for encoding and adds +//! only what is Glacier's own. See `encode.zig` for the CSRs, the core profiles, and the list of +//! what the core deliberately does NOT decode, which shapes code generation more than what it does. +//! +//! The execution model is a barrel/IMT core: resident warps issue round-robin, one instruction per +//! cycle across the core, and a warp parks on a long operation while another issues, so latency is +//! hidden by other warps rather than by scheduling inside one. Divergence is by RVV masks under v0 +//! and not by a hardware reconvergence stack, so control flow lowers to mask arithmetic. Warp +//! identity comes from `mhartid` and the resident count from a custom CSR, which is the closest +//! thing to a workgroup id the hardware has. + +pub const encode = @import("glacier/encode.zig"); diff --git a/libs/vulcan-target/glacier/encode.zig b/libs/vulcan-target/glacier/encode.zig new file mode 100644 index 0000000..75e46aa --- /dev/null +++ b/libs/vulcan-target/glacier/encode.zig @@ -0,0 +1,97 @@ +//! Glacier instruction encoding. +//! +//! A Glacier core decodes RV32I plus RV32M, the A extension atomics, a scalar FP divide, and an +//! RVV v1.0 subset, so the instruction words ARE RISC-V words and `riscv64/encode.zig` already +//! emits them. This file does not repeat those encoders; it re-exports them as `rv` and adds only +//! what is specific to Glacier: the CSRs a kernel reads to find out which warp it is, the core +//! profiles that fix the vector length, and `ebreak`, which retires a warp. +//! +//! What a Glacier core deliberately does NOT decode shapes code generation more than what it +//! does, so it is recorded here rather than left to be rediscovered: +//! +//! - No `vsetvl`. The vector length is fixed by the core profile and is not a runtime register, +//! so the lane count is a compile-time target parameter. See `Profile`. +//! - No LR/SC. The A extension atomics are there, the load-reserved pair is not. +//! - No FP square root, and FP32 truncates toward zero with subnormals flushed rather than +//! rounding to nearest. Anything that assumes IEEE default rounding differs in the last bit. +//! - No narrow stores. The load/store unit drives SEL=0xF on every store, so a store is always +//! a full 32-bit word and there is no SH encoding at all. Sub-word writes have to be packed +//! in registers before the store. + +const std = @import("std"); + +/// The RISC-V encoders, unchanged. A Glacier word is a RISC-V word. +pub const rv = @import("../riscv64/encode.zig"); + +pub const Reg = rv.Reg; + +/// EBREAK. Retires the warp: a Glacier kernel ends with this rather than returning, because a +/// warp has no caller to return to. +pub const ebreak: u32 = 0x00100073; + +/// The CSRs a Glacier core decodes. Reading one is the only CSR access it implements, and it is +/// how a kernel asks the hardware which warp is running it. +pub const Csr = struct { + /// Standard machine-information CSR: the id of the warp doing the read, unique across the + /// whole SoC. A warp owns a PC and a register file, so it is what a hart id identifies here, + /// and a second core continues the numbering rather than repeating it. + pub const mhartid: u12 = 0xF14; + + /// Custom read-only CSR: how many harts the whole SoC runs, across every core. A kernel + /// strides its work by this and offsets it by `mhartid`, so one entry point fills whatever + /// core it is launched on without being told the shape by its host. + pub const hart_count: u12 = 0xFC0; +}; + +/// Read a CSR into `rd`. CSRRS with x0 as the set mask, so it reads without writing. +pub fn csrr(rd: Reg, csr: u12) u32 { + return rv.csrrs(rd, csr, .x0); +} + +/// A GC1 core profile. The warp width IS the vector length and both are baked into the core, so +/// a kernel is built against a profile rather than discovering the shape at run time. +pub const Profile = enum { + @"gc1.n", + @"gc1.mi", + @"gc1.s", + @"gc1.f", + @"gc1.ma", + + /// Thread lanes per warp, which is also the vector length in 32-bit elements. + pub fn lanes(self: Profile) u8 { + return switch (self) { + .@"gc1.n", .@"gc1.mi" => 4, + .@"gc1.s" => 8, + .@"gc1.f", .@"gc1.ma" => 16, + }; + } + + /// Warps resident on the core. They issue round-robin, one instruction per cycle across the + /// whole core, so this is how much latency hiding is available rather than a parallelism knob + /// a kernel chooses. + pub fn warps(self: Profile) u8 { + return switch (self) { + .@"gc1.n" => 4, + .@"gc1.mi", .@"gc1.s", .@"gc1.f" => 8, + .@"gc1.ma" => 16, + }; + } +}; + +/// Load an arbitrary 32-bit constant into `rd`, returning the one or two words it takes. +/// +/// ADDI alone when the value fits its immediate, else LUI plus ADDI. ADDI SIGN EXTENDS, so a low +/// half with bit 11 set borrows one from the upper half. Getting that wrong lands the constant +/// 0x1000 away, which is the classic hand-assembly bug this exists to prevent. +pub fn loadImmediate(rd: Reg, value: i32, out: *[2]u32) []const u32 { + if (value >= -2048 and value <= 2047) { + out[0] = rv.addi(rd, .x0, @intCast(value)); + return out[0..1]; + } + const low: i32 = @as(i32, @bitCast(@as(u32, @bitCast(value)) & 0xFFF)); + const signed_low: i32 = if (low >= 0x800) low - 0x1000 else low; + const upper: u20 = @truncate(@as(u32, @bitCast(value -% signed_low)) >> 12); + out[0] = rv.lui(rd, upper); + out[1] = rv.addi(rd, rd, @intCast(signed_low)); + return out[0..2]; +} diff --git a/libs/vulcan-target/glacier/tests/mandelbrot.zig b/libs/vulcan-target/glacier/tests/mandelbrot.zig new file mode 100644 index 0000000..55b1e40 --- /dev/null +++ b/libs/vulcan-target/glacier/tests/mandelbrot.zig @@ -0,0 +1,292 @@ +//! The Mandelbrot kernel, assembled with Vulcan's encoders and diffed against Glacier's own. +//! +//! This is the oracle the Glacier backend is built against. The program is a real multi-warp +//! kernel that renders the set into a framebuffer and retires with EBREAK, and it runs on an ECP5 +//! FPGA today, so matching it word for word is a statement about silicon rather than about two +//! encoders agreeing with each other. +//! +//! The expected words come from `glacierMandelbrotKernel` in the Glacier repo +//! (packages/glacier_hdl/lib/src/boot/mandelbrot_kernel.dart), dumped for a 64x48 frame at +//! fbBase 0 with the default 16 iterations and `GlacierMandelbrotView.fit(64, 48)`. That +//! function is the ground truth: if the two disagree, this file is wrong. +//! +//! WHAT THIS PROVES is that a Glacier core's instruction words ARE RISC-V words and the existing +//! `riscv64/encode.zig` emits them correctly, so the Glacier backend needs a profile and an +//! instruction selector rather than an encoder of its own. The one thing Glacier adds is `ebreak`. +//! +//! WHAT THIS IS NOT is a test of code generation. The program below is hand-assembled, exactly as +//! the Dart is. Lowering the same image from Vulcan IR and landing on these words is the next +//! step, and this is what it will be checked against. + +const std = @import("std"); +const glacier = @import("vulcan-target").glacier.encode; + +const rv = glacier.rv; +const Reg = glacier.Reg; + +/// Fixed-point fractional bits: a value is `real * 2^fraction_bits`. +const fraction_bits: u5 = 12; + +/// The complex-plane rectangle a render covers, in fixed point. The kernel walks the view by +/// adding `dx` and `dy` to a register, so the pixel grid IS these integers. +const View = struct { + x_min: i32, + y_min: i32, + dx: i32, + dy: i32, + + /// The whole set in a `width` x `height` frame with square pixels and the real axis exactly on + /// the middle row. Deriving `y_min` as a whole number of `dy` steps is what makes the image + /// mirror about its middle row, which a render can then be checked against. + fn fit(width: i32, height: i32) View { + const span_x = toFixed(3.0); + const centre_x = toFixed(-0.6); + const dx = @divTrunc(span_x, width); + return .{ + .x_min = centre_x - @divTrunc(width, 2) * dx, + .y_min = -@divTrunc(height, 2) * dx, + .dx = dx, + .dy = dx, + }; + } + + fn toFixed(value: f64) i32 { + return @intFromFloat(@round(value * (1 << fraction_bits))); + } +}; + +/// How far to shift the surviving iteration count to make a colour: the largest shift that keeps +/// the brightest pixel inside one byte lane, so a shade cannot carry into the next channel. +fn colourShift(iterations: u32) u5 { + var shift: u5 = 0; + while ((iterations << (shift + 1)) <= 0xFF) shift += 1; + return shift; +} + +/// Assembles the renderer into `out`, returning the words written. +/// +/// The image is warp-count agnostic. GC1 issues its resident warps round-robin, so one warp can +/// only use one slot in that rotation. Rather than build a program per warp, every warp reads +/// `mhartid` and `hart_count` and takes every Nth row, so the SAME image fills whatever core it is +/// launched on and the host launches every warp at one entry point. +/// +/// Rows are interleaved rather than split into bands because the expensive pixels are the ones +/// inside the set, and they sit in a band across the middle of the frame. Bands would give one +/// warp all of them. +pub fn kernel( + out: []u32, + fb_base: i32, + width: i32, + height: i32, + iterations: u16, + view: View, +) []const u32 { + var n: usize = 0; + var pair: [2]u32 = undefined; + const put = struct { + fn one(buf: []u32, at: *usize, w: u32) void { + buf[at.*] = w; + at.* += 1; + } + fn many(buf: []u32, at: *usize, ws: []const u32) void { + for (ws) |w| one(buf, at, w); + } + }; + // Byte address of an instruction, which is what a branch offset is measured from. + const here = struct { + fn at(i: usize) i32 { + return @intCast(i * 4); + } + }; + + const row_stride = width * 4; + // |z|^2 >= 4 is the escape test, and 4.0 in fixed point needs LUI when the fraction is 12 + // bits, so it is loaded as an upper immediate rather than an ADDI. + const escape: i32 = 4 << fraction_bits; + const shade_shift = colourShift(@as(u32, iterations)); + + // The prologue: each warp asks the hardware who it is, then works out its own share. + put.one(out, &n, glacier.csrr(.x21, glacier.Csr.mhartid)); + put.one(out, &n, glacier.csrr(.x22, glacier.Csr.hart_count)); + // A row's byte pitch lives in a register, not an immediate: a wide frame does not fit the + // 12-bit ADDI field, and the start-up multiplies below take it as an operand. + put.many(out, &n, glacier.loadImmediate(.x26, row_stride, &pair)); + put.one(out, &n, rv.addi(.x18, .x0, @intCast(width))); + put.one(out, &n, rv.addi(.x19, .x0, @intCast(height))); + put.one(out, &n, rv.lui(.x17, @intCast(escape >> 12))); + put.many(out, &n, glacier.loadImmediate(.x20, view.x_min, &pair)); + put.many(out, &n, glacier.loadImmediate(.x1, fb_base, &pair)); + put.many(out, &n, glacier.loadImmediate(.x5, view.y_min, &pair)); + put.one(out, &n, rv.addi(.x3, .x21, 0)); // first row = my warp id + put.one(out, &n, rv.addi(.x12, .x0, @intCast(view.dy))); + + // Skip ahead to my first row, then derive the per-row strides: x23 is (N-1)*row_stride, which + // skips the warps behind me once the column loop has walked my own row, and x24 is N*dy. + put.one(out, &n, rv.mul(.x16, .x21, .x26)); + put.one(out, &n, rv.add(.x1, .x1, .x16)); + put.one(out, &n, rv.mul(.x16, .x21, .x12)); + put.one(out, &n, rv.add(.x5, .x5, .x16)); + put.one(out, &n, rv.addi(.x23, .x22, -1)); + put.one(out, &n, rv.mul(.x23, .x23, .x26)); + put.one(out, &n, rv.mul(.x24, .x22, .x12)); + + const row_label = here.at(n); + put.one(out, &n, rv.addi(.x4, .x20, 0)); // cx = x_min + put.one(out, &n, rv.addi(.x2, .x0, 0)); // column = 0 + + const col_label = here.at(n); + put.one(out, &n, rv.addi(.x6, .x0, 0)); // zx = 0 + put.one(out, &n, rv.addi(.x7, .x0, 0)); // zy = 0 + put.one(out, &n, rv.addi(.x8, .x0, @intCast(iterations))); + + const iter_label = here.at(n); + // A square is never negative, so the low word of the product is an unsigned value and a + // logical shift scales it back down. + put.one(out, &n, rv.mul(.x9, .x6, .x6)); + put.one(out, &n, rv.srli(.x9, .x9, fraction_bits)); // zx^2 + put.one(out, &n, rv.mul(.x10, .x7, .x7)); + put.one(out, &n, rv.srli(.x10, .x10, fraction_bits)); // zy^2 + put.one(out, &n, rv.add(.x11, .x9, .x10)); + const escape_branch = n; + put.one(out, &n, 0); // patched below: bge x11, x17, done + + // zy = 2*zx*zy + cy. The product keeps 24 fraction bits and the shift takes it back to 12. It + // must truncate toward ZERO, not toward minus infinity, so a negative product is shifted as a + // magnitude and the sign is put back afterwards. + put.one(out, &n, rv.mul(.x11, .x6, .x7)); + put.one(out, &n, rv.srai(.x16, .x11, 31)); // -1 when the product is negative + put.one(out, &n, rv.xor_(.x11, .x11, .x16)); + put.one(out, &n, rv.sub(.x11, .x11, .x16)); + put.one(out, &n, rv.srli(.x11, .x11, fraction_bits)); + put.one(out, &n, rv.xor_(.x11, .x11, .x16)); + put.one(out, &n, rv.sub(.x11, .x11, .x16)); + put.one(out, &n, rv.slli(.x11, .x11, 1)); + put.one(out, &n, rv.add(.x7, .x11, .x5)); + // zx = zx^2 - zy^2 + cx + put.one(out, &n, rv.sub(.x6, .x9, .x10)); + put.one(out, &n, rv.add(.x6, .x6, .x4)); + put.one(out, &n, rv.addi(.x8, .x8, -1)); + const iter_branch = n; + put.one(out, &n, 0); // patched below: bne x8, x0, iter + + const done_label = here.at(n); + // Colour from the iterations left: a point that survived every iteration is inside the set and + // stays black, and the sooner one escaped the brighter it is. + put.one(out, &n, rv.slli(.x11, .x8, shade_shift)); + put.one(out, &n, rv.slli(.x16, .x11, 8)); + put.one(out, &n, rv.or_(.x11, .x11, .x16)); // the same shade in blue and green + put.one(out, &n, rv.sw(.x11, .x1, 0)); + put.one(out, &n, rv.addi(.x1, .x1, 4)); // next pixel + // cx += dx. The step fits an ADDI immediate, so walking the view needs no multiply. + put.one(out, &n, rv.addi(.x4, .x4, @intCast(view.dx))); + put.one(out, &n, rv.addi(.x2, .x2, 1)); // column++ + const col_branch = n; + put.one(out, &n, 0); // patched below: bne x2, x18, col + + put.one(out, &n, rv.add(.x1, .x1, .x23)); // skip the rows the other warps own + put.one(out, &n, rv.add(.x5, .x5, .x24)); // cy += N*dy + put.one(out, &n, rv.add(.x3, .x3, .x22)); // row += N + const row_branch = n; + put.one(out, &n, 0); // patched below: blt x3, x19, row + put.one(out, &n, glacier.ebreak); + + // Patch the branches now that every label is known. A branch offset is measured from the + // branch's own address, so each is the target minus where the branch sits. + out[escape_branch] = rv.bge(.x11, .x17, @intCast(done_label - here.at(escape_branch))); + out[iter_branch] = rv.bne(.x8, .x0, @intCast(iter_label - here.at(iter_branch))); + out[col_branch] = rv.bne(.x2, .x18, @intCast(col_label - here.at(col_branch))); + out[row_branch] = rv.blt(.x3, .x19, @intCast(row_label - here.at(row_branch))); + return out[0..n]; +} + +/// `glacierMandelbrotKernel(fbBase: 0, width: 64, height: 48, view: fit(64, 48))`, dumped from the +/// Glacier repo. Ground truth, not a transcription of what Vulcan happens to emit. +const oracle = [_]u32{ + 0xf1402af3, + 0xfc002b73, + 0x10000d13, + 0x04000913, + 0x03000993, + 0x000048b7, + 0xffffea37, + 0xe66a0a13, + 0x00000093, + 0xfffff2b7, + 0xe0028293, + 0x000a8193, + 0x0c000613, + 0x03aa8833, + 0x010080b3, + 0x02ca8833, + 0x010282b3, + 0xfffb0b93, + 0x03ab8bb3, + 0x02cb0c33, + 0x000a0213, + 0x00000113, + 0x00000313, + 0x00000393, + 0x01000413, + 0x026304b3, + 0x00c4d493, + 0x02738533, + 0x00c55513, + 0x00a485b3, + 0x0315dc63, + 0x027305b3, + 0x41f5d813, + 0x0105c5b3, + 0x410585b3, + 0x00c5d593, + 0x0105c5b3, + 0x410585b3, + 0x00159593, + 0x005583b3, + 0x40a48333, + 0x00430333, + 0xfff40413, + 0xfa041ce3, + 0x00341593, + 0x00859813, + 0x0105e5b3, + 0x00b0a023, + 0x00408093, + 0x0c020213, + 0x00110113, + 0xf92116e3, + 0x017080b3, + 0x018282b3, + 0x016181b3, + 0xf731cae3, + 0x00100073, +}; + +test "the Mandelbrot kernel assembles to Glacier's own word stream" { + var buf: [128]u32 = undefined; + const got = kernel(&buf, 0, 64, 48, 16, View.fit(64, 48)); + try std.testing.expectEqualSlices(u32, &oracle, got); +} + +test "the kernel ends by retiring the warp and fits the instruction aperture" { + var buf: [128]u32 = undefined; + const got = kernel(&buf, 0, 64, 48, 16, View.fit(64, 48)); + // A warp has no caller to return to, so the program ends with EBREAK. + try std.testing.expectEqual(glacier.ebreak, got[got.len - 1]); + // Every placeholder was patched. A surviving zero would be a valid-looking word that decodes + // to nothing useful, which is exactly the bug the patching exists to avoid. + for (got) |w| try std.testing.expect(w != 0); + // The instruction aperture a host writes in one command is 8192 words. + try std.testing.expect(got.len <= 8192); +} + +test "a core profile fixes the vector length, which is the warp width" { + // There is no vsetvl: the lane count is a property of the core a kernel is built against, not + // a register it sets. These are the GC1 profiles. + try std.testing.expectEqual(@as(u8, 4), glacier.Profile.@"gc1.n".lanes()); + try std.testing.expectEqual(@as(u8, 4), glacier.Profile.@"gc1.n".warps()); + try std.testing.expectEqual(@as(u8, 4), glacier.Profile.@"gc1.mi".lanes()); + try std.testing.expectEqual(@as(u8, 8), glacier.Profile.@"gc1.mi".warps()); + try std.testing.expectEqual(@as(u8, 8), glacier.Profile.@"gc1.s".lanes()); + try std.testing.expectEqual(@as(u8, 16), glacier.Profile.@"gc1.f".lanes()); + try std.testing.expectEqual(@as(u8, 16), glacier.Profile.@"gc1.ma".warps()); +} From d5d01f9f197f5e832cac47f11a276c8fbefe932f Mon Sep 17 00:00:00 2001 From: Tristan Ross Date: Tue, 6 Oct 2026 10:13:09 -0700 Subject: [PATCH 2/7] fix(glacier): record that a narrow store destroys its neighbours --- libs/vulcan-target/glacier/encode.zig | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/libs/vulcan-target/glacier/encode.zig b/libs/vulcan-target/glacier/encode.zig index 75e46aa..831bce0 100644 --- a/libs/vulcan-target/glacier/encode.zig +++ b/libs/vulcan-target/glacier/encode.zig @@ -14,9 +14,17 @@ //! - No LR/SC. The A extension atomics are there, the load-reserved pair is not. //! - No FP square root, and FP32 truncates toward zero with subnormals flushed rather than //! rounding to nearest. Anything that assumes IEEE default rounding differs in the last bit. -//! - No narrow stores. The load/store unit drives SEL=0xF on every store, so a store is always -//! a full 32-bit word and there is no SH encoding at all. Sub-word writes have to be packed -//! in registers before the store. +//! - No narrow stores, and a narrow store is DESTRUCTIVE rather than merely wide. The scalar +//! store data path zero pads rs2 with no byte-lane placement and SEL is hardwired to 0xF, so +//! SB writes a whole word: the value in the low byte and zeros in the other three. Against +//! memory it destroys the three neighbouring bytes. SB and SH are unusable, and the only +//! lowering for a sub-word write is to pack it into a word and use SW. A memory-mapped +//! register can tolerate this (Harbor's UART decodes with a mix of address bit two and two SEL +//! bits, which is why a banner kernel appears to work), so check the device, never the +//! precedent. +//! - Narrow LOADS are fine. LB, LBU, LH and LHU all decode: the core reads the word, selects +//! the byte by the low two address bits or the halfword by bit one, then extends by the +//! instruction's unsigned bit. SEL does not apply to a read. const std = @import("std"); From 23d8cb195734385ece793a2509fae5b88b2e3c41 Mon Sep 17 00:00:00 2001 From: Tristan Ross Date: Tue, 6 Oct 2026 10:14:05 -0700 Subject: [PATCH 3/7] feat(glacier): lower the mandelbrot kernel from vulcan ir --- build.zig | 15 + libs/vulcan-target/glacier.zig | 1 + libs/vulcan-target/glacier/isel.zig | 573 ++++++++++++++++++ libs/vulcan-target/glacier/tests/lower.zig | 504 +++++++++++++++ .../glacier/tests/mandelbrot.zig | 11 +- libs/vulcan-target/glacier/tests/warp.zig | 269 ++++++++ 6 files changed, 1368 insertions(+), 5 deletions(-) create mode 100644 libs/vulcan-target/glacier/isel.zig create mode 100644 libs/vulcan-target/glacier/tests/lower.zig create mode 100644 libs/vulcan-target/glacier/tests/warp.zig diff --git a/build.zig b/build.zig index fe2f8bc..0ceccaf 100644 --- a/build.zig +++ b/build.zig @@ -732,6 +732,21 @@ pub fn build(b: *std.Build) void { }) }); test_step.dependOn(&b.addRunArtifact(glacier_mandelbrot).step); + // The same image, lowered from Vulcan IR instead of hand-assembled. A compiler picks other + // registers and another instruction order, so the two word streams differ by design. Both run + // on a warp model and the FRAMES are diffed, which is the statement that matters. + const glacier_lower = b.addTest(.{ .root_module = b.createModule(.{ + .root_source_file = b.path("libs/vulcan-target/glacier/tests/lower.zig"), + .target = target, + .optimize = optimize, + .imports = &.{ + .{ .name = "vulcan-ir", .module = vulcan_ir }, + .{ .name = "vulcan-gpu", .module = vulcan_gpu }, + .{ .name = "vulcan-target", .module = vulcan_target }, + }, + }) }); + test_step.dependOn(&b.addRunArtifact(glacier_lower).step); + // Scalar-expanded matmul over deliberately odd global pointers. The expansion is test-owned: // NVIDIA production codegen receives the ordinary scalar IR, then real hardware proves its // bytewise memory lowering preserves the exact footprint and fp32 bits. diff --git a/libs/vulcan-target/glacier.zig b/libs/vulcan-target/glacier.zig index ebe052c..714f3a7 100644 --- a/libs/vulcan-target/glacier.zig +++ b/libs/vulcan-target/glacier.zig @@ -17,3 +17,4 @@ //! thing to a workgroup id the hardware has. pub const encode = @import("glacier/encode.zig"); +pub const isel = @import("glacier/isel.zig"); diff --git a/libs/vulcan-target/glacier/isel.zig b/libs/vulcan-target/glacier/isel.zig new file mode 100644 index 0000000..95c08a1 --- /dev/null +++ b/libs/vulcan-target/glacier/isel.zig @@ -0,0 +1,573 @@ +//! Glacier instruction selection: a Vulcan IR kernel to a Glacier word stream. +//! +//! A Glacier kernel is not a function. It has no caller, no arguments and no stack, so this +//! lowering is narrower than a CPU backend on purpose: +//! +//! - A kernel takes NO parameters. Its entry-block parameters must all be hardware builtins +//! (`vulcan-gpu`'s `Builtin`), and everything else a kernel needs is a constant the host +//! already knew when it built the program. A launch shape that changes rebuilds the kernel. +//! - A kernel RETIRES. An empty `ret` becomes EBREAK, and a `ret` with a value is refused: +//! there is nowhere for the value to go. +//! - A kernel does NOT spill. A warp has no stack, so this refuses an allocation that needs +//! one rather than inventing a memory region the hardware has not promised. +//! +//! Register allocation is the shared Wimmer-Franz allocator, same as every other backend, so +//! liveness across a loop and the parallel copies on a control-flow edge are not reimplemented +//! here. This file owns the register model and the encoding. +//! +//! One thing it deliberately does NOT do yet is fuse a compare into its branch. A fused branch +//! reads the COMPARE's operands at the branch, and the allocator only knows the branch reads the +//! boolean, so fusing needs `wimmer.RegDescription.fusedOperands` to tell it. Until that is +//! wired, a compare materializes 0 or 1 into a register and the branch tests it against x0. The +//! cost is one word per branch, which is correct and slower, not wrong and fast. + +const std = @import("std"); +const ir = @import("vulcan-ir"); +const gpu = @import("vulcan-gpu"); +const wimmer = @import("../wimmer.zig"); +const glacier = @import("encode.zig"); + +const rv = glacier.rv; +const Reg = glacier.Reg; +const Function = ir.function.Function; +const Value = ir.function.Value; +const Block = ir.function.Block; +const Inst = ir.function.Inst; +const Type = ir.types.Type; + +pub const Error = std.mem.Allocator.Error || error{Unsupported}; + +/// The register this lowering keeps for itself. It holds a constant too wide for an immediate +/// field, and the Wimmer resolver routes a parallel-copy cycle through it. It is never +/// allocatable, so no value ever lives here across an instruction boundary. +const scratch: Reg = .x31; + +/// x1 to x30. A Glacier kernel has neither a call nor a stack, so the usual return-address and +/// stack-pointer reservations buy nothing and both registers carry data instead. +const allocatable: [30]u16 = blk: { + var regs: [30]u16 = undefined; + for (®s, 1..) |*r, i| r.* = i; + break :blk regs; +}; + +const scratch_set: [1]u16 = .{@backingInt(scratch)}; + +/// Where a global the kernel names lives. A Glacier kernel reaches memory by absolute address: +/// the host knows where it put the framebuffer, and the kernel is built for that address. +pub const Global = struct { name: []const u8, address: u32 }; + +pub const Options = struct { + /// The core the kernel is built for. It fixes the lane count, which is what `block_dim_x` + /// and `subgroup_size` read. + profile: glacier.Profile, + /// Absolute addresses for the symbols the kernel's `global_addr` instructions name. A name + /// that is not here is refused rather than guessed. + globals: []const Global = &.{}, +}; + +/// Lower `func` to a Glacier word stream. The caller owns the result. +pub fn compileKernel(allocator: std.mem.Allocator, func: *const Function, opts: Options) Error![]u32 { + if (func.blockCount() == 0) return error.Unsupported; + if (func.is_variadic or func.sret) return error.Unsupported; + + // The Wimmer resolver needs a block on every critical edge to put its shuffle moves on, and + // splitting mutates the function, so split a clone and leave the caller's input alone. + var work = try func.clone(allocator); + defer work.deinit(); + try ir.critical_edge.splitCriticalEdges(allocator, &work); + const reachable = try ir.reachable.neutralizeUnreachable(allocator, &work); + allocator.free(reachable); + + try checkSupported(&work, opts); + + const classes = [_]wimmer.RegClass{.{ + .name = "gpr", + .allocatable = &allocatable, + .callee_saved = &.{}, + .slot_bytes = 4, + }}; + var desc: wimmer.RegDescription = .{ + .classes = &classes, + .classOf = classOf, + .useKind = useKind, + .entry_fixed = &.{}, + .call_sites = &.{}, + .scratch = &scratch_set, + .ctx = undefined, + .copySource = copySource, + .coalesce_block_params = true, + }; + var walloc = try wimmer.allocate(allocator, &work, &desc); + defer walloc.deinit(allocator); + + // A spill needs a stack and a warp has no stack. Refuse here rather than emit a store to an + // address the hardware never promised. + for (walloc.slot_count_per_class) |n| if (n != 0) return error.Unsupported; + + var e: Emitter = .{ + .allocator = allocator, + .func = &work, + .opts = opts, + .alloc = &walloc, + }; + defer e.deinit(); + try e.run(); + return e.words.toOwnedSlice(allocator); +} + +// =========================================================================== +// What this backend accepts. + +fn classOf(_: *const anyopaque, _: *const Function, _: Value) u16 { + return 0; +} + +fn useKind(_: *const anyopaque, _: *const Function, _: Inst, _: Value) wimmer.UseKind { + return .must_have_register; +} + +/// A `reinterpret` between two 32-bit values moves no bits, so the allocator may put its result +/// and its source in one register and leave the move out. Nothing else here is a pure copy. +fn copySource(_: *const anyopaque, func: *const Function, v: Value) ?Value { + const inst = func.definingInst(v) orelse return null; + return switch (func.opcode(inst)) { + .unary => |u| if (u.op == .reinterpret) u.value else null, + else => null, + }; +} + +/// Whether `ty` is a value a Glacier register holds. One 32-bit word, nothing narrower. +/// +/// A narrow store is refused because the hardware has none: a byte store writes a whole word and +/// destroys its neighbours (see `encode.zig`). A narrow LOAD does exist, so what blocks a sub-word +/// value here is this lowering and not the core: every operation on one would have to truncate to +/// its width, and silently skipping that truncation is a wrong answer rather than a refusal. +fn isWord(func: *const Function, ty: Type) bool { + return switch (func.types.type_kind(ty)) { + .bool, .ptr => true, + .int => |i| i.bits == 32, + else => false, + }; +} + +fn isSigned(func: *const Function, ty: Type) bool { + return switch (func.types.type_kind(ty)) { + .int => |i| i.signedness == .signed, + else => false, + }; +} + +/// Reject every shape this lowering does not handle, before anything is emitted. A refusal here +/// is a clear error; a refusal halfway through emission is a truncated program. +fn checkSupported(func: *const Function, opts: Options) Error!void { + for (0..func.valueCount()) |vi| { + const v: Value = @fromBackingInt(@intCast(vi)); + if (!isWord(func, func.valueType(v))) return error.Unsupported; + } + for (func.blockParams(@fromBackingInt(0))) |p| { + const b = gpu.attrs.builtinOf(func, p) orelse return error.Unsupported; + _ = builtinSource(b) orelse return error.Unsupported; + } + for (0..func.blockCount()) |bi| { + const block: Block = @fromBackingInt(@intCast(bi)); + const insts = func.blockInsts(block); + for (insts, 0..) |inst, i| switch (func.opcode(inst)) { + // A constant must fit a 32-bit register. An unsigned type reaches 0xFFFFFFFF and a + // signed one reaches the most negative value, so the window spans both. + .iconst => |c| if (c < std.math.minInt(i32) or c > std.math.maxInt(u32)) return error.Unsupported, + .arith => |a| _ = binOp(func, a.op, func.valueType(a.lhs)) orelse return error.Unsupported, + .arith_imm => |a| _ = binOp(func, a.op, func.valueType(a.lhs)) orelse return error.Unsupported, + .icmp, .select => {}, + .load => |l| if (!isWord(func, func.valueType(l.ptr))) return error.Unsupported, + .store => |st| if (!isWord(func, func.valueType(st.value))) return error.Unsupported, + .global_addr => |g| _ = addressOf(opts, func.symbolName(g.symbol)) orelse return error.Unsupported, + .unary => |u| if (u.op != .reinterpret) return error.Unsupported, + // A conditional transfers control, so nothing may follow it in its block, and its + // block must have no terminator: a terminator there is an edge the allocator would + // resolve and this emits nothing for. + .@"if" => if (i + 1 != insts.len or func.terminator(block) != null) return error.Unsupported, + else => return error.Unsupported, + }; + if (func.terminator(block)) |term| switch (term) { + .ret => |r| if (r.count != 0) return error.Unsupported, + .jump => {}, + }; + } +} + +/// Where a hardware builtin comes from on a Glacier core, or null when the core cannot answer. +/// +/// A warp is the workgroup: it owns a program counter and a register file, and `mhartid` is +/// unique across the whole SoC, which is what a workgroup index is. The lanes inside a warp are +/// the threads, and a lane index is a VECTOR value (`vid.v`), so `thread_id_*` and `lane_id` are +/// not scalar builtins and are not answered here. +fn builtinSource(b: gpu.builtin.Builtin) ?union(enum) { csr: u12, lanes } { + return switch (b) { + .block_id_x => .{ .csr = glacier.Csr.mhartid }, + .grid_dim_x => .{ .csr = glacier.Csr.hart_count }, + .block_dim_x, .subgroup_size => .lanes, + else => null, + }; +} + +/// The low 32 bits of an IR constant, as the signed value a register holds. An unsigned constant +/// above the signed range and the negative value with the same bits are one register value. +fn lowWord(c: i64) i32 { + return @bitCast(@as(u32, @truncate(@as(u64, @bitCast(c))))); +} + +fn addressOf(opts: Options, name: []const u8) ?u32 { + for (opts.globals) |g| { + if (std.mem.eql(u8, g.name, name)) return g.address; + } + return null; +} + +/// The register-form encoder for a binary operation on an operand of type `ty`, or null when +/// Glacier has no instruction for it. Signedness comes from the operand type, so one `shr` +/// becomes a logical or an arithmetic shift depending on it. +fn binOp(func: *const Function, op: ir.function.BinOp, ty: Type) ?*const fn (Reg, Reg, Reg) u32 { + const signed = isSigned(func, ty); + return switch (op) { + .add => rv.add, + .sub => rv.sub, + .mul => rv.mul, + .div => if (signed) rv.div else rv.divu, + .rem => if (signed) rv.rem else rv.remu, + .mulh => if (signed) rv.mulh else rv.mulhu, + .bit_and => rv.and_, + .bit_or => rv.or_, + .bit_xor => rv.xor_, + .shl => rv.sll, + .shr => if (signed) rv.sra else rv.srl, + }; +} + +// =========================================================================== +// Emission. + +/// A jump whose target word is not known yet. Every forward reference is a J-type jump, never a +/// branch: a branch reaches 4 KiB and the instruction aperture is 32 KiB, so a branch can fall +/// out of range inside one legal kernel while a jump cannot. +const Fixup = struct { at: usize, target: Block }; + +const Emitter = struct { + allocator: std.mem.Allocator, + func: *const Function, + opts: Options, + alloc: *const wimmer.Allocation, + words: std.ArrayList(u32) = .empty, + block_start: std.ArrayList(u32) = .empty, + fixups: std.ArrayList(Fixup) = .empty, + + fn deinit(self: *Emitter) void { + self.words.deinit(self.allocator); + self.block_start.deinit(self.allocator); + self.fixups.deinit(self.allocator); + } + + fn put(self: *Emitter, word: u32) Error!void { + try self.words.append(self.allocator, word); + } + + fn putMany(self: *Emitter, ws: []const u32) Error!void { + try self.words.appendSlice(self.allocator, ws); + } + + /// The register holding `v` at position `pos`, or null when the allocator placed nothing + /// there because nothing reads it. + fn regAt(self: *Emitter, v: Value, pos: u32) Error!?Reg { + const segs = self.alloc.segments.get(v) orelse return null; + var found: ?wimmer.Location = null; + for (segs) |s| { + if (s.from > pos) break; + found = s.loc; + } + const loc = found orelse return null; + return switch (loc) { + .reg => |r| @as(Reg, @fromBackingInt(@as(u5, @intCast(r)))), + .slot => error.Unsupported, + }; + } + + fn reg(self: *Emitter, v: Value, pos: u32) Error!Reg { + return (try self.regAt(v, pos)) orelse error.Unsupported; + } + + /// Materialize `value` into `dst`, using one or two words. + fn constant(self: *Emitter, dst: Reg, value: i32) Error!void { + var pair: [2]u32 = undefined; + try self.putMany(glacier.loadImmediate(dst, value, &pair)); + } + + fn move(self: *Emitter, dst: Reg, src: Reg) Error!void { + if (dst != src) try self.put(rv.addi(dst, src, 0)); + } + + fn run(self: *Emitter) Error!void { + const nblocks = self.func.blockCount(); + try self.block_start.appendNTimes(self.allocator, 0, nblocks); + + var pos: u32 = 0; + for (0..nblocks) |bi| { + const block: Block = @fromBackingInt(@intCast(bi)); + self.block_start.items[bi] = @intCast(self.words.items.len); + if (bi == 0) try self.prologue(block) else try self.edgeMovesInto(block); + + try self.actionsAt(pos); // the block-parameter position + pos += 1; + const insts = self.func.blockInsts(block); + var ended = false; + for (insts) |inst| { + try self.actionsAt(pos); + if (self.func.opcode(inst) == .@"if") { + try self.emitIf(inst, pos, bi); + ended = true; + } else { + try self.emitInst(inst, pos); + } + pos += 1; + } + if (!ended) { + try self.actionsAt(pos); + try self.emitTerminator(block, bi); + } + pos += 1; + } + try self.patch(); + } + + /// The entry prologue: every entry parameter is a hardware builtin, so each one is a CSR read + /// or a core constant written into the register the allocator gave it. + fn prologue(self: *Emitter, block: Block) Error!void { + for (self.func.blockParams(block)) |p| { + const dst = (try self.regAt(p, 0)) orelse continue; + const b = gpu.attrs.builtinOf(self.func, p) orelse return error.Unsupported; + switch (builtinSource(b) orelse return error.Unsupported) { + .csr => |c| try self.put(glacier.csrr(dst, c)), + .lanes => try self.constant(dst, self.opts.profile.lanes()), + } + } + } + + /// The parallel copies on the edges that END at `block`, for the edges the predecessor cannot + /// host. See `edgeMovesOutOf` for which side hosts which edge. + fn edgeMovesInto(self: *Emitter, block: Block) Error!void { + for (self.alloc.edge_moves) |set| { + if (set.succ != block or set.moves.len == 0) continue; + if (!endsInIf(self.func, set.pred)) continue; + if (predCount(self.func, block) != 1) return error.Unsupported; + try self.emitMoves(set.moves); + } + } + + /// The parallel copies on the edges that LEAVE `block`, for the edges this block hosts. + /// + /// A block that leaves by a jump hosts its one edge, because the moves belong to that edge + /// alone. A block that leaves by a conditional cannot: it has one place to put them and two + /// paths out, so moves there would also run on the sibling path. Those sit at the top of the + /// successor instead, which is sound only when the successor has one predecessor, and + /// splitting the critical edges is what makes that true. + fn edgeMovesOutOf(self: *Emitter, block: Block) Error!void { + for (self.alloc.edge_moves) |set| { + if (set.pred != block or set.moves.len == 0) continue; + try self.emitMoves(set.moves); + } + } + + fn emitMoves(self: *Emitter, moves: []const wimmer.Move) Error!void { + for (moves) |m| { + const src = switch (m.src) { + .reg => |r| @as(Reg, @fromBackingInt(@as(u5, @intCast(r)))), + .slot => return error.Unsupported, + }; + const dst = switch (m.dst) { + .reg => |r| @as(Reg, @fromBackingInt(@as(u5, @intCast(r)))), + .slot => return error.Unsupported, + }; + try self.move(dst, src); + } + } + + /// The allocator's own moves at `pos`. A split with no stack can only be a register-to- + /// register move, so a store or a reload here means something spilled after all. + fn actionsAt(self: *Emitter, pos: u32) Error!void { + for (self.alloc.actions) |a| { + if (a.at != pos) continue; + if (a.kind != .move) return error.Unsupported; + try self.emitMoves(&[_]wimmer.Move{.{ .src = a.src, .dst = a.dst, .class = a.class, .value = a.value }}); + } + } + + fn emitInst(self: *Emitter, inst: Inst, pos: u32) Error!void { + const result = self.func.instResult(inst); + // A pure instruction nothing reads has no register to write to, so it is dropped. + const dst: ?Reg = if (result) |r| try self.regAt(r, pos) else null; + switch (self.func.opcode(inst)) { + .iconst => |c| try self.constant(dst orelse return, lowWord(c)), + .global_addr => |g| try self.constant( + dst orelse return, + @bitCast(addressOf(self.opts, self.func.symbolName(g.symbol)) orelse return error.Unsupported), + ), + .unary => |u| try self.move(dst orelse return, try self.reg(u.value, pos)), + .arith => |a| { + const d = dst orelse return; + const f = binOp(self.func, a.op, self.func.valueType(a.lhs)) orelse return error.Unsupported; + try self.put(f(d, try self.reg(a.lhs, pos), try self.reg(a.rhs, pos))); + }, + .arith_imm => |a| try self.emitArithImm(a, dst orelse return, pos), + .icmp => |c| try self.emitCompare(c, dst orelse return, pos), + .select => |s| try self.emitSelect(s, dst orelse return, pos), + .load => |l| try self.put(rv.lw(dst orelse return, try self.reg(l.ptr, pos), 0)), + .store => |st| try self.put(rv.sw(try self.reg(st.value, pos), try self.reg(st.ptr, pos), 0)), + else => return error.Unsupported, + } + } + + fn emitArithImm(self: *Emitter, a: ir.function.ArithImm, dst: Reg, pos: u32) Error!void { + const lhs = try self.reg(a.lhs, pos); + const ty = self.func.valueType(a.lhs); + const signed = isSigned(self.func, ty); + // A shift amount above the register width is not an encodable shift on RV32. + if (a.op == .shl or a.op == .shr) { + if (a.imm < 0 or a.imm > 31) return error.Unsupported; + const shamt: u6 = @intCast(a.imm); + return self.put(switch (a.op) { + .shl => rv.slli(dst, lhs, shamt), + else => if (signed) rv.srai(dst, lhs, shamt) else rv.srli(dst, lhs, shamt), + }); + } + // Subtraction has no immediate form, so it is an add of the negated constant. Negating + // the most negative value overflows, so that one goes the register route. + const imm: i64 = if (a.op == .sub) -a.imm else a.imm; + const fits = imm >= -2048 and imm <= 2047; + if (fits) switch (a.op) { + .add, .sub => return self.put(rv.addi(dst, lhs, @intCast(imm))), + .bit_and => return self.put(rv.andi(dst, lhs, @intCast(imm))), + .bit_or => return self.put(rv.ori(dst, lhs, @intCast(imm))), + .bit_xor => return self.put(rv.xori(dst, lhs, @intCast(imm))), + else => {}, + }; + const f = binOp(self.func, a.op, ty) orelse return error.Unsupported; + try self.constant(scratch, lowWord(a.imm)); + try self.put(f(dst, lhs, scratch)); + } + + /// A comparison into a 0-or-1 register. Each form writes `dst` before it reads it again, so + /// a destination that aliases a source is safe. + fn emitCompare(self: *Emitter, c: ir.function.Compare, dst: Reg, pos: u32) Error!void { + const lhs = try self.reg(c.lhs, pos); + const rhs = try self.reg(c.rhs, pos); + const signed = isSigned(self.func, self.func.valueType(c.lhs)); + const less: *const fn (Reg, Reg, Reg) u32 = if (signed) rv.slt else rv.sltu; + switch (c.op) { + .eq => { + try self.put(rv.xor_(dst, lhs, rhs)); + try self.put(rv.sltiu(dst, dst, 1)); + }, + .ne => { + try self.put(rv.xor_(dst, lhs, rhs)); + try self.put(rv.sltu(dst, .x0, dst)); + }, + .lt => try self.put(less(dst, lhs, rhs)), + .gt => try self.put(less(dst, rhs, lhs)), + .ge => { + try self.put(less(dst, lhs, rhs)); + try self.put(rv.xori(dst, dst, 1)); + }, + .le => { + try self.put(less(dst, rhs, lhs)); + try self.put(rv.xori(dst, dst, 1)); + }, + } + } + + /// A value-producing conditional. Glacier has no conditional move, so this is a move, a + /// branch over one word, and a move. The taken value goes through the scratch register first + /// so that a destination aliasing either source still reads the right bits. + fn emitSelect(self: *Emitter, s: ir.function.Select, dst: Reg, pos: u32) Error!void { + const cond = try self.reg(s.cond, pos); + const then = try self.reg(s.then, pos); + const other = try self.reg(s.@"else", pos); + try self.move(scratch, then); + try self.move(dst, other); + try self.put(rv.beq(cond, .x0, 8)); + try self.move(dst, scratch); + } + + fn emitIf(self: *Emitter, inst: Inst, pos: u32, bi: usize) Error!void { + const cf = self.func.opcode(inst).@"if"; + const cond = try self.reg(cf.cond, pos); + // The boolean is 0 or 1, so testing it against x0 is the whole condition. A false + // condition skips the then jump, so that jump is always emitted even when its target + // follows: eliding it would make the branch skip the else jump instead. + try self.put(rv.beq(cond, .x0, 8)); + try self.jumpAlways(cf.then.target); + try self.jumpTo(cf.@"else".target, bi); + } + + fn emitTerminator(self: *Emitter, block: Block, bi: usize) Error!void { + const term = self.func.terminator(block) orelse { + // A neutralized unreachable block has no terminator. Retiring is the safe word to + // leave behind: nothing branches here, and EBREAK cannot run on into the next block. + return self.put(glacier.ebreak); + }; + switch (term) { + .ret => try self.put(glacier.ebreak), + .jump => |j| { + try self.edgeMovesOutOf(block); + try self.jumpTo(j.target, bi); + }, + } + } + + /// A jump to `target`, elided when the target is the block emitted next. + fn jumpTo(self: *Emitter, target: Block, bi: usize) Error!void { + if (@backingInt(target) == bi + 1) return; + try self.jumpAlways(target); + } + + fn jumpAlways(self: *Emitter, target: Block) Error!void { + try self.fixups.append(self.allocator, .{ .at = self.words.items.len, .target = target }); + try self.put(0); + } + + fn patch(self: *Emitter) Error!void { + for (self.fixups.items) |f| { + const to: i64 = @as(i64, self.block_start.items[@backingInt(f.target)]) * 4; + const from: i64 = @as(i64, @intCast(f.at)) * 4; + const delta = to - from; + if (delta < std.math.minInt(i21) or delta > std.math.maxInt(i21)) return error.Unsupported; + self.words.items[f.at] = rv.jal(.x0, @intCast(delta)); + } + } +}; + +/// Whether `block` leaves by a conditional rather than by its terminator. A conditional transfers +/// control itself, so the terminator after one is never reached and never emitted. +fn endsInIf(func: *const Function, block: Block) bool { + const insts = func.blockInsts(block); + return insts.len != 0 and func.opcode(insts[insts.len - 1]) == .@"if"; +} + +fn predCount(func: *const Function, block: Block) usize { + var n: usize = 0; + for (0..func.blockCount()) |bi| { + const pred: Block = @fromBackingInt(@intCast(bi)); + const insts = func.blockInsts(pred); + var counted = false; + if (endsInIf(func, pred)) { + const cf = func.opcode(insts[insts.len - 1]).@"if"; + if (cf.then.target == block or cf.@"else".target == block) n += 1; + counted = true; + } + if (counted) continue; + if (func.terminator(pred)) |term| switch (term) { + .jump => |j| if (j.target == block) { + n += 1; + }, + .ret => {}, + }; + } + return n; +} diff --git a/libs/vulcan-target/glacier/tests/lower.zig b/libs/vulcan-target/glacier/tests/lower.zig new file mode 100644 index 0000000..1f1335d --- /dev/null +++ b/libs/vulcan-target/glacier/tests/lower.zig @@ -0,0 +1,504 @@ +//! The Mandelbrot kernel again, this time from Vulcan IR through the Glacier instruction selector. +//! +//! The hand-assembled program in `mandelbrot.zig` is the oracle: it matches Glacier's own word +//! stream for a frame that renders on an ECP5 FPGA today. A compiler picks different registers and +//! a different instruction order, so the two word streams do not match and never will. What must +//! match is the IMAGE, so both streams run on the warp model in `warp.zig` and the frames are +//! diffed word for word. +//! +//! The kernel built here is the same algorithm, down to the sign-magnitude fixed-point multiply. +//! That shift must truncate toward zero: an arithmetic shift of a negative product rounds toward +//! minus infinity instead, and the two differ by one unit in the last place on exactly the pixels +//! at the boundary of the set, which is where the whole image is. The types carry that difference, +//! because the signedness of a `shr` comes from its operand type. + +const std = @import("std"); +const ir = @import("vulcan-ir"); +const gpu = @import("vulcan-gpu"); +const glacier = @import("vulcan-target").glacier; + +const hand = @import("mandelbrot.zig"); +const warp = @import("warp.zig"); + +const Function = ir.function.Function; +const Value = ir.function.Value; + +const width: i32 = 64; +const height: i32 = 48; +const iterations: u16 = 16; +const fb_base: u32 = 0; +const fraction_bits: i64 = 12; + +/// Where the host put the frame. The kernel names it, and the backend turns the name into the +/// absolute address, because a Glacier kernel reaches memory by address and nothing else. +const framebuffer = "framebuffer"; + +/// Build the renderer as Vulcan IR. The block order is dominance order, which the backends rely +/// on, and every loop carries its state in block parameters. +fn buildKernel(allocator: std.mem.Allocator, view: hand.View) !Function { + var f = Function.init(allocator); + errdefer f.deinit(); + + const u32_t = try f.types.intern(.{ .int = .{ .signedness = .unsigned, .bits = 32 } }); + const i32_t = try f.types.intern(.{ .int = .{ .signedness = .signed, .bits = 32 } }); + const bool_t = try f.types.intern(.bool); + const ptr_t = try f.types.ptrGlobal(); + + const row_stride: i64 = @as(i64, width) * 4; + const escape: i64 = 4 << fraction_bits; + const shade_shift: i64 = hand.colourShift(iterations); + + const entry = try f.appendBlock(); + // A warp is the workgroup: it owns a program counter and a register file, and its id is + // unique across the SoC. The lanes inside it are the threads, and this kernel uses none. + const warp_id = try f.appendBlockParam(entry, u32_t); + const warp_count = try f.appendBlockParam(entry, u32_t); + try gpu.attrs.setBuiltin(&f, warp_id, .block_id_x); + try gpu.attrs.setBuiltin(&f, warp_count, .grid_dim_x); + + const row_loop = try f.appendBlock(); + const row = try f.appendBlockParam(row_loop, u32_t); + // The imaginary coordinate is SIGNED, because the sign-magnitude multiply below restores the + // sign of the product and then adds this to it. + const cy = try f.appendBlockParam(row_loop, i32_t); + const row_pixel = try f.appendBlockParam(row_loop, ptr_t); + + const col_loop = try f.appendBlock(); + const col = try f.appendBlockParam(col_loop, u32_t); + const cx = try f.appendBlockParam(col_loop, u32_t); + const pixel = try f.appendBlockParam(col_loop, ptr_t); + + const iter_loop = try f.appendBlock(); + const zx = try f.appendBlockParam(iter_loop, i32_t); + const zy = try f.appendBlockParam(iter_loop, i32_t); + const left = try f.appendBlockParam(iter_loop, u32_t); + + const advance = try f.appendBlock(); + const shade = try f.appendBlock(); + const shade_left = try f.appendBlockParam(shade, u32_t); + const row_end = try f.appendBlock(); + const exit = try f.appendBlock(); + + // The prologue. Each warp asks the hardware who it is, skips to its own first row, and works + // out the strides that carry it past the rows the other warps own. + const fb = try f.appendGlobalAddr(entry, ptr_t, framebuffer); + const stride = try f.appendInst(entry, u32_t, .{ .iconst = row_stride }); + const dy = try f.appendInst(entry, i32_t, .{ .iconst = view.dy }); + const row_offset = try mul(&f, entry, u32_t, warp_id, stride); + const first_pixel = try add(&f, entry, ptr_t, fb, row_offset); + const y_min = try f.appendInst(entry, i32_t, .{ .iconst = view.y_min }); + const warp_index = try reinterpret(&f, entry, i32_t, warp_id); + const warps = try reinterpret(&f, entry, i32_t, warp_count); + const cy_offset = try mul(&f, entry, i32_t, warp_index, dy); + const first_cy = try add(&f, entry, i32_t, y_min, cy_offset); + const others = try f.appendArithImm(entry, u32_t, .sub, warp_count, 1); + const row_skip = try mul(&f, entry, u32_t, others, stride); + const cy_step = try mul(&f, entry, i32_t, warps, dy); + try f.setJump(entry, row_loop, &.{ warp_id, first_cy, first_pixel }); + + const x_min = try f.appendInst(row_loop, u32_t, .{ .iconst = @as(u32, @bitCast(view.x_min)) }); + const zero_col = try f.appendInst(row_loop, u32_t, .{ .iconst = 0 }); + try f.setJump(row_loop, col_loop, &.{ zero_col, x_min, row_pixel }); + + const z_start = try f.appendInst(col_loop, i32_t, .{ .iconst = 0 }); + const left_start = try f.appendInst(col_loop, u32_t, .{ .iconst = iterations }); + try f.setJump(col_loop, iter_loop, &.{ z_start, z_start, left_start }); + + // The escape test. A square is never negative, so the low word of each product is read as an + // unsigned value and a logical shift scales it back down. + const zx_sq = try mul(&f, iter_loop, i32_t, zx, zx); + const zx_sq_u = try reinterpret(&f, iter_loop, u32_t, zx_sq); + const zx2 = try f.appendArithImm(iter_loop, u32_t, .shr, zx_sq_u, fraction_bits); + const zy_sq = try mul(&f, iter_loop, i32_t, zy, zy); + const zy_sq_u = try reinterpret(&f, iter_loop, u32_t, zy_sq); + const zy2 = try f.appendArithImm(iter_loop, u32_t, .shr, zy_sq_u, fraction_bits); + const norm = try add(&f, iter_loop, u32_t, zx2, zy2); + const limit = try f.appendInst(iter_loop, u32_t, .{ .iconst = escape }); + const escaped = try f.appendInst(iter_loop, bool_t, .{ .icmp = .{ .op = .ge, .lhs = norm, .rhs = limit } }); + try f.appendIf(iter_loop, escaped, .{ .target = shade, .args = &.{left} }, .{ .target = advance }); + + // zy = 2*zx*zy + cy. The product holds 24 fraction bits and the shift takes it back to 12. + // It has to truncate toward zero, so a negative product is shifted as a magnitude and the + // sign is put back afterwards. + const cross = try mul(&f, advance, i32_t, zx, zy); + const sign = try f.appendArithImm(advance, i32_t, .shr, cross, 31); + const flipped = try bits(&f, advance, i32_t, .bit_xor, cross, sign); + const magnitude = try sub(&f, advance, i32_t, flipped, sign); + const magnitude_u = try reinterpret(&f, advance, u32_t, magnitude); + const scaled_u = try f.appendArithImm(advance, u32_t, .shr, magnitude_u, fraction_bits); + const scaled = try reinterpret(&f, advance, i32_t, scaled_u); + const back = try bits(&f, advance, i32_t, .bit_xor, scaled, sign); + const restored = try sub(&f, advance, i32_t, back, sign); + const doubled = try f.appendArithImm(advance, i32_t, .shl, restored, 1); + const next_zy = try add(&f, advance, i32_t, doubled, cy); + const difference = try sub(&f, advance, u32_t, zx2, zy2); + const next_cx = try add(&f, advance, u32_t, difference, cx); + const next_zx = try reinterpret(&f, advance, i32_t, next_cx); + const next_left = try f.appendArithImm(advance, u32_t, .sub, left, 1); + const spent = try f.appendInst(advance, u32_t, .{ .iconst = 0 }); + const more = try f.appendInst(advance, bool_t, .{ .icmp = .{ .op = .ne, .lhs = next_left, .rhs = spent } }); + try f.appendIf( + advance, + more, + .{ .target = iter_loop, .args = &.{ next_zx, next_zy, next_left } }, + .{ .target = shade, .args = &.{next_left} }, + ); + + // Colour from the iterations left. A point that survived every iteration is inside the set + // and stays black, and the sooner one escaped the brighter it is. + const level = try f.appendArithImm(shade, u32_t, .shl, shade_left, shade_shift); + const green = try f.appendArithImm(shade, u32_t, .shl, level, 8); + const colour = try bits(&f, shade, u32_t, .bit_or, level, green); + try f.appendStore(shade, colour, pixel); + const next_pixel = try f.appendArithImm(shade, ptr_t, .add, pixel, 4); + const stepped_cx = try f.appendArithImm(shade, u32_t, .add, cx, view.dx); + const next_col = try f.appendArithImm(shade, u32_t, .add, col, 1); + const last_col = try f.appendInst(shade, u32_t, .{ .iconst = width }); + const more_cols = try f.appendInst(shade, bool_t, .{ .icmp = .{ .op = .ne, .lhs = next_col, .rhs = last_col } }); + try f.appendIf( + shade, + more_cols, + .{ .target = col_loop, .args = &.{ next_col, stepped_cx, next_pixel } }, + .{ .target = row_end }, + ); + + const skipped = try add(&f, row_end, ptr_t, next_pixel, row_skip); + const stepped_cy = try add(&f, row_end, i32_t, cy, cy_step); + const next_row = try add(&f, row_end, u32_t, row, warp_count); + const last_row = try f.appendInst(row_end, u32_t, .{ .iconst = height }); + const more_rows = try f.appendInst(row_end, bool_t, .{ .icmp = .{ .op = .lt, .lhs = next_row, .rhs = last_row } }); + try f.appendIf( + row_end, + more_rows, + .{ .target = row_loop, .args = &.{ next_row, stepped_cy, skipped } }, + .{ .target = exit }, + ); + + f.setTerminator(exit, .{ .ret = ir.function.Ret.none() }); + return f; +} + +fn mul(f: *Function, block: ir.function.Block, ty: ir.types.Type, lhs: Value, rhs: Value) !Value { + return f.appendInst(block, ty, .{ .arith = .{ .op = .mul, .lhs = lhs, .rhs = rhs } }); +} + +fn add(f: *Function, block: ir.function.Block, ty: ir.types.Type, lhs: Value, rhs: Value) !Value { + return f.appendInst(block, ty, .{ .arith = .{ .op = .add, .lhs = lhs, .rhs = rhs } }); +} + +fn sub(f: *Function, block: ir.function.Block, ty: ir.types.Type, lhs: Value, rhs: Value) !Value { + return f.appendInst(block, ty, .{ .arith = .{ .op = .sub, .lhs = lhs, .rhs = rhs } }); +} + +fn bits(f: *Function, block: ir.function.Block, ty: ir.types.Type, op: ir.function.BinOp, lhs: Value, rhs: Value) !Value { + return f.appendInst(block, ty, .{ .arith = .{ .op = op, .lhs = lhs, .rhs = rhs } }); +} + +fn reinterpret(f: *Function, block: ir.function.Block, ty: ir.types.Type, value: Value) !Value { + return f.appendInst(block, ty, .{ .unary = .{ .op = .reinterpret, .value = value } }); +} + +const pixels: usize = @intCast(width * height); +/// Instructions one warp may run before the model calls it a loop that does not end. The whole +/// frame is about 1.5 million, so this leaves room for a lowering that is several times longer +/// without letting a runaway kernel hang the test. +const budget: usize = 50_000_000; + +fn handWords(out: []u32, view: hand.View) []const u32 { + return hand.kernel(out, @bitCast(fb_base), width, height, iterations, view); +} + +fn lower(allocator: std.mem.Allocator, view: hand.View) ![]u32 { + var f = try buildKernel(allocator, view); + defer f.deinit(); + return glacier.isel.compileKernel(allocator, &f, .{ + .profile = .@"gc1.n", + .globals = &.{.{ .name = framebuffer, .address = fb_base }}, + }); +} + +test "the lowered kernel renders the frame the hand-assembled kernel renders" { + const allocator = std.testing.allocator; + const view = hand.View.fit(width, height); + + var buf: [128]u32 = undefined; + const expected = try warp.render(allocator, handWords(&buf, view), fb_base, pixels, 4, budget); + defer allocator.free(expected); + + const words = try lower(allocator, view); + defer allocator.free(words); + const got = try warp.render(allocator, words, fb_base, pixels, 4, budget); + defer allocator.free(got); + + try std.testing.expectEqualSlices(u32, expected, got); + + // A frame of zeros would pass the diff and prove nothing, and the set is a shape, so the + // frame must hold more than one colour. + var lit: usize = 0; + for (got) |px| { + if (px != 0) lit += 1; + } + try std.testing.expect(lit > pixels / 8); + try std.testing.expect(lit < pixels); +} + +test "the lowered kernel fills the same frame whatever the warp count" { + const allocator = std.testing.allocator; + const view = hand.View.fit(width, height); + const words = try lower(allocator, view); + defer allocator.free(words); + + const one = try warp.render(allocator, words, fb_base, pixels, 1, budget); + defer allocator.free(one); + for ([_]u32{ 2, 4, 8, 16 }) |warps| { + const many = try warp.render(allocator, words, fb_base, pixels, warps, budget); + defer allocator.free(many); + try std.testing.expectEqualSlices(u32, one, many); + } +} + +test "the lowered kernel retires the warp and fits the instruction aperture" { + const allocator = std.testing.allocator; + const words = try lower(allocator, hand.View.fit(width, height)); + defer allocator.free(words); + // The kernel retires once. EBREAK is not the last word: splitting the critical edges appends + // the forwarding blocks after the exit block, so the jumps that serve the loops come last. + var retires: usize = 0; + for (words) |w| { + if (w == glacier.encode.ebreak) retires += 1; + } + try std.testing.expectEqual(@as(usize, 1), retires); + try std.testing.expect(words.len <= 8192); + // Every jump was patched. A surviving zero decodes to nothing useful and would run on into + // the next instruction instead of branching. + for (words) |w| try std.testing.expect(w != 0); +} + +test "the rendered frame mirrors about its middle row" { + // `View.fit` puts the real axis exactly on the middle row, so the set is symmetric about it. + // This checks the image itself rather than the two word streams agreeing with each other. + const allocator = std.testing.allocator; + const words = try lower(allocator, hand.View.fit(width, height)); + defer allocator.free(words); + const frame = try warp.render(allocator, words, fb_base, pixels, 4, budget); + defer allocator.free(frame); + + const w: usize = @intCast(width); + const h: usize = @intCast(height); + var y: usize = 1; + while (y < h / 2) : (y += 1) { + const above = frame[(h / 2 - y) * w ..][0..w]; + const below = frame[(h / 2 + y) * w ..][0..w]; + try std.testing.expectEqualSlices(u32, above, below); + } +} + +test "the backend refuses what a Glacier warp cannot do" { + const allocator = std.testing.allocator; + + // A kernel that returns a value. There is no caller to return it to. + { + var f = Function.init(allocator); + defer f.deinit(); + const u32_t = try f.types.intern(.{ .int = .{ .signedness = .unsigned, .bits = 32 } }); + const entry = try f.appendBlock(); + const n = try f.appendInst(entry, u32_t, .{ .iconst = 7 }); + f.setTerminator(entry, .{ .ret = ir.function.Ret.one(n) }); + try std.testing.expectError( + error.Unsupported, + glacier.isel.compileKernel(allocator, &f, .{ .profile = .@"gc1.n" }), + ); + } + + // An entry parameter that is not a hardware builtin. A kernel has no parameter block to read + // it from, so the value would have to come from nowhere. + { + var f = Function.init(allocator); + defer f.deinit(); + const u32_t = try f.types.intern(.{ .int = .{ .signedness = .unsigned, .bits = 32 } }); + const entry = try f.appendBlock(); + _ = try f.appendBlockParam(entry, u32_t); + f.setTerminator(entry, .{ .ret = ir.function.Ret.none() }); + try std.testing.expectError( + error.Unsupported, + glacier.isel.compileKernel(allocator, &f, .{ .profile = .@"gc1.n" }), + ); + } + + // A global the options do not place. Guessing an address writes somewhere real. + { + var f = Function.init(allocator); + defer f.deinit(); + const ptr_t = try f.types.ptrGlobal(); + const u32_t = try f.types.intern(.{ .int = .{ .signedness = .unsigned, .bits = 32 } }); + const entry = try f.appendBlock(); + const p = try f.appendGlobalAddr(entry, ptr_t, "nowhere"); + const v = try f.appendInst(entry, u32_t, .{ .iconst = 1 }); + try f.appendStore(entry, v, p); + f.setTerminator(entry, .{ .ret = ir.function.Ret.none() }); + try std.testing.expectError( + error.Unsupported, + glacier.isel.compileKernel(allocator, &f, .{ .profile = .@"gc1.n" }), + ); + } +} + +/// The same fixed-point algorithm written plainly, so neither word stream is checked only against +/// the other. A frame that matches this matches a third description of the arithmetic. +fn reference(allocator: std.mem.Allocator, view: hand.View) ![]u32 { + const frame = try allocator.alloc(u32, pixels); + const shift = hand.colourShift(iterations); + const escape: u32 = 4 << 12; + for (0..@intCast(height)) |y| { + const cy: i32 = view.y_min + @as(i32, @intCast(y)) * view.dy; + for (0..@intCast(width)) |x| { + const cx: i32 = view.x_min + @as(i32, @intCast(x)) * view.dx; + var zx: i32 = 0; + var zy: i32 = 0; + var left: u32 = iterations; + while (true) { + const zx2: u32 = @as(u32, @bitCast(zx *% zx)) >> 12; + const zy2: u32 = @as(u32, @bitCast(zy *% zy)) >> 12; + if (zx2 +% zy2 >= escape) break; + const cross = zx *% zy; + const sign = cross >> 31; + const magnitude: u32 = @bitCast((cross ^ sign) -% sign); + // Put the sign back on after the logical shift, so the scaling truncates toward + // zero. An arithmetic shift would round toward minus infinity instead. + const scaled: i32 = @bitCast(magnitude >> 12); + zy = ((scaled ^ sign) -% sign) *% 2 +% cy; + zx = @bitCast(zx2 -% zy2 +% @as(u32, @bitCast(cx))); + left -= 1; + if (left == 0) break; + } + const level = left << shift; + frame[y * @as(usize, @intCast(width)) + x] = level | (level << 8); + } + } + return frame; +} + +test "both word streams render what the plain algorithm renders" { + const allocator = std.testing.allocator; + const view = hand.View.fit(width, height); + const expected = try reference(allocator, view); + defer allocator.free(expected); + + var buf: [128]u32 = undefined; + const by_hand = try warp.render(allocator, handWords(&buf, view), fb_base, pixels, 4, budget); + defer allocator.free(by_hand); + try std.testing.expectEqualSlices(u32, expected, by_hand); + + const words = try lower(allocator, view); + defer allocator.free(words); + const lowered = try warp.render(allocator, words, fb_base, pixels, 4, budget); + defer allocator.free(lowered); + try std.testing.expectEqualSlices(u32, expected, lowered); +} + +/// Where the small kernels below put their data. Not zero, so forming the address needs both +/// halves of a wide constant rather than a single immediate. +const data_base: u32 = 0x1000; + +fn runOnce(code: []const u32, data: []u32, hart_id: u32, hart_count: u32) !void { + var mem: warp.Memory = .{ .base = data_base, .words = data }; + var one: warp.Warp = .{ .hart_id = hart_id, .hart_count = hart_count }; + _ = try warp.run(code, &mem, &one, budget); +} + +fn compile(allocator: std.mem.Allocator, f: *const Function) ![]u32 { + return glacier.isel.compileKernel(allocator, f, .{ + .profile = .@"gc1.n", + .globals = &.{.{ .name = "data", .address = data_base }}, + }); +} + +test "a loop that loads, selects and stores" { + const allocator = std.testing.allocator; + const count: i64 = 6; + + var f = Function.init(allocator); + defer f.deinit(); + const u32_t = try f.types.intern(.{ .int = .{ .signedness = .unsigned, .bits = 32 } }); + const i32_t = try f.types.intern(.{ .int = .{ .signedness = .signed, .bits = 32 } }); + const bool_t = try f.types.intern(.bool); + const ptr_t = try f.types.ptrGlobal(); + + const entry = try f.appendBlock(); + const warp_id = try f.appendBlockParam(entry, u32_t); + try gpu.attrs.setBuiltin(&f, warp_id, .block_id_x); + const loop = try f.appendBlock(); + const index = try f.appendBlockParam(loop, u32_t); + const slot = try f.appendBlockParam(loop, ptr_t); + const exit = try f.appendBlock(); + + const base = try f.appendGlobalAddr(entry, ptr_t, "data"); + const start = try f.appendInst(entry, u32_t, .{ .iconst = 0 }); + try f.setJump(entry, loop, &.{ start, base }); + + const value = try f.appendInst(loop, i32_t, .{ .load = .{ .ptr = slot } }); + const floor = try f.appendInst(loop, i32_t, .{ .iconst = 7 }); + const above = try f.appendInst(loop, bool_t, .{ .icmp = .{ .op = .gt, .lhs = value, .rhs = floor } }); + const clamped = try f.appendInst(loop, i32_t, .{ .select = .{ .cond = above, .then = value, .@"else" = floor } }); + const out = try f.appendArithImm(loop, ptr_t, .add, slot, count * 4); + try f.appendStore(loop, clamped, out); + const next_slot = try f.appendArithImm(loop, ptr_t, .add, slot, 4); + const next_index = try f.appendArithImm(loop, u32_t, .add, index, 1); + const limit = try f.appendInst(loop, u32_t, .{ .iconst = count }); + const more = try f.appendInst(loop, bool_t, .{ .icmp = .{ .op = .ne, .lhs = next_index, .rhs = limit } }); + try f.appendIf(loop, more, .{ .target = loop, .args = &.{ next_index, next_slot } }, .{ .target = exit }); + f.setTerminator(exit, .{ .ret = ir.function.Ret.none() }); + + const words = try compile(allocator, &f); + defer allocator.free(words); + + const inputs = [_]i32{ -3, 0, 7, 8, 100, -1 }; + var data: [12]u32 = @splat(0); + for (inputs, 0..) |v, i| data[i] = @bitCast(v); + try runOnce(words, &data, 0, 1); + for (inputs, 0..) |v, i| { + try std.testing.expectEqual(@as(i32, @max(v, 7)), @as(i32, @bitCast(data[6 + i]))); + } +} + +test "a conditional reaches both arms whichever one is laid out next" { + // The then arm's target is the very next block. A jump whose target follows it can be left + // out, but not this one: the branch skips exactly one word, so dropping the then jump would + // make a false condition skip the else jump instead and fall into the then arm. + const allocator = std.testing.allocator; + + var f = Function.init(allocator); + defer f.deinit(); + const u32_t = try f.types.intern(.{ .int = .{ .signedness = .unsigned, .bits = 32 } }); + const bool_t = try f.types.intern(.bool); + const ptr_t = try f.types.ptrGlobal(); + + const entry = try f.appendBlock(); + const warp_id = try f.appendBlockParam(entry, u32_t); + try gpu.attrs.setBuiltin(&f, warp_id, .block_id_x); + const first = try f.appendBlock(); + const second = try f.appendBlock(); + const join = try f.appendBlock(); + + const base = try f.appendGlobalAddr(entry, ptr_t, "data"); + const zero = try f.appendInst(entry, u32_t, .{ .iconst = 0 }); + const later = try f.appendInst(entry, bool_t, .{ .icmp = .{ .op = .gt, .lhs = warp_id, .rhs = zero } }); + try f.appendIf(entry, later, .{ .target = first }, .{ .target = second }); + + const eleven = try f.appendInst(first, u32_t, .{ .iconst = 11 }); + try f.appendStore(first, eleven, base); + try f.setJump(first, join, &.{}); + const twenty_two = try f.appendInst(second, u32_t, .{ .iconst = 22 }); + try f.appendStore(second, twenty_two, base); + try f.setJump(second, join, &.{}); + f.setTerminator(join, .{ .ret = ir.function.Ret.none() }); + + const words = try compile(allocator, &f); + defer allocator.free(words); + + var data: [1]u32 = .{0}; + try runOnce(words, &data, 0, 2); + try std.testing.expectEqual(@as(u32, 22), data[0]); + try runOnce(words, &data, 1, 2); + try std.testing.expectEqual(@as(u32, 11), data[0]); +} diff --git a/libs/vulcan-target/glacier/tests/mandelbrot.zig b/libs/vulcan-target/glacier/tests/mandelbrot.zig index 55b1e40..0986015 100644 --- a/libs/vulcan-target/glacier/tests/mandelbrot.zig +++ b/libs/vulcan-target/glacier/tests/mandelbrot.zig @@ -15,8 +15,9 @@ //! instruction selector rather than an encoder of its own. The one thing Glacier adds is `ebreak`. //! //! WHAT THIS IS NOT is a test of code generation. The program below is hand-assembled, exactly as -//! the Dart is. Lowering the same image from Vulcan IR and landing on these words is the next -//! step, and this is what it will be checked against. +//! the Dart is. `lower.zig` compiles the same image from Vulcan IR and checks it against this one. +//! It compares the FRAME the two render, not the words: a compiler picks other registers and +//! another instruction order, so the two word streams differ by design. const std = @import("std"); const glacier = @import("vulcan-target").glacier.encode; @@ -29,7 +30,7 @@ const fraction_bits: u5 = 12; /// The complex-plane rectangle a render covers, in fixed point. The kernel walks the view by /// adding `dx` and `dy` to a register, so the pixel grid IS these integers. -const View = struct { +pub const View = struct { x_min: i32, y_min: i32, dx: i32, @@ -38,7 +39,7 @@ const View = struct { /// The whole set in a `width` x `height` frame with square pixels and the real axis exactly on /// the middle row. Deriving `y_min` as a whole number of `dy` steps is what makes the image /// mirror about its middle row, which a render can then be checked against. - fn fit(width: i32, height: i32) View { + pub fn fit(width: i32, height: i32) View { const span_x = toFixed(3.0); const centre_x = toFixed(-0.6); const dx = @divTrunc(span_x, width); @@ -57,7 +58,7 @@ const View = struct { /// How far to shift the surviving iteration count to make a colour: the largest shift that keeps /// the brightest pixel inside one byte lane, so a shade cannot carry into the next channel. -fn colourShift(iterations: u32) u5 { +pub fn colourShift(iterations: u32) u5 { var shift: u5 = 0; while ((iterations << (shift + 1)) <= 0xFF) shift += 1; return shift; diff --git a/libs/vulcan-target/glacier/tests/warp.zig b/libs/vulcan-target/glacier/tests/warp.zig new file mode 100644 index 0000000..b68eb14 --- /dev/null +++ b/libs/vulcan-target/glacier/tests/warp.zig @@ -0,0 +1,269 @@ +//! A model of one Glacier warp's scalar execution, so two word streams can be compared by what +//! they RENDER rather than by how they are spelled. +//! +//! A compiler and a hand assembler pick different registers and a different instruction order, so +//! a word diff says nothing about whether they agree. Running both and diffing the framebuffer +//! does. The FPGA is the real answer, and this is what makes a disagreement visible before a +//! kernel ever reaches it. +//! +//! It models only what a Glacier core decodes and only what a scalar kernel uses: RV32I, RV32M, +//! and a CSR read. An unknown word is an error, never a no-op, so a lowering that emits something +//! this model does not know about is caught rather than silently skipped. + +const std = @import("std"); + +pub const Error = error{ + /// A word this model does not decode. Either the kernel is wrong or the model is behind. + Illegal, + /// An access outside the framebuffer the test gave the warp. + OutOfRange, + Unaligned, + /// The warp ran longer than the caller allowed, so it is in a loop that does not end. + Budget, +}; + +/// The CSRs a Glacier core answers. Anything else is an illegal word here, because a kernel that +/// reads one is relying on hardware this model cannot stand in for. +const csr_mhartid: u12 = 0xF14; +const csr_hart_count: u12 = 0xFC0; + +const ebreak: u32 = 0x00100073; + +/// The memory a kernel writes, as words at `base`. A Glacier kernel reaches memory by absolute +/// address, so the base is part of the program's contract with its host. +pub const Memory = struct { + base: u32, + words: []u32, + + fn index(self: Memory, addr: u32) Error!usize { + if (addr % 4 != 0) return error.Unaligned; + if (addr < self.base) return error.OutOfRange; + const at = (addr - self.base) / 4; + if (at >= self.words.len) return error.OutOfRange; + return at; + } +}; + +/// One warp: a register file, a program counter, and the identity the hardware gives it. +pub const Warp = struct { + x: [32]u32 = @splat(0), + pc: u32 = 0, + hart_id: u32, + hart_count: u32, + + fn set(self: *Warp, rd: u5, value: u32) void { + if (rd != 0) self.x[rd] = value; + } +}; + +fn signExtend(value: u32, bits: u5) i32 { + const shift: u5 = @intCast(32 - @as(u6, bits)); + return @as(i32, @bitCast(value << shift)) >> shift; +} + +fn immI(word: u32) i32 { + return signExtend(word >> 20, 12); +} + +fn immS(word: u32) i32 { + const low = (word >> 7) & 0x1F; + const high = word >> 25; + return signExtend((high << 5) | low, 12); +} + +fn immB(word: u32) i32 { + const bit11 = (word >> 7) & 1; + const low = (word >> 8) & 0xF; + const mid = (word >> 25) & 0x3F; + const sign = (word >> 31) & 1; + return signExtend((sign << 12) | (bit11 << 11) | (mid << 5) | (low << 1), 13); +} + +fn immJ(word: u32) i32 { + const bit11 = (word >> 20) & 1; + const low = (word >> 21) & 0x3FF; + const high = (word >> 12) & 0xFF; + const sign = (word >> 31) & 1; + return signExtend((sign << 20) | (high << 12) | (bit11 << 11) | (low << 1), 21); +} + +/// Run `code` from word zero until it retires, and return how many instructions it took. +/// +/// `code` sits at address zero of its own aperture, which is separate from `mem`: a Glacier core +/// takes its program through the instruction aperture and its data over the bus, so nothing a +/// kernel stores can rewrite the kernel. +pub fn run(code: []const u32, mem: *Memory, warp: *Warp, budget: usize) Error!usize { + var steps: usize = 0; + while (true) { + if (steps == budget) return error.Budget; + if (warp.pc % 4 != 0) return error.Unaligned; + const at = warp.pc / 4; + if (at >= code.len) return error.OutOfRange; + const word = code[at]; + if (word == ebreak) return steps; + steps += 1; + warp.pc += 4; + try step(word, mem, warp); + } +} + +fn step(word: u32, mem: *Memory, warp: *Warp) Error!void { + const opcode = word & 0x7F; + const rd: u5 = @intCast((word >> 7) & 0x1F); + const rs1: u5 = @intCast((word >> 15) & 0x1F); + const rs2: u5 = @intCast((word >> 20) & 0x1F); + const funct3 = (word >> 12) & 0x7; + const funct7 = word >> 25; + const a = warp.x[rs1]; + const b = warp.x[rs2]; + + switch (opcode) { + // LUI + 0x37 => warp.set(rd, word & 0xFFFF_F000), + // AUIPC. The program counter has already advanced, so the base is one word back. + 0x17 => warp.set(rd, (warp.pc -% 4) +% (word & 0xFFFF_F000)), + // JAL + 0x6F => { + const target = (warp.pc -% 4) +% @as(u32, @bitCast(immJ(word))); + warp.set(rd, warp.pc); + warp.pc = target; + }, + // JALR + 0x67 => { + if (funct3 != 0) return error.Illegal; + const target = (a +% @as(u32, @bitCast(immI(word)))) & ~@as(u32, 1); + warp.set(rd, warp.pc); + warp.pc = target; + }, + // Conditional branches. + 0x63 => { + const taken = switch (funct3) { + 0 => a == b, + 1 => a != b, + 4 => @as(i32, @bitCast(a)) < @as(i32, @bitCast(b)), + 5 => @as(i32, @bitCast(a)) >= @as(i32, @bitCast(b)), + 6 => a < b, + 7 => a >= b, + else => return error.Illegal, + }; + if (taken) warp.pc = (warp.pc -% 4) +% @as(u32, @bitCast(immB(word))); + }, + // Loads. + 0x03 => { + const addr = a +% @as(u32, @bitCast(immI(word))); + if (funct3 != 2) return error.Illegal; // only LW, see encode.zig on narrow access + warp.set(rd, mem.words[try mem.index(addr)]); + }, + // Stores. + 0x23 => { + const addr = a +% @as(u32, @bitCast(immS(word))); + if (funct3 != 2) return error.Illegal; // only SW, see encode.zig on narrow stores + mem.words[try mem.index(addr)] = b; + }, + // Register-immediate arithmetic. + 0x13 => { + const imm = immI(word); + const uimm: u32 = @bitCast(imm); + const shamt: u5 = @intCast(rs2); + warp.set(rd, switch (funct3) { + 0 => a +% uimm, + 1 => a << shamt, + 2 => if (@as(i32, @bitCast(a)) < imm) 1 else 0, + 3 => if (a < uimm) 1 else 0, + 4 => a ^ uimm, + 5 => switch (funct7) { + 0x00 => a >> shamt, + 0x20 => @bitCast(@as(i32, @bitCast(a)) >> shamt), + else => return error.Illegal, + }, + 6 => a | uimm, + 7 => a & uimm, + else => unreachable, + }); + }, + // Register-register arithmetic, including the M extension. + 0x33 => warp.set(rd, try arith(funct3, funct7, a, b)), + // CSR read. CSRRS with x0 as the set mask is the only CSR access a Glacier core decodes. + 0x73 => { + if (funct3 != 2 or rs1 != 0) return error.Illegal; + warp.set(rd, switch (@as(u12, @intCast(word >> 20))) { + csr_mhartid => warp.hart_id, + csr_hart_count => warp.hart_count, + else => return error.Illegal, + }); + }, + else => return error.Illegal, + } +} + +fn arith(funct3: u32, funct7: u32, a: u32, b: u32) Error!u32 { + const sa: i32 = @bitCast(a); + const sb: i32 = @bitCast(b); + const shamt: u5 = @truncate(b); + if (funct7 == 0x01) return switch (funct3) { + 0 => a *% b, + 1 => @bitCast(@as(i32, @truncate((@as(i64, sa) * @as(i64, sb)) >> 32))), + 2 => @bitCast(@as(i32, @truncate((@as(i64, sa) * @as(i64, b)) >> 32))), + 3 => @truncate((@as(u64, a) * @as(u64, b)) >> 32), + // Division by zero gives all ones, and the most negative value over minus one wraps. + 4 => if (b == 0) + @bitCast(@as(i32, -1)) + else if (sa == std.math.minInt(i32) and sb == -1) + @bitCast(sa) + else + @bitCast(@divTrunc(sa, sb)), + 5 => if (b == 0) std.math.maxInt(u32) else a / b, + 6 => if (b == 0) + a + else if (sa == std.math.minInt(i32) and sb == -1) + 0 + else + @bitCast(@rem(sa, sb)), + 7 => if (b == 0) a else a % b, + else => unreachable, + }; + return switch (funct3) { + 0 => switch (funct7) { + 0x00 => a +% b, + 0x20 => a -% b, + else => error.Illegal, + }, + 1 => a << shamt, + 2 => if (sa < sb) 1 else 0, + 3 => if (a < b) 1 else 0, + 4 => a ^ b, + 5 => switch (funct7) { + 0x00 => a >> shamt, + 0x20 => @bitCast(sa >> shamt), + else => error.Illegal, + }, + 6 => a | b, + 7 => a & b, + else => unreachable, + }; +} + +/// Run `code` on `warps` warps that all share one framebuffer, and return the frame. +/// +/// Every warp runs the SAME program and finds its own share from `mhartid`, which is how a +/// Glacier kernel is launched. The warps run one after another here. A Glacier core issues them +/// round-robin, and the two orders give the same frame only because each warp owns the pixels it +/// writes, which is a property of the kernel this is checking. +pub fn render( + allocator: std.mem.Allocator, + code: []const u32, + fb_base: u32, + pixels: usize, + warps: u32, + budget: usize, +) (Error || std.mem.Allocator.Error)![]u32 { + const frame = try allocator.alloc(u32, pixels); + errdefer allocator.free(frame); + @memset(frame, 0); + var mem: Memory = .{ .base = fb_base, .words = frame }; + for (0..warps) |w| { + var warp: Warp = .{ .hart_id = @intCast(w), .hart_count = warps }; + _ = try run(code, &mem, &warp, budget); + } + return frame; +} From 3aa599ac38e5e82af75528039c9f881b925ffc60 Mon Sep 17 00:00:00 2001 From: Tristan Ross Date: Tue, 6 Oct 2026 11:13:06 -0700 Subject: [PATCH 4/7] fix(glacier): sub-word stores work, they do not destroy neighbours --- libs/vulcan-target/glacier/encode.zig | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/libs/vulcan-target/glacier/encode.zig b/libs/vulcan-target/glacier/encode.zig index 831bce0..80d6dc4 100644 --- a/libs/vulcan-target/glacier/encode.zig +++ b/libs/vulcan-target/glacier/encode.zig @@ -14,17 +14,13 @@ //! - No LR/SC. The A extension atomics are there, the load-reserved pair is not. //! - No FP square root, and FP32 truncates toward zero with subnormals flushed rather than //! rounding to nearest. Anything that assumes IEEE default rounding differs in the last bit. -//! - No narrow stores, and a narrow store is DESTRUCTIVE rather than merely wide. The scalar -//! store data path zero pads rs2 with no byte-lane placement and SEL is hardwired to 0xF, so -//! SB writes a whole word: the value in the low byte and zeros in the other three. Against -//! memory it destroys the three neighbouring bytes. SB and SH are unusable, and the only -//! lowering for a sub-word write is to pack it into a word and use SW. A memory-mapped -//! register can tolerate this (Harbor's UART decodes with a mix of address bit two and two SEL -//! bits, which is why a banner kernel appears to work), so check the device, never the -//! precedent. -//! - Narrow LOADS are fine. LB, LBU, LH and LHU all decode: the core reads the word, selects -//! the byte by the low two address bits or the halfword by bit one, then extends by the -//! instruction's unsigned bit. SEL does not apply to a read. +//! +//! Sub-word access is NOT one of the absences. Both halves work: LB, LBU, LH and LHU extend from +//! the addressed byte or halfword, and SB and SH drive the bus byte select for the lane they +//! address, so a neighbouring byte in the same word is untouched. This lowering emits word access +//! only, which is a limit of the lowering and not of the core: a sub-word value needs every +//! operation on it truncated to its width, and skipping that is a wrong answer rather than a +//! refusal. Packing into a word and using SW stays correct, it is just no longer forced. const std = @import("std"); From 0b3a448dffcd52b37c00e045ece8665733cf4040 Mon Sep 17 00:00:00 2001 From: Tristan Ross Date: Tue, 6 Oct 2026 11:16:47 -0700 Subject: [PATCH 5/7] feat(glacier): spill into the hart's scratch region --- libs/vulcan-target/glacier/encode.zig | 21 +++ libs/vulcan-target/glacier/isel.zig | 182 ++++++++++++++------- libs/vulcan-target/glacier/tests/lower.zig | 92 ++++++++++- libs/vulcan-target/glacier/tests/warp.zig | 41 ++++- 4 files changed, 273 insertions(+), 63 deletions(-) diff --git a/libs/vulcan-target/glacier/encode.zig b/libs/vulcan-target/glacier/encode.zig index 80d6dc4..92ea26c 100644 --- a/libs/vulcan-target/glacier/encode.zig +++ b/libs/vulcan-target/glacier/encode.zig @@ -45,6 +45,12 @@ pub const Csr = struct { /// strides its work by this and offsets it by `mhartid`, so one entry point fills whatever /// core it is launched on without being told the shape by its host. pub const hart_count: u12 = 0xFC0; + + /// Standard machine scratch CSR, read only here: the INITIAL STACK POINTER of the hart doing + /// the read, one past the top of its own scratch region. It is the top and not the base so a + /// downward stack never needs the region size, which lets the size change with the core + /// generation without a kernel changing. Reads zero on a core built with no scratch. + pub const mscratch: u12 = 0x340; }; /// Read a CSR into `rd`. CSRRS with x0 as the set mask, so it reads without writing. @@ -80,6 +86,21 @@ pub const Profile = enum { .@"gc1.ma" => 16, }; } + + /// Bytes of private scratch one hart owns, which is the whole stack a kernel gets. + /// + /// A kernel never reads this number: `mscratch` gives it one past the top of its own region + /// and the stack grows down from there, so the size can change without a kernel changing. A + /// COMPILER does read it, because it must know when a spill would walk out of the region and + /// into the hart below, and the generation is in the profile name so a later core cannot + /// quietly move it. + pub fn scratchBytes(self: Profile) u32 { + return switch (self) { + .@"gc1.n", .@"gc1.mi" => 256, + .@"gc1.s" => 512, + .@"gc1.f", .@"gc1.ma" => 1024, + }; + } }; /// Load an arbitrary 32-bit constant into `rd`, returning the one or two words it takes. diff --git a/libs/vulcan-target/glacier/isel.zig b/libs/vulcan-target/glacier/isel.zig index 95c08a1..7efbf9c 100644 --- a/libs/vulcan-target/glacier/isel.zig +++ b/libs/vulcan-target/glacier/isel.zig @@ -42,11 +42,20 @@ pub const Error = std.mem.Allocator.Error || error{Unsupported}; /// allocatable, so no value ever lives here across an instruction boundary. const scratch: Reg = .x31; -/// x1 to x30. A Glacier kernel has neither a call nor a stack, so the usual return-address and -/// stack-pointer reservations buy nothing and both registers carry data instead. -const allocatable: [30]u16 = blk: { - var regs: [30]u16 = undefined; - for (®s, 1..) |*r, i| r.* = i; +/// The stack pointer. Reserved by the Glacier ABI whether or not a kernel spills, and loaded from +/// `mscratch`, which reads one past the top of the reading hart's own scratch region. +const sp: Reg = .x2; + +/// x1 and x3 to x30. A Glacier kernel has no call, so the return-address register carries data +/// like any other. `x2` is the ABI stack pointer and `x31` is the lowering's scratch. +const allocatable: [29]u16 = blk: { + var regs: [29]u16 = undefined; + var n: usize = 0; + for (1..31) |i| { + if (i == @backingInt(sp)) continue; + regs[n] = i; + n += 1; + } break :blk regs; }; @@ -100,15 +109,19 @@ pub fn compileKernel(allocator: std.mem.Allocator, func: *const Function, opts: var walloc = try wimmer.allocate(allocator, &work, &desc); defer walloc.deinit(allocator); - // A spill needs a stack and a warp has no stack. Refuse here rather than emit a store to an - // address the hardware never promised. - for (walloc.slot_count_per_class) |n| if (n != 0) return error.Unsupported; + // A spill goes into the hart's own scratch region, so the kernel must fit there. The region + // belongs to one hart, which is why no other hart's slots are counted against it. + const slots = walloc.slot_count_per_class[0]; + if (slots > opts.profile.scratchBytes() / 4) return error.Unsupported; + // The deepest slot is reached by one signed 12-bit displacement from `sp`. + if (slots * 4 > 2048) return error.Unsupported; var e: Emitter = .{ .allocator = allocator, .func = &work, .opts = opts, .alloc = &walloc, + .spills = slots != 0, }; defer e.deinit(); try e.run(); @@ -138,10 +151,9 @@ fn copySource(_: *const anyopaque, func: *const Function, v: Value) ?Value { /// Whether `ty` is a value a Glacier register holds. One 32-bit word, nothing narrower. /// -/// A narrow store is refused because the hardware has none: a byte store writes a whole word and -/// destroys its neighbours (see `encode.zig`). A narrow LOAD does exist, so what blocks a sub-word -/// value here is this lowering and not the core: every operation on one would have to truncate to -/// its width, and silently skipping that truncation is a wrong answer rather than a refusal. +/// The core has sub-word loads AND sub-word stores (see `encode.zig`), so what blocks a narrower +/// value here is this lowering and not the hardware: every operation on one would have to truncate +/// to its width, and silently skipping that truncation is a wrong answer rather than a refusal. fn isWord(func: *const Function, ty: Type) bool { return switch (func.types.type_kind(ty)) { .bool, .ptr => true, @@ -256,6 +268,8 @@ const Emitter = struct { func: *const Function, opts: Options, alloc: *const wimmer.Allocation, + /// Whether the allocation uses the scratch region, so the prologue must load `sp`. + spills: bool, words: std.ArrayList(u32) = .empty, block_start: std.ArrayList(u32) = .empty, fixups: std.ArrayList(Fixup) = .empty, @@ -274,24 +288,28 @@ const Emitter = struct { try self.words.appendSlice(self.allocator, ws); } - /// The register holding `v` at position `pos`, or null when the allocator placed nothing - /// there because nothing reads it. - fn regAt(self: *Emitter, v: Value, pos: u32) Error!?Reg { + /// Where `v` lives at position `pos`, or null when the allocator placed it nowhere because + /// nothing reads it. + fn locAt(self: *const Emitter, v: Value, pos: u32) ?wimmer.Location { const segs = self.alloc.segments.get(v) orelse return null; var found: ?wimmer.Location = null; for (segs) |s| { if (s.from > pos) break; found = s.loc; } - const loc = found orelse return null; - return switch (loc) { - .reg => |r| @as(Reg, @fromBackingInt(@as(u5, @intCast(r)))), - .slot => error.Unsupported, - }; + return found; } - fn reg(self: *Emitter, v: Value, pos: u32) Error!Reg { - return (try self.regAt(v, pos)) orelse error.Unsupported; + /// The register holding operand `v` at `pos`. + /// + /// Every operand here is `must_have_register`, so the allocator reloads a spilled one into a + /// register before the instruction that reads it. A slot here would mean that contract broke, + /// so refuse rather than invent a reload the liveness never accounted for. + fn reg(self: *const Emitter, v: Value, pos: u32) Error!Reg { + return switch (self.locAt(v, pos) orelse return error.Unsupported) { + .reg => |r| regOf(r), + .slot => error.Unsupported, + }; } /// Materialize `value` into `dst`, using one or two words. @@ -339,14 +357,27 @@ const Emitter = struct { /// The entry prologue: every entry parameter is a hardware builtin, so each one is a CSR read /// or a core constant written into the register the allocator gave it. + /// + /// A kernel that spills loads `sp` here. A kernel that does not leaves it alone: nothing else + /// reads it, because this target has no calls, and a core built with no scratch region reads + /// zero from `mscratch` anyway. fn prologue(self: *Emitter, block: Block) Error!void { + if (self.spills) try self.put(glacier.csrr(sp, glacier.Csr.mscratch)); for (self.func.blockParams(block)) |p| { - const dst = (try self.regAt(p, 0)) orelse continue; + const home = self.locAt(p, 0) orelse continue; + const dst: Reg = switch (home) { + .reg => |r| regOf(r), + .slot => scratch, + }; const b = gpu.attrs.builtinOf(self.func, p) orelse return error.Unsupported; switch (builtinSource(b) orelse return error.Unsupported) { .csr => |c| try self.put(glacier.csrr(dst, c)), .lanes => try self.constant(dst, self.opts.profile.lanes()), } + switch (home) { + .slot => |n| try self.put(rv.sw(dst, sp, try self.slotOffset(n))), + .reg => {}, + } } } @@ -375,53 +406,80 @@ const Emitter = struct { } } + /// Realize one parallel-move set: register moves, spills, reloads, and the slot-to-slot step + /// the Wimmer resolver already routes through the class scratch. fn emitMoves(self: *Emitter, moves: []const wimmer.Move) Error!void { - for (moves) |m| { - const src = switch (m.src) { - .reg => |r| @as(Reg, @fromBackingInt(@as(u5, @intCast(r)))), - .slot => return error.Unsupported, - }; - const dst = switch (m.dst) { - .reg => |r| @as(Reg, @fromBackingInt(@as(u5, @intCast(r)))), - .slot => return error.Unsupported, - }; - try self.move(dst, src); - } + for (moves) |m| switch (m.src) { + .reg => |s| switch (m.dst) { + .reg => |d| try self.move(regOf(d), regOf(s)), + .slot => |d| try self.put(rv.sw(regOf(s), sp, try self.slotOffset(d))), + }, + .slot => |s| switch (m.dst) { + .reg => |d| try self.put(rv.lw(regOf(d), sp, try self.slotOffset(s))), + .slot => |d| { + try self.put(rv.lw(scratch, sp, try self.slotOffset(s))); + try self.put(rv.sw(scratch, sp, try self.slotOffset(d))); + }, + }, + }; + } + + /// The displacement of spill slot `n` from `sp`. The stack grows DOWN from `mscratch`, which + /// reads one past the top of the hart's region, so every slot sits below the pointer. + /// + /// Refusing when `spills` is clear is not redundant. `spills` decides whether the prologue + /// loads `sp` at all, so a slot reference without it would address off an uninitialized + /// register, and that is a silent wrong answer rather than a refusal. + fn slotOffset(self: *const Emitter, n: u32) Error!i12 { + if (!self.spills) return error.Unsupported; + const off: i64 = -(@as(i64, n) + 1) * 4; + if (off < std.math.minInt(i12)) return error.Unsupported; + return @intCast(off); } - /// The allocator's own moves at `pos`. A split with no stack can only be a register-to- - /// register move, so a store or a reload here means something spilled after all. + /// The allocator's own spills, reloads and moves at `pos`. fn actionsAt(self: *Emitter, pos: u32) Error!void { for (self.alloc.actions) |a| { if (a.at != pos) continue; - if (a.kind != .move) return error.Unsupported; try self.emitMoves(&[_]wimmer.Move{.{ .src = a.src, .dst = a.dst, .class = a.class, .value = a.value }}); } } fn emitInst(self: *Emitter, inst: Inst, pos: u32) Error!void { - const result = self.func.instResult(inst); - // A pure instruction nothing reads has no register to write to, so it is dropped. - const dst: ?Reg = if (result) |r| try self.regAt(r, pos) else null; + // A pure instruction nothing reads has no home, so it is dropped. An instruction with no + // result at all (a store) has no home either, and must NOT be dropped. + const home: ?wimmer.Location = if (self.func.instResult(inst)) |r| + (self.locAt(r, pos) orelse return) + else + null; + // A result that lives in a spill slot is computed in the scratch register and stored + // afterwards, so the opcode below never has to know where its result goes. + const dst: Reg = switch (home orelse wimmer.Location{ .reg = @backingInt(scratch) }) { + .reg => |r| regOf(r), + .slot => scratch, + }; switch (self.func.opcode(inst)) { - .iconst => |c| try self.constant(dst orelse return, lowWord(c)), + .iconst => |c| try self.constant(dst, lowWord(c)), .global_addr => |g| try self.constant( - dst orelse return, + dst, @bitCast(addressOf(self.opts, self.func.symbolName(g.symbol)) orelse return error.Unsupported), ), - .unary => |u| try self.move(dst orelse return, try self.reg(u.value, pos)), + .unary => |u| try self.move(dst, try self.reg(u.value, pos)), .arith => |a| { - const d = dst orelse return; const f = binOp(self.func, a.op, self.func.valueType(a.lhs)) orelse return error.Unsupported; - try self.put(f(d, try self.reg(a.lhs, pos), try self.reg(a.rhs, pos))); + try self.put(f(dst, try self.reg(a.lhs, pos), try self.reg(a.rhs, pos))); }, - .arith_imm => |a| try self.emitArithImm(a, dst orelse return, pos), - .icmp => |c| try self.emitCompare(c, dst orelse return, pos), - .select => |s| try self.emitSelect(s, dst orelse return, pos), - .load => |l| try self.put(rv.lw(dst orelse return, try self.reg(l.ptr, pos), 0)), + .arith_imm => |a| try self.emitArithImm(a, dst, pos), + .icmp => |c| try self.emitCompare(c, dst, pos), + .select => |s| try self.emitSelect(s, dst, pos), + .load => |l| try self.put(rv.lw(dst, try self.reg(l.ptr, pos), 0)), .store => |st| try self.put(rv.sw(try self.reg(st.value, pos), try self.reg(st.ptr, pos), 0)), else => return error.Unsupported, } + if (home) |h| switch (h) { + .slot => |n| try self.put(rv.sw(dst, sp, try self.slotOffset(n))), + .reg => {}, + }; } fn emitArithImm(self: *Emitter, a: ir.function.ArithImm, dst: Reg, pos: u32) Error!void { @@ -482,17 +540,23 @@ const Emitter = struct { } } - /// A value-producing conditional. Glacier has no conditional move, so this is a move, a - /// branch over one word, and a move. The taken value goes through the scratch register first - /// so that a destination aliasing either source still reads the right bits. + /// A value-producing conditional. Glacier has no conditional move, so each arm moves its own + /// value and the branch picks the arm. + /// + /// The branch comes FIRST so no temporary is needed. Every source is read either by the branch + /// or by the one move that runs, and `dst` is written once after that read, so a destination + /// that aliases the condition or either source is still correct. A temporary here would have + /// to be the scratch register, and that is where a spilled result is computed. fn emitSelect(self: *Emitter, s: ir.function.Select, dst: Reg, pos: u32) Error!void { const cond = try self.reg(s.cond, pos); const then = try self.reg(s.then, pos); const other = try self.reg(s.@"else", pos); - try self.move(scratch, then); - try self.move(dst, other); - try self.put(rv.beq(cond, .x0, 8)); - try self.move(dst, scratch); + // Always four words, even when a move is onto its own register, so the branch offsets do + // not depend on which registers these turn out to be. + try self.put(rv.beq(cond, .x0, 12)); // false takes the else arm + try self.put(rv.addi(dst, then, 0)); + try self.put(rv.jal(.x0, 8)); // over the else arm + try self.put(rv.addi(dst, other, 0)); } fn emitIf(self: *Emitter, inst: Inst, pos: u32, bi: usize) Error!void { @@ -543,6 +607,12 @@ const Emitter = struct { } }; +/// The register a Wimmer class-relative index names. The numbering is the register's own, so +/// index n is x_n. +fn regOf(index: u16) Reg { + return @fromBackingInt(@as(u5, @intCast(index))); +} + /// Whether `block` leaves by a conditional rather than by its terminator. A conditional transfers /// control itself, so the terminator after one is never reached and never emitted. fn endsInIf(func: *const Function, block: Block) bool { diff --git a/libs/vulcan-target/glacier/tests/lower.zig b/libs/vulcan-target/glacier/tests/lower.zig index 1f1335d..e5057d4 100644 --- a/libs/vulcan-target/glacier/tests/lower.zig +++ b/libs/vulcan-target/glacier/tests/lower.zig @@ -400,9 +400,23 @@ test "both word streams render what the plain algorithm renders" { /// halves of a wide constant rather than a single immediate. const data_base: u32 = 0x1000; +/// Run one warp over `data`, with the words after `scratch_at` standing in for the hart scratch +/// region the SoC build reserves. A hart reads one past the top of its own slice from `mscratch`. fn runOnce(code: []const u32, data: []u32, hart_id: u32, hart_count: u32) !void { - var mem: warp.Memory = .{ .base = data_base, .words = data }; - var one: warp.Warp = .{ .hart_id = hart_id, .hart_count = hart_count }; + const stride = glacier.encode.Profile.@"gc1.n".scratchBytes(); + const scratch_words = (stride / 4) * hart_count; + const scratch_at = if (data.len > scratch_words) data.len - scratch_words else data.len; + const scratch_base = data_base + @as(u32, @intCast(scratch_at)) * 4; + var mem: warp.Memory = .{ + .base = data_base, + .words = data, + .scratch = .{ .base = scratch_base, .stride = stride, .harts = hart_count }, + }; + var one: warp.Warp = .{ + .hart_id = hart_id, + .hart_count = hart_count, + .stack_top = scratch_base + (hart_id + 1) * stride, + }; _ = try warp.run(code, &mem, &one, budget); } @@ -502,3 +516,77 @@ test "a conditional reaches both arms whichever one is laid out next" { try runOnce(words, &data, 1, 2); try std.testing.expectEqual(@as(u32, 11), data[0]); } + +/// A kernel that loads `count` words, holds them all live at once, and stores their sum. Every +/// load happens before the first add, so `count` values are live together and anything above the +/// register file has to go to the scratch region. +fn buildSumKernel(allocator: std.mem.Allocator, count: usize) !Function { + var f = Function.init(allocator); + errdefer f.deinit(); + const u32_t = try f.types.intern(.{ .int = .{ .signedness = .unsigned, .bits = 32 } }); + const ptr_t = try f.types.ptrGlobal(); + + const entry = try f.appendBlock(); + const base = try f.appendGlobalAddr(entry, ptr_t, "data"); + + const loaded = try allocator.alloc(Value, count); + defer allocator.free(loaded); + for (loaded, 0..) |*v, i| { + const at = try f.appendArithImm(entry, ptr_t, .add, base, @intCast(i * 4)); + v.* = try f.appendInst(entry, u32_t, .{ .load = .{ .ptr = at } }); + } + var total = loaded[0]; + for (loaded[1..]) |v| total = try add(&f, entry, u32_t, total, v); + const out = try f.appendArithImm(entry, ptr_t, .add, base, @intCast(count * 4)); + try f.appendStore(entry, total, out); + f.setTerminator(entry, .{ .ret = ir.function.Ret.none() }); + return f; +} + +test "a kernel with more live values than registers spills into the hart's scratch" { + const allocator = std.testing.allocator; + const count = 40; // above the 29 allocatable registers + + var f = try buildSumKernel(allocator, count); + defer f.deinit(); + const words = try compile(allocator, &f); + defer allocator.free(words); + + // The spill path has to have actually run, or this test passes for the wrong reason. A kernel + // that spills reads `sp` from mscratch in its prologue, and nothing else here reads a CSR. + try std.testing.expectEqual( + glacier.encode.rv.csrrs(.x2, glacier.encode.Csr.mscratch, .x0), + words[0], + ); + + // The words past the inputs and the output stand in for the hart's scratch region, so a spill + // that walked out of its slice would be caught rather than silently land in the inputs. + const scratch_words = comptime glacier.encode.Profile.@"gc1.n".scratchBytes() / 4; + var data: [count + 1 + scratch_words]u32 = @splat(0); + var want: u32 = 0; + for (0..count) |i| { + data[i] = @intCast(i * 7 + 1); + want +%= data[i]; + } + try runOnce(words, &data, 0, 1); + try std.testing.expectEqual(want, data[count]); +} + +test "a kernel that needs more scratch than the core has is refused" { + // gc1.n holds 256 bytes per hart, so 64 slots. Far more live values than that cannot spill + // anywhere, and walking below the region would corrupt the hart underneath. + const allocator = std.testing.allocator; + var f = try buildSumKernel(allocator, 400); + defer f.deinit(); + try std.testing.expectError(error.Unsupported, compile(allocator, &f)); +} + +test "a kernel that does not spill never touches the stack pointer" { + // The ABI reserves x2, and a kernel that has nothing to spill leaves it alone rather than + // paying a word for a pointer it will not use. + const allocator = std.testing.allocator; + const words = try lower(allocator, hand.View.fit(width, height)); + defer allocator.free(words); + const load_sp = glacier.encode.rv.csrrs(.x2, glacier.encode.Csr.mscratch, .x0); + for (words) |w| try std.testing.expect(w != load_sp); +} diff --git a/libs/vulcan-target/glacier/tests/warp.zig b/libs/vulcan-target/glacier/tests/warp.zig index b68eb14..2ce4e5a 100644 --- a/libs/vulcan-target/glacier/tests/warp.zig +++ b/libs/vulcan-target/glacier/tests/warp.zig @@ -15,11 +15,14 @@ const std = @import("std"); pub const Error = error{ /// A word this model does not decode. Either the kernel is wrong or the model is behind. Illegal, - /// An access outside the framebuffer the test gave the warp. + /// An access outside the memory the test gave the warp. OutOfRange, Unaligned, /// The warp ran longer than the caller allowed, so it is in a loop that does not end. Budget, + /// A warp reached into another hart's scratch slice. On hardware this corrupts that hart and + /// shows up as its wrong answer somewhere else, so it is caught here instead. + CrossHart, }; /// The CSRs a Glacier core answers. Anything else is an illegal word here, because a kernel that @@ -27,13 +30,34 @@ pub const Error = error{ const csr_mhartid: u12 = 0xF14; const csr_hart_count: u12 = 0xFC0; +const csr_mscratch: u12 = 0x340; + const ebreak: u32 = 0x00100073; +/// The per-hart scratch region, as the SoC build lays it out: one slice of `stride` bytes per hart, +/// starting at `base`. A hart reads one past the top of ITS OWN slice from `mscratch` and the stack +/// grows down from there. +/// +/// Modelling it is worth the lines because it makes the one failure the ABI warns about visible. A +/// hart that walks out of its slice is corrupting the hart below, and on real hardware that shows +/// up as another hart's wrong answer somewhere else entirely. +pub const Scratch = struct { base: u32, stride: u32, harts: u32 }; + /// The memory a kernel writes, as words at `base`. A Glacier kernel reaches memory by absolute /// address, so the base is part of the program's contract with its host. pub const Memory = struct { base: u32, words: []u32, + /// The scratch region, when the core has one. It lies inside `words` like any other memory. + scratch: ?Scratch = null, + + /// Whether `addr` falls in a hart's scratch slice other than `hart_id`'s own. + fn crossesHart(self: Memory, addr: u32, hart_id: u32) bool { + const s = self.scratch orelse return false; + if (addr < s.base or addr >= s.base + s.stride * s.harts) return false; + const owner = (addr - s.base) / s.stride; + return owner != hart_id; + } fn index(self: Memory, addr: u32) Error!usize { if (addr % 4 != 0) return error.Unaligned; @@ -50,6 +74,9 @@ pub const Warp = struct { pc: u32 = 0, hart_id: u32, hart_count: u32, + /// What `mscratch` reads: one past the top of this hart's own scratch slice. Zero on a core + /// built with no scratch, same as the hardware. + stack_top: u32 = 0, fn set(self: *Warp, rd: u5, value: u32) void { if (rd != 0) self.x[rd] = value; @@ -148,16 +175,19 @@ fn step(word: u32, mem: *Memory, warp: *Warp) Error!void { }; if (taken) warp.pc = (warp.pc -% 4) +% @as(u32, @bitCast(immB(word))); }, - // Loads. + // Loads. Only LW: the lowering emits nothing narrower, so a narrow load here would be a + // lowering this model has not been taught rather than one it should quietly accept. 0x03 => { const addr = a +% @as(u32, @bitCast(immI(word))); - if (funct3 != 2) return error.Illegal; // only LW, see encode.zig on narrow access + if (funct3 != 2) return error.Illegal; + if (mem.crossesHart(addr, warp.hart_id)) return error.CrossHart; warp.set(rd, mem.words[try mem.index(addr)]); }, - // Stores. + // Stores. Only SW, for the same reason as the loads. 0x23 => { const addr = a +% @as(u32, @bitCast(immS(word))); - if (funct3 != 2) return error.Illegal; // only SW, see encode.zig on narrow stores + if (funct3 != 2) return error.Illegal; + if (mem.crossesHart(addr, warp.hart_id)) return error.CrossHart; mem.words[try mem.index(addr)] = b; }, // Register-immediate arithmetic. @@ -189,6 +219,7 @@ fn step(word: u32, mem: *Memory, warp: *Warp) Error!void { warp.set(rd, switch (@as(u12, @intCast(word >> 20))) { csr_mhartid => warp.hart_id, csr_hart_count => warp.hart_count, + csr_mscratch => warp.stack_top, else => return error.Illegal, }); }, From 5ea27d53e33fa341b2fac922e4f8f4d8432eb7af Mon Sep 17 00:00:00 2001 From: Tristan Ross Date: Tue, 6 Oct 2026 11:30:43 -0700 Subject: [PATCH 6/7] feat(glacier): accept a workgroup barrier, which is a warp's lanes in lockstep --- libs/vulcan-target/glacier/encode.zig | 13 +++++++++ libs/vulcan-target/glacier/isel.zig | 14 +++++++++ libs/vulcan-target/glacier/tests/lower.zig | 34 ++++++++++++++++++++++ 3 files changed, 61 insertions(+) diff --git a/libs/vulcan-target/glacier/encode.zig b/libs/vulcan-target/glacier/encode.zig index 92ea26c..3bc36fc 100644 --- a/libs/vulcan-target/glacier/encode.zig +++ b/libs/vulcan-target/glacier/encode.zig @@ -53,6 +53,19 @@ pub const Csr = struct { pub const mscratch: u12 = 0x340; }; +/// The cross-WARP barrier: FENCE with fm = 0001, which has no operands, so it is one constant. +/// A warp parks here until every other LIVE warp reaches one, and a warp that has already retired +/// does not hold the rest. +/// +/// This is NOT what Vulcan's `barrier` opcode means. That one synchronizes a workgroup, a workgroup +/// here is one warp, and the lanes of a warp are elements of a single instruction stream, so they +/// cannot be at different points in the program and have nothing to wait for. This barrier is +/// wider: it is a GRID barrier, which the IR has no scope for. See `isel.zig`. +/// +/// fm = 0001 is a RESERVED FENCE encoding. A future RISC-V spec version could claim it and give +/// this word another meaning. +pub const warp_barrier: u32 = 0x1000000F; + /// Read a CSR into `rd`. CSRRS with x0 as the set mask, so it reads without writing. pub fn csrr(rd: Reg, csr: u12) u32 { return rv.csrrs(rd, csr, .x0); diff --git a/libs/vulcan-target/glacier/isel.zig b/libs/vulcan-target/glacier/isel.zig index 7efbf9c..5bbf9d2 100644 --- a/libs/vulcan-target/glacier/isel.zig +++ b/libs/vulcan-target/glacier/isel.zig @@ -194,6 +194,9 @@ fn checkSupported(func: *const Function, opts: Options) Error!void { .store => |st| if (!isWord(func, func.valueType(st.value))) return error.Unsupported, .global_addr => |g| _ = addressOf(opts, func.symbolName(g.symbol)) orelse return error.Unsupported, .unary => |u| if (u.op != .reinterpret) return error.Unsupported, + .barrier => |b| switch (b.scope) { + .workgroup, .subgroup => {}, + }, // A conditional transfers control, so nothing may follow it in its block, and its // block must have no terminator: a terminator there is an edge the allocator would // resolve and this emits nothing for. @@ -474,6 +477,17 @@ const Emitter = struct { .select => |s| try self.emitSelect(s, dst, pos), .load => |l| try self.put(rv.lw(dst, try self.reg(l.ptr, pos), 0)), .store => |st| try self.put(rv.sw(try self.reg(st.value, pos), try self.reg(st.ptr, pos), 0)), + // A barrier over a workgroup or a subgroup emits NOTHING, and that is the answer + // rather than a shortcut. A workgroup here is one warp, and the lanes of a warp are + // elements of one instruction stream, so they cannot reach different points in the + // program and have nothing to wait for. The memory half needs nothing either: one + // stream, one bus, and in-order completion already order the accesses. + // + // Glacier's own `warp_barrier` is deliberately NOT emitted here. It synchronizes + // WARPS, which is wider than this opcode asks for, and over-synchronizing is not + // conservative: a warp parks until every other live warp arrives, so a warp that took + // a different amount of work would hang here. The IR has no grid scope to spell that. + .barrier => {}, else => return error.Unsupported, } if (home) |h| switch (h) { diff --git a/libs/vulcan-target/glacier/tests/lower.zig b/libs/vulcan-target/glacier/tests/lower.zig index e5057d4..2ac6058 100644 --- a/libs/vulcan-target/glacier/tests/lower.zig +++ b/libs/vulcan-target/glacier/tests/lower.zig @@ -590,3 +590,37 @@ test "a kernel that does not spill never touches the stack pointer" { const load_sp = glacier.encode.rv.csrrs(.x2, glacier.encode.Csr.mscratch, .x0); for (words) |w| try std.testing.expect(w != load_sp); } + +test "a workgroup barrier emits nothing, because a workgroup is one warp" { + // The lanes of a warp are elements of one instruction stream, so they cannot be at different + // points in the program. Glacier's own barrier synchronizes WARPS, which is wider than this + // opcode asks for, and emitting it would park a warp until every other warp arrived. + const allocator = std.testing.allocator; + + var plain = Function.init(allocator); + defer plain.deinit(); + var barriered = Function.init(allocator); + defer barriered.deinit(); + + for ([_]*Function{ &plain, &barriered }, 0..) |f, i| { + const u32_t = try f.types.intern(.{ .int = .{ .signedness = .unsigned, .bits = 32 } }); + const ptr_t = try f.types.ptrGlobal(); + const entry = try f.appendBlock(); + const base = try f.appendGlobalAddr(entry, ptr_t, "data"); + const v = try f.appendInst(entry, u32_t, .{ .iconst = 5 }); + if (i == 1) try f.appendBarrier(entry, .workgroup); + try f.appendStore(entry, v, base); + f.setTerminator(entry, .{ .ret = ir.function.Ret.none() }); + } + + const without = try compile(allocator, &plain); + defer allocator.free(without); + const with = try compile(allocator, &barriered); + defer allocator.free(with); + try std.testing.expectEqualSlices(u32, without, with); + + // And the cross-warp barrier is recorded even though nothing emits it, so the encoding and + // the caution about its reserved FENCE field live where a reader will look. + try std.testing.expectEqual(@as(u32, 0x1000000F), glacier.encode.warp_barrier); + for (with) |w| try std.testing.expect(w != glacier.encode.warp_barrier); +} From 76a96c32da686e5a52464f6f8b5ffadb408c228b Mon Sep 17 00:00:00 2001 From: Tristan Ross Date: Tue, 6 Oct 2026 11:41:15 -0700 Subject: [PATCH 7/7] fix(glacier): the warp barrier is per core, not per device --- libs/vulcan-target/glacier/encode.zig | 15 ++++++++------- libs/vulcan-target/glacier/isel.zig | 12 ++++++++---- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/libs/vulcan-target/glacier/encode.zig b/libs/vulcan-target/glacier/encode.zig index 3bc36fc..18ea04b 100644 --- a/libs/vulcan-target/glacier/encode.zig +++ b/libs/vulcan-target/glacier/encode.zig @@ -53,14 +53,15 @@ pub const Csr = struct { pub const mscratch: u12 = 0x340; }; -/// The cross-WARP barrier: FENCE with fm = 0001, which has no operands, so it is one constant. -/// A warp parks here until every other LIVE warp reaches one, and a warp that has already retired -/// does not hold the rest. +/// The cross-warp barrier: FENCE with fm = 0001, which has no operands, so it is one constant. /// -/// This is NOT what Vulcan's `barrier` opcode means. That one synchronizes a workgroup, a workgroup -/// here is one warp, and the lanes of a warp are elements of a single instruction stream, so they -/// cannot be at different points in the program and have nothing to wait for. This barrier is -/// wider: it is a GRID barrier, which the IR has no scope for. See `isel.zig`. +/// The scope is ONE CORE. A warp parks until every other live warp OF ITS OWN CORE arrives, and a +/// warp that has already retired does not hold the rest. A second core's warps are not involved, so +/// a barrier across cores has to be built from the atomics instead. +/// +/// That scope has no name in Vulcan's IR, which offers only a workgroup and a subgroup. Both of +/// those are one warp here, so this instruction is wider than either and `isel.zig` emits it for +/// neither. See there for why a wider barrier is not the safe choice. /// /// fm = 0001 is a RESERVED FENCE encoding. A future RISC-V spec version could claim it and give /// this word another meaning. diff --git a/libs/vulcan-target/glacier/isel.zig b/libs/vulcan-target/glacier/isel.zig index 5bbf9d2..e79edbd 100644 --- a/libs/vulcan-target/glacier/isel.zig +++ b/libs/vulcan-target/glacier/isel.zig @@ -483,10 +483,14 @@ const Emitter = struct { // program and have nothing to wait for. The memory half needs nothing either: one // stream, one bus, and in-order completion already order the accesses. // - // Glacier's own `warp_barrier` is deliberately NOT emitted here. It synchronizes - // WARPS, which is wider than this opcode asks for, and over-synchronizing is not - // conservative: a warp parks until every other live warp arrives, so a warp that took - // a different amount of work would hang here. The IR has no grid scope to spell that. + // Glacier's own `warp_barrier` is deliberately NOT emitted here. It synchronizes every + // warp of one core, which is wider than this opcode asks for, and a wider barrier is + // NOT the safe choice: a warp parks until its siblings arrive, so a warp that took a + // different amount of work waits for warps that had no reason to meet it. The core can + // time that out and raise a sticky error instead of waiting, but the watchdog budget + // defaults to zero, which disables it, so a build as generated today waits forever. + // Either way the barrier does not do what the IR asked. The IR has no scope for one + // core's warps, so there is nothing to spell it with. .barrier => {}, else => return error.Unsupported, }