Memory hierarchy concept page: CPU registers, L1/L2/L3 caches, DRAM (NUMA-local and cross-socket), NVMe SSD, SATA SSD, HDD, S3, Tape. Shows three scenarios: cache-friendly hot loop vs random pointer chase, NUMA pin vs cross-socket access, hot loop vs page fault to disk. Includes ADRs on when to optimize for cache locality and NVMe vs SATA selection.
Архитектор должен в голове знать что L1 cache в ~100 000 раз быстрее SSD, SSD — в ~100 раз быстрее HDD, а сетевой round-trip к Европе — 150 ms. Без этих чисел невозможно ответить на простые вопросы: «зачем нам кеш?», «почему columnar store быстрее row store на аналитике?», «почему наш монолит с 64 GB RAM держит 50K RPS, а распределённые микросервисы — 5K?», «почему этот endpoint тормозит — CPU, RAM, диск или сеть?».
Иерархия памяти — это физика, на которой стоит вся системная архитектура. Пока ты её не интернализовал, любое архитектурное решение — гадание.
Каждый следующий уровень памяти ~100× медленнее предыдущего и ~10× больше по объёму. Найди уровень, на котором живёт hot path, — и оптимизируй именно его. Всё остальное — преждевременная оптимизация.
Полезная единица для интуиции — CPU cycle (~0.3 ns на 3 GHz CPU):
| Операция | Латентность | В CPU-циклах | Аналогия (если 1 cycle = 1 секунда) |
|---|---|---|---|
| L1 hit | 0.5 ns | 1–2 | мгновенно |
| L3 hit | 25 ns | ~50 | минута |
| DRAM | 100 ns | ~200 | 3 минуты |
| NVMe random read | 100 μs | ~200 000 | 2 дня |
| HDD seek | 10 ms | ~20 000 000 | 7 месяцев |
| Network DC roundtrip | 0.5 ms | ~1 000 000 | 11 дней |
| Network cross-continent | 150 ms | ~300 000 000 | ~10 лет |
Если CPU тратит на L1-промах 200 циклов в DRAM — это «3 минуты ожидания». Если стреляет в сеть — это «10 лет». Любая архитектура, которая ходит в сеть на горячем пути, по определению медленнее любой, которая укладывается в RAM.
Три зоны, объединённые потоками данных одного приложения.
Socket 0 (NUMA node 0) — два ядра (core0, core1) с приватными регистрами, L1 (32 KB, 0.5 ns) и L2 (512 KB, 7 ns). Между ними shared L3 (25 ns, 16 MB) и локальный DRAM (100 ns, 64 GB) того же сокета. Это «своя» память — ходить дёшево.
Socket 1 (NUMA node 1) — второе ядро (core2) с собственной L1/L3 и своим локальным DRAM. Между сокетами — линк QPI/UPI (~300 ns на удалённое чтение). Если поток выполняется на socket 1, а данные лежат на DRAM socket 0 — каждое обращение делает cross-socket hop.
Storage hierarchy — nvme (PCIe 4.0, ~100 μs, 1M IOPS), sata (~200 μs, ~100K IOPS), hdd (10 ms seek, ~100 IOPS), s3 (объектное хранилище, ~100 ms TTFB) и tape/Glacier (часы). Стрелки между ними — типичный путь «горячее → холодное» при tier-демоушене.
Цифры на рёбрах — это либо время (0.5 ns, 100 μs), либо CPU cycles (1 cycle, 200 cycles, 200K cycles). Конвертация: 1 ns ≈ 3 cycles на современном CPU.
Hot loop vs pointer chase. Один и тот же объём работы — суммирование N чисел — даёт разницу в ~100× в зависимости от расположения данных. Линейный массив (for i: sum += arr[i]) триггерит hardware prefetcher: процессор подгружает следующую 64-байтную cache line в L1 заранее, и весь цикл бежит на скорости L1 (~1 cycle на операцию). Linked list или дерево с указателями (while n: n = n->next) даёт L1 miss → L2 miss → L3 miss → DRAM на каждом узле — потому что адреса узлов разбросаны по куче и предсказать следующий невозможно. 100 ns DRAM × миллиард обращений = секунды против миллисекунд. Этот же эффект делает columnar store (ClickHouse) на порядки быстрее row store на аналитике — соседние значения колонки лежат в одной cache line.
NUMA pin vs cross-socket. На больших серверах (≥ 32 ядра, 2+ сокета) DRAM физически разделён между сокетами. Поток, аллоцировавший память на socket 0, должен либо там же и считать, либо платить QPI-hop +200 ns и ~30 % bandwidth penalty при cross-socket access. На JVM/Go-приложениях с GC, которые мигрируют потоки между ядрами без NUMA-awareness, это вполне может выпить 20-40 % производительности. Лечится numactl --cpunodebind=0 --membind=0 (жёсткий pin), NUMA-aware аллокаторами (jemalloc, mimalloc) или sharding процессов по сокетам.
Hot loop vs page fault. Пока working set приложения умещается в RAM, всё работает на скоростях DRAM/L3. Как только working set превышает физическую память, OS начинает эвиктить страницы → следующее обращение к выгруженной странице вызывает page fault → трап в kernel → DMA с диска. На NVMe это ~100 μs (200K циклов CPU простаивает), на HDD — ~10 ms (20M циклов). Тысяча page faults в секунду = ~20 % CPU тратится на ожидание, и ты увидишь это не в top (CPU занят I/O wait), а только в vmstat (si/so non-zero) или iostat. Это причина, по которой Redis быстр (всё в RAM), а Postgres под нагрузкой требует достаточно RAM под shared_buffers + page cache.
ADR-001 — Optimize for cache locality only when hot path is measured. Cache-aware layout (Struct-of-Arrays вместо Array-of-Structs, packed structs, sequential iteration) стоит в 1.5–3× больше инженерного времени, чем идиоматичный OOP. Без измерений разработчики оптимизируют не те функции и тормозят фичи. Правило: cache-friendly код — только если функция в топ-10 профайлера по CPU% и perf stat -e cache-misses показывает >5 % miss rate. Исключения, где cache-layout важен с первого дня: HFT, видеокодеки, columnar engines (>1B ops/sec на одно ядро). См. ноду l3-0 на диаграмме.
ADR-002 — NVMe over SATA for any random-IO workload. NVMe (PCIe 4.0+) даёт ~1M IOPS и ~7 GB/s sequential на один диск. SATA SSD — ~100K IOPS и ~550 MB/s. Стоимость на TB отличается в 1.5–2×, но IOPS — в 10×. Дефолт: NVMe для OLTP-баз, очередей, cache spill, любых random-read нагрузок. SATA — только для cold/archive, где важны байты/$. Sequential bulk (видео-транскодинг, log archival) на SATA работает нормально. См. ноду nvme на диаграмме.
sendfile() zero-copy (DMA напрямую из page cache в socket, минуя userspace). Поэтому брокер на одной машине держит миллионы msgs/sec.shared_buffers (типично 25 % RAM) + полагается на page cache OS. Точка перегиба: working set > RAM → каждый запрос page-faults → tps падает на порядок.vmstat si/so > 0), оно уже мертво — каждое обращение к выгруженной странице тормозит в 1000× раз. Дешевле OOM-kill, чем swap.HashMap<String, Object> в горячем цикле. Pointer-heavy структуры обнуляют пользу cache. Если цикл считает по миллиарду записей — паковать в int[]/double[].Этот ментальный аппарат — не повод преждевременно оптимизировать. Если твой сервис обслуживает 100 RPS и latency запроса 200 ms — никого не волнует, L1 hit rate у тебя 70 % или 99 %. Сначала измерь, где боттлнек (профайлер, APM, perf, distributed tracing), и только потом применяй знания об иерархии к конкретному горячему пути. Кэш-friendly код в холодном CRUD-эндпоинте — потеря времени и читаемости. Оптимизируй то, что измерил, и не оптимизируй то, что не измерил.
Также иерархия памяти не описывает distributed-проблемы: consistency, replication, partitioning. Для них смотри ::concept{slug="cap-theorem"} и ::concept{slug="caching-strategies"}.