Learning Platform
Глоссарий Troubleshooting
Урок 04.01 · 28 мин
Начальный
Memory hierarchyCPU cacheLatency

Зачем junior data engineer знать про память

Можно прожить карьеру data engineer, ни разу не вспомнив, что у CPU есть кэш. Но рано или поздно вы упретесь в ситуацию: ETL-скрипт обрабатывает миллион событий за 30 секунд, а потом «такой же» скрипт на «таком же» железе вдруг 5 минут. Big-O одинаковый. Объёмы те же. В чём дело?

Дело почти всегда в том, как код работает с памятью. В этом модуле — выход на уровень железа: иерархия памяти, кэш-линии, branch prediction. После этого модуля вы поймёте, почему два алгоритма с одинаковой big-O могут отличаться в 10-100 раз и как это поправить.

Иерархия памяти на современном CPU

Память на современном компьютере — это не «одно большое место хранения». Это многоуровневая иерархия: от очень маленькой и очень быстрой к очень большой и медленной.

Иерархия памяти от самой быстрой к самой медленной

Каждый следующий уровень в десятки/сотни раз медленнее и в десятки раз больше предыдущего. CPU всегда сначала пытается достать данные из самого быстрого слоя.

Регистры CPU~16-32 шт, <1 nsДоли наносекунды. Самые быстрые. Хранят значения, с которыми CPU работает прямо сейчас. Помещаются единичные значения.
L1 cache~32-64 KB, ~5 cycles (~1.5 ns)Самый ближний к ядру кэш. Per-core, не разделяется между ядрами. Сюда CPU стремится положить горячие данные.
L2 cache~256 KB - 1 MB, ~12 cycles (~4 ns)Чуть дальше, но всё ещё близко. Per-core. Промежуточный буфер между L1 и L3.
L3 cache~4-32 MB, ~40 cycles (~12 ns)Общий для всех ядер CPU. Последний уровень кэша перед RAM. На современных серверных CPU может быть до 256 MB.
RAM (DDR4/DDR5)~8-128 GB, ~200 cycles (~60 ns)Основная память. Каждый промах кэша — обращение сюда, и это всегда болезненно.
NVMe SSD~256 GB - 4 TB, ~50000 ns (50 us)Современный быстрый диск. Чтение блоками. В 1000 раз медленнее RAM.
SATA SSD~256 GB - 4 TB, ~150000 nsСтарый SSD интерфейс. Медленнее NVMe в 3 раза.
HDD магнитный~1-20 TB, ~5-10 ms (~10000000 ns)Механический диск. На порядки медленнее SSD. Random read особенно болезненно.
Сеть (LAN)~1 Gbps, ~500000 nsЗапрос к соседнему серверу. Полмиллисекунды на round-trip минимум.
Сеть (datacenter cross-region)~80 msЗапрос между дата-центрами. Восемьдесят миллисекунд = 80 миллионов наносекунд.

Эти числа надо прочувствовать, не просто прочитать. Вот таблица соотношений: если бы 1 такт CPU = 1 секунда (так наш мозг лучше схватывает):

Latency в человеческих масштабах

Если 1 такт CPU = 1 секунда, тогда: L1 — 5 секунд, RAM — 4 минуты, SSD — 12 часов, HDD — 8 месяцев. Junior, который этого не чувствует, делает странные оптимизации.

1 cycle1 секундаБазовая единица — одна арифметическая операция в регистре
L1 hit5 секундОчень быстро — почти как регистр
L2 hit12 секундМинута туда-обратно
L3 hit40 секундВсё ещё в пределах разумного
RAM access3-4 минутыКаждый cache miss это сходить за чашкой кофе
NVMe SSD14 часовДень просрочки. Запрос на диск — серьёзное решение.
HDD2-3 месяцаПолгода на одну операцию. Поэтому современные DB избегают seek-ов.
Cross-region network2.5 годаНе делайте queries в другой регион в hot path

Когда вы видите код вроде «загрузить файл, обработать, записать обратно» — представьте эту шкалу. Каждое обращение к диску — это часы относительно работы в RAM. Каждый промах кэша — минуты относительно работы в L1.

Почему такая иерархия вообще существует

Технически можно было бы сделать всю память «как L1» — быстрой и близкой к CPU. Но это слишком дорого и физически невозможно.

  • Скорость зависит от расстояния. L1 расположен буквально на кристалле ядра. RAM — на отдельной планке в десятках сантиметров. Свет проходит сантиметр за ~30 пикосекунд. Сигналу нужно дойти и вернуться — это уже наносекунды для удалённой памяти.
  • Скорость стоит транзисторов. SRAM (как L1) — 6 транзисторов на бит. DRAM (RAM) — 1 транзистор + 1 конденсатор на бит. SRAM в 6 раз дороже.
  • Объём ограничен площадью кристалла. Современный CPU имеет ~100 квадратных миллиметров. На этом нельзя разместить 8GB SRAM — финансово и физически нереально.

Компромисс: ставим маленький быстрый кэш рядом, большой медленный — далеко. CPU автоматически перемещает данные между уровнями в зависимости от паттернов доступа.

Что происходит при lst[i] в Python

Возьмём простейшую операцию — доступ по индексу. На уровне CPython это:

  1. CPython считает адрес объекта list в памяти (есть в локальной переменной — регистр).
  2. По заголовку list находит указатель на массив ob_item.
  3. По смещению i * 8 берёт указатель на объект-элемент.
  4. Чтобы прочитать сам элемент (например, int), нужен ещё один шаг к адресу этого int.

Теперь — где это всё в иерархии памяти?

  • Заголовок list — недавно использовался, скорее всего в L1.
  • Массив ob_item — если list небольшой и недавний, тоже в L1/L2. Если большой и долго не трогался — частично в RAM.
  • Сами объекты-элементы (int) — это отдельные объекты в куче. Их адреса разбросаны по памяти. Каждый — отдельный поход в RAM, если не в кэше.

Поэтому Python lst[i] для int-а — это минимум два чтения: указатель в массиве, потом объект. На «холодных» данных оба могут быть промахами кэша — это до 400-600 ns на одну простую операцию.

Numpy array, наоборот, хранит int-ы плотно: arr[i] — это одно чтение прямо в массив. Поэтому numpy быстрее Python list для числовых операций в 10-100 раз — не из-за «другого языка», а из-за memory layout.

Попробуй сам: cache vs RAM на одинаковой работе

Сделаем простой эксперимент: пройдёмся по массивам разного размера и замерим время. Идея в том, что маленькие массивы помещаются в L1, средние — в L2/L3, большие — только в RAM. Время на обход «одного элемента» будет разное.

import timeit
import sys

def sum_list(lst):
    s = 0
    for x in lst:
        s += x
    return s

sizes = [
    (1_000,        "~32 KB (fits L1)"),
    (10_000,       "~320 KB (fits L2)"),
    (100_000,      "~3 MB (fits L3 maybe)"),
    (1_000_000,    "~32 MB (RAM)"),
    (10_000_000,   "~320 MB (RAM, cold)"),
]

for n, desc in sizes:
    lst = list(range(n))
    t = min(timeit.repeat(lambda: sum_list(lst), number=3, repeat=3)) / 3
    per_elem_ns = (t / n) * 1e9
    print(f"n={n:>10}  {desc:<25}  per elem: {per_elem_ns:>5.1f} ns")
TIP

Угадайте: будет ли per-element время одинаковым для всех n, или будет расти на больших n?

Типичный вывод:

n=      1000  ~32 KB (fits L1)           per elem:   42 ns
n=     10000  ~320 KB (fits L2)          per elem:   45 ns
n=    100000  ~3 MB (fits L3 maybe)      per elem:   55 ns
n=   1000000  ~32 MB (RAM)               per elem:   90 ns
n=  10000000  ~320 MB (RAM, cold)        per elem:  150 ns

Видите: per-element время растёт в 3-4 раза с увеличением n, хотя big-O чистый O(n). Это и есть эффект иерархии памяти. На маленьких n всё в L1 — быстро. На больших — кэш промахивается, CPU ждёт RAM — медленно.

(Числа на Python чуть смазаны — слишком много overhead. На numpy/C эффект чище: можно увидеть прямо границы L1->L2->L3->RAM.)

Почему это критично для DE

Junior читает: «обработка миллионов событий за минуты — норма». А реально это упражнение в правильной работе с памятью.

Конкретные DE-сценарии:

  1. Streaming aggregation на 1B событий. Если каждое событие требует random RAM access — 1B * 60ns = 60 секунд только на чтение, без обработки. Если работаем sequentially из L1 — секунды.
  2. JOIN двух таблиц. Hash join строит hash table в памяти. Если table fits in L3 — fast. Если в RAM — slower. Если в swap — disaster (swap = SSD, на 1000x медленнее RAM).
  3. Sort на 10GB. Если влезает в RAM целиком — in-memory sort работает минуты. Если нет — external sort с чтением/записью на диск, часы.

Вы будете видеть это в работе постоянно. Спарковский executor «вдруг стал медленный» на конкретном датасете — почти всегда это либо вылет в spill-to-disk (память кончилась), либо плохой cache locality в кастомном коде.

Главное правило работы с памятью

Это правило применимо везде в DE:

Делайте sequential access. Избегайте random access. Маленькие горячие данные держите в RAM. Большие холодные — стримом с диска.

Из этого выводятся практические следствия:

  • Колоночные форматы (Parquet, ORC) — быстрее чтения, чем row-based, потому что один столбец читается sequentially.
  • Hash join держит малую таблицу в RAM, по большой проходит sequentially.
  • pandas.DataFrame хранит колонки contiguously — поэтому df['col'].sum() быстрее, чем итерация по строкам.
  • Streaming обработка в Polars/DuckDB лучше pandas для больших файлов — не пытается уместить всё в RAM.

В следующих уроках разберём конкретные механизмы — cache lines (что и как CPU реально читает из памяти за один раз), prefetcher (как CPU предугадывает будущие чтения), branch prediction.

Memory hierarchy с точки зрения ОС Paging и MMU: как virtual address становится physical Mutability vs immutability на уровне памяти и cache
Проверка знанийKnowledge check
Junior меряет: проход по list из 1000 элементов даёт 42 ns на элемент, проход по list из 10M элементов — 150 ns. Big-O для обоих O(n) с одинаковым телом цикла. Почему per-element время разное?
ОтветAnswer
Это эффект иерархии памяти. List из 1000 (~32KB) полностью помещается в L1 cache — каждое обращение к памяти быстрое (~1-2 ns). List из 10M (~320MB) не помещается даже в L3 — каждое обращение требует RAM access (~60 ns), плюс не помогает prefetcher из-за слишком большого размера. Big-O правильно прогнозирует, что время растёт линейно с n, но НЕ говорит, что коэффициент постоянный — он зависит от того, в каком уровне памяти живут данные. Это фундаментальная ограниченность big-O как модели — она не учитывает реальное железо.

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

Результат: 0 из 0
Концептуальный
Вопрос 1 из 5. Какой примерный latency у уровней памяти на современном CPU?

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

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

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

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