Задержка памяти на облачном сервере: измерено, а не угадано
Коротко (проверено 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: мы поможем решить, изменит ли цифры выделенный сервер.
