Version: 0.1 (included in Magpie v0.1) Date: 2026-03-01 Status: Draft (Revision 1 — post-consensus review) Supersedes: SPEC.md §31 (GPU Compute — SPIR-V only) Review: Planner + Architect + Critic consensus review completed 2026-03-01
- Overview
- Backends
- Architecture
- Type System Additions
- Kernel Model
- Capability System
- Backend Codegen Specifications
- MLX Integration
- Runtime Architecture
- Kernel Registry
- Profiling System
- Manifest Configuration
- Diagnostic Codes
- Tool Discovery
- Testing Strategy
- Implementation Priority
- Code Examples
This specification expands Magpie's GPU support from a single SPIR-V/Vulkan prototype to a comprehensive multi-backend GPU compute system with six backends and an ML framework integration. All features described herein are part of Magpie v0.1.
| Decision | Choice |
|---|---|
| Dispatch layer | Raw vendor APIs (no wgpu abstraction) |
| Driver loading | Runtime dynamic loading (dlopen/LoadLibrary) |
| Kernel restrictions | Portable core + unsafe escape with capability annotations |
| Workgroup config | Per-backend specialization |
| Memory model | Unified copy-based with memory hints |
| Multi-target | Build-time backend selection |
| Error handling | TResult<T, gpu.TError> with 12-category gpu.ErrorKind enum |
| Fallback | Opt-in CPU fallback (three testing tiers) |
| Launch API | Always 3D (current) |
| Profiling | Integrated event-based tracing + vendor counters |
| Device discovery | Unified device list with backend query |
| MLX model | Separate mlx package (not in std), host array API |
| Registry | Multi-blob with runtime backend selection |
| Backend | Target | Shader Format | API | Priority |
|---|---|---|---|---|
| Vulkan Compute | target(spv) |
SPIR-V 1.6 | Vulkan C API | Exists (upgrade) |
| Metal | target(msl) |
MSL (native emission) | Metal.framework | 1st |
| MLX | Host API only | N/A (uses Metal internally) | MLX C++ API | 2nd |
| CUDA | target(ptx) |
PTX via llc |
CUDA Driver API | 3rd |
| HIP/ROCm | target(hip) |
HSACO via llc |
HIP Driver API (hipModule*) |
4th |
| WebGPU | target(wgsl) |
WGSL (native emission) | WebGPU C API | 5th (second-class) |
Status: Prototype exists in magpie_gpu (~1735 lines). Upgrade to production quality.
- SPIR-V 1.6,
GLComputeexecution model - Runtime dispatches via Vulkan C API (
vkCreateInstance,vkCreateComputePipeline,vkCmdDispatch) - Buffer params →
StorageBufferdescriptors (set 0, sequential bindings) - Scalar params → push constants (std430 alignment, rounded to 16 bytes)
- Workgroup size via SPIR-V specialization constants (override at pipeline creation)
GlobalInvocationIdbuiltin forgpu.global_id
- Native MSL emission from MPIR (no SPIRV-Cross dependency)
- Runtime dispatches via
Metal.framework(MTLDevice,MTLComputePipelineState,MTLComputeCommandEncoder) - Buffer params → Metal argument buffers (sequential indices)
- Scalar params → Metal constant buffer or
setBytes: - Workgroup size via
dispatchThreadgroups:threadsPerThreadgroup:at dispatch time - Address spaces:
device(buffers),constant(scalars),threadgroup(shared memory),thread(locals) thread_position_in_gridforgpu.global_idthreadgroup_position_in_gridforgpu.workgroup_idthreads_per_threadgroupforgpu.workgroup_sizethread_position_in_threadgroupforgpu.thread_id
Metal-specific features (via unsafe gpu fn):
gpu.metal.simd_shuffle— SIMD group data exchangegpu.metal.simd_sum— SIMD group reductiongpu.metal.simd_prefix_sum— SIMD group prefix scangpu.metal.imageblock— imageblock memory accessgpu.metal.tile_memory— tile memory for tile shaders
- Generates LLVM IR with
nvptx64-nvidia-cudatriple - Shells out to
llc -march=nvptx64to produce PTX text - Runtime dispatches via CUDA Driver API (
cuModuleLoadData,cuLaunchKernel) - Buffer params → device pointers passed as kernel arguments
- Scalar params → passed directly as kernel arguments
- Workgroup size via
cuLaunchKernelgrid/block dimensions - Address spaces: 1 (global/device), 3 (shared), 4 (constant), 5 (local)
threadIdx.x/y/zforgpu.thread_idblockIdx.x/y/zforgpu.workgroup_idblockDim.x/y/zforgpu.workgroup_sizeblockIdx * blockDim + threadIdxforgpu.global_id
- Generates LLVM IR with
amdgcn-amd-amdhsatriple - Shells out to
llc -march=amdgcn+lldto produce HSACO ELF - Runtime dispatches via HIP Driver API (
hipModuleLoadData,hipModuleLaunchKernel) - Structurally identical to CUDA backend — same LLVM IR →
llc→ blob pattern - Buffer params → device pointers as kernel arguments
- Address spaces: 1 (global), 3 (shared/LDS), 4 (constant)
- Same thread/block/grid intrinsic mapping as CUDA
Second-class target. Only supports a documented subset of gpu fn features.
- Native WGSL text emission from MPIR
- Runtime dispatches via WebGPU C API (
wgpuDeviceCreateComputePipeline,wgpuComputePassEncoderDispatchWorkgroups) - Buffer params →
@group(0) @binding(N) var<storage, read_write> buf: array<T> - Scalar params →
@group(0) @binding(N) var<uniform> params: Paramsstruct - Workgroup size via
@workgroup_size(x, y, z)attribute (compile-time only) global_invocation_idbuiltin forgpu.global_id
WGSL limitations (compile-time enforced):
- No pointers or pointer arithmetic
- No structs-of-arrays
- No unions or tagged unions
- Limited type system (no arbitrary value structs in buffers)
- Max workgroup size: 256 per dimension
- Max storage buffers: 8 per pipeline
- Compiler MUST emit
MPG_WGSL_1001error for unsupported patterns
MLX is NOT a kernel target. It provides a high-level array computation and ML API. See Section 8: MLX Integration for full specification.
The GPU subsystem is organized as one crate per backend plus a shared core:
magpie_gpu/ — shared validation, kernel layout, BackendEmitter trait, registry IR
magpie_gpu_spirv/ — SPIR-V 1.6 binary emission (existing code, extracted)
magpie_gpu_msl/ — Metal Shading Language text emission
magpie_gpu_ptx/ — LLVM IR generation (nvptx64 triple) + llc invocation
magpie_gpu_hip/ — LLVM IR generation (amdgcn triple) + llc invocation
magpie_gpu_wgsl/ — WGSL text emission
magpie_mlx/ — MLX runtime bindings, array ops, nn, optim, grad
Dependency graph:
magpie_gpu (magpie_mpir, magpie_types, magpie_diag) — core crate, renamed from magpie_gpu_core
├── magpie_gpu_spirv
├── magpie_gpu_msl
├── magpie_gpu_ptx
├── magpie_gpu_hip
└── magpie_gpu_wgsl
magpie_mlx (magpie_types, magpie_diag) — standalone, links MLX C++ API
magpie_rt — runtime backends (Vulkan, Metal, CUDA, HIP, WebGPU dispatch)
magpie_gpu (core) defines the shared trait that all backend crates implement:
pub trait BackendEmitter {
/// Backend identifier (used in registry and diagnostics)
fn backend_id(&self) -> GpuBackend;
/// Validate kernel is compatible with this backend
fn validate_kernel(&self, kernel: &MpirFn, types: &MpirTypeTable) -> Result<(), Vec<Diagnostic>>;
/// Compute parameter layout for this backend
fn compute_layout(&self, kernel: &MpirFn, types: &MpirTypeTable) -> Result<KernelLayout, String>;
/// Emit kernel blob (SPIR-V binary, MSL text, PTX text, HSACO ELF, WGSL text)
fn emit_kernel(&self, kernel: &MpirFn, types: &MpirTypeTable, layout: &KernelLayout)
-> Result<Vec<u8>, String>;
/// File extension for emitted kernel artifacts
fn artifact_extension(&self) -> &str;
}#[repr(u8)]
pub enum GpuBackend {
Spv = 1, // Vulkan SPIR-V
Msl = 2, // Metal Shading Language
Ptx = 3, // CUDA PTX
Hip = 4, // HIP/ROCm HSACO
Wgsl = 5, // WebGPU WGSL
}The existing MPIR GPU intrinsics (GpuThreadId, GpuWorkgroupId, GpuWorkgroupSize, GpuGlobalId) are zero-operand variants that implicitly mean dimension 0. This spec requires parameterized dimension selection for 3D dispatch. The following breaking change to MpirOp is required in Phase 0:
Current (v0.1 prototype):
// magpie_mpir/src/lib.rs — current zero-operand variants
GpuThreadId, // no fields — always dim 0
GpuWorkgroupId, // no fields — always dim 0
GpuWorkgroupSize, // no fields — always dim 0
GpuGlobalId, // no fields — always dim 0Required (this spec):
// magpie_mpir/src/lib.rs — parameterized variants
GpuThreadId { dim: u8 }, // dim ∈ {0, 1, 2}
GpuWorkgroupId { dim: u8 }, // dim ∈ {0, 1, 2}
GpuWorkgroupSize { dim: u8 }, // dim ∈ {0, 1, 2}
GpuGlobalId { dim: u8 }, // dim ∈ {0, 1, 2}Cascade of changes required:
magpie_mpir/src/lib.rs— Adddim: u8field to all four variants; update operand collector (operands()currently returnsvec![]for these)magpie_hir/src/lib.rs— Adddimfield to correspondingHirOpvariantsmagpie_ast/src/lib.rs— Adddimfield toAstOpGPU variantsmagpie_parse/src/lib.rs— Parse{ dim=const.u32 N }syntax for GPU intrinsicsmagpie_sema/src/lib.rs— Lower AST dim → HIR dimmagpie_driver/src/lib.rs— Update HIR → MPIR lowering for dim fieldmagpie_codegen_llvm/src/lib.rs— Usediminstead of hardcoded"0"(line 1816)magpie_gpu/src/lib.rs— Usedimfor SPIR-VGlobalInvocationIdcomponent extraction (currently hardcoded viaids.const_int_0)magpie_own/src/lib.rs,magpie_arc/src/lib.rs,magpie_mono/src/lib.rs— Update pattern matches
MPIR verifier rule: dim MUST be 0, 1, or 2. Values outside this range emit MPS0001 (invalid operand).
MpirFn GPU metadata (BREAKING):
The MpirFn struct (magpie_mpir/src/lib.rs:631-639) must carry GPU kernel metadata so that BackendEmitter can access the target backend, workgroup configuration, and capability requirements:
// Current MpirFn (lines 631-639):
pub struct MpirFn {
pub sid: Sid,
pub name: String,
pub params: Vec<(LocalId, TypeId)>,
pub ret_ty: TypeId,
pub blocks: Vec<MpirBlock>,
pub locals: Vec<MpirLocalDecl>,
pub is_async: bool,
}
// Required MpirFn:
pub struct MpirFn {
pub sid: Sid,
pub name: String,
pub params: Vec<(LocalId, TypeId)>,
pub ret_ty: TypeId,
pub blocks: Vec<MpirBlock>,
pub locals: Vec<MpirLocalDecl>,
pub is_async: bool,
pub gpu_meta: Option<MpirGpuMeta>, // NEW — None for non-GPU fns
}
pub struct MpirGpuMeta {
pub target: GpuBackend, // spv, msl, ptx, hip, wgsl
pub workgroup: [u32; 3], // default (64, 1, 1) if annotation omitted
pub is_unsafe: bool, // true for `unsafe gpu fn`
pub requires: Vec<String>, // capability annotations
}The driver's HIR → MPIR lowering must propagate AstGpuFnDecl.{target, workgroup, is_unsafe, requires} through HIR and into MpirFn.gpu_meta.
GpuLaunch/GpuLaunchAsync 3D grid/block (BREAKING):
The existing MpirOp::GpuLaunch uses scalar groups/threads fields but this spec requires 3D dispatch. The MPIR must change:
// Current (scalar):
GpuLaunch {
device: MpirValue,
kernel: Sid,
groups: MpirValue, // single scalar — 1D only
threads: MpirValue, // single scalar — 1D only
args: Vec<MpirValue>,
}
// Required (3D):
GpuLaunch {
device: MpirValue,
kernel: Sid,
grid: [MpirValue; 3], // grid=[gx, gy, gz]
block: [MpirValue; 3], // block=[bx, by, bz]
args: Vec<MpirValue>,
}Same change applies to GpuLaunchAsync. The LLVM codegen must pass all 6 dimensions to mp_rt_gpu_launch_sync/mp_rt_gpu_launch_async instead of hardcoding i32 1 for y and z.
The compiler and runtime currently use different TypeId values for GPU types. This MUST be unified in Phase 0 before any new GPU types are added:
Current mismatch:
| Type | Compiler (magpie_types) |
Runtime (magpie_rt) |
|---|---|---|
gpu.TDevice |
TypeId(30) |
TYPE_ID_GPU_DEVICE_RT = 9001 |
gpu.TBuffer<?> |
TypeId(31) |
TYPE_ID_GPU_BUFFER_RT = 9002 |
gpu.TFence |
TypeId(32) |
TYPE_ID_GPU_FENCE_RT = 9003 |
gpu.TKernel |
— | TYPE_ID_GPU_KERNEL_RT = 9004 |
Resolution: The runtime MUST adopt the compiler's TypeId values. The runtime constants change to:
// magpie_rt/src/lib.rs — updated to match fixed_type_ids
const TYPE_ID_GPU_DEVICE_RT: u32 = 30; // was 9001
const TYPE_ID_GPU_BUFFER_RT: u32 = 31; // was 9002
const TYPE_ID_GPU_FENCE_RT: u32 = 32; // was 9003
const TYPE_ID_GPU_KERNEL_RT: u32 = 33; // was 9004 — NOTE: conflicts with gpu.TErrorConflict resolution: gpu.TKernel (runtime-only type, not exposed to language) moves to TypeId 50. New GPU types start at 33:
| TypeId | Type | Notes |
|---|---|---|
| 30 | gpu.TDevice |
unchanged |
| 31 | gpu.TBuffer<?> (base) |
unchanged |
| 32 | gpu.TFence |
unchanged |
| 33 | gpu.TError |
NEW |
| 34 | gpu.ErrorKind |
NEW |
| 35 | gpu.TProfileSession |
NEW |
| 36 | gpu.TProfileEvent |
NEW |
| 37 | gpu.TMemoryStats |
NEW |
| 38 | mlx.TArray<?> (base) |
NEW |
| 39 | mlx.nn.TLayerHandle |
NEW |
| 40 | mlx.optim.TOptimizerHandle |
NEW |
| 50 | gpu.TKernel (runtime-internal) |
moved from 9004 |
TypeIds 17-29 and 41-49 are reserved for future use.
All backend dispatch is loaded at runtime via platform-specific dynamic loading:
| Backend | Library | Symbol Prefix | Platforms |
|---|---|---|---|
| Vulkan | libvulkan.so / libvulkan.dylib / vulkan-1.dll |
vk* |
Linux, macOS (MoltenVK), Windows |
| Metal | Metal.framework |
MTL* (Obj-C) |
macOS, iOS |
| CUDA | libcuda.so / nvcuda.dll |
cu* |
Linux, Windows |
| HIP | libamdhip64.so |
hip* |
Linux |
| WebGPU | libwgpu_native.so / wgpu_native.dll |
wgpu* |
All (via wgpu-native) |
| MLX | libmlx.dylib |
mlx_* |
macOS |
Loading behavior:
- At runtime,
mp_rt_gpu_init()attempts todlopenthe library for the configured backend - If the library is not found, GPU operations return
Err(gpu.TError { kind = BackendUnavailable, ... }) - No compile-time feature flags — all backend stubs are always compiled into
magpie_rt - Single binary works everywhere; backends activate based on available drivers
Add bf16 (bfloat16) as a first-class Magpie primitive type:
| Property | Value |
|---|---|
| TypeId | 16 (next available after f64=15) |
| LLVM type | bfloat |
| Size | 2 bytes |
| Alignment | 2 bytes |
| Copy | yes |
| Send | yes |
| Sync | yes |
Operations supported:
bf16.add,bf16.sub,bf16.mul,bf16.div— arithmeticbf16.to_f32,f32.to_bf16— conversionfcmp.*— comparisonsconst.bf16 <value>— literals (parsed as f32, truncated to bf16)
Rationale: bf16 is essential for ML workloads (MLX, CUDA tensor cores) and GPU compute in general. It is useful beyond just MLX.
Required Rust integration (magpie_types):
// PrimType enum — add variant
pub enum PrimType {
// ... existing variants ...
Bf16, // NEW — bfloat16
}
// PrimType methods — required updates
impl PrimType {
pub fn is_float(&self) -> bool {
matches!(self, F16 | F32 | F64 | Bf16) // add Bf16
}
pub fn is_signed(&self) -> bool {
matches!(self, I8 | I16 | I32 | I64 | I128 | F16 | F32 | F64 | Bf16) // add Bf16
}
pub fn bit_width(&self) -> u32 {
match self {
Bf16 => 16, // NEW
// ... existing ...
}
}
}
// prim_layout() — add entry
Bf16 => Layout { size: 2, align: 2 },
// lookup_by_prim() — add mapping
PrimType::Bf16 => fixed_type_ids::BF16, // TypeId(16)
// fixed_type_ids — add constant
pub const BF16: TypeId = TypeId(16);Coercion rules:
bf16does NOT implicitly coerce to or from any other type- Explicit conversion only:
bf16.to_f32andf32.to_bf16 - No
bf16 → f64orf16 → bf16— must go through f32 - Arithmetic on bf16 follows IEEE round-to-nearest-even after each operation
LLVM codegen:
// magpie_codegen_llvm — llvm_ty() method
TypeId(16) => "bfloat", // LLVM bfloat type (supported since LLVM 11)Backend support:
| Backend | bf16 Support | Notes |
|---|---|---|
| SPIR-V | Extension SPV_KHR_bfloat16 |
Requires Vulkan 1.4+ driver |
| Metal/MSL | Native bfloat type |
Apple Silicon M1+ |
| CUDA/PTX | sm_80+ (nv_bfloat16) |
Ampere and later |
| HIP/HSACO | Native bfloat16_t |
CDNA1+ / RDNA3+ |
| WebGPU/WGSL | NOT SUPPORTED | Emit MPG_WGSL_1003 |
Reserved TypeIds: 17, 18, 19 are reserved for future numeric types (e.g., f8e4m3, f8e5m2, complex64).
New heap struct type replacing Str in GPU error positions:
heap struct gpu.TError {
field kind: gpu.ErrorKind
field backend: Str
field message: Str
field code: i32
}
TypeId: 33 (next available after gpu.TFence=32)
enum gpu.ErrorKind {
DeviceLost ; 0 — GPU device became unavailable
OutOfMemory ; 1 — GPU or host memory exhausted
LaunchFailed ; 2 — kernel dispatch failed
InvalidKernel ; 3 — kernel blob is corrupt or incompatible
BackendUnavailable ; 4 — requested backend not available at runtime
BufferError ; 5 — buffer allocation, read, write, or copy failed
TimeoutExpired ; 6 — fence wait or device sync timed out
Unsupported ; 7 — operation not supported on this backend/device
CompilationFailed ; 8 — shader/kernel compilation error at runtime (JIT)
DriverError ; 9 — driver-level error or crash
ResourceExhausted ; 10 — descriptor, binding, or pipeline limits exceeded
ValidationFailed ; 11 — GPU validation/debug layer error
}
TypeId: 34
All GPU host APIs now return TResult<T, gpu.TError> instead of TResult<T, Str>.
ABI migration from Str errors:
The current runtime GPU functions use MpRtHeader** out_errmsg (a Str handle) for error output. This spec changes the error type to gpu.TError. The migration proceeds as follows:
- Phase 0: Add
MpRtGpuErrorstruct andmp_rt_gpu_error_new()constructor tomagpie_rt - Phase 0: Update all existing GPU runtime functions to return
MpRtGpuError*viaout_errinstead ofStrviaout_errmsg. The parameter name changes fromout_errmsgtoout_errto signal the type change. - Phase 0: Update
magpie_codegen_llvmGPU op lowering to expectgpu.TErrorreturn types instead ofStr - Phase 0: Register
gpu.TError(TypeId 33) inMpRtTypeInfowithdrop_fn = mp_rt_gpu_error_dropthat releases the innerbackend: Strandmessage: Strfields
Affected runtime functions (all in magpie_rt/src/lib.rs):
// Old signature:
int32_t mp_rt_gpu_device_default(MpRtHeader** out_dev, MpRtHeader** out_errmsg);
// New signature:
int32_t mp_rt_gpu_device_default(MpRtHeader** out_dev, MpRtHeader** out_err);All 15+ GPU runtime functions with out_errmsg parameters follow the same pattern. The int32_t return value (0=success, -1=error) is unchanged.
| Type | TypeId | Description |
|---|---|---|
gpu.TDevice |
30 | GPU device handle (unchanged) |
gpu.TBuffer<T> |
31 (base) | Typed device buffer (unchanged) |
gpu.TFence |
32 | Async completion handle (unchanged) |
gpu.TError |
33 | Structured GPU error (NEW) |
gpu.ErrorKind |
34 | Error category enum (NEW) |
gpu.TProfileSession |
35 | Profiling session handle (NEW) |
gpu.TProfileEvent |
36 | Profiling event handle (NEW) |
By default, all gpu fn declarations use the portable core restriction set. These restrictions guarantee the kernel compiles and runs correctly on ALL backends (including WGSL second-class with its documented subset).
Portable core restrictions (MUST):
- No heap allocation (
New,ArrNew,MapNew,StrBuilderNew,CallableCapture) →MPG_CORE_1100 - No ARC/ownership ops (
ArcRetain/Release,Share,CloneShared) →MPG_CORE_1101 - No
TCallable/dynamic dispatch (CallIndirect,ArrMap/Filter/Reduce/Foreach) →MPG_CORE_1102 - No recursion →
MPG_CORE_1103 - No
Str,Array,Map,TCallabletypes →MPG_CORE_1104–MPG_CORE_1107 - Allowed types: primitives (including
bf16), value structs of primitives,gpu.TBuffer<T>handles - All OOB checks MUST be explicit unless inside
unsafe {}
Non-portable GPU features are accessible via unsafe gpu fn with mandatory requires() annotations:
unsafe gpu fn @device_alloc_kernel(
%buf: gpu.TBuffer<f32>,
%n: u32
) -> unit target(ptx) requires(device_malloc) {
bb0:
%gid: u32 = gpu.global_id { dim=const.u32 0 }
; ... use device-side malloc ...
ret
}
Rules for unsafe gpu fn:
- MUST have a
requires(cap1, cap2, ...)annotation listing all non-portable capabilities used - Compiler checks that each listed capability is supported by the declared
target() - Compiler error
MPG_CORE_1200if a capability is listed but the target doesn't support it - Compiler error
MPG_CORE_1201if a non-portable feature is used without the correspondingrequires()annotation - Compiler warning
MPG_CORE_1202for everyunsafe gpu fnreminding that the kernel is non-portable
gpu.shared<N,T> and gpu.barrier are portable across all backends. All 5 kernel backends MUST implement them.
gpu fn @reduce_sum(
%input: gpu.TBuffer<f32>,
%output: gpu.TBuffer<f32>,
%n: u32
) -> unit target(spv) {
bb0:
%tid: u32 = gpu.thread_id { dim=const.u32 0 }
%gid: u32 = gpu.global_id { dim=const.u32 0 }
%shared: ptr = gpu.shared<256, f32>
; gpu.shared<N,T> returns a `ptr` to workgroup-local memory in the
; backend-appropriate address space (threadgroup for Metal, addrspace(3)
; for CUDA/HIP, workgroup variable for SPIR-V/WGSL).
; This pointer is valid only within the current workgroup invocation.
; gpu.buffer_load and gpu.buffer_store accept both gpu.TBuffer<T>
; handles (for device buffers) and ptr values (for shared memory).
; NOTE: gpu.shared does NOT allocate a heap object — it references
; statically-sized workgroup memory. This is compatible with the
; "no heap allocation in gpu fn" restriction (MPG_CORE_1100).
gpu.barrier
ret
}
Per-backend shared memory limits (compile-time enforced):
| Backend | Max Shared Memory | Diagnostic |
|---|---|---|
| Vulkan/SPIR-V | 48 KB (typical, device-dependent) | MPG_SPV_2001 |
| Metal/MSL | 32 KB (Apple GPU) | MPG_MSL_2001 |
| CUDA/PTX | 48 KB (default), 96 KB (opt-in) | MPG_PTX_2001 |
| HIP/HSACO | 64 KB (typical) | MPG_HIP_2001 |
| WebGPU/WGSL | 16 KB (spec minimum) | MPG_WGSL_2001 |
Workgroup size is specified via an optional annotation on gpu fn and realized differently per backend:
gpu fn @kernel_add(
%in: gpu.TBuffer<f32>,
%out: gpu.TBuffer<f32>,
%n: u32
) -> unit target(spv) workgroup(256, 1, 1) {
; ...
}
If workgroup() is omitted, the default is (64, 1, 1).
Per-backend realization:
| Backend | Mechanism |
|---|---|
| Vulkan/SPIR-V | Specialization constants (OpSpecConstant) overridable at pipeline creation |
| Metal/MSL | dispatchThreadgroups:threadsPerThreadgroup: at dispatch time |
| CUDA/PTX | cuLaunchKernel block dimensions |
| HIP/HSACO | hipModuleLaunchKernel block dimensions |
| WebGPU/WGSL | @workgroup_size(x, y, z) attribute (compile-time only, not runtime overridable) |
Grammar extension:
GpuFnDecl = "gpu" "fn" FnName "(" Params? ")" "->" Type
"target" "(" BackendId ")"
[ "workgroup" "(" Int "," Int "," Int ")" ]
Block
UnsafeGpuFnDecl = "unsafe" "gpu" "fn" FnName "(" Params? ")" "->" Type
"target" "(" BackendId ")"
[ "workgroup" "(" Int "," Int "," Int ")" ]
"requires" "(" CapList ")"
Block
BackendId = "spv" | "msl" | "ptx" | "hip" | "wgsl"
CapList = Capability { "," Capability }
Capability = Ident [ "." Ident ]Note: FnName, Params, and Type are existing EBNF non-terminals from SPEC.md §7.2. UnsafeGpuFnDecl MUST be added to the Decl production alongside GpuFnDecl.
Required AST struct changes (magpie_ast/src/lib.rs):
// Current struct (lines 171-174):
pub struct AstGpuFnDecl {
pub inner: AstFnDecl,
pub target: String,
}
// Required struct:
pub struct AstGpuFnDecl {
pub inner: AstFnDecl,
pub target: String, // "spv", "msl", "ptx", "hip", "wgsl"
pub is_unsafe: bool, // NEW — true for `unsafe gpu fn`
pub workgroup: Option<[u32; 3]>, // NEW — workgroup(x, y, z) annotation
pub requires: Vec<String>, // NEW — requires(cap1, cap2, ...) list
}
// AstDecl enum — add variant for unsafe:
pub enum AstDecl {
// ... existing ...
GpuFn(AstGpuFnDecl), // existing — now handles both safe and unsafe
// No separate UnsafeGpuFn variant; is_unsafe field distinguishes them
}The launch API remains 3D with unchanged syntax:
%result: TResult<unit, gpu.TError> = gpu.launch {
device=%dev,
kernel=@kernel_add,
grid=[%gx, %gy, %gz],
block=[%bx, %by, %bz],
args=[%buf_in, %buf_out, %n]
}
gpu.launch(synchronous) →TResult<unit, gpu.TError>— REQUIRED on all backendsgpu.launch_async→TResult<gpu.TFence, gpu.TError>— OPTIONAL
Unused grid/block dimensions MUST be set to const.u32 1.
Capabilities are used in requires() annotations on unsafe gpu fn:
Hardware capabilities:
| Capability | Description | SPV | MSL | PTX | HIP | WGSL |
|---|---|---|---|---|---|---|
device_malloc |
Device-side dynamic memory allocation | no | no | yes | yes | no |
recursion |
Recursive function calls in kernel | no | no | yes | yes | no |
dynamic_parallelism |
Launch child kernels from device | no | no | yes | yes | no |
atomics_f32 |
32-bit floating-point atomics | yes* | yes | yes | yes | no |
atomics_f64 |
64-bit floating-point atomics | no | no | yes | yes | no |
subgroups |
Subgroup/warp/SIMD-group operations | yes | yes | yes | yes | no |
cooperative_groups |
Cooperative group primitives | no | no | yes | yes | no |
int64 |
64-bit integer in kernels | yes | yes | yes | yes | no |
f16_arithmetic |
Native half-precision arithmetic | yes | yes | yes | yes | yes |
Resource limit capabilities:
| Capability | Description | Query |
|---|---|---|
max_workgroup_size |
Maximum threads per workgroup | gpu.host.@device_max_workgroup_size(dev) |
max_shared_bytes |
Maximum shared memory per workgroup | gpu.host.@device_max_shared_bytes(dev) |
max_buffers |
Maximum buffer bindings per kernel | gpu.host.@device_max_buffers(dev) |
warp_size |
Warp/wavefront/SIMD-group width | gpu.host.@device_warp_size(dev) |
Backend-specific capabilities (accessible only via unsafe gpu fn with corresponding target):
| Capability | Backend | Description |
|---|---|---|
metal.simd_shuffle |
MSL | SIMD group shuffle operations |
metal.simd_reduce |
MSL | SIMD group reduction (sum, min, max) |
metal.simd_prefix |
MSL | SIMD group prefix scan |
metal.imageblock |
MSL | Imageblock memory access |
metal.tile_memory |
MSL | Tile shader memory |
cuda.tensor_cores |
PTX | Tensor core matrix operations |
cuda.cooperative_launch |
PTX | Cooperative kernel launch |
hip.wave64 |
HIP | AMD 64-wide wavefront operations |
The compiler validates requires() annotations at kernel declaration:
- Parse
requires(cap1, cap2, ...)from theunsafe gpu fndeclaration - Look up the declared
target()backend - For each capability, check the backend's supported capability set
- Emit
MPG_CORE_1200if any capability is unsupported on the target - During kernel body validation, check that all non-portable ops have corresponding
requires()entries
Crate: magpie_gpu_spirv (extracted from current magpie_gpu)
Changes from current implementation:
- Extract from monolithic
magpie_gpuinto dedicated crate - Implement
BackendEmittertrait - Add specialization constants for workgroup size (currently hardcoded 64×1×1)
- Add shared memory support (
OpVariablewithWorkgroupstorage class) - Add
bf16type support (SPIR-VOpTypeFloat 16withBfloat16capability) - Upgrade error returns from
Strtogpu.TError
SPIR-V structure per kernel:
; Capability declarations
OpCapability Shader
OpCapability StorageBuffer16BitAccess ; if bf16 used
; Memory model
OpMemoryModel Logical GLSL450
; Entry point
OpEntryPoint GLCompute %main "main" %gl_GlobalInvocationId
; Execution mode with specialization constants
OpExecutionMode %main LocalSize %spec_x %spec_y %spec_z
; Decorations for buffers
OpDecorate %buf0 DescriptorSet 0
OpDecorate %buf0 Binding 0
; ... function body ...
Crate: magpie_gpu_msl
Emits Metal Shading Language source code directly from MPIR. No SPIRV-Cross dependency.
MSL structure per kernel:
#include <metal_stdlib>
using namespace metal;
kernel void kernel_add(
device float* in [[buffer(0)]],
device float* out [[buffer(1)]],
constant uint& n [[buffer(2)]],
uint gid [[thread_position_in_grid]]
) {
if (gid >= n) return;
out[gid] = in[gid] + 1.0f;
}MPIR → MSL mapping:
| MPIR Construct | MSL Output |
|---|---|
gpu.TBuffer<T> param |
device T* name [[buffer(N)]] |
| Scalar param | constant T& name [[buffer(N)]] |
gpu.global_id { dim=0 } |
uint gid [[thread_position_in_grid]] |
gpu.thread_id { dim=0 } |
uint tid [[thread_position_in_threadgroup]] |
gpu.workgroup_id { dim=0 } |
uint wgid [[threadgroup_position_in_grid]] |
gpu.workgroup_size { dim=0 } |
uint wgsz [[threads_per_threadgroup]] |
gpu.shared<N,T> |
threadgroup T shared_mem[N] |
gpu.barrier |
threadgroup_barrier(mem_flags::mem_threadgroup) |
gpu.buffer_load<T> { buf, idx } |
buf[idx] |
gpu.buffer_store<T> { buf, idx, v } |
buf[idx] = v |
i.add { a, b } |
a + b |
cbr %cond bb1 bb2 |
if (cond) { ... } else { ... } (structured) |
phi |
Local variable with assignments in predecessor blocks |
Control flow structuring:
MSL requires structured control flow (no arbitrary goto). The WGSL backend has the same requirement. Both backends share a common CFG structurizer implemented in magpie_gpu (core crate), avoiding duplicated effort.
Shared CFG Structurizer (magpie_gpu::structurize)
The structurizer converts MPIR's unstructured CFG (basic blocks with br/cbr/ret terminators) into a structured intermediate representation consumed by both MSL and WGSL emitters.
Algorithm: Relooper-style (based on Emscripten's Relooper by Alon Zakai, 2011), with the following phases:
- Dominator tree construction — compute immediate dominators via the Lengauer-Tarjan algorithm
- Loop detection — identify natural loops via back-edge detection in the dominator tree
- Region formation — group blocks into
StructuredRegionnodes using dominance frontiers - Pattern matching — recognize
if-then,if-then-else,while,do-while,break,continuepatterns - Phi elimination — convert SSA phi nodes into local variable assignments at predecessor block exits
Structured intermediate representation:
// magpie_gpu/src/structurize.rs
pub enum StructuredNode {
/// Straight-line code (one basic block, no control flow)
Block {
label: BlockId,
instrs: Vec<MpirInstr>,
},
/// if (cond) { then } else { else_ }
IfElse {
cond: MpirValue,
then_branch: Vec<StructuredNode>,
else_branch: Vec<StructuredNode>,
},
/// loop { body } — natural loop with break/continue
Loop {
body: Vec<StructuredNode>,
},
/// break out of enclosing Loop
Break { depth: u32 },
/// continue to top of enclosing Loop
Continue { depth: u32 },
/// return from kernel
Return,
/// Local variable assignment (from phi elimination)
Assign {
local: LocalId,
value: MpirValue,
},
}
pub fn structurize_cfg(func: &MpirFn) -> Result<Vec<StructuredNode>, StructurizeError>;Error handling:
- Irreducible CFGs (CFGs that cannot be expressed with structured control flow without code duplication): emit
MPG_MSL_1001/MPG_WGSL_1001at compile time. The structurizer does NOT attempt node splitting to make irreducible CFGs reducible — this is deferred to a future version. - In practice, Magpie's SSA form with
cbr/brterminators produces reducible CFGs for all normal programs. Irreducible CFGs can only arise from computed gotos or very unusual loop nesting, neither of which Magpie's surface syntax can produce.
Estimated size: ~1,500-2,000 lines for the structurizer + ~500 lines per backend for structured → text emission.
Backend consumption:
- MSL emitter: walks
Vec<StructuredNode>, emits C-like Metal syntax withif/else/while - WGSL emitter: walks
Vec<StructuredNode>, emits WGSL syntax withif/else/loop/break/continuing
Crate: magpie_gpu_ptx
Generates LLVM IR text with nvptx64-nvidia-cuda target triple, writes to .ll file, invokes llc -march=nvptx64 -mcpu=sm_70 to produce PTX.
LLVM IR structure per kernel:
target datalayout = "e-i64:64-i128:128-v16:16-v32:32-n16:32:64"
target triple = "nvptx64-nvidia-cuda"
define void @kernel_add(float addrspace(1)* %in, float addrspace(1)* %out, i32 %n) {
entry:
%tid = call i32 @llvm.nvvm.read.ptx.sreg.tid.x()
%bid = call i32 @llvm.nvvm.read.ptx.sreg.ctaid.x()
%bdim = call i32 @llvm.nvvm.read.ptx.sreg.ntid.x()
%gid = add i32 %tid, ...
; ... kernel body ...
ret void
}
!nvvm.annotations = !{!0}
!0 = !{void (float addrspace(1)*, float addrspace(1)*, i32)* @kernel_add, !"kernel", i32 1}MPIR → LLVM IR (nvptx64) mapping:
| MPIR Construct | LLVM IR Output |
|---|---|
gpu.TBuffer<T> param |
T addrspace(1)* %name (global address space) |
| Scalar param | T %name (by value) |
gpu.global_id { dim=0 } |
Compute from tid.x + ctaid.x * ntid.x |
gpu.thread_id { dim=0 } |
@llvm.nvvm.read.ptx.sreg.tid.x() |
gpu.workgroup_id { dim=0 } |
@llvm.nvvm.read.ptx.sreg.ctaid.x() |
gpu.workgroup_size { dim=0 } |
@llvm.nvvm.read.ptx.sreg.ntid.x() |
gpu.shared<N,T> |
@shared_mem = addrspace(3) global [N x T] undef |
gpu.barrier |
call void @llvm.nvvm.barrier0() |
gpu.buffer_load<T> |
load T, T addrspace(1)* %ptr |
gpu.buffer_store<T> |
store T %val, T addrspace(1)* %ptr |
llc invocation:
llc -march=nvptx64 -mcpu=sm_70 -O2 -o kernel.ptx kernel.llCrate: magpie_gpu_hip
Structurally identical to PTX backend, but targets amdgcn-amd-amdhsa triple.
LLVM IR structure per kernel:
target datalayout = "e-p:64:64-p1:64:64-p2:32:32-p3:32:32-p4:64:64-p5:32:32-..."
target triple = "amdgcn-amd-amdhsa"
define amdgpu_kernel void @kernel_add(float addrspace(1)* %in, float addrspace(1)* %out, i32 %n) #0 {
entry:
%gid = call i32 @llvm.amdgcn.workitem.id.x()
; ... kernel body ...
ret void
}
attributes #0 = { "amdgpu-flat-work-group-size"="64,256" }MPIR → LLVM IR (amdgcn) mapping:
| MPIR Construct | LLVM IR Output |
|---|---|
gpu.thread_id { dim=0 } |
@llvm.amdgcn.workitem.id.x() |
gpu.workgroup_id { dim=0 } |
@llvm.amdgcn.workgroup.id.x() |
gpu.barrier |
call void @llvm.amdgcn.s.barrier() |
gpu.shared<N,T> |
@shared_mem = addrspace(3) global [N x T] undef |
Build pipeline:
llc -march=amdgcn -mcpu=gfx1030 -O2 -filetype=obj -o kernel.o kernel.ll
ld.lld -shared -o kernel.hsaco kernel.oCrate: magpie_gpu_wgsl
Emits WGSL source text directly from MPIR.
WGSL structure per kernel:
@group(0) @binding(0) var<storage, read_write> input: array<f32>;
@group(0) @binding(1) var<storage, read_write> output: array<f32>;
struct Params {
n: u32,
}
@group(0) @binding(2) var<uniform> params: Params;
@compute @workgroup_size(64, 1, 1)
fn kernel_add(@builtin(global_invocation_id) gid: vec3<u32>) {
if (gid.x >= params.n) { return; }
output[gid.x] = input[gid.x] + 1.0;
}Documented subset (what WGSL supports):
- Buffer load/store via array indexing
- Scalar parameters via uniform struct
- Shared memory via
var<workgroup> - Barrier via
workgroupBarrier() - All integer and float arithmetic
- Comparison and branching
gpu.global_id,gpu.thread_id,gpu.workgroup_id,gpu.workgroup_size
Not supported on WGSL (compiler errors):
- Value structs in buffers →
MPG_WGSL_1001 - Pointer operations →
MPG_WGSL_1002 bf16type →MPG_WGSL_1003(WGSL hasf16but nobf16)- Any
requires()capability →MPG_WGSL_1004 - More than 8 storage buffers →
MPG_WGSL_1005
MLX is exposed as a separate package (mlx, not std.mlx) providing a host-side array computation and ML API. MLX is NOT a kernel target — it does not use gpu fn. Instead, it provides high-level operations that internally dispatch to Metal compute shaders via Apple's MLX C++ library.
Package structure:
mlx.array — Array creation, manipulation, element-wise ops
mlx.linalg — Linear algebra (matmul, solve, inv, svd)
mlx.nn — Neural network layers
mlx.optim — Optimizers
mlx.grad — Automatic differentiation
mlx.random — Random number generation
mlx.fft — Fast Fourier transforms
mlx.TArray<T> is an opaque heap handle backed by an mlx::core::array via C++ FFI. It does NOT wrap gpu.TBuffer<T>.
Design rationale: An earlier draft proposed wrapping
gpu.TBuffer<T>, but this is architecturally unsound: (1) the ARC pass would emit retain/release on a buffer field that doesn't exist at runtime, corrupting reference counts; (2) MLX arrays manage their own shape/strides internally, so Magpie-side fields would go stale after reshape operations; (3) users could applygpu.buffer_loadto the inner buffer, which would crash because no actual GPU buffer exists. The opaque handle pattern avoids all three issues.
; mlx.TArray<T> is an opaque heap handle to mlx::core::array
; Shape, strides, and rank are queried via runtime FFI calls
; ARC manages the handle lifetime; drop_fn calls mlx_array_free
heap struct mlx.TArray<T> {
; Opaque — internal layout managed by magpie_mlx runtime
}
Runtime representation:
// magpie_mlx runtime — C FFI wrapper around mlx::core::array
typedef struct MpRtMlxArrayPayload {
void* mlx_array_ptr; // Pointer to mlx::core::array (C++ object)
} MpRtMlxArrayPayload;
// Registered with MpRtTypeInfo:
// type_id = 38
// drop_fn = mp_rt_mlx_array_drop (calls mlx::core::array destructor)
// flags = FLAG_HEAP | FLAG_HAS_DROPShape and metadata accessors (runtime FFI calls, not struct field access):
fn mlx.array.@shape<T: type>(%a: borrow mlx.TArray<T>) -> Array<u64>
fn mlx.array.@strides<T: type>(%a: borrow mlx.TArray<T>) -> Array<u64>
fn mlx.array.@ndim<T: type>(%a: borrow mlx.TArray<T>) -> u32
fn mlx.array.@size<T: type>(%a: borrow mlx.TArray<T>) -> u64
fn mlx.array.@dtype<T: type>(%a: borrow mlx.TArray<T>) -> u32
Conversion between gpu.TBuffer and mlx.TArray:
; Create an MLX array from a GPU buffer (copies data)
fn mlx.array.@from_buffer<T: type>(%buf: borrow gpu.TBuffer<T>, %shape: borrow Array<u64>) -> TResult<mlx.TArray<T>, gpu.TError>
; Export an MLX array to a GPU buffer (copies data)
fn mlx.array.@to_buffer<T: type>(%a: borrow mlx.TArray<T>, %dev: borrow gpu.TDevice) -> TResult<gpu.TBuffer<T>, gpu.TError>
These are explicit copy operations — there is no zero-cost view between the two types.
Supported element types: f32, f64, f16, bf16, i8, i16, i32, i64, u8, u16, u32, u64, bool
; Creation
fn mlx.array.@zeros<T: type>(%shape: borrow Array<u64>) -> TResult<mlx.TArray<T>, gpu.TError>
fn mlx.array.@ones<T: type>(%shape: borrow Array<u64>) -> TResult<mlx.TArray<T>, gpu.TError>
fn mlx.array.@full<T: type>(%shape: borrow Array<u64>, %value: T) -> TResult<mlx.TArray<T>, gpu.TError>
fn mlx.array.@from_buffer<T: type>(%buf: borrow gpu.TBuffer<T>, %shape: borrow Array<u64>) -> TResult<mlx.TArray<T>, gpu.TError>
fn mlx.array.@arange<T: type>(%start: T, %stop: T, %step: T) -> TResult<mlx.TArray<T>, gpu.TError>
; Shape manipulation
fn mlx.array.@reshape<T: type>(%a: borrow mlx.TArray<T>, %shape: borrow Array<u64>) -> TResult<mlx.TArray<T>, gpu.TError>
fn mlx.array.@transpose<T: type>(%a: borrow mlx.TArray<T>) -> TResult<mlx.TArray<T>, gpu.TError>
fn mlx.array.@expand_dims<T: type>(%a: borrow mlx.TArray<T>, %axis: i32) -> TResult<mlx.TArray<T>, gpu.TError>
fn mlx.array.@squeeze<T: type>(%a: borrow mlx.TArray<T>, %axis: i32) -> TResult<mlx.TArray<T>, gpu.TError>
fn mlx.array.@shape<T: type>(%a: borrow mlx.TArray<T>) -> Array<u64>
fn mlx.array.@ndim<T: type>(%a: borrow mlx.TArray<T>) -> u32
; Element-wise operations
fn mlx.array.@add<T: type>(%a: borrow mlx.TArray<T>, %b: borrow mlx.TArray<T>) -> TResult<mlx.TArray<T>, gpu.TError>
fn mlx.array.@sub<T: type>(%a: borrow mlx.TArray<T>, %b: borrow mlx.TArray<T>) -> TResult<mlx.TArray<T>, gpu.TError>
fn mlx.array.@mul<T: type>(%a: borrow mlx.TArray<T>, %b: borrow mlx.TArray<T>) -> TResult<mlx.TArray<T>, gpu.TError>
fn mlx.array.@div<T: type>(%a: borrow mlx.TArray<T>, %b: borrow mlx.TArray<T>) -> TResult<mlx.TArray<T>, gpu.TError>
fn mlx.array.@neg<T: type>(%a: borrow mlx.TArray<T>) -> TResult<mlx.TArray<T>, gpu.TError>
fn mlx.array.@abs<T: type>(%a: borrow mlx.TArray<T>) -> TResult<mlx.TArray<T>, gpu.TError>
fn mlx.array.@exp<T: type>(%a: borrow mlx.TArray<T>) -> TResult<mlx.TArray<T>, gpu.TError>
fn mlx.array.@log<T: type>(%a: borrow mlx.TArray<T>) -> TResult<mlx.TArray<T>, gpu.TError>
fn mlx.array.@sqrt<T: type>(%a: borrow mlx.TArray<T>) -> TResult<mlx.TArray<T>, gpu.TError>
fn mlx.array.@pow<T: type>(%a: borrow mlx.TArray<T>, %b: borrow mlx.TArray<T>) -> TResult<mlx.TArray<T>, gpu.TError>
; Reduction
fn mlx.array.@sum<T: type>(%a: borrow mlx.TArray<T>, %axis: i32) -> TResult<mlx.TArray<T>, gpu.TError>
fn mlx.array.@mean<T: type>(%a: borrow mlx.TArray<T>, %axis: i32) -> TResult<mlx.TArray<T>, gpu.TError>
fn mlx.array.@max<T: type>(%a: borrow mlx.TArray<T>, %axis: i32) -> TResult<mlx.TArray<T>, gpu.TError>
fn mlx.array.@min<T: type>(%a: borrow mlx.TArray<T>, %axis: i32) -> TResult<mlx.TArray<T>, gpu.TError>
fn mlx.array.@argmax<T: type>(%a: borrow mlx.TArray<T>, %axis: i32) -> TResult<mlx.TArray<i32>, gpu.TError>
fn mlx.array.@argmin<T: type>(%a: borrow mlx.TArray<T>, %axis: i32) -> TResult<mlx.TArray<i32>, gpu.TError>
; Evaluation (materialize lazy computation graph)
fn mlx.array.@eval<T: type>(%a: borrow mlx.TArray<T>) -> TResult<unit, gpu.TError>
fn mlx.linalg.@matmul<T: type>(%a: borrow mlx.TArray<T>, %b: borrow mlx.TArray<T>) -> TResult<mlx.TArray<T>, gpu.TError>
fn mlx.linalg.@norm<T: type>(%a: borrow mlx.TArray<T>) -> TResult<mlx.TArray<T>, gpu.TError>
fn mlx.linalg.@inv<T: type>(%a: borrow mlx.TArray<T>) -> TResult<mlx.TArray<T>, gpu.TError>
fn mlx.linalg.@svd<T: type>(%a: borrow mlx.TArray<T>) -> TResult<mlx.TSvdResult<T>, gpu.TError>
fn mlx.linalg.@solve<T: type>(%a: borrow mlx.TArray<T>, %b: borrow mlx.TArray<T>) -> TResult<mlx.TArray<T>, gpu.TError>
Neural network layers use a layered architecture: opaque runtime handles internally, wrapped in typed Magpie structs that expose parameters.
Runtime layer (opaque):
; Opaque handle for any nn layer
heap struct mlx.nn.TLayerHandle {
; ... opaque FFI pointer to mlx::nn module ...
}
Magpie wrapper layer (typed):
; Linear layer
heap struct mlx.nn.TLinear {
field handle: mlx.nn.TLayerHandle
field in_features: u64
field out_features: u64
}
fn mlx.nn.@linear(%in_features: u64, %out_features: u64) -> TResult<mlx.nn.TLinear, gpu.TError>
fn mlx.nn.@linear_forward(%layer: borrow mlx.nn.TLinear, %input: borrow mlx.TArray<f32>) -> TResult<mlx.TArray<f32>, gpu.TError>
fn mlx.nn.@linear_weight(%layer: borrow mlx.nn.TLinear) -> mlx.TArray<f32>
fn mlx.nn.@linear_bias(%layer: borrow mlx.nn.TLinear) -> mlx.TArray<f32>
Available layers:
| Layer | Constructor | Parameters |
|---|---|---|
mlx.nn.TLinear |
mlx.nn.@linear(in, out) |
weight, bias |
mlx.nn.TConv1d |
mlx.nn.@conv1d(in_ch, out_ch, kernel_size) |
weight, bias |
mlx.nn.TConv2d |
mlx.nn.@conv2d(in_ch, out_ch, kernel_size) |
weight, bias |
mlx.nn.TLayerNorm |
mlx.nn.@layer_norm(dims) |
weight, bias |
mlx.nn.TBatchNorm |
mlx.nn.@batch_norm(num_features) |
weight, bias, running_mean, running_var |
mlx.nn.TRnn |
mlx.nn.@rnn(input_size, hidden_size) |
weight_ih, weight_hh, bias |
mlx.nn.TLstm |
mlx.nn.@lstm(input_size, hidden_size) |
weight_ih, weight_hh, bias_ih, bias_hh |
mlx.nn.TGru |
mlx.nn.@gru(input_size, hidden_size) |
weight_ih, weight_hh, bias_ih, bias_hh |
mlx.nn.TTransformerEncoderLayer |
mlx.nn.@transformer_encoder_layer(d_model, nhead) |
self_attn, ff weights |
mlx.nn.TEmbedding |
mlx.nn.@embedding(num_embeddings, dim) |
weight |
mlx.nn.TMultiHeadAttention |
mlx.nn.@multi_head_attention(d_model, nhead) |
q/k/v/out projections |
Activation functions (stateless):
fn mlx.nn.@relu<T: type>(%x: borrow mlx.TArray<T>) -> TResult<mlx.TArray<T>, gpu.TError>
fn mlx.nn.@gelu<T: type>(%x: borrow mlx.TArray<T>) -> TResult<mlx.TArray<T>, gpu.TError>
fn mlx.nn.@sigmoid<T: type>(%x: borrow mlx.TArray<T>) -> TResult<mlx.TArray<T>, gpu.TError>
fn mlx.nn.@tanh<T: type>(%x: borrow mlx.TArray<T>) -> TResult<mlx.TArray<T>, gpu.TError>
fn mlx.nn.@softmax<T: type>(%x: borrow mlx.TArray<T>, %axis: i32) -> TResult<mlx.TArray<T>, gpu.TError>
fn mlx.nn.@dropout<T: type>(%x: borrow mlx.TArray<T>, %p: f32) -> TResult<mlx.TArray<T>, gpu.TError>
Autograd uses mlx.grad as a host operation that wraps a TCallable and returns a new TCallable computing gradients. This uses MLX's runtime tracing — no compiler-generated adjoints.
; Compute gradient of a scalar-valued function
fn mlx.grad.@grad(%fn: TCallable<TSigLoss>) -> TCallable<TSigLossGrad>
; Value-and-gradient (returns both the function value and its gradient)
fn mlx.grad.@value_and_grad(%fn: TCallable<TSigLoss>) -> TCallable<TSigValueAndGrad>
; Vector-Jacobian product
fn mlx.grad.@vjp<T: type>(%fn: TCallable<TSigFwd>, %primals: borrow mlx.TArray<T>, %cotangents: borrow mlx.TArray<T>)
-> TResult<mlx.TGradResult<T>, gpu.TError>
; Jacobian-vector product
fn mlx.grad.@jvp<T: type>(%fn: TCallable<TSigFwd>, %primals: borrow mlx.TArray<T>, %tangents: borrow mlx.TArray<T>)
-> TResult<mlx.TGradResult<T>, gpu.TError>
How it works:
- User defines a loss function as a
TCallablethat takesmlx.TArray<T>and returnsmlx.TArray<T>(scalar) mlx.grad.@grad(loss_fn)returns a newTCallablethat, when called, traces the computation graph and computes gradients via reverse-mode AD- All
mlx.array.*andmlx.nn.*ops are traceable — they participate in the computation graph mlx.array.@eval()materializes lazy computations
; Optimizer types
heap struct mlx.optim.TAdam {
field handle: mlx.optim.TOptimizerHandle
field learning_rate: f32
}
heap struct mlx.optim.TSgd {
field handle: mlx.optim.TOptimizerHandle
field learning_rate: f32
field momentum: f32
}
heap struct mlx.optim.TAdamW {
field handle: mlx.optim.TOptimizerHandle
field learning_rate: f32
field weight_decay: f32
}
; Constructors
fn mlx.optim.@adam(%lr: f32) -> TResult<mlx.optim.TAdam, gpu.TError>
fn mlx.optim.@sgd(%lr: f32, %momentum: f32) -> TResult<mlx.optim.TSgd, gpu.TError>
fn mlx.optim.@adamw(%lr: f32, %wd: f32) -> TResult<mlx.optim.TAdamW, gpu.TError>
; Update step (generic over optimizer type)
fn mlx.optim.@step_adam(%opt: mutborrow mlx.optim.TAdam, %params: mutborrow Array<mlx.TArray<f32>>, %grads: borrow Array<mlx.TArray<f32>>) -> TResult<unit, gpu.TError>
fn mlx.optim.@step_sgd(%opt: mutborrow mlx.optim.TSgd, %params: mutborrow Array<mlx.TArray<f32>>, %grads: borrow Array<mlx.TArray<f32>>) -> TResult<unit, gpu.TError>
fn mlx.optim.@step_adamw(%opt: mutborrow mlx.optim.TAdamW, %params: mutborrow Array<mlx.TArray<f32>>, %grads: borrow Array<mlx.TArray<f32>>) -> TResult<unit, gpu.TError>
fn mlx.random.@normal<T: type>(%shape: borrow Array<u64>) -> TResult<mlx.TArray<T>, gpu.TError>
fn mlx.random.@uniform<T: type>(%shape: borrow Array<u64>, %low: T, %high: T) -> TResult<mlx.TArray<T>, gpu.TError>
fn mlx.random.@bernoulli(%shape: borrow Array<u64>, %p: f32) -> TResult<mlx.TArray<bool>, gpu.TError>
fn mlx.random.@seed(%s: u64) -> unit
- macOS only — MLX requires Apple Silicon (M1 or later)
- Runtime:
libmlx.dylibloaded viadlopen - If MLX is unavailable, all
mlx.*functions returnErr(gpu.TError { kind = BackendUnavailable, backend = "mlx", ... }) - MLX operations use Apple's unified memory — no explicit host↔device transfers needed
// Called at program startup (from generated main)
void mp_rt_gpu_init(void);mp_rt_gpu_init performs:
- Attempt to
dlopenthe configured backend library - Load all function pointers into a dispatch table
- If loading fails, set backend status to
BackendUnavailable - Register kernel blobs from the embedded registry
Devices are presented as a unified, deduplicated list across all available backends:
; Device discovery (unified across backends)
fn gpu.host.@device_count() -> u32
fn gpu.host.@device_default() -> TResult<gpu.TDevice, gpu.TError>
fn gpu.host.@device_by_index(%idx: u32) -> TResult<gpu.TDevice, gpu.TError>
fn gpu.host.@device_name(%dev: borrow gpu.TDevice) -> Str
fn gpu.host.@device_backends(%dev: borrow gpu.TDevice) -> Array<Str>
; Device capability queries
fn gpu.host.@device_max_workgroup_size(%dev: borrow gpu.TDevice) -> u32
fn gpu.host.@device_max_shared_bytes(%dev: borrow gpu.TDevice) -> u32
fn gpu.host.@device_max_buffers(%dev: borrow gpu.TDevice) -> u32
fn gpu.host.@device_warp_size(%dev: borrow gpu.TDevice) -> u32
fn gpu.host.@device_memory_total(%dev: borrow gpu.TDevice) -> u64
fn gpu.host.@device_memory_available(%dev: borrow gpu.TDevice) -> u64
Deduplication rules:
- Same physical GPU appearing in multiple backends (e.g., Apple GPU via both Metal and MoltenVK) is reported once
gpu.host.@device_backends(dev)returns all backends that can target this device (e.g.,["msl", "spv"])gpu.host.@device_default()picks the best backend automatically:- macOS: Metal
- Linux with NVIDIA GPU: CUDA
- Linux with AMD GPU: HIP
- Linux with no proprietary driver: Vulkan
- WebAssembly: WebGPU
- Fallback: CPU (if opt-in enabled)
The buffer API extends the current model with optional memory hints:
; Memory placement hints
enum gpu.MemoryHint {
Auto ; 0 — runtime decides (default)
DeviceLocal ; 1 — prefer GPU-only memory (fastest for compute)
Unified ; 2 — prefer unified/shared memory (zero-copy on Apple Silicon)
Staging ; 3 — prefer host-visible memory (optimal for transfers)
Cached ; 4 — prefer cached host memory (optimal for CPU readback)
}
; gpu.MemoryHint is a value enum with no heap allocation.
; It is lowered as a plain u32 discriminant — no TypeId needed.
; The runtime ABI passes it as `uint32_t hint`.
; Extended buffer creation with hint
fn gpu.host.@buffer_new<T: type>(
%dev: borrow gpu.TDevice,
%len: u64,
%usage_flags: u32,
%hint: gpu.MemoryHint
) -> TResult<gpu.TBuffer<T>, gpu.TError>
; Existing APIs (unchanged signatures, updated error type)
fn gpu.host.@buffer_from_array<T: type>(%dev: borrow gpu.TDevice, %src: borrow Array<T>, %usage_flags: u32) -> TResult<gpu.TBuffer<T>, gpu.TError>
fn gpu.host.@buffer_to_array<T: type>(%buf: borrow gpu.TBuffer<T>) -> TResult<Array<T>, gpu.TError>
fn gpu.host.@buffer_len<T: type>(%buf: borrow gpu.TBuffer<T>) -> u64
fn gpu.host.@buffer_copy<T: type>(%src: borrow gpu.TBuffer<T>, %dst: borrow gpu.TBuffer<T>) -> TResult<unit, gpu.TError>
Hint behavior per backend:
| Hint | Vulkan | Metal | CUDA | HIP | WebGPU |
|---|---|---|---|---|---|
Auto |
Device-local | Unified | Device | Device | Storage |
DeviceLocal |
VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT |
MTLStorageModePrivate |
cuMemAlloc |
hipMalloc |
Storage |
Unified |
Host-visible + device-local | MTLStorageModeShared (zero-copy) |
cuMemAllocManaged |
hipMallocManaged |
N/A → Auto |
Staging |
Host-visible + coherent | MTLStorageModeShared |
Pinned host | Pinned host | Map buffer |
Cached |
Host-cached | MTLStorageModeShared |
cuMemAllocHost |
hipHostMalloc |
Map buffer |
All GPU runtime functions return TResult<T, gpu.TError>.
Runtime ABI for gpu.TError:
typedef struct MpRtGpuError {
MpRtHeader header; // ARC-managed heap object
int32_t kind; // gpu.ErrorKind discriminant (0-11)
MpRtHeader* backend; // Str: "spv", "msl", "ptx", "hip", "wgsl", "mlx"
MpRtHeader* message; // Str: human-readable error message
int32_t code; // vendor-specific error code (VkResult, cudaError_t, etc.)
} MpRtGpuError;Default behavior: GPU operations return Err(gpu.TError { kind = BackendUnavailable, ... }) when no GPU is available.
Opt-in CPU fallback:
# Magpie.toml
[gpu]
fallback = "cpu" # Enable CPU simulation for gpu.launchOr per-launch:
%result: TResult<unit, gpu.TError> = gpu.launch {
device=%dev,
kernel=@kernel_add,
grid=[%gx, const.u32 1, const.u32 1],
block=[const.u32 64, const.u32 1, const.u32 1],
args=[%buf_in, %buf_out, %n],
fallback=cpu
}
When CPU fallback is active, the runtime interprets the kernel on the CPU, simulating thread/workgroup IDs by iterating over the grid.
The kernel registry supports multiple blob entries per kernel for future flexibility, even though build-time selection produces only one blob type per build:
typedef struct MpRtGpuKernelBlob {
uint8_t backend; // GpuBackend discriminant
const uint8_t* data; // Pointer to blob data
uint32_t data_len; // Blob size in bytes
} MpRtGpuKernelBlob;
typedef struct MpRtGpuKernelEntry {
uint64_t sid_hash; // FNV-1a hash of kernel SID
uint32_t num_blobs; // Number of blob entries (typically 1)
const MpRtGpuKernelBlob* blobs; // Array of blobs
uint32_t num_params; // Parameter count
const MpRtGpuParam* params; // Parameter layout
uint32_t num_buffers; // Buffer parameter count
uint32_t push_const_size; // Push constant block size (bytes)
} MpRtGpuKernelEntry;Runtime blob selection:
- Iterate
blobsarray - Find entry matching the active backend
- If no match, return
Err(gpu.TError { kind = InvalidKernel, ... })
The driver generates <stem>.gpu_registry.ll containing:
- Constant byte arrays for each kernel blob
MpRtGpuKernelEntryarray as a global constant@mp_gpu_register_all_kernelsfunction that callsmp_rt_gpu_register_kernels
Magpie provides an integrated GPU profiling system with three layers:
- Event-based tracing — Timeline of all GPU operations with timestamps
- Vendor counters — Hardware performance counters (when available)
- Export — Chrome trace format (JSON) for visualization
heap struct gpu.TProfileSession {
; Opaque handle to profiling session
}
heap struct gpu.TProfileEvent {
field name: Str
field start_ns: u64
field end_ns: u64
field metadata: Map<Str, Str>
}
TypeIds: gpu.TProfileSession = 35, gpu.TProfileEvent = 36
; Session management
fn gpu.profile.@begin(%dev: borrow gpu.TDevice) -> TResult<gpu.TProfileSession, gpu.TError>
fn gpu.profile.@end(%session: gpu.TProfileSession) -> TResult<Array<gpu.TProfileEvent>, gpu.TError>
fn gpu.profile.@export_chrome_trace(%events: borrow Array<gpu.TProfileEvent>, %path: borrow Str) -> TResult<unit, gpu.TError>
; Manual event markers
fn gpu.profile.@mark_begin(%session: mutborrow gpu.TProfileSession, %name: borrow Str) -> TResult<unit, gpu.TError>
fn gpu.profile.@mark_end(%session: mutborrow gpu.TProfileSession, %name: borrow Str) -> TResult<unit, gpu.TError>
; Automatic profiling (wraps gpu.launch with timing)
fn gpu.profile.@launch_profiled(
%session: mutborrow gpu.TProfileSession,
%dev: borrow gpu.TDevice,
%kernel: Sid,
%grid: [u32; 3],
%block: [u32; 3],
%args: ...
) -> TResult<unit, gpu.TError>
; Memory statistics
fn gpu.profile.@memory_stats(%dev: borrow gpu.TDevice) -> TResult<gpu.TMemoryStats, gpu.TError>
heap struct gpu.TMemoryStats {
field allocated_bytes: u64
field peak_allocated_bytes: u64
field num_allocations: u64
field num_frees: u64
field device_total_bytes: u64
field device_available_bytes: u64
}
When available, profiling sessions collect vendor-specific hardware counters:
| Backend | Counter API | Available Counters |
|---|---|---|
| CUDA | CUPTI | SM occupancy, memory throughput, L1/L2 cache hit rate, warp efficiency |
| HIP | ROCm perf counters | CU occupancy, LDS utilization, VALU/SALU utilization |
| Metal | Metal GPU counters | Shader ALU utilization, memory bandwidth, occupancy |
| Vulkan | VK_KHR_performance_query |
Vendor-dependent |
| WebGPU | None | Timing only |
Counter query API:
fn gpu.profile.@available_counters(%dev: borrow gpu.TDevice) -> Array<Str>
fn gpu.profile.@enable_counters(%session: mutborrow gpu.TProfileSession, %counters: borrow Array<Str>) -> TResult<unit, gpu.TError>
fn gpu.profile.@read_counters(%session: borrow gpu.TProfileSession) -> TResult<Map<Str, f64>, gpu.TError>
The gpu.profile.@export_chrome_trace function writes a JSON file compatible with chrome://tracing and Perfetto:
{
"traceEvents": [
{"name": "kernel_add", "cat": "gpu.launch", "ph": "X", "ts": 1000, "dur": 500, "pid": 0, "tid": 0},
{"name": "buffer_write", "cat": "gpu.transfer", "ph": "X", "ts": 200, "dur": 100, "pid": 0, "tid": 0}
]
}[gpu]
backend = "msl" # Default backend: "spv", "msl", "ptx", "hip", "wgsl"
fallback = "none" # "none" (default) or "cpu"
device_index = -1 # -1 = auto-select, 0+ = specific device
mock_in_tests = false # Enable mock mode for `magpie test`[gpu.profiles.dev]
fallback = "cpu" # CPU fallback in development
mock_in_tests = true # Mock GPU in tests
[gpu.profiles.release]
fallback = "none" # No fallback in release
mock_in_tests = false
[gpu.profiles.test]
fallback = "cpu" # CPU fallback for CI
mock_in_tests = false # But run actual (simulated) kernels[gpu.tools]
llc = "/usr/local/opt/llvm/bin/llc" # Override llc path for PTX/HSACO
lld = "/usr/local/opt/llvm/bin/ld.lld" # Override lld path for HSACO[mlx]
enabled = true # Enable MLX integrationGPU diagnostics use backend-prefixed codes:
MPG_<BACKEND>_<NUMBER>
Where <BACKEND> is one of: CORE, SPV, MSL, PTX, HIP, WGSL, MLX, PROF.
Migration from existing codes: The current codebase uses flat MPG codes (e.g., MPG1100). This spec introduces backend-prefixed codes. The following migration mapping MUST be applied:
| Old Code (current codebase) | New Code (this spec) | Location |
|---|---|---|
MPG1100 |
MPG_CORE_1100 |
magpie_gpu/src/lib.rs, magpie_driver/src/lib.rs |
MPG1101 |
MPG_CORE_1101 |
magpie_gpu/src/lib.rs |
MPG1102 |
MPG_CORE_1102 |
magpie_gpu/src/lib.rs |
MPG1103 |
MPG_CORE_1103 |
magpie_gpu/src/lib.rs |
MPG1104 |
MPG_CORE_1104 |
magpie_gpu/src/lib.rs |
MPG1105 |
MPG_CORE_1105 |
magpie_gpu/src/lib.rs |
MPG1106 |
MPG_CORE_1106 |
magpie_gpu/src/lib.rs |
MPG1107 |
MPG_CORE_1107 |
magpie_gpu/src/lib.rs |
MPG1200 |
MPG_CORE_1300 |
magpie_driver/src/lib.rs (backend unavailable) |
MPG1201 |
MPG_CORE_1201 |
magpie_gpu/src/lib.rs (shared unsupported) |
The MPG_ prefix with backend tag is the canonical format going forward. All new diagnostics MUST use the new format. The magpie_diag crate's diagnostic code parser must be updated to accept the MPG_XXX_NNNN format alongside the legacy MPGNNNN format during the transition period.
| Code | Severity | Title |
|---|---|---|
MPG_CORE_1100 |
error | Heap allocation in gpu fn |
MPG_CORE_1101 |
error | ARC operation in gpu fn |
MPG_CORE_1102 |
error | Dynamic dispatch in gpu fn |
MPG_CORE_1103 |
error | Recursion in gpu fn |
MPG_CORE_1104 |
error | Str type in gpu fn |
MPG_CORE_1105 |
error | Array type in gpu fn |
MPG_CORE_1106 |
error | Map type in gpu fn |
MPG_CORE_1107 |
error | TCallable type in gpu fn |
MPG_CORE_1200 |
error | Unsupported capability for target |
MPG_CORE_1201 |
error | Non-portable feature without requires() |
MPG_CORE_1202 |
warning | unsafe gpu fn is non-portable |
MPG_CORE_1300 |
error | Backend not available at compile time |
MPG_CORE_1301 |
error | llc invocation failed |
MPG_CORE_1302 |
error | Invalid workgroup size |
| Code | Severity | Title |
|---|---|---|
MPG_SPV_2001 |
error | Shared memory exceeds device limit |
MPG_SPV_2002 |
error | SPIR-V validation failed |
MPG_SPV_2003 |
warning | Suboptimal buffer layout |
| Code | Severity | Title |
|---|---|---|
MPG_MSL_1001 |
error | Irreducible control flow (cannot structure for MSL) |
MPG_MSL_2001 |
error | Shared memory exceeds 32KB limit |
MPG_MSL_2002 |
error | MSL compilation failed |
MPG_MSL_2003 |
error | Metal-specific op without requires() annotation |
| Code | Severity | Title |
|---|---|---|
MPG_PTX_2001 |
error | Shared memory exceeds limit |
MPG_PTX_2002 |
error | PTX compilation via llc failed |
MPG_PTX_2003 |
warning | Compute capability too low for feature |
| Code | Severity | Title |
|---|---|---|
MPG_HIP_2001 |
error | Shared memory exceeds LDS limit |
MPG_HIP_2002 |
error | HSACO generation via llc failed |
| Code | Severity | Title |
|---|---|---|
MPG_WGSL_1001 |
error | Value struct in buffer (unsupported in WGSL) |
MPG_WGSL_1002 |
error | Pointer operation (unsupported in WGSL) |
MPG_WGSL_1003 |
error | bf16 type (unsupported in WGSL) |
MPG_WGSL_1004 |
error | requires() capability (unsupported in WGSL) |
MPG_WGSL_1005 |
error | Too many storage buffers (max 8) |
MPG_WGSL_1006 |
error | Workgroup size exceeds 256 per dimension |
| Code | Severity | Title |
|---|---|---|
MPG_MLX_1001 |
error | MLX not available (not macOS / no Apple Silicon) |
MPG_MLX_1002 |
error | Unsupported element type for MLX array |
MPG_MLX_1003 |
error | Shape mismatch in MLX operation |
MPG_MLX_1004 |
warning | MLX operation on CPU (no GPU acceleration) |
| Code | Severity | Title |
|---|---|---|
MPG_PROF_1001 |
warning | Vendor counters not available on this backend |
MPG_PROF_1002 |
error | Profiling session already active |
MPG_PROF_1003 |
warning | Counter not supported on this device |
For PTX and HSACO backends, the compiler needs llc (and ld.lld for HSACO). Discovery order:
[gpu.tools] llc = "..."inMagpie.toml(explicit override)MAGPIE_LLC_PATHenvironment variable- Same directory as the
clangused for host linking (found viawhich clang) PATHsearch forllc- Platform-specific defaults:
- macOS:
/usr/local/opt/llvm/bin/llc,/opt/homebrew/opt/llvm/bin/llc - Linux:
/usr/lib/llvm-*/bin/llc - ROCm:
/opt/rocm/llvm/bin/llc(for HIP target)
- macOS:
If llc is not found, emit MPG_CORE_1301 error with installation instructions.
| Backend | Required llc Target |
|---|---|
| PTX | nvptx64 (llc --version must list NVPTX) |
| HIP | amdgcn (llc --version must list AMDGPU) |
The compiler MUST verify that llc supports the required target before attempting codegen. Emit MPG_CORE_1301 if the target is missing.
| Tier | Mode | What's Tested | When |
|---|---|---|---|
| Tier 1: Mock | gpu.launch returns Ok(unit) immediately |
Host-side GPU orchestration logic, error handling, buffer management | Unit tests, CI |
| Tier 2: CPU Fallback | Kernels execute on CPU, simulating threads | Kernel correctness, numerical accuracy | Integration tests, CI |
| Tier 3: Real GPU | Full GPU dispatch via vendor API | Performance, real hardware behavior | Manual, GPU-enabled CI |
# Magpie.toml for CI
[gpu.profiles.test]
fallback = "cpu"
mock_in_tests = false # Tier 2: run kernels on CPU
# Magpie.toml for unit tests
[gpu.profiles.test]
mock_in_tests = true # Tier 1: mock launchesProgrammatic control:
; In test functions
fn @test_gpu_host_logic() -> unit {
bb0:
; Mock mode: gpu.launch returns Ok immediately
; Test that host logic handles results correctly
%dev: gpu.TDevice = ... ; mock device
%result: TResult<unit, gpu.TError> = gpu.launch { ... }
; assert result is Ok
ret
}
Each backend crate MUST include:
- Unit tests for codegen output verification (check emitted SPIR-V/MSL/PTX/LLVM IR/WGSL text)
- Integration test that compiles a vector-add kernel and verifies the blob is valid
- Round-trip test: emit → load → validate (where tooling permits)
Phase 0: Shared Infrastructure (magpie_gpu refactor)
├── Extract BackendEmitter trait
├── Move SPIR-V code to magpie_gpu_spirv
├── Add gpu.TError + gpu.ErrorKind types
├── Add bf16 primitive type
├── Update kernel registry to multi-blob format
├── Implement unified device discovery API
├── Add workgroup() annotation to parser
└── Add requires() annotation to parser
Phase 1: Metal / MSL (highest priority — testable on dev machine)
├── magpie_gpu_msl crate (native MSL emission)
├── Metal runtime backend (dlopen Metal.framework)
├── Metal-specific ops (gpu.metal.simd_*)
└── Tests on Apple Silicon
Phase 2: MLX Integration
├── magpie_mlx crate
├── mlx.array, mlx.linalg operations
├── mlx.nn layers + wrappers
├── mlx.optim optimizers
├── mlx.grad autograd (host op via TCallable)
└── End-to-end neural network training test
Phase 3: CUDA / PTX
├── magpie_gpu_ptx crate (LLVM IR → llc → PTX)
├── CUDA Driver API runtime backend (dlopen libcuda)
├── llc discovery + validation
└── Tests on NVIDIA GPU
Phase 4: HIP / ROCm
├── magpie_gpu_hip crate (LLVM IR → llc → HSACO)
├── HIP Driver API runtime backend (dlopen libamdhip64)
└── Tests on AMD GPU
Phase 5: WebGPU / WGSL (second-class)
├── magpie_gpu_wgsl crate (native WGSL emission)
├── WebGPU runtime backend (dlopen wgpu-native)
├── WGSL restriction enforcement
└── Tests via wgpu-native
Phase 6: Profiling System
├── Event-based tracing infrastructure
├── Chrome trace export
├── Vendor counter integration (CUPTI, ROCm, Metal)
└── Profiling API implementation
Phase 7: Polish
├── CPU fallback upgrade (three-tier testing)
├── Memory hint optimization per backend
├── Diagnostic message quality
└── Documentation and examples
| Crate | Estimated Lines | Complexity |
|---|---|---|
magpie_gpu (core, refactored) |
~800 | Medium |
magpie_gpu_spirv (extracted) |
~1500 | Already exists |
magpie_gpu_msl |
~2000 | High (control flow structuring) |
magpie_gpu_ptx |
~1000 | Medium (LLVM IR + subprocess) |
magpie_gpu_hip |
~800 | Medium (shares patterns with PTX) |
magpie_gpu_wgsl |
~1200 | Medium (WGSL restrictions) |
magpie_mlx |
~3000 | High (full ML stack FFI) |
| Runtime additions | ~4000 | High (5 backend dispatch implementations) |
module gpu_example_spv
exports { @main }
imports { }
digest 0000000000000000000000000000000000000000000000000000000000000000
gpu fn @kernel_add(
%in: gpu.TBuffer<f32>,
%out: gpu.TBuffer<f32>,
%n: u32
) -> unit target(spv) workgroup(256, 1, 1) {
bb0:
%gid: u32 = gpu.global_id { dim=const.u32 0 }
%in_bounds: bool = icmp.ult { lhs=%gid, rhs=%n }
cbr %in_bounds bb1 bb2
bb1:
%x: f32 = gpu.buffer_load<f32> { buf=%in, idx=%gid }
%y: f32 = f.add { a=%x, b=const.f32 1.0 }
gpu.buffer_store<f32> { buf=%out, idx=%gid, v=%y }
br bb2
bb2:
ret
}
fn @main() -> i64 {
bb0:
%dev_r: TResult<gpu.TDevice, gpu.TError> = gpu.host.@device_default {}
; ... check result, create buffers, launch kernel ...
%n: u32 = const.u32 1024
%launch_r: TResult<unit, gpu.TError> = gpu.launch {
device=%dev,
kernel=@kernel_add,
grid=[const.u32 4, const.u32 1, const.u32 1],
block=[const.u32 256, const.u32 1, const.u32 1],
args=[%buf_in, %buf_out, %n]
}
ret const.i64 0
}
module gpu_example_msl
exports { @main }
imports { }
digest 0000000000000000000000000000000000000000000000000000000000000000
gpu fn @kernel_add(
%in: gpu.TBuffer<f32>,
%out: gpu.TBuffer<f32>,
%n: u32
) -> unit target(msl) workgroup(256, 1, 1) {
bb0:
%gid: u32 = gpu.global_id { dim=const.u32 0 }
%in_bounds: bool = icmp.ult { lhs=%gid, rhs=%n }
cbr %in_bounds bb1 bb2
bb1:
%x: f32 = gpu.buffer_load<f32> { buf=%in, idx=%gid }
%y: f32 = f.add { a=%x, b=const.f32 1.0 }
gpu.buffer_store<f32> { buf=%out, idx=%gid, v=%y }
br bb2
bb2:
ret
}
fn @main() -> i64 {
bb0:
; On macOS, device_default() selects Metal automatically
%dev_r: TResult<gpu.TDevice, gpu.TError> = gpu.host.@device_default {}
; ... identical host code to SPIR-V example ...
ret const.i64 0
}
module gpu_example_ptx
exports { @main }
imports { }
digest 0000000000000000000000000000000000000000000000000000000000000000
gpu fn @kernel_add(
%in: gpu.TBuffer<f32>,
%out: gpu.TBuffer<f32>,
%n: u32
) -> unit target(ptx) workgroup(256, 1, 1) {
bb0:
%gid: u32 = gpu.global_id { dim=const.u32 0 }
%in_bounds: bool = icmp.ult { lhs=%gid, rhs=%n }
cbr %in_bounds bb1 bb2
bb1:
%x: f32 = gpu.buffer_load<f32> { buf=%in, idx=%gid }
%y: f32 = f.add { a=%x, b=const.f32 1.0 }
gpu.buffer_store<f32> { buf=%out, idx=%gid, v=%y }
br bb2
bb2:
ret
}
fn @main() -> i64 {
bb0:
; On Linux with NVIDIA GPU, device_default() selects CUDA
%dev_r: TResult<gpu.TDevice, gpu.TError> = gpu.host.@device_default {}
; ... identical host code ...
ret const.i64 0
}
module gpu_example_hip
exports { @main }
imports { }
digest 0000000000000000000000000000000000000000000000000000000000000000
gpu fn @kernel_add(
%in: gpu.TBuffer<f32>,
%out: gpu.TBuffer<f32>,
%n: u32
) -> unit target(hip) workgroup(256, 1, 1) {
bb0:
%gid: u32 = gpu.global_id { dim=const.u32 0 }
%in_bounds: bool = icmp.ult { lhs=%gid, rhs=%n }
cbr %in_bounds bb1 bb2
bb1:
%x: f32 = gpu.buffer_load<f32> { buf=%in, idx=%gid }
%y: f32 = f.add { a=%x, b=const.f32 1.0 }
gpu.buffer_store<f32> { buf=%out, idx=%gid, v=%y }
br bb2
bb2:
ret
}
fn @main() -> i64 {
bb0:
; On Linux with AMD GPU, device_default() selects HIP
%dev_r: TResult<gpu.TDevice, gpu.TError> = gpu.host.@device_default {}
; ... identical host code ...
ret const.i64 0
}
module gpu_example_wgsl
exports { @main }
imports { }
digest 0000000000000000000000000000000000000000000000000000000000000000
; NOTE: WGSL is second-class. Only primitive buffer types supported.
gpu fn @kernel_add(
%in: gpu.TBuffer<f32>,
%out: gpu.TBuffer<f32>,
%n: u32
) -> unit target(wgsl) workgroup(64, 1, 1) {
bb0:
%gid: u32 = gpu.global_id { dim=const.u32 0 }
%in_bounds: bool = icmp.ult { lhs=%gid, rhs=%n }
cbr %in_bounds bb1 bb2
bb1:
%x: f32 = gpu.buffer_load<f32> { buf=%in, idx=%gid }
%y: f32 = f.add { a=%x, b=const.f32 1.0 }
gpu.buffer_store<f32> { buf=%out, idx=%gid, v=%y }
br bb2
bb2:
ret
}
fn @main() -> i64 {
bb0:
%dev_r: TResult<gpu.TDevice, gpu.TError> = gpu.host.@device_default {}
; ... identical host code, but workgroup max 256 per dim ...
ret const.i64 0
}
module gpu_example_unsafe_ptx
exports { @main }
imports { }
digest 0000000000000000000000000000000000000000000000000000000000000000
; Non-portable kernel: uses CUDA device-side malloc
unsafe gpu fn @dynamic_kernel(
%out: gpu.TBuffer<f32>,
%n: u32
) -> unit target(ptx) workgroup(256, 1, 1) requires(device_malloc) {
bb0:
%gid: u32 = gpu.global_id { dim=const.u32 0 }
%in_bounds: bool = icmp.ult { lhs=%gid, rhs=%n }
cbr %in_bounds bb1 bb2
bb1:
; Use device malloc (CUDA-only)
; ... device-side dynamic allocation ...
br bb2
bb2:
ret
}
module gpu_example_unsafe_msl
exports { @main }
imports { }
digest 0000000000000000000000000000000000000000000000000000000000000000
unsafe gpu fn @simd_reduce_kernel(
%input: gpu.TBuffer<f32>,
%output: gpu.TBuffer<f32>,
%n: u32
) -> unit target(msl) workgroup(256, 1, 1) requires(metal.simd_reduce) {
bb0:
%gid: u32 = gpu.global_id { dim=const.u32 0 }
%in_bounds: bool = icmp.ult { lhs=%gid, rhs=%n }
cbr %in_bounds bb1 bb2
bb1:
%x: f32 = gpu.buffer_load<f32> { buf=%input, idx=%gid }
; Metal SIMD group reduction (non-portable)
%sum: f32 = gpu.metal.simd_sum { v=%x }
; First thread in simdgroup writes result
gpu.buffer_store<f32> { buf=%output, idx=%gid, v=%sum }
br bb2
bb2:
ret
}
module gpu_example_shared
exports { @main }
imports { }
digest 0000000000000000000000000000000000000000000000000000000000000000
; Portable: works on all backends (shared memory is first-class)
gpu fn @reduce_sum(
%input: gpu.TBuffer<f32>,
%output: gpu.TBuffer<f32>,
%n: u32
) -> unit target(spv) workgroup(256, 1, 1) {
bb0:
%tid: u32 = gpu.thread_id { dim=const.u32 0 }
%gid: u32 = gpu.global_id { dim=const.u32 0 }
%shared: ptr = gpu.shared<256, f32>
; Load input into shared memory
%in_bounds: bool = icmp.ult { lhs=%gid, rhs=%n }
cbr %in_bounds bb1 bb2
bb1:
%val: f32 = gpu.buffer_load<f32> { buf=%input, idx=%gid }
gpu.buffer_store<f32> { buf=%shared, idx=%tid, v=%val }
br bb3
bb2:
gpu.buffer_store<f32> { buf=%shared, idx=%tid, v=const.f32 0.0 }
br bb3
bb3:
gpu.barrier
; Tree reduction in shared memory
; (simplified — full reduction would loop with stride halving)
%half: u32 = const.u32 128
%should_reduce: bool = icmp.ult { lhs=%tid, rhs=%half }
cbr %should_reduce bb4 bb5
bb4:
%my_val: f32 = gpu.buffer_load<f32> { buf=%shared, idx=%tid }
%partner_idx: u32 = i.add { a=%tid, b=%half }
%partner_val: f32 = gpu.buffer_load<f32> { buf=%shared, idx=%partner_idx }
%sum: f32 = f.add { a=%my_val, b=%partner_val }
gpu.buffer_store<f32> { buf=%shared, idx=%tid, v=%sum }
br bb5
bb5:
gpu.barrier
; Thread 0 writes result
%is_zero: bool = icmp.eq { lhs=%tid, rhs=const.u32 0 }
cbr %is_zero bb6 bb7
bb6:
%final: f32 = gpu.buffer_load<f32> { buf=%shared, idx=const.u32 0 }
%wgid: u32 = gpu.workgroup_id { dim=const.u32 0 }
gpu.buffer_store<f32> { buf=%output, idx=%wgid, v=%final }
br bb7
bb7:
ret
}
module mlx_training_example
exports { @main }
imports { mlx.array, mlx.nn, mlx.optim, mlx.grad, mlx.random }
digest 0000000000000000000000000000000000000000000000000000000000000000
; Define a simple 2-layer MLP for MNIST
; Input: 784 -> Hidden: 128 -> Output: 10
fn @create_model() -> TResult<Array<mlx.nn.TLinear>, gpu.TError> {
bb0:
; Create layers
%l1_r: TResult<mlx.nn.TLinear, gpu.TError> = mlx.nn.@linear { in_features=const.u64 784, out_features=const.u64 128 }
%l1: mlx.nn.TLinear = try %l1_r bb_err
%l2_r: TResult<mlx.nn.TLinear, gpu.TError> = mlx.nn.@linear { in_features=const.u64 128, out_features=const.u64 10 }
%l2: mlx.nn.TLinear = try %l2_r bb_err
; Pack into array
%layers: Array<mlx.nn.TLinear> = arr.new { v0=%l1, v1=%l2 }
%ok: TResult<Array<mlx.nn.TLinear>, gpu.TError> = enum.new<TResult, 0> { v=%layers }
ret %ok
bb_err:
%err: gpu.TError = phi [bb0: %l1_r.err] [bb0: %l2_r.err]
%fail: TResult<Array<mlx.nn.TLinear>, gpu.TError> = enum.new<TResult, 1> { v=%err }
ret %fail
}
fn @forward(
%layers: borrow Array<mlx.nn.TLinear>,
%x: borrow mlx.TArray<f32>
) -> TResult<mlx.TArray<f32>, gpu.TError> {
bb0:
; Layer 1 + ReLU
%l1: borrow mlx.nn.TLinear = arr.get { arr=%layers, idx=const.u64 0 }
%h1_r: TResult<mlx.TArray<f32>, gpu.TError> = mlx.nn.@linear_forward { layer=%l1, input=%x }
%h1: mlx.TArray<f32> = try %h1_r bb_err
%a1_r: TResult<mlx.TArray<f32>, gpu.TError> = mlx.nn.@relu<f32> { x=%h1 }
%a1: mlx.TArray<f32> = try %a1_r bb_err
; Layer 2 + Softmax
%l2: borrow mlx.nn.TLinear = arr.get { arr=%layers, idx=const.u64 1 }
%h2_r: TResult<mlx.TArray<f32>, gpu.TError> = mlx.nn.@linear_forward { layer=%l2, input=%a1 }
%h2: mlx.TArray<f32> = try %h2_r bb_err
%out_r: TResult<mlx.TArray<f32>, gpu.TError> = mlx.nn.@softmax<f32> { x=%h2, axis=const.i32 1 }
%out: mlx.TArray<f32> = try %out_r bb_err
%ok: TResult<mlx.TArray<f32>, gpu.TError> = enum.new<TResult, 0> { v=%out }
ret %ok
bb_err:
; ... error propagation ...
ret %fail
}
fn @main() -> i64 {
bb0:
; Create model and optimizer
%model_r: TResult<Array<mlx.nn.TLinear>, gpu.TError> = call @create_model {}
; ... unwrap model ...
%opt_r: TResult<mlx.optim.TAdam, gpu.TError> = mlx.optim.@adam { lr=const.f32 0.001 }
; ... unwrap optimizer ...
; Training loop (10 epochs)
; For each batch:
; 1. Forward pass
; 2. Compute loss (cross-entropy)
; 3. Backward pass via mlx.grad
; 4. Optimizer step
; 5. mlx.array.@eval to materialize
; ... training loop implementation ...
ret const.i64 0
}
module gpu_profiling_example
exports { @main }
imports { }
digest 0000000000000000000000000000000000000000000000000000000000000000
gpu fn @kernel_work(
%data: gpu.TBuffer<f32>,
%n: u32
) -> unit target(spv) workgroup(256, 1, 1) {
bb0:
%gid: u32 = gpu.global_id { dim=const.u32 0 }
%in_bounds: bool = icmp.ult { lhs=%gid, rhs=%n }
cbr %in_bounds bb1 bb2
bb1:
%x: f32 = gpu.buffer_load<f32> { buf=%data, idx=%gid }
%y: f32 = f.mul { a=%x, b=%x }
gpu.buffer_store<f32> { buf=%data, idx=%gid, v=%y }
br bb2
bb2:
ret
}
fn @main() -> i64 {
bb0:
%dev_r: TResult<gpu.TDevice, gpu.TError> = gpu.host.@device_default {}
%dev: gpu.TDevice = try %dev_r bb_fail
; Start profiling session
%session_r: TResult<gpu.TProfileSession, gpu.TError> = gpu.profile.@begin { dev=%dev }
%session: gpu.TProfileSession = try %session_r bb_fail
; Enable vendor counters if available
%counters: Array<Str> = gpu.profile.@available_counters { dev=%dev }
%enable_r: TResult<unit, gpu.TError> = gpu.profile.@enable_counters { session=%session, counters=%counters }
; Mark a region
gpu.profile.@mark_begin { session=%session, name="computation" }
; Run kernel multiple times
%n: u32 = const.u32 1048576
; ... create buffer, launch kernel 10 times ...
gpu.profile.@mark_end { session=%session, name="computation" }
; Read hardware counters (MUST be called before @end, which consumes the session)
%hw_counters_r: TResult<Map<Str, f64>, gpu.TError> = gpu.profile.@read_counters { session=%session }
; End session (consumes %session — no further use after this point)
%events_r: TResult<Array<gpu.TProfileEvent>, gpu.TError> = gpu.profile.@end { session=%session }
%events: Array<gpu.TProfileEvent> = try %events_r bb_fail
; Export Chrome trace
%export_r: TResult<unit, gpu.TError> = gpu.profile.@export_chrome_trace {
events=%events,
path="profile_trace.json"
}
ret const.i64 0
bb_fail:
ret const.i64 1
}
GpuFnDecl = "gpu" "fn" FnName "(" Params? ")" "->" Type
"target" "(" BackendId ")"
[ "workgroup" "(" Int "," Int "," Int ")" ]
Block
UnsafeGpuFnDecl = "unsafe" "gpu" "fn" FnName "(" Params? ")" "->" Type
"target" "(" BackendId ")"
[ "workgroup" "(" Int "," Int "," Int ")" ]
"requires" "(" CapList ")"
Block
BackendId = "spv" | "msl" | "ptx" | "hip" | "wgsl"
CapList = Capability { "," Capability }
Capability = Ident [ "." Ident ] ; e.g., "device_malloc" or "metal.simd_shuffle"
; Decl production — add UnsafeGpuFnDecl:
Decl = FnDecl | AsyncFnDecl | UnsafeFnDecl | GpuFnDecl | UnsafeGpuFnDecl
| TypeDecl | ExternModuleDecl | GlobalDecl | ImplDecl | SigDeclNote: FnName, Params, and Type are existing non-terminals from SPEC.md §7.2.
; Metal-specific ops (unsafe gpu fn only)
MetalOp = "gpu.metal.simd_shuffle" "{" ... "}"
| "gpu.metal.simd_sum" "{" ... "}"
| "gpu.metal.simd_prefix_sum" "{" ... "}"MemoryHint = "Auto" | "DeviceLocal" | "Unified" | "Staging" | "Cached"// Error construction
MpRtHeader* mp_rt_gpu_error_new(int32_t kind, const char* backend, const char* message, int32_t code);
int32_t mp_rt_gpu_error_kind(MpRtHeader* err);
MpRtHeader* mp_rt_gpu_error_message(MpRtHeader* err);
MpRtHeader* mp_rt_gpu_error_backend(MpRtHeader* err);
int32_t mp_rt_gpu_error_code(MpRtHeader* err);
// Unified device discovery
uint32_t mp_rt_gpu_device_count_unified(void);
int32_t mp_rt_gpu_device_default_unified(MpRtHeader** out_dev, MpRtHeader** out_err);
int32_t mp_rt_gpu_device_backends(MpRtHeader* dev, MpRtHeader** out_arr, MpRtHeader** out_err);
// Device capabilities
uint32_t mp_rt_gpu_device_max_workgroup_size(MpRtHeader* dev);
uint32_t mp_rt_gpu_device_max_shared_bytes(MpRtHeader* dev);
uint32_t mp_rt_gpu_device_max_buffers(MpRtHeader* dev);
uint32_t mp_rt_gpu_device_warp_size(MpRtHeader* dev);
uint64_t mp_rt_gpu_device_memory_total(MpRtHeader* dev);
uint64_t mp_rt_gpu_device_memory_available(MpRtHeader* dev);
// Extended buffer with memory hint
int32_t mp_rt_gpu_buffer_new_hinted(MpRtHeader* dev, uint32_t elem_type_id, uint32_t elem_size,
uint64_t len, uint32_t usage_flags, uint32_t hint, MpRtHeader** out_buf, MpRtHeader** out_err);
// Profiling
int32_t mp_rt_gpu_profile_begin(MpRtHeader* dev, MpRtHeader** out_session, MpRtHeader** out_err);
int32_t mp_rt_gpu_profile_end(MpRtHeader* session, MpRtHeader** out_events, MpRtHeader** out_err);
int32_t mp_rt_gpu_profile_mark_begin(MpRtHeader* session, MpRtHeader* name, MpRtHeader** out_err);
int32_t mp_rt_gpu_profile_mark_end(MpRtHeader* session, MpRtHeader* name, MpRtHeader** out_err);
int32_t mp_rt_gpu_profile_export_chrome(MpRtHeader* events, MpRtHeader* path, MpRtHeader** out_err);
int32_t mp_rt_gpu_profile_available_counters(MpRtHeader* dev, MpRtHeader** out_arr);
int32_t mp_rt_gpu_profile_enable_counters(MpRtHeader* session, MpRtHeader* counters, MpRtHeader** out_err);
int32_t mp_rt_gpu_profile_read_counters(MpRtHeader* session, MpRtHeader** out_map, MpRtHeader** out_err);
int32_t mp_rt_gpu_profile_memory_stats(MpRtHeader* dev, MpRtHeader** out_stats, MpRtHeader** out_err);
// MLX (separate from GPU dispatch)
int32_t mp_rt_mlx_init(void);
int32_t mp_rt_mlx_array_zeros(uint32_t elem_type_id, const uint64_t* shape, uint32_t ndim, MpRtHeader** out, MpRtHeader** out_err);
int32_t mp_rt_mlx_array_matmul(MpRtHeader* a, MpRtHeader* b, MpRtHeader** out, MpRtHeader** out_err);
// ... (full MLX ABI follows same pattern)See Section 3.5 for the full TypeId unification table. Summary of new allocations:
| TypeId | Type | Notes |
|---|---|---|
| 16 | bf16 |
New primitive type |
| 17-19 | reserved | Future numeric types |
| 30-32 | gpu.TDevice, gpu.TBuffer<?>, gpu.TFence |
Existing (runtime must adopt these IDs) |
| 33 | gpu.TError |
NEW — replaces Str errors |
| 34 | gpu.ErrorKind |
NEW — error category enum |
| 35 | gpu.TProfileSession |
NEW |
| 36 | gpu.TProfileEvent |
NEW |
| 37 | gpu.TMemoryStats |
NEW |
| 38 | mlx.TArray<?> (base) |
NEW |
| 39 | mlx.nn.TLayerHandle |
NEW |
| 40 | mlx.optim.TOptimizerHandle |
NEW |
| 41-49 | reserved | Future GPU/MLX types |
| 50 | gpu.TKernel (runtime-internal) |
Moved from runtime's former 9004 |
IMPORTANT: The runtime's private TypeIds (9001-9004) are deprecated. See Section 3.5 for the migration path.
| Feature | SPV | MSL | PTX | HIP | WGSL |
|---|---|---|---|---|---|
| Buffer load/store | yes | yes | yes | yes | yes |
| Shared memory | yes | yes | yes | yes | yes |
| Barrier | yes | yes | yes | yes | yes |
| Atomics (int) | yes | yes | yes | yes | yes |
| Atomics (f32) | ext | yes | yes | yes | no |
| Atomics (f64) | no | no | yes | yes | no |
| bf16 | ext | yes | yes | yes | no |
| f16 arithmetic | yes | yes | yes | yes | yes |
| Subgroups | yes | yes (SIMD) | yes (warp) | yes (wave) | no |
| Device malloc | no | no | yes | yes | no |
| Recursion | no | no | yes | yes | no |
| Dynamic parallelism | no | no | yes | yes | no |
| Cooperative groups | no | no | yes | yes | no |
| Value structs in buffers | yes | yes | yes | yes | no |
| Max workgroup size | 1024 | 1024 | 1024 | 1024 | 256 |
| Max shared memory | 48KB | 32KB | 48-96KB | 64KB | 16KB |
End of GPU Expansion Specification