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.

Leave a Comment

Your email address will not be published. Required fields are marked *