Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

Zig++

Zig++ is to Zig what TypeScript is to JavaScript: a superset that adds the features people kept asking for. Every valid Zig program is a valid Zig++ program, unless it names something priv (write @"priv" instead).

Like Zig, Zig++ is a general-purpose programming language and toolchain for maintaining robust, optimal, and reusable software. Unlike Zig, it has private fields, it will never drop LLVM, it welcomes AI, and its BDFL is Matthew Neel. Zig++ is a fork of Zig, and nearly all of the compiler was written by upstream Zig contributors.

The source is at github.com/mattneel/zigpp, where this book’s sources live as doc/book/.

What Zig++ adds

  • Private fields. A struct or union field marked priv can only be named from the file that declares its type. See Private fields.
  • LLVM forever, and a blessed path to GPUs: std.gpu runs Zig++ and its standard library on NVIDIA and AMD GPUs. See LLVM is forever and GPU Programming.
  • AI in the toolchain. See AI Policy and Governance.
  • Any Zig version, automatically. A project’s build.zig.zon can pin the exact compiler version it is built with, and zig runs that version instead of itself, downloading it on first use. See Any Zig Version.
  • A BDFL and one rule: talk about code. See Governance.

Questions people ask

Does Zig++ compile to Zig, the way TypeScript compiles to JavaScript? No. It compiles to machine code, C, WebAssembly, PTX, and AMD GPU code objects.

Is Zig++ stable? Zig++ follows semantic versioning exactly as closely as TypeScript does. The compiler is at 0.17.0-dev, every push to master is a release, and Versions and Releases explains what the version string means.

Can upstream Zig build Zig++? No. Zig++ changed std.lang.Type, and an upstream Zig binary cannot compile against it. Use the CMake build, bootstrap.c, or an existing Zig++ binary; see Building from Source.

Which LLVM does Zig++ use? LLVM, Clang, and LLD 23.1.2.

Where is the standard library documentation? At /std/, generated from the newest release, and the language reference is at /langref.html. Both are also served for the version you installed: doc/langref.html is in every release archive, and zig std serves the autodocs and opens a browser tab.

Where to start

  1. Installing — download a release, check its SHA256SUMS, and run zig.
  2. What Zig++ Adds — private fields, LLVM, std.gpu.
  3. GPU Programming — kernels, the CUDA and HIP host APIs, and the standalone test that runs them.
  4. Building from Source — the CMake build, the bootstrap compiler, and the devkits that CI uses.

Installing

Installing Zig++ is one line. On Linux and macOS:

curl -fsSL https://zigpp.lol/ppup | sh

On Windows, in PowerShell:

irm https://zigpp.lol/ppup.ps1 | iex

Both run ppup, Zig++’s installer and toolchain manager. It unpacks the newest release, makes it the default zig, installs ppup itself, and puts it on your PATH so you can install more versions later. From then on:

zig version
0.17.0-dev.2380+zigpp.add158e97

ppup

CommandWhat it does
ppupInstall the newest release as the default toolchain, install ppup, and add it to PATH
ppup updateInstall the newest release and make it the default
ppup install <version|latest>Install a version, e.g. ppup install 0.17.0-dev.2380+zigpp.add158e97
ppup default [<version>]Show the default toolchain, or make a version the default
ppup listList the installed toolchains, marking the default
ppup uninstall <version>Remove a toolchain; the default one refuses until another is the default
ppup self updateReplace ppup with the newest one from zigpp.lol
ppup self uninstallRemove every toolchain, ppup, and its PATH entry
ppup helpList the commands, and ppup --version prints ppup’s own version

Where the toolchains live:

Linux, macOS~/.zigpp: toolchains/<version>/ holds each release, bin/zig is the default compiler, bin/ppup is ppup
Windows%LOCALAPPDATA%\zigpp: toolchains\<version>\ holds each release, current is a directory junction to the default one, bin\ppup.ps1 (and a bin\ppup.cmd shim) is ppup

ppup adds two lines to the profile of your shell—~/.zshrc, ~/.bashrc, ~/.config/fish/conf.d/ppup.fish, or ~/.profile: a # Zig++ (ppup) comment and the line that puts its bin directory on PATH. ppup self uninstall removes them again. The environment variables PPUP_HOME and PPUP_NO_MODIFY_PATH=1 (the --no-modify-path option does the same) move the installation and keep ppup out of your profile:

curl -fsSL https://zigpp.lol/ppup | sh -s -- --no-modify-path
PPUP_HOME=/opt/zigpp curl -fsSL https://zigpp.lol/ppup | sh

Zig++ publishes releases for these four targets; anything else (an x86_64 Mac, say) fails with a message listing them:

TargetArchive
x86_64-linuxzig-x86_64-linux-<version>.tar.xz
aarch64-linuxzig-aarch64-linux-<version>.tar.xz
aarch64-macoszig-aarch64-macos-<version>.tar.xz
x86_64-windowszig-x86_64-windows-<version>.zip

The Windows build is x86_64 only, so on ARM64 Windows ppup installs that one and says that it runs under emulation. The Release workflow publishes a release on every push to master, and the Downloads chapter lists the archives of every release with their SHA-256 checksums; Versions and Releases explains what <version> looks like.

There is no package manager for Zig++ and no pre-built binary from anywhere but these releases: ppup, or unpacking an archive by hand, are the two ways in. A compiler built from source is the other option; see Building from Source.

Unpack an archive anywhere

A Zig installation is two things: the zig executable, and the lib/ directory. At runtime, the executable searches up the file system for lib/, relative to itself:

  • lib/
  • lib/zig/
  • ../lib/
  • ../lib/zig/
  • and so on

In other words, you can unpack a release of Zig++ anywhere and begin using it immediately, with no installation step, although the search also supports installing it globally (/usr/bin/zig and /usr/lib/zig/). This is all ppup does: it unpacks toolchains/<version>/ and points bin/zig at it.

A release archive holds:

zig-x86_64-linux-0.17.0-dev.2361+zigpp.5b96e6d21/
    zig                 the compiler
    lib/                the standard library, builtin headers, and build system
    doc/langref.html    the language reference
    LICENSE
    README.md

On Windows the executable is zig.exe, and the archive is a .zip.

tar -xJf zig-x86_64-linux-0.17.0-dev.2361+zigpp.5b96e6d21.tar.xz
./zig-x86_64-linux-0.17.0-dev.2361+zigpp.5b96e6d21/zig version
0.17.0-dev.2361+zigpp.5b96e6d21

Verify the download

Each release has a SHA256SUMS asset: the SHA-256 of every archive and of index.json, in the format sha256sum reads. Download the release’s assets into one directory, then check them:

sha256sum -c SHA256SUMS

If you downloaded only the archive for your target, check that one line and skip the rest:

sha256sum -c --ignore-missing SHA256SUMS
zig-aarch64-linux-0.17.0-dev.2361+zigpp.5b96e6d21.tar.xz: OK
zig-aarch64-macos-0.17.0-dev.2361+zigpp.5b96e6d21.tar.xz: OK
zig-x86_64-linux-0.17.0-dev.2361+zigpp.5b96e6d21.tar.xz: OK
zig-x86_64-windows-0.17.0-dev.2361+zigpp.5b96e6d21.zip: OK
index.json: OK

ppup checks the same sums itself, and unpacks nothing that fails the check.

The same hashes, with the size of each archive, are in index.json, which uses the format of ziglang.org’s download index, so tools that read that one can read this one.

Documentation for the version you have

  • The language reference is doc/langref.html inside the archive, and this site serves the newest release’s copy at /langref.html.

  • The standard library documentation is served by the compiler itself:

    zig std
    

    It generates the autodocs for the lib/ next to the executable and opens a browser tab. This site serves the newest release’s copy at /std/.

Next

What Zig++ Adds

Zig++ is a superset of Zig: every valid Zig program is a valid Zig++ program, unless it names something priv (write @"priv" instead). This chapter covers what it adds to the language and to the toolchain.

Private fields

Struct fields are public by default, meaning that they can be accessed from any file. A field declared with the priv keyword is private: it can only be accessed by name from within the file which declares the struct, just like a declaration which is not marked pub. Private fields allow a type to protect its invariants, and communicate which fields are implementation details rather than part of its API.

/// A fixed-capacity buffer. `len` is private, so code in other files cannot
/// break the invariant that `len` never exceeds the capacity of `bytes`.
pub const Buffer = struct {
    bytes: [16]u8 = undefined,
    priv len: usize = 0,

    pub fn append(buf: *Buffer, byte: u8) error{Overflow}!void {
        if (buf.len == buf.bytes.len) return error.Overflow;
        buf.bytes[buf.len] = byte;
        buf.len += 1;
    }

    pub fn slice(buf: *const Buffer) []const u8 {
        return buf.bytes[0..buf.len];
    }
};

Code in the file that declares the type names len normally, including in tests. Code in any other file cannot name it, whether to read it, write it, take its address, call it, or specify its value in an initialization expression:

const Buffer = @import("test_private_fields.zig").Buffer;

test "access a private field from another file" {
    var buf: Buffer = .{};
    try buf.append('a');
    buf.len = 100;
}

// error: field 'len' of struct 'test_private_fields.Buffer' is private

Private fields are still a part of every value of the type. If a private field has no default value, the struct can only be initialized within the file which declares it, typically by a public initialization function.

Field privacy only applies to syntax which names a field. @typeInfo reports private fields along with all other fields, indicating their privacy with the @"priv" field attribute, and builtins which name fields with strings, such as @field, @FieldType, and @offsetOf, can be used with any field. This allows generic code, such as formatting, comparison, hashing, and serialization, to operate on all types:

const info = @typeInfo(Buffer).@"struct";
try expect(std.mem.eql(u8, info.field_names[1], "len"));
try expect(info.field_attrs[1].@"priv");

// Builtins which name fields with strings are not subject to field privacy.
var buf: Buffer = .{};
try buf.append('z');
try expect(@field(buf, "len") == 1);

A type created with @Struct or @Union has private fields where the @"priv" field attribute is set; these fields are private to the file containing the reification builtin call.

Fields of unions can be marked priv as well. Outside of the file which declares the union, a private field cannot be accessed, initialized, or have its payload captured by a switch prong which names it. The tag of a union is not affected by field privacy, so it can still be compared against and switched on, and else and inline else prongs can capture any payload.

Enum fields and tuple fields cannot be marked priv.

priv precedes comptime in a field declaration, so the grammar rule for a container field is:

ContainerField <- doc_comment? KEYWORD_priv? KEYWORD_comptime? (IDENTIFIER COLON)? TypeExpr ByteAlign? (EQUAL Expr)?

Because priv is a keyword, an identifier with that name is written @"priv". That is the whole of the incompatibility with Zig: a Zig program that declares, or names, something priv needs quotes around it, and one that expects to reach private fields from other files needs a different design.

Upstream closed the proposal for private fields as not planned. Zig++’s release smoke test compiles a struct with a private field, runs it, and checks that naming that field from another file fails with a diagnostic that mentions private.

The full rules, with the tests above as runnable examples, are in the language reference: Private Fields.

LLVM is forever

Upstream Zig plans to drop its dependency on the LLVM libraries, and to eliminate its dependency on the LLVM library API calls. Zig++ will never phase out LLVM. In package.json terms, LLVM stays in dependencies.

Zig++ builds with LLVM, Clang, and LLD 23.1.2. The CMake build requires the 23.x development libraries, and refuses a llvm-config older than 23 or newer than 24.

The LLVM-less build path exists only to bootstrap: cc -o bootstrap bootstrap.c && ./bootstrap produces a zig2 that is a stage2 build without LLVM extensions, and it lacks release-mode optimizations, some ELF, COFF/PE, and WebAssembly linking features, the ability to create static archives from object files, the ability to compile assembly files, and the ability to compile C, C++, Objective-C, and Objective-C++. It is enough to run ./zig2 build and produce a real Zig++ compiler. See Building from Source.

LLVM, and MLIR above it, are how Zig++ goes the final stretch on GPUs: the blessed path lowers Zig++ directly to PTX and to AMD GPU code objects, with first-class GPU intrinsics.

std.gpu

std.gpu is a port of ugpu into the standard library. Kernels are plain Zig functions, and they can use the rest of the standard library as long as they avoid operating system services:

  • Kernels are exported functions with the .kernel calling convention, and std.gpu has CUDA’s indexing (threadIdx, blockIdx, blockDim, gridDim, globalId), syncThreads (the new @workGroupBarrier builtin), warp shuffles, votes and reductions, atomics, fast math approximations, and print.
  • The standard library runs on the GPU: std.fmt, std.json, std.mem, std.base64, hash maps, and array lists, with allocators for shared memory and for the CUDA device heap in std.gpu.allocators. A panic in a kernel reports its message to the host.
  • Every NVPTX and AMDGPU module carries the compiler-rt routines that it calls, so @sin, @exp, @log, f128, and float parsing work in kernels, with the same results as on the host, bit for bit. Upstream Zig crashes LLVM on @sin for NVPTX.
  • std.gpu.cuda loads the CUDA driver at run time, so programs build without the CUDA toolkit, and launches kernels from the host. std.gpu.hip does the same with the HIP runtime of AMD GPUs, on Linux and on Windows, where it needs no libc.

GPU Programming covers the device API, the host APIs, and how to build and run the kernels. Still to come: MLIR lowering for tensor cores and kernel fusion, and GPUs beyond NVIDIA’s and AMD’s.

AI in the toolchain

Upstream Zig bans LLMs from issues, patches, and bug tracker comments. Zig++ welcomes them, and it is building AI code generation into the build system; see AI Policy and Governance.

GPU Programming

std.gpu runs Zig++ on NVIDIA and AMD GPUs. This chapter is written from the documentation comments in lib/std/gpu.zig, lib/std/gpu/*.zig, and from test/standalone/gpu, which is the end-to-end test of everything described here.

// kernels.zig
const gpu = @import("std").gpu;

export fn wave(data: [*]f32, amplitude: f32, n: u32) callconv(.kernel) void {
    const i = gpu.globalId(.x);
    if (i < n) data[i] = amplitude * @sin(data[i]);
}

An exported function with the .kernel calling convention is a kernel. Kernels can use the rest of the standard library as long as they avoid operating system services: std.fmt, std.json, std.mem, std.base64, hash maps, array lists, and the device allocators all work on the GPU.

The device API

The device-side functions in std.gpu are implemented for NVPTX and AMDGPU. The indexing functions and syncThreads use builtins that also exist for SPIR-V.

Indexing

pub const Dim = enum(u2) { x, y, z };

pub inline fn threadIdx(comptime dim: Dim) u32
pub inline fn blockIdx(comptime dim: Dim) u32
pub inline fn blockDim(comptime dim: Dim) u32
pub inline fn gridDim(comptime dim: Dim) u32
pub inline fn globalId(comptime dim: Dim) u32

These are CUDA’s indexing functions. globalId(dim) is blockIdx(dim) * blockDim(dim) + threadIdx(dim): the index of the calling thread within the whole grid. threadIdx, blockIdx, and blockDim are @workItemId, @workGroupId, and @workGroupSize.

Synchronization

pub inline fn syncThreads() void

Waits until every thread of the block has reached this call, and makes the memory writes that each thread made before the call visible to the others. Every thread of the block must reach the same call; anything else is undefined behavior. It is @workGroupBarrier().

Warps

pub const warp_size: comptime_int
pub const WarpMask = @Int(.unsigned, warp_size);
pub inline fn laneId() u32

warp_size is 32, except on AMD: 64 before GFX10, and 32 from GFX10 unless wavefrontsize64 or wavefrontsize32 selects otherwise. laneId() is the index of the calling thread within its warp, from 0 to warp_size - 1.

pub inline fn shflDown(value: anytype, delta: u32) @TypeOf(value)
pub inline fn shflUp(value: anytype, delta: u32) @TypeOf(value)
pub inline fn shflXor(value: anytype, lane_mask: u32) @TypeOf(value)
pub inline fn shflBroadcast(value: anytype, src_lane: u32) @TypeOf(value)

Warp shuffles. Every thread of the warp that has not exited must call them together, and a value read from a thread that has exited is undefined. shflUp and shflDown read delta lanes below or above, and yield the caller’s own value when that lane is outside the warp; shflXor reads laneId() ^ lane_mask; shflBroadcast reads src_lane modulo warp_size. Shuffles support values of at most 64 bits.

pub inline fn all(predicate: bool) bool
pub inline fn any(predicate: bool) bool
pub inline fn uniform(predicate: bool) bool
pub inline fn ballot(predicate: bool) WarpMask
pub inline fn popcount(predicate: bool) u32

pub fn warpReduceSum(value: anytype) @TypeOf(value)
pub fn warpReduceMax(value: anytype) @TypeOf(value)
pub fn warpReduceMin(value: anytype) @TypeOf(value)

Votes and reductions. all and any are true when every, or any, lane’s predicate is true; uniform is true when the predicate has the same value for every lane; ballot has a set bit for each true lane, and popcount counts them. The reductions return the result to every lane, and integer overflow is checked like +.

Atomics

pub inline fn atomicAdd(ptr: anytype, operand: @TypeOf(ptr.*)) @TypeOf(ptr.*)
pub inline fn atomicExchange(ptr: anytype, operand: @TypeOf(ptr.*)) @TypeOf(ptr.*)
pub inline fn atomicCAS(ptr: anytype, expected: @TypeOf(ptr.*), new_value: @TypeOf(ptr.*)) @TypeOf(ptr.*)
pub inline fn atomicMin(ptr: anytype, operand: @TypeOf(ptr.*)) @TypeOf(ptr.*)
pub inline fn atomicMax(ptr: anytype, operand: @TypeOf(ptr.*)) @TypeOf(ptr.*)

Each returns the value the location held before the operation. They use relaxed ordering, visible to the whole device, and ptr may be a global, shared, or generic pointer. For other orderings, use @atomicRmw and @cmpxchgStrong.

Fast math

pub const fast = struct {
    pub inline fn sin(x: f32) f32
    pub inline fn cos(x: f32) f32
};

Hardware approximations: PTX sin.approx.f32 and cos.approx.f32, or AMD v_sin_f32 and v_cos_f32. They are faster than @sin and @cos and their error is bounded in absolute terms rather than relative to the input.

Printing and panics

pub fn print(comptime fmt: []const u8, args: anytype) void
pub fn assertFail(message: []const u8) noreturn

print formats like std.fmt and writes to the host’s standard output, truncating at a 256-byte stack buffer. NVIDIA prints at the next host/device synchronization. AMD writes into a 1 MiB buffer that hip.Context.loadModule connects and hip.Context.synchronize drains, dropping output beyond that; a code object loaded by another host prints nothing.

assertFail stops the launch and reports the message with the block and thread that failed, truncated to 255 bytes. std.debug.defaultPanic calls it for .cuda and .amdhsa targets, so a panic in a kernel reports its message to the host. After a failed assertion, CUDA’s next Context.synchronize returns error.Assert and the context cannot run more kernels, while HIP’s next synchronization writes to stderr, returns error.Assert, and leaves the context usable.

Allocators

pub const device_heap: std.mem.Allocator

pub fn BumpAllocator(comptime size: usize) type

device_heap is the CUDA device heap: memory that outlives a block, freed with free. Its default size is 8 MiB, unless the host changes cuda.Limit.malloc_heap_size before the heap is first used. It is implemented with NVPTX-only syscalls, and is a compile error on other architectures.

BumpAllocator(size) carves allocations out of block-shared memory, which is gone when the block finishes:

var heap: [16 * 1024]u8 addrspace(.shared) = undefined;

export fn kernel() callconv(.kernel) void {
    var bump = std.gpu.allocators.BumpAllocator(heap.len).init(&heap);
    if (std.gpu.threadIdx(.x) == 0) {
        var list: std.ArrayList(u32) = .empty;
        list.append(bump.allocator(), 42) catch return;
    }
}

Every thread of the block must call init, with the same buffer, before any of them allocates: it waits at a barrier for the thread that writes the offset of the unused memory. used() reports how much has been allocated, allocations may race with each other, and only the most recent allocation can be returned to the allocator. Buffers are limited to 4 GiB.

For memory used by a single thread, std.heap.FixedBufferAllocator needs no synchronization. All three implement std.mem.Allocator, so the containers in std work in kernels.

Compiling kernels

For NVIDIA, compile the kernel module to PTX with zig build-obj, then load the PTX with cuda.Context.loadModule:

zig build-obj -target nvptx64-cuda -mcpu=sm_75 -O ReleaseFast -fno-emit-bin -femit-asm=kernels.ptx kernels.zig

PTX for an older -mcpu runs on newer GPUs, because the driver compiles it for the GPU that loads it.

For AMD, compile it to a code object with zig build-lib -dynamic, then load it with hip.Context.loadModule:

zig build-lib -dynamic -target amdgcn-amdhsa -mcpu=gfx1036 -O ReleaseFast kernels.zig

A code object only runs on the architecture that -mcpu names; hip.Device.archName reports it, as gfx1036, or with features as gfx90a:sramecc+:xnack-, whose compiler spelling is -mcpu=gfx90a+sramecc-xnack. On AMD, the first shared variable can live at address 0, so index shared variables or @addrSpaceCast them to generic pointers instead of building a shared pointer with @ptrFromInt(0).

In a build script, compile kernels with b.addObject and embed getEmittedAsm() for PTX, or b.addLibrary with .linkage = .dynamic and embed getEmittedBin() for a code object:

const kernels = b.addObject(.{
    .name = "kernels",
    .root_module = b.createModule(.{
        .root_source_file = b.path("kernels.zig"),
        .target = kernel_target,
        .optimize = .fast,
    }),
});
exe.root_module.addAnonymousImport("kernels.ptx", .{
    .root_source_file = kernels.getEmittedAsm(),
});

test/standalone/gpu/build.zig builds both kinds, in a debug and a fast variant, for a set of AMD architectures given as -Damdgpu-arch (comma-separated, default gfx1030).

The host APIs

std.gpu.cuda is the CUDA driver API and std.gpu.hip is the HIP runtime library. They expose the same names, and a program switches between them by changing the import:

std.gpu.cudastd.gpu.hip
Loads at run timelibcuda.so.1libamdhip64.so.7, .so.6, .so, or amdhip64_7.dll, _6.dll
PlatformLinux, with libcLinux with libc, and Windows without it (ntdll.LdrLoadDll)
Toolkit needed to buildnonenone
Module imagePTX, null-terminated: [:0]const u8a code object for one architecture: []const u8
Allocation failureerror.OutOfDeviceMemoryerror.OutOfMemory

The CUDA runtime library, NVRTC, events, and graphs are not part of std.gpu.cuda; events, graphs, the memory pools of stream-ordered allocation, and the rest of the runtime are not part of std.gpu.hip. Both require the Driver to stay open, and not to move, while objects that point to it are in use.

Both namespaces export Driver, Device, Context, Module, Function, Buffer(T), Stream, LaunchConfig, DevicePtr, Version, ComputeCapability, Attribute, Limit, ModuleOptions, Error, and OpenError. The sequence for a kernel launch is the same in both:

var driver = try cuda.Driver.open();
defer driver.close();

const context = try (try driver.device(0)).retainPrimaryContext();
defer context.release();

const module = try context.loadModule(@embedFile("kernels.ptx"), .{});
defer module.unload();

var data: [1000]f32 = undefined;
for (&data, 0..) |*x, i| x.* = @floatFromInt(i);

const buffer = try context.alloc(f32, data.len);
defer buffer.free();
try buffer.copyFromHost(&data);

const wave = try module.function("wave");
try wave.launch(cuda.LaunchConfig.linear(data.len, 256), .{ buffer, @as(f32, 2), @as(u32, data.len) });

try context.synchronize();
try buffer.copyToHost(&data);

Driver.open() returns error.DriverNotFound when the library is absent and error.IncompatibleDriver when it lacks a function Zig++ needs. retainPrimaryContext makes the context current. Module.function looks up a kernel by the name it was exported with, and takes a null-terminated name.

Launch configuration

pub const Dim3 = struct { x: u32 = 1, y: u32 = 1, z: u32 = 1 };
pub const LaunchConfig = struct {
    grid: Dim3 = .{},
    block: Dim3 = .{},
    shared_memory: u32 = 0,
    stream: ?Stream = null,
};

shared_memory is dynamic shared memory per block, on top of any the kernel declares statically, and a null stream selects the null stream. LaunchConfig.linear(n, block_size) covers n threads with blocks of block_size, rounding the grid up; block_size must not be zero, and n == 0 produces no blocks, which launch rejects with error.InvalidValue.

The launch arguments are passed in parameter order, one per kernel parameter:

  • a Buffer(T) passes the device address it holds;
  • a DevicePtr (an enum(u64) { _ }, a device memory address) passes unchanged;
  • integers, floats, booleans, enums, vectors, and extern or packed structs pass by value.

A slice or a host pointer as a kernel argument, an untyped compile-time number, a value with an unsupported layout, or a non-tuple argument list is a compile error. Scalars need an explicit type: @as(u32, data.len).

pub const DevicePtr = enum(u64) { _ };

pub fn Buffer(comptime T: type) type

Buffer(T) has fields driver, ptr, and len, and the methods free, copyFromHost, copyToHost, and zero. Copies may be shorter than the buffer, never longer. Stream has destroy and synchronize, and HIP’s stream synchronization does not itself flush the GPU print and assert output: that happens at Context.synchronize.

Attributes, limits, and error sets

Attribute and Limit are enums of the vendor’s numeric codes: Attribute has the same tags in both namespaces, with different numeric values, so use the tags. Limit is stack_size, printf_fifo_size, and malloc_heap_size, and HIP’s runtime reports error.UnsupportedLimit for printf_fifo_size, which is not the buffer that std.gpu.print uses.

OpenError is error{ DriverNotFound, IncompatibleDriver } plus Error. Beyond the difference in allocation failure, HIP’s set has EccNotCorrectable, SetOnActiveProcess, and no PTX-JIT or profiler codes; CUDA’s has EccUncorrectable, PrimaryContextActive, and the PTX and profiler codes. Both have error.Assert, which is what a kernel assertion turns into at the next synchronization.

Running and testing on a GPU

test/standalone/gpu is the end-to-end test: a port of the examples of the ugpu project, plus kernels that cover the rest of std.gpu. Its host program computes the expected results on the CPU and compares them with what the GPU produced, and each of the twenty test groups — vector_add, reduce, histogram, warp, matrix_mul, convolution, stencil, stdlib, hashmap, base64, string_search, json, dynamic, printf, hello_gpu, builtin_math, f128, parse_float, device_heap, and bump_allocator — reports its launches, checks, and failures, and exits non-zero if any check fails.

cd test/standalone/gpu
zig build test

The CUDA variant is built on Linux, and the HIP variant on Linux and Windows. Each kernel image is built in a debug and a fast variant, and the host program runs once normally and once with assert as an argument, which launches a kernel that indexes out of bounds and checks that the reported message names the index, the length, and the failing thread.

A machine without the driver or without a device is not a failure: the test reports it and passes, for error.DriverNotFound, error.NoDevice, a driver that sees no devices, or an AMD code object that matches no device (it suggests the -Damdgpu-arch value to build for). Everything else fails.

CI runs it as part of the standalone tests:

zig build test-standalone -Dskip-non-native -Dskip-release

The GPU suite compiles its kernels for both vendors on a runner with no GPU driver, where the suite then skips itself.

What is not there yet

  • MLIR lowering for tensor cores and kernel fusion.
  • GPUs beyond NVIDIA’s and AMD’s.
  • Warp shuffles of values wider than 64 bits, and std.gpu.allocators.device_heap on architectures other than NVPTX, both of which are compile errors.
  • On HIP, ModuleOptions.error_log is ignored, while CUDA’s PTX JIT fills it in when a module fails to load.

Versions and Releases

What a version says

0.17.0-dev.2361+zigpp.5b96e6d21
└────┬───┘ └─┬─┘ └──────┬──────┘
     │       │          └─ build metadata: zigpp, and the commit it was built from
     │       └─ commits since the 0.16.0 tag
     └─ the upstream version it is based on

The base version is the last upstream Zig release that this commit descends from: 0.17.0. 0.16.0 is the tag that the commit count is measured from, so dev.2361 is 2361 commits after it. Both numbers come from git describe, and the commit is its abbreviated hash. The base version and the tagged ancestor are checked against each other, and a checkout where git describe cannot answer is built as the plain base version.

The version is what zig version prints, what zig env reports, and what names the archives of a release. It is also what tells two Zig++ builds apart: unlike 0.17.0-dev, it is impossible for two different Zig++ compilers to report the same version.

The builder takes -Dversion-string to set it by hand, which the release workflow does not need: it reads the version out of the compiler it just built.

Rolling releases

The Release workflow publishes a release on every push to master, for four targets:

TargetRunner that checks it
x86_64-linuxubuntu-24.04
aarch64-linuxubuntu-24.04-arm
aarch64-macosmacos-15
x86_64-windowswindows-2025

The jobs are: build a bootstrap compiler from source, pack an archive for each target, unpack and smoke-test each archive on a runner of that architecture, and publish. The smoke test runs zig version and zig env, compiles and runs a program that uses private fields, checks that naming a private field from another file is rejected, and compiles and runs C with the Clang and libc the archive carries.

A pull request that changes the release machinery builds the archives and checks them, and publishes nothing.

The release’s tag is the version without its build metadata — zigpp-0.17.0-dev.2361 — because + is legal in a tag but awkward in URLs. build.zig and CMakeLists.txt exclude zigpp-* tags when they derive the version, so the tag itself does not change it.

Each release carries:

  • one archive per target, named zig-<arch>-<os>-<version>.tar.xz, or .zip for Windows;
  • index.json, the download index described below;
  • SHA256SUMS, the SHA-256 of every archive and of index.json.

Publishing the same version twice replaces the assets, so a publish that failed halfway can be completed by re-running it.

The download index

/download/index.json collects every release in one document, in the format of ziglang.org’s own download index, so tools that read that one can read this one. master is the newest release, and every release also has a key of its own:

{
  "master": {
    "version": "0.17.0-dev.2361+zigpp.5b96e6d21",
    "date": "2026-09-24",
    "x86_64-linux": {
      "tarball": "https://github.com/mattneel/zigpp/releases/download/zigpp-0.17.0-dev.2361/zig-x86_64-linux-0.17.0-dev.2361%2Bzigpp.5b96e6d21.tar.xz",
      "shasum": "e3b0c442...",
      "size": "52345678"
    },
    "aarch64-linux": { "...": "..." },
    "aarch64-macos": { "...": "..." },
    "x86_64-windows": { "...": "..." }
  },
  "0.17.0-dev.2361+zigpp.5b96e6d21": { "...": "same as master" }
}

Each target key holds the URL of its archive, its SHA-256, and its size in bytes. Every release has the same four in the same place, so a script that wants the newest Linux build for its architecture reads .master["x86_64-linux"].tarball and checks it against .shasum.

Because every push to master is a release, master in this index is a real build, not a nightly snapshot: it is the newest published release.

What the site serves

The standard library documentation at /std/ and the language reference at /langref.html are generated from the newest release, so they describe the compiler you download from Downloads rather than an unreleased master.

Until the first release is published, both are stand-ins that say so, and the Downloads chapter explains what a release will contain.

Any Zig Version

A project can pin the exact compiler it is built with, in its build.zig.zon:

.minimum_zig_version = "0.15.1",

When it does, every zig command run in that project, or in any directory below it, runs that version instead of the zig that was invoked: zig build, zig test, zig env, and zig cc alike. The first time a version is needed, Zig++ downloads it into the global cache and verifies it; after that, it only reads the project’s build.zig.zon and starts the installed compiler.

zig init writes the version of the compiler that ran it, so a new project keeps building with the compiler that made it.

The version names the exact compiler, the way anyzig reads it, not a lower bound. It can be:

VersionWhere it comes fromVerified with
a Zig++ release, 0.17.0-dev.2361+zigpp.5b96e6d21the GitHub release zigpp-0.17.0-dev.2361the SHA-256 in the release’s index.json
an upstream Zig release, 0.15.1ziglang.org/downloadthe SHA-256 in upstream’s download index, and the minisign signature
an upstream dev build, 0.16.0-dev.1234+abcdef012ziglang.org/buildsthe minisign signature

Every upstream archive is checked against the minisign signature that ziglang.org publishes next to it, made with the Zig Software Foundation’s key, and the signature has to name that archive. ziglang.org keeps only its recent dev builds, so an archive it no longer has comes from one of the community mirrors, tried in a random order, whose copy has to pass the same check. An archive that fails its check is never installed.

Running a version by hand

zig any 0.15.1 version   # run that exact version, installing it if needed
zig any list             # the installed versions, one per line
ZIG_ANY=off zig version  # the zig that was invoked, whatever the project pins

zig any <version> takes any command after the version, the same way zig does.

Where versions go

Each version lives in <global cache>/any/<version>/, next to the packages that zig fetch downloads; zig env prints the global cache directory. An install downloads and unpacks in a temporary directory and moves the result into place in one step, so a version is either complete or absent, even if the download is interrupted. Two zig commands that need the same new version at the same time end up with one install of it.

Deleting a version’s directory uninstalls it.

What the other version sees

The other version runs with the same arguments and the same environment, except that ZIG_LIB_DIR is removed: it points at the standard library of the zig that was invoked.

A compiler that runs from <global cache>/any/<version>/ never dispatches to that version again, so an installed Zig++ does not restart itself. Every other zig still dispatches: a build step that runs zig from PATH gets the pinned version too, and a project nested inside another one, pinning a different version, gets its own. zig any <version> starts its compiler with ZIG_ANY=off, so the version asked for runs even inside a project that pins another one.

If no source has the version, zig says what it tried, and that ZIG_ANY=off runs the compiler that was invoked instead.

A project created by a Zig++ that was never released, such as one you built from source, pins a version that has nothing to download. Build it with ZIG_ANY=off, or change minimum_zig_version to a released version.

Downloads

Zig++ publishes a release on every push to master, for x86_64-linux, aarch64-linux, aarch64-macos, and x86_64-windows. The newest is 0.17.0-dev.2383+zigpp.bceb3a816, published 2026-09-24.

What a release contains

An archive is named zig-<arch>-<os>-<version>.tar.xz, or .zip on Windows. It holds the zig executable, lib/, doc/langref.html, LICENSE, and README.md. The Installing chapter covers how the compiler finds the lib/ it carries.

SHA256SUMS lists the SHA-256 of every archive and of index.json.

0.17.0-dev.2383+zigpp.bceb3a816

2026-09-24 · zigpp-0.17.0-dev.2383

SHA256SUMS:

88c6dd087f6040129a19782875dd62ce6d9a3ee1d0c837a41520e9d2b0c270b6  zig-aarch64-linux-0.17.0-dev.2383+zigpp.bceb3a816.tar.xz
3c95e4b6a7796b1f9572256a9843299197e3565c3c59bdab267c9dbf653969a3  zig-aarch64-macos-0.17.0-dev.2383+zigpp.bceb3a816.tar.xz
85b697808c93d9a6a8163b3027b9f3d1ce6af26391822da6e30d0dd64554209a  zig-x86_64-linux-0.17.0-dev.2383+zigpp.bceb3a816.tar.xz
e41d124c48248ed78fbbac1cab4250f29524502a23b6d9c20a30f87140b1573d  zig-x86_64-windows-0.17.0-dev.2383+zigpp.bceb3a816.zip

0.17.0-dev.2376+zigpp.b926cb810

2026-09-24 · zigpp-0.17.0-dev.2376

SHA256SUMS:

4232d06b57059da725db85e44c7269941c58fded3cca93cf5198e0c044402a8c  zig-aarch64-linux-0.17.0-dev.2376+zigpp.b926cb810.tar.xz
762a64b811656274d3da9d22b5ecd5c7971b4cedec140e46f26741173d4b28ee  zig-aarch64-macos-0.17.0-dev.2376+zigpp.b926cb810.tar.xz
ca7bd086ed96b6cc5ebfee57251fdb736a5a467a557ea913040b7ed9e8fca5f9  zig-x86_64-linux-0.17.0-dev.2376+zigpp.b926cb810.tar.xz
a9e95c3f34f296add9662096ffa09e5b43dd511b58d372bc4b99ec55471584aa  zig-x86_64-windows-0.17.0-dev.2376+zigpp.b926cb810.zip

0.17.0-dev.2373+zigpp.add158e97

2026-09-24 · zigpp-0.17.0-dev.2373

SHA256SUMS:

723baa7b6ac79443b960b600a61f21c9f3881cee83f567e0a5bdbf4d797f7f34  zig-aarch64-linux-0.17.0-dev.2373+zigpp.add158e97.tar.xz
7148a807800498e2a4a4748a1ad802799b589fb79cf9c106d18c87130f8509d2  zig-aarch64-macos-0.17.0-dev.2373+zigpp.add158e97.tar.xz
fc062a67e5ecab871f10d00af08d53bfe6249e837f1fbefda58f0e15ddd0a6cc  zig-x86_64-linux-0.17.0-dev.2373+zigpp.add158e97.tar.xz
8f78651bfc9435b77edf3376e9315740b84156543e991746ba0092732741ad1d  zig-x86_64-windows-0.17.0-dev.2373+zigpp.add158e97.zip

Verifying a download

sha256sum -c SHA256SUMS

The archives and SHA256SUMS of a release are its assets, so download them into one directory first. The archives are a Zig++ compiler and its lib/: unpack one anywhere and run zig from it, with no installation step (see the Installing chapter).

The machine-readable index of every release is at download/index.json: master is the newest release, and every release has a key of its own.

Building from Source

Build Zig++ from source when you need a target that has no release, when you are developing the compiler, or when you want to build it against your own LLVM 23 libraries. There are four routes, from the least to the most preparation:

  1. A Zig++ bootstrap devkit, which brings LLVM, Clang, and LLD with it. This is what Zig++’s own CI and releases use.
  2. The CMake build, against LLVM, Clang, and LLD 23.x you installed yourself.
  3. An existing Zig++ compiler, which is the quickest route if you already have one.
  4. bootstrap.c, which needs nothing but a C compiler, and produces a compiler that cannot use the LLVM backend.

Requirements

Whichever route you take, the CMake build needs:

  • CMake 3.15 or later;
  • a system C/C++ toolchain;
  • the LLVM, Clang, and LLD development libraries, version 23.x, built with the same system C/C++ toolchain.

Zig++ accepts LLVM 23.x: the CMake module that finds LLVM rejects anything older than 23 and anything newer than 24, and it checks that the libraries it found can target AMDGPU and NVPTX. CMAKE_PREFIX_PATH points the build at a non-system LLVM:

mkdir build
cd build
cmake .. -DCMAKE_PREFIX_PATH="$HOME/local/llvm23"
make install

This produces stage3/bin/zig, relative to the build directory: the Zig compiler built by itself.

By default the build prefers the shared LLVM libraries. -DZIG_STATIC=ON links them statically, which is what releases do; it is not compatible with glibc. -DZIG_SHARED_LLVM=ON and -DZIG_STATIC_LLVM=ON choose explicitly, and asking for both at once is an error.

Building against an LLVM you built with Zig

If you have LLVM, Clang, and LLD libraries that were built by Zig, an existing Zig++ compiler can build Zig++ against them:

"$ZIG_PREFIX/zig" build \
  -p stage3 \
  --search-prefix "$LLVM_PREFIX" \
  --zig-lib-dir "lib" \
  -Dstatic-llvm

$LLVM_PREFIX is the directory that holds include/llvm/Pass.h and lib/libLLVMCore.a. The result is stage3/bin/zig.

The compiler that runs the build must be Zig++, not upstream Zig: Zig++ changed std.lang.Type, so an upstream Zig binary cannot compile against it. Upstream’s zig-bootstrap is still a good way to get the LLVM, Clang, and LLD libraries, as long as you use a Zig++ compiler to build with them.

Devkits

zigpp-bootstrap is zig-bootstrap for Zig++, and it makes the devkits that Zig++’s CI and releases build with. A devkit is a prebuilt tree of static LLVM, Clang, and LLD libraries, zlib and zstd, and a Zig++ compiler, with the headers to build against.

It builds LLVM, Clang, and LLD 23.1.2 from the llvmorg-23.1.2 release source, checked against its SHA-256 and patched with the patches that zig-bootstrap uses for LLVM 23, plus zlib 1.3.1 and zstd 1.5.2.

Host requirements:

  • a C++ compiler that can build LLVM, Clang, and LLD: GCC 5.1 or later, or Clang;
  • CMake 3.20 or later, and Ninja or another build system that CMake supports;
  • curl, tar with xz support, patch, and sha256sum or shasum;
  • a POSIX system (sh, mkdir, cd) and Python 3.
git clone --recursive https://github.com/mattneel/zigpp-bootstrap
cd zigpp-bootstrap
./build x86_64-linux-musl baseline

<arch>-<os>-<abi> is a Zig target and <mcpu> is a -mcpu value of Zig: baseline for a generic CPU of the architecture, or native. The Zig++ distribution for the target appears in out/zig-x86_64-linux-musl-baseline/.

The first run builds LLVM, Clang, and LLD twice: once for the host, to build Zig++ and the LLVM tools the cross build needs, and once for the target, with Zig++ as the cross compiler. Later runs for other targets reuse the host build. CMAKE_GENERATOR=Ninja and CMAKE_BUILD_PARALLEL_LEVEL tune it, ZIG_SRC builds another Zig++ checkout instead of the submodule, and ZIG_VERSION sets the version string it is built as.

To get just the libraries and compiler for a target, as one archive:

./devkit x86_64-linux-musl baseline
out/devkit/zig+llvm+lld+clang-x86_64-linux-musl-0.17.0-dev.2361+zigpp.5b96e6d21.tar.xz

That archive holds the target’s libraries and headers, bin/zig, and lib/zig/ for a computer of that target. On Windows, ./devkit x86_64-windows-gnu baseline packages the same thing as a .zip. Unpack it anywhere and build with it:

zig build -Dtarget=x86_64-linux-musl -Dstatic-llvm --search-prefix /path/to/devkit

./publish uploads every devkit of the version in zig-version to the devkit-<version> release of zigpp-bootstrap, with a SHA256SUMS file, and Zig++’s .github/scripts/devkit.sh names the version that CI downloads. So you do not have to build a devkit to use one: download it, unpack it, and use it as the --search-prefix above.

An upstream Zig devkit is not a Zig++ compiler, and it cannot build one. Use a Zig++ devkit, whose LLVM is 23.1.2.

How CI builds Zig++

Zig++’s CI does not install LLVM. It downloads the x86_64-linux-musl devkit, caches it under ~/deps, and runs the CMake build with the devkit’s Zig++ as the C and C++ compiler and the devkit’s libraries as the search prefix:

cmake .. \
  -DCMAKE_INSTALL_PREFIX=stage3 \
  -DCMAKE_PREFIX_PATH="$PREFIX" \
  -DCMAKE_BUILD_TYPE=Release \
  -DCMAKE_C_COMPILER="$PREFIX/bin/zig;cc;-target;x86_64-linux-musl;-mcpu=baseline" \
  -DCMAKE_CXX_COMPILER="$PREFIX/bin/zig;c++;-target;x86_64-linux-musl;-mcpu=baseline" \
  -DZIG_TARGET_TRIPLE=x86_64-linux-musl \
  -DZIG_TARGET_MCPU=baseline \
  -DZIG_STATIC=ON \
  -DZIG_NO_LIB=ON \
  -GNinja
ninja install

-DZIG_NO_LIB=ON leaves lib/ to the checkout, so the scripts point ZIG_LIB_DIR at this repository’s lib/. The compiler that comes out is build-bootstrap/stage3/bin/zig, and the release workflow cross-compiles every target’s archive with it.

Building without LLVM

The only system dependency of this route is a C compiler:

cc -o bootstrap bootstrap.c
./bootstrap

This produces zig2 in the current working directory: a stage2 build of the compiler, reported as 0.17.0-dev.bootstrap, without LLVM extensions, and therefore lacking:

  • release-mode optimizations;
  • some ELF linking features, some COFF/PE linking features, and some WebAssembly linking features;
  • the ability to create static archives from object files;
  • the ability to compile assembly files;
  • the ability to compile C, C++, Objective-C, and Objective-C++ files.

It still provides an LLVM backend that produces bitcode files, which a separately installed Clang can optimize and compile, and a C backend that produces C source. From there, the build system installs a compiler as usual:

./zig2 build

Zig++ is not going upstream’s way on LLVM: LLVM is a permanent, first-class dependency of Zig++, and the LLVM-less zig2 above exists only to bootstrap it. Upstream’s project to completely eliminate the dependency on LLVM library API calls will not be resolved here, and LLVM is forever explains why.

AI Policy and Governance

AI policy

Upstream Zig bans LLMs from issues, patches, and bug tracker comments. Zig++ welcomes them. The first Zig++ language feature, private fields, was implemented, tested, and documented by Claude, and the commit is signed that way.

Zig++ is also building AI code generation into the build system. Today that is Zigger, a package that adds a zig build gen step: it reads SPEC.md, runs the Claude CLI to implement it, runs zig build test, feeds any failures back, and repeats until the tests pass (up to 10 times by default). With -Dtdd=true it writes failing tests first. The plan is to make this pipeline part of std.Build.

The goal: by September 2027, all new Zig++ code is written through the Zigger pipeline.

Governance

Upstream Zig is BDFN (Benevolent Dictator For Now). Zig++ is BDFL: Matthew Neel is the Benevolent Dictator For Life and has final say on the design and implementation of everything.

Zig++ has no Code of Conduct. It has one rule: talk about code. Issues, pull requests, reviews, and comments are for the compiler, the language, the standard library, and the tools. Everything else, politics included, is off topic and will be closed.

Language proposals are welcome. Zig++ is made of them.

Contributing

The source, the issues, and the pull requests are at github.com/mattneel/zigpp. CI builds Zig++ with a bootstrap devkit and runs, in this order:

build-bootstrap/stage3/bin/zig build test-fmt
build-bootstrap/stage3/bin/zig test lib/std/std.zig -lc
build-bootstrap/stage3/bin/zig test test/behavior.zig
build-bootstrap/stage3/bin/zig build test-cases -Dskip-non-native -Denable-llvm
build-bootstrap/stage3/bin/zig build test-standalone -Dskip-non-native -Dskip-release

The standalone tests include the GPU suite: its kernels compile for NVIDIA and AMD GPUs, and it skips itself on a machine without a GPU driver.

Building from Source has the same bootstrap the CI job uses, so a change can be tested against the compiler CI builds.