Introduction
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
privcan only be named from the file that declares its type. See Private fields. - LLVM forever, and a blessed path to GPUs:
std.gpuruns 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.zoncan pin the exact compiler version it is built with, andzigruns 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
- Installing — download a release, check its SHA256SUMS, and
run
zig. - What Zig++ Adds — private fields, LLVM,
std.gpu. - GPU Programming — kernels, the CUDA and HIP host APIs, and the standalone test that runs them.
- 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
| Command | What it does |
|---|---|
ppup | Install the newest release as the default toolchain, install ppup, and add it to PATH |
ppup update | Install 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 list | List the installed toolchains, marking the default |
ppup uninstall <version> | Remove a toolchain; the default one refuses until another is the default |
ppup self update | Replace ppup with the newest one from zigpp.lol |
ppup self uninstall | Remove every toolchain, ppup, and its PATH entry |
ppup help | List 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:
| Target | Archive |
|---|---|
| x86_64-linux | zig-x86_64-linux-<version>.tar.xz |
| aarch64-linux | zig-aarch64-linux-<version>.tar.xz |
| aarch64-macos | zig-aarch64-macos-<version>.tar.xz |
| x86_64-windows | zig-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.htmlinside 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 stdIt 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 for the language features.
- GPU Programming to run Zig++ on an NVIDIA or AMD GPU.
- Any Zig Version for pinning a version per project.
- Building from Source for targets without a
release, for the
zig2bootstrap compiler, and for LLVM 23 development.
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
.kernelcalling convention, andstd.gpuhas CUDA’s indexing (threadIdx,blockIdx,blockDim,gridDim,globalId),syncThreads(the new@workGroupBarrierbuiltin), warp shuffles, votes and reductions, atomics, fast math approximations, andprint. - 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 instd.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@sinfor NVPTX. std.gpu.cudaloads the CUDA driver at run time, so programs build without the CUDA toolkit, and launches kernels from the host.std.gpu.hipdoes 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.cuda | std.gpu.hip | |
|---|---|---|
| Loads at run time | libcuda.so.1 | libamdhip64.so.7, .so.6, .so, or amdhip64_7.dll, _6.dll |
| Platform | Linux, with libc | Linux with libc, and Windows without it (ntdll.LdrLoadDll) |
| Toolkit needed to build | none | none |
| Module image | PTX, null-terminated: [:0]const u8 | a code object for one architecture: []const u8 |
| Allocation failure | error.OutOfDeviceMemory | error.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(anenum(u64) { _ }, a device memory address) passes unchanged; - integers, floats, booleans, enums, vectors, and
externorpackedstructs 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_heapon architectures other than NVPTX, both of which are compile errors. - On HIP,
ModuleOptions.error_logis 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:
| Target | Runner that checks it |
|---|---|
| x86_64-linux | ubuntu-24.04 |
| aarch64-linux | ubuntu-24.04-arm |
| aarch64-macos | macos-15 |
| x86_64-windows | windows-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.zipfor Windows; index.json, the download index described below;SHA256SUMS, the SHA-256 of every archive and ofindex.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:
| Version | Where it comes from | Verified with |
|---|---|---|
a Zig++ release, 0.17.0-dev.2361+zigpp.5b96e6d21 | the GitHub release zigpp-0.17.0-dev.2361 | the SHA-256 in the release’s index.json |
an upstream Zig release, 0.15.1 | ziglang.org/download | the SHA-256 in upstream’s download index, and the minisign signature |
an upstream dev build, 0.16.0-dev.1234+abcdef012 | ziglang.org/builds | the 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
| Target | Archive | Size |
|---|---|---|
| x86_64-linux | zig-x86_64-linux-0.17.0-dev.2383+zigpp.bceb3a816.tar.xz | 59.1 MiB |
| aarch64-linux | zig-aarch64-linux-0.17.0-dev.2383+zigpp.bceb3a816.tar.xz | 54.3 MiB |
| aarch64-macos | zig-aarch64-macos-0.17.0-dev.2383+zigpp.bceb3a816.tar.xz | 55.3 MiB |
| x86_64-windows | zig-x86_64-windows-0.17.0-dev.2383+zigpp.bceb3a816.zip | 101.6 MiB |
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
| Target | Archive | Size |
|---|---|---|
| x86_64-linux | zig-x86_64-linux-0.17.0-dev.2376+zigpp.b926cb810.tar.xz | 59.1 MiB |
| aarch64-linux | zig-aarch64-linux-0.17.0-dev.2376+zigpp.b926cb810.tar.xz | 54.3 MiB |
| aarch64-macos | zig-aarch64-macos-0.17.0-dev.2376+zigpp.b926cb810.tar.xz | 55.3 MiB |
| x86_64-windows | zig-x86_64-windows-0.17.0-dev.2376+zigpp.b926cb810.zip | 101.6 MiB |
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
| Target | Archive | Size |
|---|---|---|
| x86_64-linux | zig-x86_64-linux-0.17.0-dev.2373+zigpp.add158e97.tar.xz | 59.1 MiB |
| aarch64-linux | zig-aarch64-linux-0.17.0-dev.2373+zigpp.add158e97.tar.xz | 54.3 MiB |
| aarch64-macos | zig-aarch64-macos-0.17.0-dev.2373+zigpp.add158e97.tar.xz | 55.3 MiB |
| x86_64-windows | zig-x86_64-windows-0.17.0-dev.2373+zigpp.add158e97.zip | 101.5 MiB |
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:
- A Zig++ bootstrap devkit, which brings LLVM, Clang, and LLD with it. This is what Zig++’s own CI and releases use.
- The CMake build, against LLVM, Clang, and LLD 23.x you installed yourself.
- An existing Zig++ compiler, which is the quickest route if you already have one.
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,tarwith xz support,patch, andsha256sumorshasum;- 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.