Código Cache-Friendly em Zig: Data-Oriented Design na Prática

O gargalo de performance numero um em software moderno nao e a CPU — e o acesso a memoria. Uma cache miss pode custar 100x mais que uma operacao aritmetica. Neste artigo, exploramos como estruturar dados em Zig para maximizar hits de cache e minimizar acessos lentos a RAM.

Continuacao do artigo sobre benchmarking em Zig.

Hierarquia de Memoria

Latencia de Acesso

NivelTamanho TipicoLatenciaEquivalente
Registrador~1 KB~0.5 ns1 passo
L1 Cache32-64 KB~1 ns2 passos
L2 Cache256 KB - 1 MB~4 ns8 passos
L3 Cache4-32 MB~12 ns24 passos
RAM8-64 GB~100 ns200 passos
SSD256 GB+~100 μs200.000 passos

Uma cache miss de L1 para RAM custa 100x mais. Otimizar acesso a cache e a otimizacao mais impactante que voce pode fazer.

Array of Structs vs Struct of Arrays

O Problema: AoS (Array of Structs)

const std = @import("std");

// AoS — layout tradicional (orientado a objetos)
const Particula = struct {
    x: f32,     // 4 bytes
    y: f32,     // 4 bytes
    z: f32,     // 4 bytes
    vx: f32,    // 4 bytes
    vy: f32,    // 4 bytes
    vz: f32,    // 4 bytes
    massa: f32, // 4 bytes
    cor: u32,   // 4 bytes — total: 32 bytes
};

// Para atualizar posicoes, precisamos ler TODA a struct (32 bytes)
// mesmo que so usemos x, y, z, vx, vy, vz (24 bytes)
fn atualizarPosicoesAoS(particulas: []Particula, dt: f32) void {
    for (particulas) |*p| {
        p.x += p.vx * dt;
        p.y += p.vy * dt;
        p.z += p.vz * dt;
    }
}

A Solucao: SoA (Struct of Arrays)

// SoA — layout orientado a dados
const Particulas = struct {
    x: []f32,
    y: []f32,
    z: []f32,
    vx: []f32,
    vy: []f32,
    vz: []f32,
    massa: []f32,
    cor: []u32,
    len: usize,

    pub fn init(allocator: std.mem.Allocator, n: usize) !Particulas {
        return .{
            .x = try allocator.alloc(f32, n),
            .y = try allocator.alloc(f32, n),
            .z = try allocator.alloc(f32, n),
            .vx = try allocator.alloc(f32, n),
            .vy = try allocator.alloc(f32, n),
            .vz = try allocator.alloc(f32, n),
            .massa = try allocator.alloc(f32, n),
            .cor = try allocator.alloc(u32, n),
            .len = n,
        };
    }

    pub fn deinit(self: Particulas, allocator: std.mem.Allocator) void {
        allocator.free(self.x);
        allocator.free(self.y);
        allocator.free(self.z);
        allocator.free(self.vx);
        allocator.free(self.vy);
        allocator.free(self.vz);
        allocator.free(self.massa);
        allocator.free(self.cor);
    }
};

// Agora cada array e contiguous — o prefetcher da CPU adora isso
fn atualizarPosicoesSoA(p: *Particulas, dt: f32) void {
    for (0..p.len) |i| {
        p.x[i] += p.vx[i] * dt;
        p.y[i] += p.vy[i] * dt;
        p.z[i] += p.vz[i] * dt;
    }
}

Benchmark: AoS vs SoA

test "benchmark AoS vs SoA" {
    const N = 1_000_000;
    const dt: f32 = 0.016; // 60 FPS
    const allocator = std.testing.allocator;

    // === AoS ===
    const particulas_aos = try allocator.alloc(Particula, N);
    defer allocator.free(particulas_aos);
    for (particulas_aos) |*p| p.* = .{
        .x = 0, .y = 0, .z = 0,
        .vx = 1, .vy = 2, .vz = 3,
        .massa = 1, .cor = 0xFFFFFF,
    };

    // Warmup
    for (0..10) |_| atualizarPosicoesAoS(particulas_aos, dt);

    var timer_aos = try std.time.Timer.start();
    for (0..100) |_| atualizarPosicoesAoS(particulas_aos, dt);
    const tempo_aos = timer_aos.read();

    // === SoA ===
    var particulas_soa = try Particulas.init(allocator, N);
    defer particulas_soa.deinit(allocator);
    for (0..N) |i| {
        particulas_soa.vx[i] = 1;
        particulas_soa.vy[i] = 2;
        particulas_soa.vz[i] = 3;
    }

    // Warmup
    for (0..10) |_| atualizarPosicoesSoA(&particulas_soa, dt);

    var timer_soa = try std.time.Timer.start();
    for (0..100) |_| atualizarPosicoesSoA(&particulas_soa, dt);
    const tempo_soa = timer_soa.read();

    std.debug.print(
        \\AoS: {d} ms
        \\SoA: {d} ms
        \\Speedup: {d:.2}x
        \\
    , .{
        tempo_aos / std.time.ns_per_ms,
        tempo_soa / std.time.ns_per_ms,
        @as(f64, @floatFromInt(tempo_aos)) / @as(f64, @floatFromInt(tempo_soa)),
    });
}

MultiArrayList do Zig

O Zig oferece std.MultiArrayList que implementa SoA automaticamente:

const std = @import("std");

const Entidade = struct {
    x: f32,
    y: f32,
    saude: u16,
    tipo: enum(u8) { jogador, inimigo, projetil },
    ativo: bool,
};

test "MultiArrayList — SoA automatico" {
    var lista = std.MultiArrayList(Entidade){};
    defer lista.deinit(std.testing.allocator);

    // Adicionar entidades
    try lista.append(std.testing.allocator, .{
        .x = 10, .y = 20, .saude = 100, .tipo = .jogador, .ativo = true,
    });
    try lista.append(std.testing.allocator, .{
        .x = 50, .y = 30, .saude = 50, .tipo = .inimigo, .ativo = true,
    });

    // Acessar como SoA — cada campo e um slice contiguous
    const slice = lista.slice();
    const xs = slice.items(.x);
    const ys = slice.items(.y);
    const saudes = slice.items(.saude);

    // Atualizar apenas posicoes — acesso contiguous, cache-friendly
    for (xs, ys) |*x, *y| {
        x.* += 1.0;
        y.* += 0.5;
    }

    try std.testing.expectApproxEqAbs(@as(f32, 11.0), xs[0], 0.001);
    try std.testing.expectApproxEqAbs(@as(f32, 51.0), xs[1], 0.001);

    // Filtrar por saude — acessa apenas o array de saude
    var vivos: u32 = 0;
    for (saudes) |s| {
        if (s > 0) vivos += 1;
    }
    try std.testing.expectEqual(@as(u32, 2), vivos);
}

Alinhamento e Padding

Entendendo Padding de Structs

const std = @import("std");

// Struct com padding desperdicado
const MalAlinhada = struct {
    a: bool,   // 1 byte + 3 bytes padding
    b: u32,    // 4 bytes
    c: bool,   // 1 byte + 1 byte padding
    d: u16,    // 2 bytes
    // Total: 12 bytes (mas so 8 bytes de dados uteis!)
};

// Struct otimizada — campos ordenados por tamanho decrescente
const BemAlinhada = struct {
    b: u32,    // 4 bytes
    d: u16,    // 2 bytes
    a: bool,   // 1 byte
    c: bool,   // 1 byte
    // Total: 8 bytes — zero padding desperdicado!
};

test "tamanho de structs com padding" {
    std.debug.print("MalAlinhada: {d} bytes\n", .{@sizeOf(MalAlinhada)});
    std.debug.print("BemAlinhada: {d} bytes\n", .{@sizeOf(BemAlinhada)});

    // Packed struct remove todo padding
    const Packed = packed struct {
        a: bool,
        b: u32,
        c: bool,
        d: u16,
    };
    std.debug.print("Packed: {d} bytes\n", .{@sizeOf(Packed)});
}

Alinhamento Explicito para Cache Lines

// Alinhar estrutura a cache line de 64 bytes
// para evitar false sharing em codigo multi-threaded
const ContadorThread = struct {
    valor: std.atomic.Value(u64) align(64) = std.atomic.Value(u64).init(0),
    // 56 bytes de padding implicito ate proxima cache line
};

test "contadores alinhados evitam false sharing" {
    var contadores: [8]ContadorThread = .{.{}} ** 8;

    // Cada contador esta em sua propria cache line
    // Threads podem incrementar sem interferencia
    for (&contadores) |*c| {
        _ = c.valor.fetchAdd(1, .monotonic);
    }
}

Prefetching Manual

/// Prefetch de dados para o cache antes de usa-los
fn processarComPrefetch(dados: []const u32) u64 {
    var soma: u64 = 0;
    const prefetch_distance = 16; // 16 elementos a frente

    for (dados, 0..) |v, i| {
        // Prefetch dados que serao usados em breve
        if (i + prefetch_distance < dados.len) {
            @prefetch(&dados[i + prefetch_distance], .{
                .rw = .read,
                .locality = 3, // Manter no cache L1
            });
        }
        soma += v;
    }
    return soma;
}

Hot/Cold Splitting

Separe dados acessados frequentemente dos acessados raramente:

// Dados quentes — acessados em todo frame
const EntidadeHot = struct {
    x: f32,
    y: f32,
    velocidade_x: f32,
    velocidade_y: f32,
};

// Dados frios — acessados raramente
const EntidadeCold = struct {
    nome: []const u8,
    sprite_path: []const u8,
    criado_em: i64,
    ultima_interacao: i64,
    descricao: []const u8,
};

const Mundo = struct {
    // Arrays quentes — cache-friendly para update loop
    hot: std.MultiArrayList(EntidadeHot),
    // Arrays frios — nao poluem o cache no loop principal
    cold: std.MultiArrayList(EntidadeCold),

    pub fn update(self: *Mundo, dt: f32) void {
        // So toca dados quentes — muito cache-friendly
        const slice = self.hot.slice();
        const xs = slice.items(.x);
        const ys = slice.items(.y);
        const vxs = slice.items(.velocidade_x);
        const vys = slice.items(.velocidade_y);

        for (xs, ys, vxs, vys) |*x, *y, vx, vy| {
            x.* += vx * dt;
            y.* += vy * dt;
        }
    }
};

Conclusao

Codigo cache-friendly pode ser 10-100x mais rapido que codigo com acesso aleatorio a memoria. As tecnicas-chave sao: usar SoA em vez de AoS para loops que acessam poucos campos, manter dados quentes separados dos frios, alinhar estruturas corretamente e preferir acesso sequencial. O std.MultiArrayList do Zig torna a implementacao de SoA trivial.

Proximo Artigo

No Artigo 3: SIMD e Vetorizacao, exploramos como processar multiplos dados simultaneamente usando instrucoes vetorizadas.

Conteudo Relacionado

Continue aprendendo Zig

Explore mais tutoriais e artigos em português para dominar a linguagem Zig.