Skip to content
30 changes: 30 additions & 0 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -717,6 +717,36 @@ 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);

// 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.
Expand Down
1 change: 1 addition & 0 deletions libs/vulcan-target.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
20 changes: 20 additions & 0 deletions libs/vulcan-target/glacier.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
//! 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");
pub const isel = @import("glacier/isel.zig");
136 changes: 136 additions & 0 deletions libs/vulcan-target/glacier/encode.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
//! 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.
//!
//! 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");

/// 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;

/// 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;
};

/// The cross-warp barrier: FENCE with fm = 0001, which has no operands, so it is one constant.
///
/// 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.
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);
}

/// 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,
};
}

/// 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.
///
/// 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];
}
Loading
Loading