Learning Platform
Глоссарий Troubleshooting
Урок 07.01 · 18 мин
Начальный
MemoryCacheL1L2L3RAMLatency

Memory hierarchy — registers, cache, RAM, disk и реальная latency

Многие думают, что «память» в компьютере — это RAM. Один большой объект, к которому CPU может обратиться за константное время. Это упрощение, и оно мешает понимать производительность. На самом деле память — это иерархия, и разница между «быстрой» и «медленной» памятью — 6 порядков (миллион раз).

Когда вы пишете for i in range(1_000_000): data[i] и недоумеваете, почему это медленно работает на большом массиве — ответ в memory hierarchy. Когда Spark говорит «out of memory», когда Postgres медленный после ребута, когда numpy в 100 раз быстрее list Python — все эти ситуации напрямую связаны с тем, как уровни памяти работают.

В этом уроке разберём иерархию, реальную latency каждого уровня, и почему cache-friendly код может быть в 10-100x быстрее эквивалентного cache-unfriendly.


Все уровни иерархии

Современный компьютер имеет 5-6 уровней памяти. Каждый уровень в 10-100 раз медленнее предыдущего, но в 10-100 раз больше.

Memory hierarchy 2026 -- размеры и латенси
CPU registers~30 register на x86_64 (RAX, RBX, ..., R15, XMM, YMM, ZMM). Доступ за 0 ns (внутри cycle CPU). Управляет компилятор
L1 cacheL1 data cache (L1d) -- 32-64 KB на ядро. Самый быстрый кэш. Доступ за 1 ns (~3-5 cycles). На каждое ядро свой
L2 cache256 KB - 1 MB на ядро. Доступ за 3-10 ns. На каждое ядро свой (или между парой ядер)
L3 cache8-128 MB shared на сокет. Доступ за 10-40 ns. Общий между всеми ядрами одного сокета. Часто LLC (Last Level Cache)
RAM (DDR4/DDR5)Главная память. 16-512 GB типично на серверах. Доступ за 60-100 ns. На NUMA-системах -- remote node 150-300 ns
NVMe SSDSolid-state storage на PCIe. Доступ за 10-100 мкс (100,000x медленнее RAM!). 100 GB - 100 TB. Random IOPS до 1M
SATA SSDSSD на SATA. ~100 мкс latency, GB-TB. Медленнее NVMe
HDD (spinning)Магнитный диск. 5-10 МС random access (mechanical seek). Sequential GB/s. Огромная разница sequential vs random
Network (LAN)Сетевой запрос в пределах datacenter: 100 мкс - 1 мс. Через Internet: 10-200 мс

Запомните таблицу: L1 = 1 ns, RAM = 100 ns, SSD = 100 мкс (100,000 ns), HDD = 10 мс (10,000,000 ns).

Видно: RAM медленнее L1 в 100 раз. SSD медленнее RAM в 1000 раз. HDD медленнее SSD в 100 раз. Каждый шаг — 2-3 порядка.


Зачем нужна иерархия

Почему не сделать «всю память L1»? Технически нельзя:

Trade-off: speed vs size vs cost
SRAM (cache)Static RAM. 6 транзисторов на бит. Быстро, но дорого по площади. Используется для L1/L2/L3 caches
DRAM (main)Dynamic RAM. 1 транзистор + 1 конденсатор на бит. Дешевле, медленнее. Нужен refresh. Используется как main memory
Flash (SSD)NAND флеш. Несколько бит на ячейку (MLC, TLC, QLC). Намного дешевле, но запись медленнее
Speed of light limitДаже идеальная память не может работать быстрее чем 30 cm/ns (свет в проводе). Распределённая 1 TB SRAM физически не может быть L1 -- слишком далеко от CPU
Cost: $1000 for 1 GB SRAMЕсли делать всю память на SRAM -- 64 GB будет стоить десятки тысяч долларов и не поместится на cpu die. DRAM 1 GB -- copecks

Решение — иерархия. Часто используемые данные — в L1 (быстрое + маленькое). Редкие — в RAM (большое + не очень быстрое). Архивные — на диске. Hardware прозрачно кэширует данные между уровнями.


Cache line — единица обмена

CPU не работает с памятью по байту. Он работает с cache lines — блоками обычно 64 байта.

Cache line: единица обмена между уровнями
Read of byte at addr XПрограмма делает: load [X]. CPU не загружает 1 байт -- грузит всю cache line, содержащую X
Load 64-byte cache lineЕсли X выровнен по 64 байтам, грузится X..X+63. Иначе -- округление вниз по 64. Все 64 байта окажутся в L1
Next byte X+1: HITЕсли потом читаем X+1 -- он уже в L1. Cache hit, 1 ns
Byte X+1000: MISSЕсли читаем X+1000 -- это уже другая cache line. Cache miss, грузим из L2/L3/RAM -- 4-100 ns

Ключевое следствие: последовательный доступ к памяти на порядки быстрее случайного. Если вы итерируете массив подряд, после первого доступа следующие 63 байта уже в L1. Если вы прыгаете по случайным адресам — каждый доступ это cache miss.

# Размер cache line:
getconf LEVEL1_DCACHE_LINESIZE
# 64    -- стандарт на x86, ARM

# Размер L1 data cache:
getconf LEVEL1_DCACHE_SIZE
# 32768  -- 32 KB на ядро

# L2:
getconf LEVEL2_CACHE_SIZE
# 524288 -- 512 KB на ядро

# L3:
getconf LEVEL3_CACHE_SIZE
# 33554432 -- 32 MB на сокет

Sequential vs random access — драматическая разница

Простой эксперимент. Создадим массив 1 миллион int (4 MB — не помещается в L1 32KB, но помещается в L3). Сравним время суммирования при последовательном и случайном доступе.

#include <stdio.h>
#include <stdlib.h>
#include <time.h>

#define N 1000000

int main() {
    int* arr = malloc(N * sizeof(int));
    for (int i = 0; i < N; i++) arr[i] = i;

    // Sequential access:
    struct timespec t1, t2;
    long sum = 0;
    clock_gettime(CLOCK_MONOTONIC, &t1);
    for (int i = 0; i < N; i++) sum += arr[i];
    clock_gettime(CLOCK_MONOTONIC, &t2);
    printf("Sequential: %.2f ms\n", (t2.tv_nsec - t1.tv_nsec) / 1e6);

    // Shuffle indexes
    int* idx = malloc(N * sizeof(int));
    for (int i = 0; i < N; i++) idx[i] = i;
    for (int i = N-1; i > 0; i--) {
        int j = rand() % (i+1);
        int t = idx[i]; idx[i] = idx[j]; idx[j] = t;
    }

    // Random access:
    sum = 0;
    clock_gettime(CLOCK_MONOTONIC, &t1);
    for (int i = 0; i < N; i++) sum += arr[idx[i]];
    clock_gettime(CLOCK_MONOTONIC, &t2);
    printf("Random: %.2f ms\n", (t2.tv_nsec - t1.tv_nsec) / 1e6);
}

Типичные результаты:

  • Sequential: 1-2 мс (1M доступов за 1 мс — 1 ns per access)
  • Random: 30-100 мс (1M доступов за 50 мс — 50 ns per access)

50x разница на одном и том же массиве! При последовательном чтении hardware prefetcher предсказывает следующий доступ и подтягивает данные заранее. При случайном — каждый доступ это cache miss.

# Запустить вышеупомянутый код:
gcc -O0 access.c -o access  # без оптимизаций для чистоты эксперимента
./access

# Замерить cache misses через perf:
perf stat -e cache-references,cache-misses ./access
# Покажет, сколько cache misses из всех доступов
# Random будет иметь много misses, sequential -- мало

Что значит для data engineering

Knowledge о cache hierarchy фундаментально меняет, как вы пишете код. Несколько правил:

1. Arrays of structs vs struct of arrays

# Array of Structs (AoS) -- классический OOP подход:
class Point:
    def __init__(self, x, y, z, name):
        self.x = x; self.y = y; self.z = z; self.name = name
points = [Point(...) for _ in range(1_000_000)]

# Если нужно посчитать сумму всех X:
sum_x = sum(p.x for p in points)
# Каждый доступ к p.x = cache miss, потому что между x'ами лежат y, z, name
# Struct of Arrays (SoA) -- numpy-стиль:
xs = np.array([...])  # подряд
ys = np.array([...])
zs = np.array([...])
sum_x = xs.sum()
# Все xs подряд -- идеально для cache

SoA в numpy/pandas в 10-100x быстрее AoS в Python lists для аналитики. Это и есть column-oriented storage в данных (parquet, arrow). Cache locality.

Cache lines и locality: фундамент производительности структур данных

2. Loop order matters

// Двумерный массив 1000x1000:
int matrix[1000][1000];

// БЫСТРО -- row by row (как лежит в памяти):
for (int i = 0; i < 1000; i++)
    for (int j = 0; j < 1000; j++)
        sum += matrix[i][j];

// МЕДЛЕННО -- column by column:
for (int j = 0; j < 1000; j++)
    for (int i = 0; i < 1000; i++)
        sum += matrix[i][j];
// Каждый доступ к matrix[i][j] = другая строка = cache miss
// На 1000x1000 разница может быть 5-10x

В data engineering это работает для табличных данных. Чтение row-by-row из row-oriented хранилища (PostgreSQL) — быстро. Чтение колонок — медленно. Обратное для column-oriented (Parquet, ClickHouse) — агрегации по колонке быстрые, чтение всей строки медленнее.

3. Working set size

Если ваш горячий набор данных (working set) помещается в L3 cache — всё будет fast. Не помещается — каждый доступ может уйти в RAM.

L1 = 32-64 KB -- небольшая активная часть алгоритма
L2 = 256-1024 KB -- разумный working set
L3 = 8-128 MB -- большой working set
RAM = ваш весь dataset

При проектировании high-performance pipeline думайте: что у меня в working set? Если 100 MB — хорошо, если 100 GB — скорее всего хитов в L3 будет мало.


NVMe — быстрая нижняя память

NVMe SSD сильно изменили картину. SATA SSD имеют latency 100-200 мкс, NVMe — 10-50 мкс. Это:

Storage latency 2026
HDDMechanical disk. Seek 5-10 ms. Sequential 100-200 MB/s. На современных серверах редко -- только архивы
SATA SSDSATA III: 6 Gb/s. Latency 100-200 мкс. До 600 MB/s sequential
NVMe gen3PCIe 3.0 NVMe: 3 GB/s sequential. Latency 50-100 мкс
NVMe gen4PCIe 4.0 NVMe: 7 GB/s sequential. Latency 15-30 мкс
NVMe gen5PCIe 5.0 NVMe: 14 GB/s sequential. Latency 10-15 мкс. Production 2024+
Optane (deprecated)Intel Optane (3D XPoint). Латенси 1-5 мкс. Между RAM и SSD. Дискатнинуирован в 2022, но был интересен

NVMe gen4-5 настолько быстры, что для random reads они становятся сравнимы с network latency. Это меняет архитектуру: можно использовать диск как очень большую RAM (с paging, mmap, etc).

Сеть как ещё один уровень memory hierarchy
# Информация о ваших дисках:
lsblk -d -o NAME,SIZE,TYPE,TRAN
# nvme0n1   500G disk nvme
# sda      4000G disk sata

# Бенчмарк latency:
sudo fio --name=randread --rw=randread --bs=4k --size=1G --filename=/tmp/test --runtime=10
# read: IOPS=200k, lat (usec): avg=20 -- 20 мкс average latency

rm /tmp/test

Network — ещё один уровень иерархии

Для распределённых систем сеть — ещё один уровень в memory hierarchy.

Network latency хорошо знать
Same rackТот же rack в datacenter. ~5-50 мкс RTT. Иногда через ToR-switch
Same datacenterМежду rack'ами в одном DC. 100-500 мкс RTT
Same regionAWS us-east-1 in-region. 1-5 мс RTT
Cross-regionAWS us-east-1 to eu-west-1. 80-150 мс. Сложно для синхронных операций
Globe (worst case)Australia to Europe. 200-300 мс. Speed of light limit, не software issue

В data engineering это важно: distributed query на 5 серверов в том же DC — RTT 200 мкс. Distributed query через регионы — 100 мс. Один HTTP-запрос cross-region может занять больше времени, чем 100 GB sequential reads с локального NVMe.


Попробуй сам

# 1. Узнать параметры памяти своей машины:
getconf LEVEL1_DCACHE_SIZE
getconf LEVEL2_CACHE_SIZE
getconf LEVEL3_CACHE_SIZE
getconf LEVEL1_DCACHE_LINESIZE

# 2. Бенчмарк sequential vs random:
cat > /tmp/access.c << 'EOF'
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#define N 10000000
int main() {
    int* a = malloc(N*sizeof(int));
    int* idx = malloc(N*sizeof(int));
    for (int i=0; i<N; i++) { a[i]=i; idx[i]=i; }
    for (int i=N-1; i>0; i--) { int j=rand()%(i+1); int t=idx[i]; idx[i]=idx[j]; idx[j]=t; }
    long sum;
    struct timespec t1, t2;
    sum=0;
    clock_gettime(CLOCK_MONOTONIC, &t1);
    for (int i=0; i<N; i++) sum += a[i];
    clock_gettime(CLOCK_MONOTONIC, &t2);
    printf("Sequential: %.1f ms (sum=%ld)\n", (t2.tv_sec-t1.tv_sec)*1e3 + (t2.tv_nsec-t1.tv_nsec)/1e6, sum);
    sum=0;
    clock_gettime(CLOCK_MONOTONIC, &t1);
    for (int i=0; i<N; i++) sum += a[idx[i]];
    clock_gettime(CLOCK_MONOTONIC, &t2);
    printf("Random:     %.1f ms (sum=%ld)\n", (t2.tv_sec-t1.tv_sec)*1e3 + (t2.tv_nsec-t1.tv_nsec)/1e6, sum);
}
EOF
gcc -O2 /tmp/access.c -o /tmp/access
/tmp/access
# Sequential будет 5-30 мс, Random 200-1000 мс -- 30-50x разница

# 3. perf stat покажет cache misses:
perf stat -e cache-misses,cache-references,L1-dcache-load-misses /tmp/access
# Будет видно: sequential ~1% miss rate, random 30-50% miss rate

# 4. Сравнить numpy vs Python list:
python3 -c "
import time, numpy as np
n = 10_000_000
lst = list(range(n))
t1 = time.time(); s = sum(lst); print(f'Python list sum: {time.time()-t1:.3f}s')

arr = np.arange(n)
t1 = time.time(); s = arr.sum(); print(f'NumPy sum: {time.time()-t1:.3f}s')
"
# NumPy в 50-100x быстрее -- cache friendly

# 5. Замерить NVMe latency:
sudo fio --name=test --rw=randread --bs=4k --size=100M \
    --filename=/tmp/fio.test --runtime=5 --direct=1
rm /tmp/fio.test

Проверка знанийKnowledge check
Junior спрашивает: 'У меня Python скрипт обрабатывает CSV файл 5 GB. На сервере с 64 GB RAM этого должно хватать с запасом, файл целиком в память грузится. Но почему-то работает медленно -- 20 минут на простую агрегацию. Что не так?'
ОтветAnswer
Несколько возможных причин, и почти все связаны с memory hierarchy: 1. Python list vs NumPy/Pandas. Если ты делаешь list of dicts или list of tuples -- это Array of Structs. Каждый dict/tuple -- отдельная heap-аллокация, поля разбросаны по памяти. Кэш-хитов почти нет. Используй pandas DataFrame или numpy arrays -- они column-oriented (Struct of Arrays), и итерация по колонке идёт линейно. Ускорение типично 10-100x. 2. Iteration vs vectorization. df.apply(lambda row: ...) почти всегда медленно. df['col'].mean() или vectorized операции -- быстро, потому что под капотом C-loop с cache prefetch. Не пиши for-loop'ы по DataFrame. 3. Working set > L3 cache. 5 GB > 30 MB L3. Каждый доступ к новой части данных может быть cache miss. Но это не должно давать 20 минут -- скорее 1-2 минуты. 4. Не используешь чанки. Если делаешь pd.read_csv('5gb.csv'), потом df.groupby() -- pandas может построить временные структуры в 10-20 GB памяти, swap начнёт. Лучше chunked: for chunk in pd.read_csv('5gb.csv', chunksize=100_000): aggregate. 5. CSV парсинг -- сам по себе медленно. CSV -- text format, парсинг каждой строки -- много CPU. Используй Parquet или Arrow -- columnar, бинарный, в 10-50x быстрее парсится. 6. Возможно, дисковое чтение -- bottleneck. df_pre_load на HDD = 100 MB/s = 50 секунд. На NVMe = 5 секунд. Проверь: iostat -x 1 во время работы -- если %util высокий, диск bottleneck. 7. Может быть swap. free -h -- если used > free, и swap активен, ты thrashing. Каждый доступ может уходить в swap чтобы дисциплинированно ходит в swap -- 10 мс latency вместо 100 ns. Это даст 100,000x замедление. Диагностика: - htop -- посмотри %CPU и %MEM. Если CPU low, MEM high -- swap. Если CPU 100% one core -- single-threaded. - iostat -x 1 -- диск нагрузка - python -m cProfile script.py -- посмотри где время Правильный подход: 1. Используй Parquet вместо CSV 2. Если CSV неизбежен -- pl.read_csv (polars быстрее pandas), или duckdb для агрегаций 3. Используй chunked processing если данных больше чем 2x RAM 4. Векторизуй вместо итераций 5. Профилируй до того, как оптимизировать 20 минут на 5 GB агрегацию -- очень медленно. На современной NVMe-машине с правильным кодом это секунды до минуты.

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 6. Примерные latency для разных уровней памяти на современном x86_64 сервере:

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 5