MÓDULO 06 · CONCEITO 07 DE 12

CPU cache, locality & false sharing

Cache lines de 64 bytes, hierarquia L1/L2/L3, prefetching. Mike Acton e a defesa do data-oriented design (CppCon 2014). Por que dois contadores em variáveis vizinhas podem rodar 10× mais devagar que separados — e como design de struct define o ganho.

Tempo de leitura ~22 min Pré-requisito Conceito 02 (pilha de latência) Próximo Alocação de memória e pressão de GC

Em setembro de 2014, na CppCon em Bellevue, Mike Acton — então engineering director da Insomniac Games (estúdio por trás de Ratchet & Clank, Spider-Man) — subiu ao palco com uma palestra que ficaria conhecida pelo título "Data-Oriented Design and C++". O argumento era polêmico para uma audiência de OOP: a orientação a objetos clássica, com sua ênfase em encapsulamento e hierarquia, ignora sistematicamente o que o hardware moderno realmente faz com seu código. A tese de Acton: "The purpose of all programs, and all parts of those programs, is to transform data from one form to another". Programas eficientes desenham layout de dados primeiro, código depois.

O argumento de Acton se ancorava em fato físico conhecido por engenheiros de jogos e sistemas embarcados há décadas, mas ignorado pela maioria do mainstream: RAM é lenta, cache é rápido, e a diferença entre as duas é enorme. Acessar um dado que está em L1 cache custa ~1 ns; o mesmo dado em RAM custa ~80 ns. Fator 80×. Como o hardware busca dados, e como os dados estão organizados em memória, definem se cada acesso vai ser hit (rápido) ou miss (lento).

Em sistemas web típicos, esse fator é diluído por I/O dominante (banco, rede). Mas em hot paths computacionais — game engines, ML inference, ferramentas de build, processamento de mídia, hot paths de bancos e brokers de mensagem —, locality de cache vira a diferença entre adequado e impressionante. Sêniores que tocam hot path precisam saber o que acontece debaixo, mesmo se na maior parte do trabalho a preocupação é distante.

Este conceito articula a hierarquia de cache moderna, explica o que são cache lines e como prefetcher de hardware funciona, mostra os padrões de acesso que exploram cache (locality espacial e temporal) e os que destroem (random access em estrutura grande, false sharing). Os exemplos vão dos clássicos (sequencial vs random sobre array) até os contraintuitivos (false sharing em contadores concorrentes). O conceito conecta com 04 (concorrência) e 08 (alocação) do módulo anterior; aqui o foco é o nível de cache da CPU.

A hierarquia moderna — L1, L2, L3, RAM

CPU moderna não acessa RAM diretamente para cada operação. Entre o registrador e a RAM, há três níveis de cache, cada um menor e mais rápido que o seguinte. Em CPU típica de servidor 2026 (Intel Xeon, AMD EPYC, ARM Graviton):

NÍVEL    TAMANHO    LATÊNCIA   COMPARTILHADO
L1d      32–48 KB   ~1 ns      por core (privado)
L1i      32–48 KB   ~1 ns      por core (privado, instruções)
L2       512K–2 MB  ~3 ns      por core (privado)
L3 (LLC) 16–256 MB  ~10 ns     entre cores (compartilhado)
RAM      GB         ~80 ns     entre cores

L1 é dividida em L1d (data) e L1i (instruções). L2 unifica os dois. L3 (Last-Level Cache, ou LLC) é compartilhada por todos os cores do mesmo socket — e é exatamente aí que a coordenação entre threads vira interessante (e onde false sharing vive).

Cada nível é "filtrado" pelo anterior. Acesso a dado tenta L1 primeiro; se miss, tenta L2; se miss, tenta L3; se miss, vai à RAM. Cada miss é mais caro que o anterior. Programa eficiente em performance é programa cujo padrão de acesso mantém os dados no nível mais próximo possível da CPU.

Cache line — a unidade de transferência

A CPU não busca dado byte a byte. Busca em unidades chamadas cache lines, de tamanho fixo — tipicamente 64 bytes em arquiteturas x86_64 modernas (alguns ARM têm 128 bytes). Quando você lê 1 byte de RAM, a CPU traz 64 bytes em volta dele para o cache. Os 63 bytes adicionais ficam disponíveis grátis — se você usar.

Esse fato simples define quase tudo sobre performance de cache. Iterar sequencialmente sobre um array explora cache line: você lê um elemento, os 8 vizinhos (em array de int de 8 bytes) já estão em cache, próximo acesso é hit. Iterar aleatoriamente sobre o mesmo array destrói cache: cada elemento é em cache line diferente, miss em cada acesso.

Em estruturas de dados, isso muda decisões fundamentais. Lista encadeada (cada nó alocado separadamente, com ponteiro para o próximo) tem péssima locality: cada node->next é tipicamente cache miss. Array contíguo de structs tem ótima locality: iterar é sequencial, prefetcher do hardware antecipa as próximas cache lines. Em hot paths, array beats lista encadeada por fator de 5–20×, mesmo que a complexidade assintótica seja a mesma O(n).

Os dois tipos de locality

Cache funciona porque programas exibem dois padrões de acesso a dados:

Locality espacial. Se você acessa um endereço, provavelmente vai acessar endereços próximos em breve. Iterar sobre array, ler campos de uma struct em sequência — todos exploram locality espacial. O prefetcher de hardware reconhece o padrão e busca cache lines à frente preventivamente.

Locality temporal. Se você acessa um endereço, provavelmente vai acessar o mesmo endereço de novo em breve. Loop que opera sobre o mesmo elemento, função chamada várias vezes com mesmos argumentos — exploram locality temporal. Cache mantém os dados recentemente acessados.

Programas com bom locality acessam padrões previsíveis; programas com péssima locality acessam aleatoriamente em estrutura grande. A regra do design é simples: organize dados para que padrões de acesso explorem as duas localities.

Struct of Arrays vs Array of Structs

O padrão mais célebre do data-oriented design é a escolha entre AoS e SoA — Array of Structs e Struct of Arrays. Considere o cenário: você tem 10.000 entidades com posição (x, y, z) e velocidade (vx, vy, vz). Como armazenar?

Array of Structs (AoS): a forma OOP natural.

struct Particle {
    float x, y, z;        // 12 bytes
    float vx, vy, vz;     // 12 bytes
    // 24 bytes por struct, sem padding
}
Particle particles[10000];

// update positions
for (int i = 0; i < 10000; i++) {
    particles[i].x += particles[i].vx * dt;
    particles[i].y += particles[i].vy * dt;
    particles[i].z += particles[i].vz * dt;
}

O acesso lê x, vx juntos (locality boa dentro da struct). O problema aparece em outro cenário — operação que só toca posição:

// só lê posições, não velocidades
for (int i = 0; i < 10000; i++) {
    sum_x += particles[i].x;
}

Cada cache line de 64 bytes traz ~2.5 structs (24 bytes cada), e portanto contém os x, y, z, vx, vy, vz de várias particles. Você lê só x de cada, mas a CPU trouxe os 23 bytes de y, z, vx, vy, vz que vão ser ignorados. Banda de memória desperdiçada — você usa só ~17% do que foi trazido para cache.

Struct of Arrays (SoA): a forma data-oriented.

struct ParticleSystem {
    float* x;     // array de 10000 floats contíguos
    float* y;
    float* z;
    float* vx;
    float* vy;
    float* vz;
}

// só lê posições
for (int i = 0; i < 10000; i++) {
    sum_x += system.x[i];
}

Agora cada cache line traz 16 floats consecutivos do array x. Banda de memória 100% usada para o cálculo. Performance pode ser 4–8× melhor para operações que tocam um subset dos campos.

Quando AoS ganha: operações que tocam vários campos simultaneamente (update de posição+velocidade ao mesmo tempo). Quando SoA ganha: operações que tocam um ou poucos campos sobre todas as entidades. Em um sistema com várias operações distintas, a escolha é trade-off — e às vezes vale ter ambas as representações (vetor de structs de imutáveis + arrays paralelos de mutáveis).

Game engines modernas (ECS — Entity Component System) usam SoA religiosamente. Unity DOTS (Data-Oriented Technology Stack), Unreal Engine 5 com Mass Entity, Bevy em Rust — todos exploram a ideia. Em domínio de aplicação web, raramente vale a complicação; em hot paths numéricos (ML, simulação, processamento de mídia), o ganho compensa.

False sharing — o bug invisível em código concorrente

O fenômeno mais surpreendente em CPU cache é false sharing. Acontece quando duas threads escrevem em variáveis que são logicamente independentes mas fisicamente residem na mesma cache line. Por causa do protocolo de coerência de cache (MESI ou variantes), cada escrita força invalidação da cache line nas outras CPUs, criando tráfego de coordenação ao nível de cache que destrói performance.

O exemplo canônico: dois contadores, cada um incrementado por uma thread diferente.

// caso ruim — false sharing
struct Contadores {
    long counter1;       // thread A incrementa
    long counter2;       // thread B incrementa
    // ambos cabem na mesma cache line de 64 bytes
};

// thread A
for (int i = 0; i < 1_000_000_000; i++) c.counter1++;

// thread B (em paralelo)
for (int i = 0; i < 1_000_000_000; i++) c.counter2++;

Conceitualmente, as threads não compartilham nada — cada uma escreve em variável própria. Mas a cache line é compartilhada fisicamente, e o protocolo MESI exige que cada CPU "ganhe" a cache line antes de escrever, invalidando a cópia da outra. O resultado: o que deveria ser perfeitamente paralelo vira sequencial, e pior — vira sequencial com overhead de coordenação. Performance pode ser 10× pior que sequencial single-thread.

A solução é forçar as variáveis a ficarem em cache lines diferentes, com padding:

// caso bom — sem false sharing
struct ContadoresPadded {
    long counter1;
    char padding[56];    // empurra counter2 para próxima cache line
    long counter2;
};

Ou em linguagem com suporte a alinhamento explícito, usar atributo:

// C# 9+
[StructLayout(LayoutKind.Explicit, Size = 128)]  // 2 cache lines
public struct PaddedCounter
{
    [FieldOffset(0)]   public long Value;
    // o resto é padding
}

// Java tem @Contended (sun.misc.Contended em pré-9, jdk.internal.vm.annotation.Contended em 9+)

// Go: sem atributo dedicado, mas idiomático é
type PaddedCounter struct {
    value uint64
    _pad  [56]byte    // padding manual
}

O experimento que mostra cache em ação

Os números acima ficam abstratos até você medir em sua máquina. O experimento abaixo executa em todas as linguagens mainstream e mostra a hierarquia de cache diretamente.

C# — sequencial vs random sobre array
using System.Diagnostics;

const int N = 64 * 1024 * 1024 / sizeof(int);   // 64 MB de ints
var arr = new int[N];
for (int i = 0; i < N; i++) arr[i] = i;

// sequencial — locality espacial perfeita
var sw = Stopwatch.StartNew();
long sum = 0;
for (int i = 0; i < N; i++) sum += arr[i];
sw.Stop();
Console.WriteLine($"sequencial: {sw.Elapsed.TotalMilliseconds:F2} ms");

// strided — pula cache lines
sw.Restart();
sum = 0;
for (int stride = 0; stride < 16; stride++)
    for (int i = stride; i < N; i += 16)
        sum += arr[i];
sw.Stop();
Console.WriteLine($"strided 16: {sw.Elapsed.TotalMilliseconds:F2} ms");

// random — pessimo
var rnd = new Random(42);
var indices = Enumerable.Range(0, N).OrderBy(_ => rnd.Next()).ToArray();
sw.Restart();
sum = 0;
for (int i = 0; i < N; i++) sum += arr[indices[i]];
sw.Stop();
Console.WriteLine($"random:     {sw.Elapsed.TotalMilliseconds:F2} ms");

Em CPU típica: sequencial ~50 ms, strided ~80 ms, random ~600–1000 ms. Fator 10–20× entre sequencial e random. Esse experimento, rodado uma vez em sua máquina, fixa a hierarquia de cache no conhecimento permanentemente.

Python — false sharing com numpy/threading
# Python tem GIL, então threading não paraleliza CPU bem.
# multiprocessing tem overhead de IPC. Para demonstrar false
# sharing, vamos usar numpy + numba.

import numpy as np
from numba import njit, prange
import time

# array compartilhado entre "threads" via prange (paralelo OpenMP-like)
N = 100_000_000
data = np.zeros(8, dtype=np.int64)    # 8 int64 = 64 bytes = 1 cache line

@njit(parallel=True)
def increment_close(data, n):
    for thread in prange(2):
        for i in range(n):
            data[thread] += 1     # data[0] e data[1] na mesma cache line!

@njit(parallel=True)
def increment_far(data, n):
    for thread in prange(2):
        for i in range(n):
            data[thread * 7] += 1  # data[0] e data[7]: 56 bytes apart, cache lines diferentes

t0 = time.perf_counter()
increment_close(data.copy(), N)
print(f"close (false sharing):   {time.perf_counter() - t0:.2f}s")

t0 = time.perf_counter()
increment_far(data.copy(), N)
print(f"far (sem false sharing): {time.perf_counter() - t0:.2f}s")

Com numba parallel, você pode demonstrar false sharing em Python. Em CPython puro o GIL serializa execução; em numba (LLVM-compiled, sem GIL), o efeito aparece. Próximo de 5–10× de diferença.

Go — false sharing em contadores concorrentes
package main

import (
    "fmt"
    "sync"
    "sync/atomic"
    "time"
)

const iterations = 100_000_000

// versão ruim: contadores adjacentes
type Bad struct {
    a uint64
    b uint64
    // a e b cabem na mesma cache line (16 bytes < 64)
}

// versão boa: padding entre contadores
type Good struct {
    a   uint64
    _   [56]byte    // padding até completar 64 bytes
    b   uint64
    _   [56]byte
}

func runBad() time.Duration {
    var b Bad
    var wg sync.WaitGroup
    wg.Add(2)
    start := time.Now()
    go func() {
        defer wg.Done()
        for i := 0; i < iterations; i++ {
            atomic.AddUint64(&b.a, 1)
        }
    }()
    go func() {
        defer wg.Done()
        for i := 0; i < iterations; i++ {
            atomic.AddUint64(&b.b, 1)
        }
    }()
    wg.Wait()
    return time.Since(start)
}

func runGood() time.Duration {
    var g Good
    var wg sync.WaitGroup
    wg.Add(2)
    start := time.Now()
    go func() {
        defer wg.Done()
        for i := 0; i < iterations; i++ {
            atomic.AddUint64(&g.a, 1)
        }
    }()
    go func() {
        defer wg.Done()
        for i := 0; i < iterations; i++ {
            atomic.AddUint64(&g.b, 1)
        }
    }()
    wg.Wait()
    return time.Since(start)
}

func main() {
    fmt.Printf("false sharing:    %v\n", runBad())
    fmt.Printf("sem false sharing: %v\n", runGood())
}

Em CPU típica multi-core: Bad ~3-5 segundos, Good ~0.5-1 segundo. Fator 5-10× só pelo padding. Mesma carga lógica, hardware diferente respondendo — isso é o bug invisível.

Prefetching — a ajuda silenciosa do hardware

A CPU moderna não espera você pedir dado para começar a buscá-lo. O prefetcher observa o padrão de acesso e busca cache lines à frente preventivamente. Há prefetcher de hardware (na CPU, transparente) e prefetch de software (instruções como __builtin_prefetch em GCC).

O hardware prefetcher reconhece padrões simples: sequencial (forward, backward), strided regular. Não reconhece random nem indireção via ponteiro. Por isso iterar array é fast (prefetcher antecipa); iterar lista encadeada é slow (impossível antecipar — o próximo endereço só é conhecido após carregar o nó atual).

A consequência prática: arrays venceram listas encadeadas em quase todo cenário em hardware moderno. A vantagem teórica de O(1) para inserção em meio de lista raramente compensa a penalidade de cache. Em C++, std::vector superando std::list em quase tudo é sintoma; em Java/.NET, ArrayList superando LinkedList; em Python, list é sempre array; em Go, []T é o default e container/list é raramente usado.

O que isso significa para você no dia a dia

A maior parte do trabalho de aplicação web não toca hot paths sensíveis a cache. Não vale otimizar manualmente para cache no controlador HTTP. Mas há casos em código de aplicação que ganham significativamente com awareness:

Loops sobre coleções grandes em hot path. Iterar sobre um milhão de objetos, somando ou transformando — sequencial sobre array vence indireção. Considere List<T> sobre LinkedList<T>; ImmutableArray sobre estruturas com ponteiros.

Structs vs classes em .NET. Struct pequena (até ~16 bytes) frequentemente é mais rápida que classe — sem boxing, sem alocação no heap, melhor locality em arrays. Para ValueObjects de domínio, struct é frequentemente a escolha certa.

Concorrência em counters / state. Múltiplas threads atualizando estado adjacente é receita para false sharing. Em .NET, PaddedAtomic; em Java, @Contended; em Go, padding manual em struct.

Layout de objetos hot. Em .NET, [StructLayout(LayoutKind.Sequential, Pack=1)] pode ajudar a compactar struct. Em Go, ordenar campos do maior para o menor reduz padding interno (compilador insere padding para alinhar).

Quando NÃO se preocupar com cache

Para a maioria das aplicações de produto, cache não é o gargalo. Antes de gastar tempo otimizando layout de cache, verifique:

O sistema é I/O-bound? Se 95% do tempo é em banco/rede, otimizar cache CPU é desperdício. Foque no I/O.

O profile mostra hot path com cache miss? Sem evidência de cache miss dominante, otimização especulativa. Use perf stat em Linux para medir cache misses; se o ratio é baixo, não vale tempo.

O ônus de manutenção compensa o ganho? Layout SoA ou padding manual torna código mais difícil de ler. Em hot path bem identificado e estável, compensa. Em código que muda toda semana, custo é alto demais.

armadilha em produção

False sharing em counters de métrica. Times às vezes criam estrutura para coletar métricas locais por thread — counter de requests, counter de erros, etc. — agrupando-as em uma struct para "ficar organizado". Sob carga, a métrica vira gargalo invisível: cada thread escreve em "sua" variável, mas todas estão na mesma cache line, e a coordenação de invalidação domina o tempo. Sintoma: serviço com instrumentação mais rica fica mais lento que sem instrumentação. Defesa: padding entre counters (cada um em cache line própria), ou agregação local com flush periódico. Ferramentas como prometheus-client em vários idiomas tratam internamente.

Padrões reconhecíveis no profile

Cache miss é difícil de ver em profile padrão (CPU profile mede tempo, não mostra causa). Ferramentas que ajudam:

perf stat (Linux) mede counters de hardware:

perf stat -e cache-misses,cache-references,cycles,instructions ./meu_programa
# saída:
#   X cache-misses
#   Y cache-references
#   ratio = X/Y * 100%
# acima de 10% é alto; investigar layout

Intel VTune e AMD uProf são profilers comerciais que mostram cache miss por linha de código. Em hot paths críticos, são ferramenta apropriada.

perf c2c (Linux moderno, baseado em eBPF) detecta especificamente false sharing contention.

heurística do sênior

Antes de mergulhar em cache, faça três perguntas. "O sistema é I/O-bound ou CPU-bound?" Se I/O-bound, cache CPU não é o problema. "O profile mostra hot path com sinal de cache miss?" Se não, otimização seria especulativa. "Esse código vai mudar com frequência?" Se sim, otimização aumenta dívida de manutenção. Quando as três respostas batem em "sim, vale" — sistema CPU-bound, hot path identificado, código estável —, então locality e false sharing são levas legítimas. Em todos os outros casos, deixe para depois.

Por que importa para a sua carreira

Conhecimento de cache CPU separa sêniores que tocam hot path de sêniores que tocam só borda. Em entrevistas para vagas em sistemas críticos (engines, brokers, databases, ML inference), perguntas sobre cache line e false sharing são reais — não apenas curiosidade acadêmica. Em pos-mortem de "performance estranha" (sistema rápido em testes, lento em produção; escalabilidade subaprouvel), false sharing é causa candidata frequente, e quem reconhece o sintoma encurta diagnóstico em horas. Em discussão de design de estruturas de dados em sistema de alto throughput, articular SoA vs AoS é vocabulário que distingue. Mesmo em times que não tocam hot path, o conhecimento forma a base para entender por que certos benchmarks falham — "essa otimização melhorou 5%, e era esperado 30%; provavelmente cache miss continuou alto".

Como praticar

  1. Reproduzir a hierarquia de cache. Implemente o experimento sequencial vs random vs strided em sua linguagem principal. Varie o tamanho do array de 1 KB até 1 GB; plote o tempo por elemento em função do tamanho. Você vai ver "patamares" correspondentes a L1, L2, L3, RAM. Esse gráfico, uma vez visualizado, fixa a hierarquia permanentemente.
  2. Demonstrar false sharing. Implemente o experimento de Go (ou equivalente em C# ou C++) que compara contadores adjacentes vs paddados. Meça a diferença em sua máquina. Plote como função do número de threads. Identifique a fronteira entre "rápido" e "false sharing começa a doer". Esse exercício torna o fenômeno tangível.
  3. Auditoria de hot path. Em algum sistema seu, identifique um loop quente que opera sobre coleção grande. Use perf stat -e cache-misses,cache-references (ou equivalente no Windows: ETW + WPA) para medir o ratio. Se acima de 10%, hipótese é cache miss dominante. Refatore para layout mais cache-friendly (array contíguo, struct compacta). Mensure de novo. Documente o antes/depois — esse é o tipo de trabalho que gera respeito do time.

Referências para aprofundar

  1. artigo What Every Programmer Should Know About Memory — Ulrich Drepper (Red Hat, 2007). akkadia.org/drepper/cpumemory.pdf — 114 páginas sobre cache, NUMA, prefetching. Datado em alguns detalhes mas conceitualmente atual. Indispensável.
  2. livro Computer Systems: A Programmer's Perspective (3ª ed.) — Bryant & O'Hallaron (Pearson, 2015). Cap. 6 (The Memory Hierarchy) é o tratamento universitário canônico. Inclui exercícios concretos para calibrar intuição.
  3. livro The Software Optimization Cookbook — Richard Gerber et al. (Intel Press, 2006). Engenharia de Intel sobre otimização para Intel x86. Antigo mas conceitualmente atual.
  4. livro Game Programming Patterns — Robert Nystrom (gratuito online). gameprogrammingpatterns.com — Cap. "Data Locality" é a melhor introdução acessível ao tema. Lê-se em uma noite.
  5. livro Pro .NET Performance — Goldshtein, Zurbalev, Flatow (Apress, 2012). Cap. 3 e 5 cobrem layout de struct/class no CLR e implicações de cache. Datado em algumas APIs mas a teoria é atual.
  6. paper The False Sharing Problem in Multi-threaded Programs — Bolosky & Scott (DEC, 1993). Paper que primeiro formalizou false sharing. Curtinho, didático, fundador.
  7. docs Intel 64 and IA-32 Architectures Optimization Reference Manual. intel.com/content/www/us/en/developer/articles/technical/intel-sdm.html — A bíblia oficial sobre otimização x86. Capítulos 2 e 3 cobrem cache em profundidade.
  8. docs Linux perf — perf stat. man-pages.dev e Brendan Gregg tutorials. perf stat -e cache-misses,cache-references é o comando canônico.
  9. docs Java — @Contended (jdk.internal.vm.annotation). Documentação oficial JDK. Mostra como Java 9+ permite controlar padding para evitar false sharing.
  10. artigo Mechanical Sympathy — Martin Thompson (blog series, 2011+). mechanical-sympathy.blogspot.com — Thompson aplicou cache awareness ao desenvolver o Disruptor pattern. Posts sobre cache lines, false sharing, e LMAX são clássicos.
  11. vídeo Data-Oriented Design and C++ — Mike Acton (CppCon, 2014). YouTube. A palestra fundadora que articulou data-oriented design para audiência mainstream. Provocadora, didática, ainda relevante 12 anos depois.
  12. vídeo CPU Cache and Why You Should Care — Scott Meyers (várias palestras 2014–2018). YouTube. Meyers (autor de Effective C++) apresenta o tema em vários eventos. A versão de 2014 é canônica.