Opóźnienie pamięci w chmurze: zmierzone, nie zgadnięte
W skrócie (sprawdzone 2026-11-01): Wpis, który w ten weekend trafił na stronę główną Hacker News, dowodzi, że procesor pokazujący 100% obciążenia często po prostu czeka na RAM, i pokazuje GROUP BY, który przyspieszył, wykonując więcej pracy, ale rzadziej sięgając do pamięci. Sprawdziliśmy ten sam pomysł na serwerze w chmurze, który pisze ten blog: odczyt kosztuje około 2 ns, gdy dane mieszczą się w L1, i około 200 ns przy zbiorze roboczym 256 MB, mniej więcej sto razy więcej. Dla wynajmujących serwery ta różnica znaczy więcej niż liczba rdzeni w zamówieniu. Poniżej skrypt, nasze liczby i to, co zmieniają w wyborze między chmurą a serwerem dedykowanym.
Źródło. "It's the memory latency, stupid", autor Maor Kern (Pivot / Epsio Labs, opublikowano 2026-10-28), dyskusja na Hacker News, 175 punktów do 2026-11-01. Ich liczby podajemy tak, jak drukuje je wpis; nasze zmierzyliśmy sami.
Co zmierzył wpis Pivot
Test to jedno zapytanie, GROUP BY UserID na tabeli ClickBench "hits" (około 99 milionów wierszy, około 17 milionów różnych użytkowników), przypięte do jednego rdzenia serwera bare metal AMD EPYC 9124 ze 128 GB DDR5. Zwykła tablica haszująca z 17 milionami kluczy nie mieści się w żadnym cache, więc prawie każde wyszukiwanie idzie do RAM. Podział wierszy najpierw na 4096 partycji, każda z małą tablicą haszującą, dodaje pracy, ale trzyma każdą tablicę w cache:
| Wskaźnik (jeden rdzeń) | Zwykła tablica haszująca | Partycjonowanie radix |
|---|---|---|
| Czas, 1. / 2. uruchomienie | 3.9 s / 3.6 s | 3.3 s / 2.7 s |
| Sloty czekające na pamięć | 50.1% | 23.5% |
| Sloty z użyteczną pracą | 16.1% | 32.2% |
| Odczyty z DRAM | 76.2 mln | 25.7 mln |
Wersja z partycjami wykonuje więcej operacji (19.4 mld wobec 11.4 mld), a mimo to kończy pierwsza. Zoptymalizowana wersja autora działała 1.85 s na jednym rdzeniu i 0.17 s na wszystkich 16. Lekcja nie dotyczy jednej bazy danych: "CPU 100%" w top mówi, że rdzeń nie stoi bezczynnie, a nie że liczy.
Zmierz sam: memlat.js
Klasyczny sposób na zobaczenie opóźnienia to pointer chase: tablica, w której każde pole przechowuje indeks następnego, w losowej kolejności, co jedną linię cache (64 bajty). Każdy odczyt zależy od poprzedniego, więc procesor nie może ich ani pobrać z wyprzedzeniem, ani nałożyć na siebie, a czas jednego kroku to opóźnienie poziomu, w którym żyje zbiór roboczy. Na naszej maszynie blogowej nie ma kompilatora C, więc to JavaScript dla Bun: JIT zamienia pętlę na kilka instrukcji maszynowych, co trochę dodaje do każdej liczby. Porównuj progi, nie wartości bezwzględne. Zapisz jako 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
Wynik, uruchomione na serwerze DCXV Cloud z Bun 1.4.4 dnia 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
Drugie uruchomienie tego samego ranka różniło się najwyżej o 2 ns do 1 MB i najwyżej o 12 ns powyżej:
| Zbiór roboczy | ns na odczyt, uruchomienie 1 / 2 | Gdzie żyje |
|---|---|---|
| 32 KB | 2.2 / 2.4 | cache danych L1 |
| 256 KB | 12.5 / 10.5 | L2, częściowo już L3 |
| 1 MB | 24.3 / 24.3 | L3 |
| 16 MB | 138.4 / 137.2 | przechodzenie tablic stron, L3 i DRAM |
| 256 MB | 205.1 / 193.5 | DRAM |
Co mówią liczby
- Gość nie widzi prawdziwych cache. Maszyna wirtualna zgłasza 4 MB L2 i 16 MB L3; Intel podaje dla Xeon E5-2698 v4 50 MB cache (Intel ark, sprawdzone 2026-11-01). Hiperwizor przedstawia własną topologię, a
lscpuw serwerze w chmurze czyta ją, nie krzem. Mierz sprzęt, zamiast ufać etykietom. - Urwisko pojawia się długo przed zapełnieniem cache. Między 1 MB a 4 MB opóźnienie rośnie pięciokrotnie, daleko poniżej 50 MB. Przy stronach 4 KB losowe przejście po 4 MB dotyka 1 024 stron, więcej niż mieści TLB, a w maszynie wirtualnej chybienie TLB przechodzi przez tablice stron gościa i hosta; do tego L3 jest współdzielony z innymi rdzeniami gniazda. Nie rozdzielaliśmy tych przyczyn. Wniosek praktyczny jest ten sam: w maszynie wirtualnej planuj tak, jakby szybki zbiór roboczy miał kilka megabajtów.
- Więcej RAM to nie szybszy RAM. Od 1 MB do 256 MB koszt odczytu rośnie około ośmiokrotnie. Więcej gigabajtów daje pojemność, nie szybkość; zadanie ograniczone opóźnieniem przyspiesza mniejszy gorący zbiór, jak pokazuje partycjonowanie Pivot.
Co to znaczy przy wynajmie serwerów
- Serwery w chmurze pasują do większości aplikacji webowych, API i małych baz: ich gorące dane mieszczą się w cache, a na sieć lub dysk czekają dużo wcześniej niż na RAM. Dobieraj rozmiar według zmierzonego zużycia pamięci, jak w naszym przewodniku po rozmiarach serwerów w chmurze.
- Serwery dedykowane opłacają się, gdy duży zbiór roboczy jest czytany losowo przez cały dzień: analityka, duże złączenia haszujące, cache w pamięci, serwer PostgreSQL, którego indeksy nie mieszczą się już w RAM. Dostajesz cały L3 i wszystkie kanały pamięci bez sąsiadów i możesz włączyć huge pages, które zmniejszają chybienia TLB. Skonfiguruj go na stronie serwera dedykowanego.
- Przed każdym upgrade'em uruchom test na serwerze, który już masz, i sprawdź, czy wolne zadanie jest ograniczone opóźnieniem. Podwojenie rdzeni nie pomoże wątkowi, który połowę czasu stoi. Sama pamięć zdrożała w tym roku, zob. RAM serwerów w 2026, więc najtańszym rozwiązaniem bywa kod, który dotyka jej mniej.
Przetestuj przed zakupem
Nasze centra danych Tier III w Pradze i Covilhã oferują serwery w chmurze i serwery dedykowane. Uruchom serwer w chmurze, odpal memlat.js obok własnego obciążenia i wyślij wynik na sales@dcxv.com: pomożemy zdecydować, czy serwer dedykowany zmieniłby te liczby.
