Зачем junior data engineer знать про память
Можно прожить карьеру data engineer, ни разу не вспомнив, что у CPU есть кэш. Но рано или поздно вы упретесь в ситуацию: ETL-скрипт обрабатывает миллион событий за 30 секунд, а потом «такой же» скрипт на «таком же» железе вдруг 5 минут. Big-O одинаковый. Объёмы те же. В чём дело?
Дело почти всегда в том, как код работает с памятью. В этом модуле — выход на уровень железа: иерархия памяти, кэш-линии, branch prediction. После этого модуля вы поймёте, почему два алгоритма с одинаковой big-O могут отличаться в 10-100 раз и как это поправить.
Иерархия памяти на современном CPU
Память на современном компьютере — это не «одно большое место хранения». Это многоуровневая иерархия: от очень маленькой и очень быстрой к очень большой и медленной.
Каждый следующий уровень в десятки/сотни раз медленнее и в десятки раз больше предыдущего. CPU всегда сначала пытается достать данные из самого быстрого слоя.
Эти числа надо прочувствовать, не просто прочитать. Вот таблица соотношений: если бы 1 такт CPU = 1 секунда (так наш мозг лучше схватывает):
Если 1 такт CPU = 1 секунда, тогда: L1 — 5 секунд, RAM — 4 минуты, SSD — 12 часов, HDD — 8 месяцев. Junior, который этого не чувствует, делает странные оптимизации.
Когда вы видите код вроде «загрузить файл, обработать, записать обратно» — представьте эту шкалу. Каждое обращение к диску — это часы относительно работы в 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 это:
- CPython считает адрес объекта list в памяти (есть в локальной переменной — регистр).
- По заголовку list находит указатель на массив
ob_item. - По смещению
i * 8берёт указатель на объект-элемент. - Чтобы прочитать сам элемент (например, 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")
Угадайте: будет ли 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-сценарии:
- Streaming aggregation на 1B событий. Если каждое событие требует random RAM access — 1B * 60ns = 60 секунд только на чтение, без обработки. Если работаем sequentially из L1 — секунды.
- JOIN двух таблиц. Hash join строит hash table в памяти. Если table fits in L3 — fast. Если в RAM — slower. Если в swap — disaster (swap = SSD, на 1000x медленнее RAM).
- 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