Latência de memória num servidor cloud: medida, não suposta
Resposta curta (verificado em 2026-11-01): Um post que chegou à primeira página do Hacker News neste fim de semana defende que um CPU a 100% muitas vezes está apenas à espera da RAM, e mostra um GROUP BY que ficou mais rápido fazendo mais trabalho mas tocando menos na memória. Testámos a mesma ideia no servidor cloud que escreve este blog: uma leitura custa cerca de 2 ns quando os dados cabem na L1 e cerca de 200 ns com um conjunto de trabalho de 256 MB, umas cem vezes mais. Para quem aluga servidores, essa diferença conta mais do que o número de núcleos na encomenda. Abaixo estão o script, os nossos números e o que mudam na escolha entre cloud e dedicado.
Fonte. "It's the memory latency, stupid", de Maor Kern (Pivot / Epsio Labs, publicado em 2026-10-28), discutido no Hacker News com 175 pontos até 2026-11-01. Os números deles vêm como o post os publica; os nossos foram medidos por nós.
O que o post da Pivot mediu
O teste é uma consulta, GROUP BY UserID sobre a tabela "hits" do ClickBench (cerca de 99 milhões de linhas, cerca de 17 milhões de utilizadores distintos), fixada num núcleo de um servidor bare metal AMD EPYC 9124 com 128 GB DDR5. Uma tabela hash simples com 17 milhões de chaves não cabe em nenhuma cache, por isso quase cada pesquisa vai à RAM. Dividir primeiro as linhas em 4096 partições, cada uma com uma tabela hash pequena, acrescenta trabalho mas mantém cada tabela em cache:
| Métrica (um núcleo) | Tabela hash simples | Particionamento radix |
|---|---|---|
| Tempo, 1.ª / 2.ª execução | 3.9 s / 3.6 s | 3.3 s / 2.7 s |
| Slots à espera de memória | 50.1% | 23.5% |
| Slots com trabalho útil | 16.1% | 32.2% |
| Leituras da DRAM | 76.2 milhões | 25.7 milhões |
A versão particionada executa mais operações (19.4 mil milhões contra 11.4 mil milhões) e mesmo assim termina primeiro. A versão otimizada do autor correu em 1.85 s num núcleo e em 0.17 s nos 16. A lição não é sobre uma só base de dados: "CPU 100%" no top diz que o núcleo não está parado, não que está a calcular.
Meça você mesmo: memlat.js
A forma clássica de ver a latência é um pointer chase: um array em que cada posição guarda o índice da seguinte, em ordem aleatória, separadas por uma linha de cache (64 bytes). Cada leitura depende da anterior, por isso o CPU não consegue pré-carregar nem sobrepô-las, e o tempo por salto é a latência do nível onde vive o conjunto de trabalho. Na nossa máquina do blog não há compilador de C, por isso é JavaScript para Bun: o JIT transforma o ciclo em poucas instruções de máquina, o que acrescenta um pouco a cada número. Compare os degraus, não os valores absolutos. Guarde-o como memlat.js:
// memlat.js - pointer-chase memory latency by working-set size (bun memlat.js)
import { readFileSync, readdirSync } from 'node:fs'
const cpu = readFileSync('/proc/cpuinfo', 'utf8').match(/model name\s*:\s*(.+)/)?.[1] ?? '?'
const base = '/sys/devices/system/cpu/cpu0/cache'
const caches = readdirSync(base).filter(d => d.startsWith('index')).map(d => {
const r = f => readFileSync(`${base}/${d}/${f}`, 'utf8').trim()
return `L${r('level')}${r('type') === 'Data' ? 'd' : r('type') === 'Instruction' ? 'i' : ''} ${r('size')}`
})
console.log(`CPU: ${cpu}\nCaches: ${caches.join(', ')}`)
// one random cycle through n slots, 64 bytes (16 x int32) apart: one cache line per hop
function ring(bytes) {
const n = Math.max(2, Math.floor(bytes / 64))
const order = new Uint32Array(n)
for (let i = 0; i < n; i++) order[i] = i
for (let i = n - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
const t = order[i]; order[i] = order[j]; order[j] = t
}
const a = new Int32Array(n * 16)
for (let i = 0; i < n; i++) a[order[i] * 16] = order[(i + 1) % n] * 16
return a
}
function chase(a, hops) {
let p = 0
for (let i = 0; i < hops; i++) p = a[p]
return p
}
const hops = 20_000_000
console.log('working set ns/load')
for (const kb of [16, 32, 256, 1024, 4096, 16384, 65536, 262144]) {
const a = ring(kb * 1024)
chase(a, hops / 4) // warm up: JIT and caches
const best = []
for (let r = 0; r < 3; r++) {
const t = Bun.nanoseconds()
const sink = chase(a, hops)
best.push((Bun.nanoseconds() - t) / hops)
if (sink < 0) console.log(sink)
}
const label = kb >= 1024 ? `${kb / 1024} MB` : `${kb} KB`
console.log(`${label.padStart(10)} ${Math.min(...best).toFixed(1).padStart(7)}`)
}
bun memlat.js
Resultado, executado num servidor DCXV Cloud com Bun 1.4.4 em 2026-11-01:
CPU: Intel(R) Xeon(R) CPU E5-2698 v4 @ 2.20GHz
Caches: L2 4096K, L1d 32K, L3 16384K, L1i 32K
working set ns/load
16 KB 2.0
32 KB 2.2
256 KB 12.5
1 MB 24.3
4 MB 122.2
16 MB 138.4
64 MB 164.7
256 MB 205.1
Uma segunda execução na mesma manhã variou no máximo 2 ns até 1 MB e no máximo 12 ns acima disso:
| Conjunto de trabalho | ns por leitura, execução 1 / 2 | Onde vive |
|---|---|---|
| 32 KB | 2.2 / 2.4 | cache de dados L1 |
| 256 KB | 12.5 / 10.5 | L2, a transbordar para a L3 |
| 1 MB | 24.3 / 24.3 | L3 |
| 16 MB | 138.4 / 137.2 | travessias de tabelas de páginas, L3 e DRAM |
| 256 MB | 205.1 / 193.5 | DRAM |
O que os números dizem
- O convidado não vê as caches reais. A VM declara 4 MB de L2 e 16 MB de L3; a Intel indica 50 MB de cache para o Xeon E5-2698 v4 (Intel ark, verificado em 2026-11-01). O hipervisor apresenta a sua própria topologia, e o
lscpudentro de um servidor cloud lê essa, não o silício. Meça o hardware em vez de confiar nas etiquetas. - A queda chega muito antes de a cache encher. Entre 1 MB e 4 MB a latência multiplica-se por cinco, muito abaixo dos 50 MB. Com páginas de 4 KB, um percurso aleatório de 4 MB toca 1.024 páginas, mais do que a TLB guarda, e numa VM uma falha de TLB percorre as tabelas de páginas do convidado e do anfitrião; além disso, a L3 é partilhada com os outros núcleos do socket. Não separámos estas causas. A conclusão prática é a mesma: numa VM, planeie como se o seu conjunto de trabalho rápido tivesse poucos megabytes.
- Mais RAM não é RAM mais rápida. De 1 MB a 256 MB o custo por leitura cresce cerca de oito vezes. Mais gigabytes dão capacidade, não velocidade; o que acelera uma tarefa limitada pela latência é um conjunto quente menor, como mostra o particionamento da Pivot.
O que significa se aluga servidores
- Os servidores cloud servem a maioria das apps web, APIs e bases de dados pequenas: os seus dados quentes cabem em cache, e esperam pela rede ou pelo disco muito antes de esperarem pela RAM. Dimensione-os pelo uso de memória medido, como no nosso guia de dimensionamento de servidores cloud.
- Os servidores dedicados compensam quando um conjunto de trabalho grande é lido ao acaso o dia todo: análise de dados, hash joins grandes, caches em memória, um servidor PostgreSQL cujos índices já não cabem na RAM. Fica com toda a L3 e todos os canais de memória sem vizinhos, e pode ativar huge pages, que reduzem as falhas de TLB. Configure um na página de servidor dedicado.
- Antes de fazer upgrade a qualquer um, corra o teste no servidor que já tem e veja se a sua tarefa lenta está limitada pela latência. Duplicar os núcleos não ajuda uma thread que passa metade do tempo parada. A própria memória ficou mais cara este ano, ver RAM de servidor em 2026, por isso a solução mais barata é muitas vezes código que lhe toca menos.
Teste antes de comprar
Os nossos data centers Tier III em Praga e na Covilhã disponibilizam servidores cloud e servidores dedicados. Inicie um servidor cloud, corra memlat.js ao lado da sua própria carga e envie o resultado para sales@dcxv.com: ajudamos a decidir se um servidor dedicado mudaria os números.
