Затримка пам'яті на хмарному сервері: виміряно, а не вгадано
Коротко (перевірено 2026-11-01): Пост, що потрапив на головну Hacker News цими вихідними, доводить: процесор, який показує 100% завантаження, часто просто чекає на RAM. Автор показує GROUP BY, що став швидшим, виконуючи більше роботи, але рідше звертаючись до пам'яті. Ми перевірили ту саму ідею на хмарному сервері, який пише цей блог: одне читання коштує близько 2 нс, коли дані вміщуються в L1, і близько 200 нс при робочому наборі 256 МБ, приблизно в сто разів більше. Для тих, хто орендує сервери, цей розрив важить більше, ніж кількість ядер у замовленні. Нижче скрипт, наші цифри і що вони змінюють у виборі між хмарою та виділеним сервером.
Джерело. "It's the memory latency, stupid", автор Maor Kern (Pivot / Epsio Labs, опубліковано 2026-10-28), обговорення на Hacker News, 175 балів станом на 2026-11-01. Їхні цифри наведено так, як їх друкує пост; наші ми виміряли самі.
Що виміряв пост Pivot
Тест - один запит, GROUP BY UserID по таблиці ClickBench "hits" (близько 99 мільйонів рядків, близько 17 мільйонів унікальних користувачів), закріплений на одному ядрі bare-metal сервера AMD EPYC 9124 зі 128 ГБ DDR5. Звичайна хеш-таблиця на 17 мільйонів ключів не вміщується в жоден кеш, тож майже кожен пошук іде в RAM. Якщо спершу розбити рядки на 4096 розділів, кожен зі своєю маленькою хеш-таблицею, роботи більшає, але кожна таблиця лишається в кеші:
| Показник (одне ядро) | Звичайна хеш-таблиця | Radix-розбиття |
|---|---|---|
| Час, 1-й / 2-й запуск | 3.9 с / 3.6 с | 3.3 с / 2.7 с |
| Слоти, що чекають на пам'ять | 50.1% | 23.5% |
| Слоти з корисною роботою | 16.1% | 32.2% |
| Читання з DRAM | 76.2 млн | 25.7 млн |
Версія з розбиттям виконує більше операцій (19.4 млрд проти 11.4 млрд) і все одно фінішує першою. Оптимізована версія автора відпрацювала за 1.85 с на одному ядрі та за 0.17 с на всіх 16. Урок не про одну базу даних: "CPU 100%" у top означає, що ядро не простоює, а не що воно рахує.
Виміряйте самі: memlat.js
Класичний спосіб побачити затримку - pointer chase: масив, де кожна комірка зберігає індекс наступної, у випадковому порядку, на відстані однієї кеш-лінії (64 байти). Кожне читання залежить від попереднього, тож процесор не може ні завантажити дані наперед, ні виконати читання паралельно, і час одного кроку дорівнює затримці того рівня, де живе робочий набір. На нашій машині для блогу немає компілятора C, тому це JavaScript для Bun: JIT перетворює цикл на кілька машинних інструкцій, що трохи додає до кожної цифри. Порівнюйте сходинки, а не абсолютні значення. Збережіть як 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
Результат, запущено на хмарному сервері DCXV з Bun 1.4.4 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
Другий запуск того ж ранку відрізнявся щонайбільше на 2 нс до 1 МБ і щонайбільше на 12 нс вище:
| Робочий набір | нс на читання, запуск 1 / 2 | Де живе |
|---|---|---|
| 32 КБ | 2.2 / 2.4 | кеш даних L1 |
| 256 КБ | 12.5 / 10.5 | L2, частково в L3 |
| 1 МБ | 24.3 / 24.3 | L3 |
| 16 МБ | 138.4 / 137.2 | обхід таблиць сторінок, L3 і DRAM |
| 256 МБ | 205.1 / 193.5 | DRAM |
Що кажуть цифри
- Гостьова система не бачить справжніх кешів. ВМ показує L2 на 4 МБ і L3 на 16 МБ; Intel для Xeon E5-2698 v4 вказує 50 МБ кешу (Intel ark, перевірено 2026-11-01). Гіпервізор подає свою топологію, і
lscpuу хмарному сервері читає її, а не кремній. Вимірюйте залізо, а не довіряйте підписам. - Обрив настає задовго до заповнення кешу. Між 1 МБ і 4 МБ затримка зростає в п'ять разів, хоча до 50 МБ ще далеко. Зі сторінками 4 КБ випадковий обхід 4 МБ зачіпає 1 024 сторінки, більше, ніж вміщує TLB, а у ВМ промах TLB обходить і гостьові, і хостові таблиці сторінок; до того ж L3 спільний з іншими ядрами сокета. Ми не розділяли ці причини. Практичний висновок той самий: у ВМ плануйте так, ніби ваш швидкий робочий набір - кілька мегабайтів.
- Більше RAM не означає швидшу RAM. Від 1 МБ до 256 МБ ціна одного читання зростає приблизно у вісім разів. Додаткові гігабайти дають обсяг, а не швидкість; задачу, обмежену затримкою, прискорює менший гарячий набір, як показує розбиття в Pivot.
Що це означає для оренди серверів
- Хмарні сервери підходять більшості вебзастосунків, API та невеликих баз: їхні гарячі дані вміщуються в кеш, і вони чекають на мережу чи диск задовго до того, як почнуть чекати на RAM. Обирайте розмір за виміряним використанням пам'яті, як у нашому посібнику з вибору розміру хмарного сервера.
- Виділені сервери окупаються, коли великий робочий набір цілий день читається випадково: аналітика, великі хеш-з'єднання, кеші в пам'яті, сервер PostgreSQL, індекси якого вже не вміщуються в RAM. Ви отримуєте весь L3 і всі канали пам'яті без сусідів і можете ввімкнути huge pages, що зменшують промахи TLB. Зберіть конфігурацію на сторінці виділеного сервера.
- Перед будь-яким апгрейдом запустіть тест на сервері, який уже маєте, і перевірте, чи ваша повільна задача обмежена затримкою. Подвоєння ядер нічого не дасть одному потоку, що половину часу стоїть. Сама пам'ять цього року подорожчала, див. серверна RAM у 2026, тож найдешевше рішення часто - код, який торкається її менше.
Перевірте до покупки
Наші дата-центри Tier III у Празі та Ковільяні запускають хмарні сервери і виділені сервери. Запустіть хмарний сервер, виконайте memlat.js поруч зі своїм навантаженням і надішліть результат на sales@dcxv.com: ми допоможемо вирішити, чи змінить цифри виділений сервер.
