Cache Affinity: Por Que Seus Dados Não Devem Viajar Entre Núcleos
CPU · Memória · Performance
Seus dados estão no lugar errado e isso está custando caro
Você pode escrever o algoritmo mais elegante do mundo. Se os dados não estiverem no cache certo, no núcleo certo, na hora certa, você está deixando performance na mesa — às vezes 10x, às vezes 100x.
Tem uma verdade brutal sobre performance que pouca gente internaliza: o gargalo não é a CPU. É o caminho entre a CPU e a memória.
Em 2026, um core de CPU ainda processa instruções na casa de ~0.25 nanossegundos. Mas buscar um dado da RAM principal leva ~100 nanossegundos. Isso são 400 ciclos de CPU parados, esperando. E se o dado estiver no socket errado de uma máquina NUMA? 200-300 nanossegundos. Literalmente o dobro.
Cache affinity não é um truque. É a diferença entre um sistema que entrega latência previsível e um que derrete sob carga porque cada requisição é um cache miss garantido.
A Hierarquia Que Controla Tudo
Seção intitulada “A Hierarquia Que Controla Tudo”Antes de falar de afinidade, você precisa visualizar o campo de jogo:
Core 0 Core 1 Core 2 Core 3 L1 (32KB) L1 (32KB) L1 (32KB) L1 (32KB) L2 (256KB) L2 (256KB) L2 (256KB) L2 (256KB) \ | | / \____________________________________________________/ | L3 Shared (16-32MB) | RAM (64-256GB) | [Socket 1 — NUMA Node 1]Latências reais (Intel Sapphire Rapids, 2024):
| Nível | Latência | Penalidade vs L1 |
|---|---|---|
| L1 | ~1 ns | 1x |
| L2 | ~4 ns | 4x |
| L3 | ~12 ns | 12x |
| RAM local | ~80 ns | 80x |
| RAM remota (NUMA) | ~140 ns | 140x |
O que isso significa na prática: se você acessar prices[i] e o dado estiver na RAM, você poderia
ter executado 320 instruções no tempo de espera. Num loop de 1 milhão de elementos, isso é
tempo suficiente para processar a fatura inteira do cartão de crédito de um mês.
Cache Affinity: O Conceito Que Muda Tudo
Seção intitulada “Cache Affinity: O Conceito Que Muda Tudo”Cache affinity é o princípio de manter dados e threads colados ao mesmo núcleo de CPU para maximizar cache hits.
Quando uma thread processa dados no Core 3 e acumula estado na L1 e L2 daquele core, ela tem affinity com aquele núcleo. Se o scheduler do SO mover essa thread para o Core 7, todo o estado de cache é perdido. A thread precisa reconstruir seu working set do zero, acessando L3 ou RAM. Isso se chama cold cache.
O impacto é brutal:
// Simulando perda de cache affinity#include <benchmark/benchmark.h>#include <vector>#include <thread>#include <sched.h>
constexpr size_t WORKING_SET = 16 * 1024 * 1024; // 16 MB — maior que L2, menor que L3
void process_chunk(std::vector<int>& data, size_t start, size_t end) { int sum = 0; for (size_t i = start; i < end; ++i) sum += data[i]; // touch all data benchmark::DoNotOptimize(sum);}
// Cenário 1: thread fixa no mesmo core — cache quente após primeira iteraçãostatic void BM_CacheWarm(benchmark::State& state) { std::vector<int> data(WORKING_SET / sizeof(int), 1); cpu_set_t cpuset; CPU_ZERO(&cpuset); CPU_SET(2, &cpuset); pthread_setaffinity_np(pthread_self(), sizeof(cpuset), &cpuset);
for (auto _ : state) { process_chunk(data, 0, data.size()); }}BENCHMARK(BM_CacheWarm);
// Cenário 2: thread migrando entre cores a cada iteração — cache sempre friostatic void BM_CacheCold(benchmark::State& state) { std::vector<int> data(WORKING_SET / sizeof(int), 1);
int core = 2; for (auto _ : state) { cpu_set_t cpuset; CPU_ZERO(&cpuset); CPU_SET(core, &cpuset); pthread_setaffinity_np(pthread_self(), sizeof(cpuset), &cpuset); core = (core == 2) ? 6 : 2; // alterna entre cores a cada iteração
process_chunk(data, 0, data.size()); }}BENCHMARK(BM_CacheCold);
// Resultado típico:// BM_CacheWarm: ~8ms por iteração (dados na L3 do mesmo core, parcialmente L2)// BM_CacheCold: ~14ms por iteração (dados migram, invalidação de cache a cada troca)// Penalidade: ~75% mais lento apenas por perder afinidade de cache75% mais lento. Sem mudar o algoritmo. Sem mudar os dados. Só mudando onde a thread roda.
False Sharing: O Inimigo Silencioso
Seção intitulada “False Sharing: O Inimigo Silencioso”Cache affinity não é só sobre manter dados perto. É sobre mantê-los separados quando threads diferentes escrevem neles.
O protocolo de coerência de cache (MESI no x86) garante que todas as CPUs vejam a memória corretamente. Mas o preço é alto: quando o Core 0 escreve numa cache line, todas as cópias daquela cache line nos outros cores são invalidadas.
Se duas threads escrevem em variáveis diferentes que caem na mesma cache line, você tem false sharing: contenção de hardware onde não existe contenção lógica.
// Exemplo clássico de false sharingstruct CounterPair { alignas(64) uint64_t counter_a; // Core 0 escreve aqui uint64_t counter_b; // Core 1 escreve aqui — MESMA cache line! // counter_b está nos bytes 8-15. A cache line vai de 0 a 63. // Cada escrita em counter_b invalida counter_a no Core 0. E vice-versa.};
// Medindo o impactoCounterPair counters{};
void thread_a() { for (int i = 0; i < 100'000'000; ++i) counters.counter_a++; // ~300M ciclos com false sharing}
void thread_b() { for (int i = 0; i < 100'000'000; ++i) counters.counter_b++; // disputa a mesma cache line a cada escrita}
// Rodando thread_a e thread_b em paralelo:// Com false sharing: ~800ms// Sem false sharing: ~180ms (adicionando padding entre counter_a e counter_b)// Penalidade: ~4.4x mais lentoO fix é simples e devastadoramente eficaz:
struct CounterPairFixed { alignas(64) uint64_t counter_a; // Cache line 0: bytes 0-7, padding até 63 alignas(64) uint64_t counter_b; // Cache line 1: bytes 64-71, padding até 127 // Agora cada thread opera em uma cache line exclusiva. // Zero invalidação cruzada.};Toda estrutura de dados compartilhada entre threads deve ser cache-line-aligned. Se você tem uma SPSC queue, head e tail precisam estar em cache lines separadas. Se você tem um array de locks, cada lock precisa ocupar sua própria cache line. Se você tem um hot path com múltiplos campos sendo escritos por threads diferentes, cada campo precisa de sua própria cache line.
Cache Line Alignment Profissional
Seção intitulada “Cache Line Alignment Profissional”Em C++ moderno, o jeito mais expressivo de forçar alinhamento:
#include <new> // std::hardware_destructive_interference_size
// C++17: o compilador te diz o tamanho mínimo para evitar false sharingconstexpr size_t CACHE_LINE = std::hardware_destructive_interference_size;// Tipicamente 64 no x86, 128 no Apple Silicon, 256 em algumas arquiteturas POWER
// Uso idiomáticostruct alignas(CACHE_LINE) PaddedCounter { uint64_t value;};
// Ou, para arrays:struct ThreadLocalState { alignas(CACHE_LINE) char padding_before[0]; int active_orders; double risk_exposure; char strategy_id; alignas(CACHE_LINE) char padding_after[0]; // Força cada instância a ocupar uma cache line completa};
std::array<ThreadLocalState, MAX_THREADS> thread_states;O alignas(CACHE_LINE) char padding_XX[0] é um truque sujo e elegante: o array de tamanho zero
herda o alinhamento, forçando o próximo campo a cair na próxima cache line. Alguns compiladores
aceitam alignas(64) char _pad[0] como extensão, mas o padrão é usar struct wrapping.
Técnica Avançada: Cache Warming
Seção intitulada “Técnica Avançada: Cache Warming”Se você sabe que vai precisar de um conjunto de dados daqui a pouco, por que esperar o cache miss?
#include <xmmintrin.h>
// Prefetch em 4 níveis de agressividade:// _MM_HINT_T0 — traz para todos os níveis de cache (L1, L2, L3)// _MM_HINT_T1 — traz para L2 e L3// _MM_HINT_T2 — traz para L3 apenas// _MM_HINT_NTA — non-temporal: traz mas não polui caches superiores
void prefetch_order_book(const double* prices, size_t count, size_t ahead) { for (size_t i = 0; i < count; ++i) { if (i + ahead < count) _mm_prefetch(reinterpret_cast<const char*>(&prices[i + ahead]), _MM_HINT_T0); process(prices[i]); }}
// Em sistemas de trading: prefetch os próximos 16 níveis do book enquanto processa o atualMas cuidado: prefetch não é grátis. Cada instrução de prefetch ocupa recursos do load buffer. Se você prefetch coisas que não vai usar, está desperdiçando bandwidth de memória que poderia estar servindo acessos reais.
Regra prática:
- Prefetch com
ahead = 8a16elementos — esconde latência de L2/L3 - Prefetch com
ahead = 64a128elementos — esconde latência de RAM - Use
_MM_HINT_NTApara dados que você vai ler uma vez só (streaming) - NUNCA prefetch dentro de um
ifque raramente é verdadeiro
NUMA Awareness: Quando Sua Máquina É Maior Que Um Socket
Seção intitulada “NUMA Awareness: Quando Sua Máquina É Maior Que Um Socket”Em servidores com 2 ou 4 sockets, a RAM é dividida fisicamente. Cada socket tem sua própria memória local (“próxima”) e pode acessar a memória dos outros sockets (“remota”). A diferença de latência é brutal: 80ns vs 140ns.
NUMA-aware programming significa: se uma thread roda no socket 0, os dados que ela acessa devem estar alocados na RAM do socket 0.
#include <numa.h>#include <numaif.h>
// Alocar memória vinculada a um nó NUMA específicovoid* numa_alloc(size_t bytes, int node) { void* ptr = numa_alloc_onnode(bytes, node); if (!ptr) throw std::bad_alloc(); return ptr;}
// Migrar thread para um core específicovoid pin_thread_to_core(int core_id) { cpu_set_t cpuset; CPU_ZERO(&cpuset); CPU_SET(core_id, &cpuset); pthread_setaffinity_np(pthread_self(), sizeof(cpuset), &cpuset);}
// Setup completo: thread no core 4 (socket 0), dados alocados na RAM do socket 0void setup_numa_aware(int numa_node, int core_on_that_node) { pin_thread_to_core(core_on_that_node); auto* buffer = static_cast<double*>(numa_alloc(1 << 20, numa_node)); // 1MB no nó certo // ... use buffer numa_free(buffer, 1 << 20);}Em sistemas de HFT, é comum alocar o order book inteiro com numa_alloc_onnode() e pinar as
threads de processamento para cores no mesmo socket. Isso garante que 100% dos acessos sejam
locais. O custo de não fazer isso é pagar latência de cross-socket em cada atualização de preço.
Como Medir Tudo Isso
Seção intitulada “Como Medir Tudo Isso”Falar de cache affinity sem medir é astrologia. Ferramentas reais:
# Cache misses por processoperf stat -e cache-references,cache-misses,L1-dcache-load-misses,LLC-load-misses ./seu_programa
# Perfil detalhado: quais linhas de código causam mais cache missesperf record -e cache-misses ./seu_programaperf report
# False sharing: a métrica que entrega o diagnósticoperf stat -e l2_rqsts.all_rfo ./seu_programa# RFO = Request For Ownership — a CPU pedindo posse exclusiva de uma cache line# Se esse número for alto entre threads que não compartilham dados lógicos,# você tem false sharing. Garantido.
# Cachegrind: simulação precisa da hierarquia de cachevalgrind --tool=cachegrind --branch-sim=yes ./seu_programacg_annotate cachegrind.out.XXXXA métrica que eu mais persigo: ciclos por instrução (CPI). Num sistema saudável com boa cache affinity, CPI fica abaixo de 1.0. Se está acima de 2.0, você está passando mais tempo esperando memória do que executando instruções.
Resumo Prático: O Checklist da Cache Affinity
Seção intitulada “Resumo Prático: O Checklist da Cache Affinity”- Alinhe tudo a cache lines.
alignas(64)oustd::hardware_destructive_interference_size - Separe dados de escrita por thread. Cada thread deve ter ownership exclusivo de suas cache lines. Se duas threads escrevem na mesma cache line, você tem false sharing.
- Pinning de thread. Se uma thread é dona de um conjunto de dados, deixe-a colada ao mesmo
core.
pthread_setaffinity_np. - Pense em SoA (Struct of Arrays). Se você itera sobre um campo específico de uma coleção, aquele campo deve ser contíguo na memória.
- NUMA matters. Em máquinas multi-socket, aloque memória no nó onde a thread roda.
numa_alloc_onnode. - Prefetch com propósito. Se você sabe o que vai acessar, avise a CPU. Mas não exagere.
- Meça, não suponha.
perf stat, cachegrind, CPI. Dados reais, não intuição. - A primeira regra de performance: os números não mentem. Se você não mediu, não otimizou.
Cache affinity não é um tópico acadêmico. É engenharia de performance que separa sistemas que escalam de sistemas que colapsam sob carga. E a beleza é que os ganhos são enormes com mudanças relativamente pequenas no layout de dados e política de scheduling.
Da próxima vez que seu sistema estiver lento, antes de mexer no algoritmo, pergunte: meus dados estão no lugar certo?