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, to AMD GPU code objects, and to Metal libraries, 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.std.gpu.metalloads the Metal framework at run time and runs kernels that Zig++ compiles for theair64target into a.metallib, in process, with no Xcode, Metal toolchain, or macOS SDK. Apple GPUs have nof64, noprint, and only relaxed atomics, which are compile errors in their kernels.
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.
std.Io.Threadz
std.Io.Threadz is an Io implementation whose unit of work is a task: a
function on its own stack that parks at every Io call that has to wait, while
its worker thread runs the next task. Code written against std.Io runs on it
unchanged, so moving a program between Io.Threaded and Io.Threadz changes
one declaration:
var threadz: std.Io.Threadz = undefined;
try threadz.init(gpa, .{});
defer threadz.deinit();
const io = threadz.io();
- The thread that calls
initbecomes the first worker and runs as the main task. More workers start as work arrives, one per CPU by default;setWorkerLimitlowers the count, and-jsets it in a compiler built with-Dio-mode=evented. - Every task gets its own mapping with a guard page below the stack, and the OS
commits the stack as the task touches it. Tasks reserve 256 KiB unless
InitOptions.stack_sizesays otherwise. - A task’s affinity decides where it runs whenever it becomes runnable.
.sticky, the default, resumes it on the worker that last ran it, and another worker takes it only from a worker with more than one task waiting..pinnedkeeps it on one worker, for tasks that own that worker’s resources..freelets whichever worker is idle take it.concurrentWithandgroupConcurrentWithtake the affinity and the stack size of one task. - A task may complete 128
Iooperations that nobody has to wait for before the next one goes to the back of its worker’s queue, with its affinity unchanged. A worker takes the task it will run next, and a stuck worker’s slot for that task is the watchdog’s to hand to the shared queue, so that a worker pays no locked instruction for its own: the watchdog has the backend issue a process-wide barrier, re-reads what that worker wrote at its last switch, and takes the slot only when the worker has not switched since. Counting operations without parking bounds what one task can take from a worker without ever blocking on it, and the count starts over at every park or yield.scheduler.budgetis the 128. - Every task has an id, unique among the tasks the program made, and a name:
the function it was spawned with, from
Io.spawnedName. A log line and, in step 7, the task dump use them. Io.blocking(fn, args)is for a call that blocks without making anIocall of its own, such as a C library call or a raw syscall. It returnsfn’s result, errors and all. On Threadz it runs on a dirty pool, anIo.Threadedinstance the Threadz instance starts with the first such call, while the calling task parks; the arguments and the result stay on the task’s stack, where the pool thread reads them and writes the result back. Sixteen calls that each block for 200 ms finish in 205 ms, and the workers go on running tasks while they block. The call is not cancelable once it has started: a cancelation request takes effect at the calling task’s next cancelation point after it returns.Io.Threaded, and the implementations with no pool of their own, make the call on the calling thread.- One watchdog thread per instance, started with the first task the instance
makes or runs, samples every worker every 10 ms. A worker whose current task
has not switched out for 100 ms is stuck. Two rounds ten milliseconds apart,
and the worker thread’s CPU time between them, tell the kinds apart: a thread
that used less than a fifth of the wall time is blocked, in the kernel or in
a C call that never said it blocks, and its task is named in one
std.logwarning scoped.threadz, with its id, the function it was spawned with, and how long it has run; its queued tasks, including the ones others made runnable for it and the one in its slot for the next task, become takeable by every worker whatever their number; and one replacement worker is started for it, so that parallelism stays, which stops once it has nothing to run and the stuck worker switches out again. A thread that kept using its time is computing, which is what a compiler task is: its queued work is taken just the same, but no worker is replaced —-jand the worker limit mean what they say — and nothing is logged. An instance whose worker limit is one is covered for a blocked task, whose replacement is where the tasks behind it run; a computing task holds the only worker, which is what a limit of one means. Pinned tasks on a stuck worker wait for the worker, which they own the resources of.Threadz.stats()reads the counters: workers running, replacements, workers stuck now, the episodes of each kind, the rounds of samples the watchdog made, and the task and kind of the last episode. A worker pays one store per switch for the watchdog, of the task it runs, and one load of a flag when it takes the task in its slot. The watchdog waits without a timeout while every worker is parked, so an idle program is not woken to sample workers that are all asleep. - Threads outside the pool can use the synchronization primitives, whose
futexes they wait on in the kernel. Other
Iocalls from those threads are not supported yet. io.arena()returns an allocator whose memory belongs to the task: it is created at the first call, out of the implementation’s own allocator, and released in one step when the task returns, whether the task freed it or not. It is where a task puts what it builds up and throws away, such as one request’s parsed headers, and a value that outlives the task must not point into it: move what crosses a task boundary byownedtransfer, into another allocator, or by copying it. The main thread has one too, released bydeinit, andio.blockingruns on a thread of its own, which sees neither the calling task’s arena nor its scopes.io.Scoped(T)is the task-local value. The key is declared once at container level,const current = std.Io.Scoped(u32), bound around a call withcurrent.run(io, value, function, args), and read withcurrent.get(io)- in that call, and in every task spawned inside it, a child of a child as much as a child, each by its own copy, until it returns. Outside the call the binding is gone.threadlocalcannot serve this on Threadz: a task migrates at a park point, and the fiber switch does not move the thread’s TLS base, sothreadlocalis worker-local here, not task-local, andIo.Scopedis what a per-task value uses. The compiler’s per-thread ids and a logger’s context are the intended movers.std.Io.Supervisoris a group of tasks with OTP’s restart policy. Children are added withadd- the function they run and its arguments, which are copied - before the firstrun.one_for_onerestarts the child that failed,one_for_allcancels and restarts every child, andrest_for_onethe child that failed and the ones added after it. A child that returns normally is not restarted, which is OTP’stransientand the only restart type in this cut, and a panic stays fatal. More restarts thanintensitywithinperiod- three in five seconds by default - cancel every child and returnerror.RestartIntensityExceeded, with the error it gave up on inlastChildError. Cancelingruncancels the children and returnserror.Canceled.Io.Queuetakes its overflow policy atinitWithOptions:.block, the default and the behaviour a queue always had;.drop, where a put that finds the queue full drops what does not fit, counts it, and never waits, for a mailbox that keeps the newest sample; and.drop_and_resync, which does the same and makes the nextgetreturnerror.Resynconce, for a consumer that must notice a drop rather than act on stale data.droppedElementscounts what was dropped. Thegeterror set haserror.Resyncin it, which only adrop_and_resyncqueue returns.
On Linux, Threadz runs on io_uring, with a ring per worker: sockets, DNS,
processes, timers, futexes, and file reads are ring operations. The file
operations io_uring can only finish on a kernel thread of its own, such as
writes to a file, statx, and creating a file, are made on the worker instead,
because the trip to that thread and back costs more than the call.
A task that drives io_uring itself, such as a server’s event loop, can borrow
its worker’s ring instead of making one of its own. acquireRing lends it to a
task pinned to that worker. The task queues SQEs on the ring with
ring_owner_bit set in their user_data, and Ring.waitCqes parks it until
one of its completions arrives. The worker handles every other completion as
before and runs its other tasks while the loop is parked. fromIo finds the
Threadz behind an Io, and workerLimit says how many workers a program may
pin tasks to.
zix’s io_uring servers for HTTP/1 and WebSocket, HTTP/2, gRPC and HTTP/3 run on Threadz this way, each loop a task pinned to its own worker, and keep their multishot receives, buffer rings and batched submissions. On a 32-thread laptop, across the 45 load tiers of their HttpArena profiles, they served at a geometric mean of 1.01 times the throughput of the same servers on threads of their own, with single tiers from 0.83 to 1.23 times, inside the laptop’s own run-to-run spread.
zig build test-threadz runs the Io tests on Threadz, and the test runner’s
--io=threadz runs any test on it. The design, and the steps still to come
(the kqueue and IOCP cores, arenas and supervisors, the task dump and the
scheduler gauges, and the async/await keywords), are in
the Threadz proposal.
AI in the toolchain
Upstream Zig bans LLMs from issues, patches, and bug tracker comments. Zig++ welcomes them, and it is putting AI on both sides of compilation: in the programs it builds and in the build itself; see AI Policy and Governance.