Zig Performance Optimization
Zig programs are fast by default — no garbage collector, no hidden abstractions, direct memory access. But knowing how to measure, identify, and improve performance is a separate skill. This topic covers benchmarking, profiling, memory layout optimization, compiler hints, and the mindset behind systematic performance work.
The Optimization Process
Wrong approach: Right approach:
"This looks slow, Measure → Profile → Identify
let me optimize it" bottleneck → Optimize → Measure
again → Confirm gain
↓ ↓
Wastes time Improves the right thing
May make things worse Confirmed by data
Build Modes for Performance
zig build -Doptimize=Debug ← safe, slow (development) zig build -Doptimize=ReleaseSafe ← fast + safety checks zig build -Doptimize=ReleaseFast ← fastest, no safety checks zig build -Doptimize=ReleaseSmall ← smallest binary
Always benchmark with ReleaseFast. Debug builds can be 10–50× slower due to safety checks and disabled optimizations. Comparing benchmark numbers from debug builds is misleading.
Measuring Time
const std = @import("std");
pub fn main() void {
const N = 10_000_000;
const start = std.time.nanoTimestamp();
var sum: u64 = 0;
for (0..N) |i| sum +%= i * i;
const end = std.time.nanoTimestamp();
const elapsed_ms = @as(f64, @floatFromInt(end - start)) / 1_000_000.0;
std.debug.print("Sum: {d}\n", .{sum});
std.debug.print("Time: {d:.3} ms\n", .{elapsed_ms});
}
Timing hierarchy: nanosecond (ns) = 1 microsecond (µs) = 1,000 ns millisecond (ms) = 1,000,000 ns second (s) = 1,000,000,000 ns std.time.nanoTimestamp() → current time in nanoseconds Difference = elapsed nanoseconds
CPU Cycle Counter Benchmarking
// Repeat measurements and take the minimum (removes noise)
fn benchmark(comptime name: []const u8, comptime f: fn() void, runs: u32) void {
var min_cycles: u64 = std.math.maxInt(u64);
for (0..runs) |_| {
const start = rdtsc();
f();
const end = rdtsc();
const cycles = end - start;
if (cycles < min_cycles) min_cycles = cycles;
}
std.debug.print("{s}: {d} cycles (min of {d} runs)\n",
.{name, min_cycles, runs});
}
Memory Layout — Cache Friendliness
Modern CPUs are much faster at reading sequential memory than random memory. When data is scattered across RAM, the CPU stalls waiting for memory — called a cache miss.
Cache miss model: L1 cache hit: ~4 cycles (fast) L2 cache hit: ~12 cycles L3 cache hit: ~40 cycles RAM access: ~200 cycles (slow) Accessing arr[0], arr[1], arr[2] sequentially: → CPU loads a cache line (64 bytes) containing all of them → Only first access is slow; rest are L1 hits Accessing random linked list nodes scattered in memory: → Each node access may be a cache miss → 200 cycles per node instead of 4
Struct of Arrays (SoA) vs Array of Structs (AoS):
AoS (worse for iterating one field):
struct Particle { x, y, z, vx, vy, vz, mass, life }
[p0: x,y,z,vx,vy,vz,mass,life][p1: x,y,z,...][p2: ...]
If you only update x and vx, you load all other fields too.
SoA (better for iterating one field):
xs: [x0, x1, x2, ...]
ys: [y0, y1, y2, ...]
vxs: [vx0, vx1, vx2, ...]
Updating all x: load xs array → pure cache hits
Avoid Unnecessary Allocations in Hot Paths
Hot path = code that runs millions of times
// Slow: allocates and frees on every call
fn processItem(alloc: std.mem.Allocator, item: Item) !Result {
const buf = try alloc.alloc(u8, 256); // allocation in hot path!
defer alloc.free(buf);
// ...
}
// Fast: reuse a pre-allocated buffer
fn processItems(items: []Item, buf: []u8) void {
for (items) |item| {
processWithBuffer(item, buf); // no allocation per item
}
}
Branch Prediction — Help the CPU Guess Right
CPUs predict the outcome of branches and speculatively execute ahead.
A wrong prediction costs ~15 cycles to undo.
// Hard to predict (random data):
for (data) |val| {
if (val > threshold) process(val); // branch unpredictable
}
// Better: sort data first so branches are predictable
std.mem.sort(u32, data, {}, std.sort.asc(u32));
// Now all "below threshold" come first → branch is predictable
Compiler Hints
@branchHint(.likely) → tell compiler this branch is usually taken
@branchHint(.unlikely) → tell compiler this branch is rarely taken
@branchHint(.cold) → this code runs rarely (error paths)
if (err != null) {
@branchHint(.unlikely); // errors are rare
handleError(err);
}
Inlining and Loop Unrolling
inline fn fastSquare(x: f32) f32 { return x * x; }
// Compiler pastes the body at each call site — no function call overhead
// Unroll small loops manually or with inline for:
inline for (0..4) |i| {
result[i] = process(input[i]);
}
// Compiler generates 4 separate process() calls with no loop overhead
Profiling Tools
Linux: perf stat ./myapp ← CPU counters (cycles, cache misses) perf record ./myapp ← sample-based profiler perf report ← show hot functions valgrind --tool=callgrind ← instruction-level profiling macOS: Instruments (Xcode) ← time profiler, allocations, leaks Cross-platform: Tracy profiler ← real-time game/server profiler (Zig has Tracy integration in std.debug)
The 80/20 Rule of Performance
80% of execution time is in 20% of the code. Workflow: 1. Profile to find the 20% (hot functions) 2. Optimize only those functions 3. Measure again to confirm improvement 4. Stop when "fast enough" for your requirements Premature optimization: → Optimizing code that is NOT in the hot 20% → Wastes developer time → Makes code harder to read and maintain → Rarely improves real-world performance
Zig's design philosophy — explicit memory, no hidden allocations, no GC — means that a straightforward Zig program written by a beginner is often already faster than the equivalent written in garbage-collected languages. Systematic optimization on top of that baseline produces programs that match or exceed hand-written C in performance benchmarks.
