Learning Platform
Глоссарий Troubleshooting
Урок 05.04 · 28 мин
Начальный
cachespatial-localitynumpyarrayperformance

CPU не работает напрямую с RAM

Процессор не читает RAM напрямую — это слишком медленно. Между регистрами и RAM есть иерархия кэшей: L1, L2, L3, и только потом DRAM. Каждый уровень больше и медленнее предыдущего:

Иерархия памяти и типовое время доступа

Цифры приблизительные, для современного x86. Кэш быстрее RAM в десятки раз.

Регистры~1 такт16-32 64-битных регистра в CPU, скорость на уровне такта
L1 cache~32 KB, ~4 тактаотдельный на каждое ядро, на сам чип CPU
L2 cache~256 KB - 1 MB, ~12 тактовна чипе, иногда общий с другим ядром
L3 cache~8-32 MB, ~40 тактовобщий для всех ядер чипа
RAM (DRAM)GB, ~200-400 тактовглавная память, на отдельных модулях

Чтение из L1 — ~4 такта. Чтение из RAM — ~200-400 тактов. В 100 раз медленнее. Если ваша программа всё время бьёт мимо кэша — она бьёт по производительности, и это не фикситcя более быстрым алгоритмом.

Как работает spatial locality

Когда CPU читает байт из RAM, он не загружает один байт. Он загружает cache line — обычно 64 байта. Это значит: если вы прочитали xs[0], то xs[1], xs[2], …, xs[15] (при 4 байтах на int) уже в L1, бесплатно. Это и есть spatial locality: рядом лежащие в памяти данные читаются вместе.

Массив (array.array, numpy.ndarray) идеально использует этот эффект: 1M int32 = 4 MB, плотно, ровно. При последовательном проходе кэш-линии загружаются по очереди, prefetcher CPU их предугадывает заранее.

Python list теряет эту локальность: в буфере лежат указатели, а сами объекты разбросаны по куче. Каждое чтение xs[i] + 1 означает: прочесть указатель (один cache miss), потом по указателю прочесть PyObject (другой cache miss, другая cache line), потом сам ob_digit. Каждое значение — отдельный «прыжок» в произвольное место памяти, никакого spatial locality.

Доступ к значениям: numpy vs list

numpy читает соседние int32 одной загрузкой. list прыгает в разные адреса для каждого элемента.

numpy[10][20][30][40]один cache line содержит 16 int32, prefetcher работает
64 байта= 16 int32 в L1один поход в память, 16 значений готовы
listptr -> obj1 ; ptr -> obj2 ; ptr -> obj3каждый obj лежит в произвольном месте кучи
4 cache missesна 4 значенияв худшем случае каждое значение — новая cache line

Бенчмарк: суммирование 1M int

Сравним три структуры на одной задаче: посчитать сумму.

import time
import array
import numpy as np

N = 1_000_000

# три варианта одних и тех же данных
lst = list(range(N))
arr = array.array('i', range(N))
ndarr = np.arange(N, dtype='int32')

def measure(fn, repeats=10):
    t0 = time.perf_counter()
    for _ in range(repeats):
        fn()
    t1 = time.perf_counter()
    return (t1 - t0) / repeats

print(f"sum(list):           {measure(lambda: sum(lst))*1000:>7.2f} ms")
print(f"sum(array.array):    {measure(lambda: sum(arr))*1000:>7.2f} ms")
print(f"sum(numpy iter):     {measure(lambda: sum(ndarr))*1000:>7.2f} ms")
print(f"numpy.ndarray.sum:   {measure(lambda: ndarr.sum())*1000:>7.2f} ms")

Типовой результат (CPython 3.13, Apple M-серии):

sum(list):              5.20 ms
sum(array.array):      13.50 ms     # медленнее list?!
sum(numpy iter):       45.00 ms     # ещё медленнее?!
numpy.ndarray.sum:      0.17 ms     # быстрее в 30 раз

Сюрприз: sum() на array.array и numpy через python-итерацию медленнее, чем на list. Почему?

Где локальность реально работает

Python sum() — это python-loop. Для каждого элемента:

  • __next__ итератора возвращает PyObject.
  • + вызывает nb_add (или __add__).

Когда мы итерируем array.array или numpy.ndarray, CPython создаёт PyLongObject из int32 на каждом шаге — это аллокация и refcounting. Получается, что spatial locality буфера полезна только до момента, как мы начали обернуть значения в PyObject. Дальше — обычный overhead интерпретатора.

А вот ndarr.sum() — это native C-функция (np.add.reduce). Она читает буфер напрямую, использует SIMD, не создаёт никаких PyObject в цикле. Поэтому в 30 раз быстрее.

Главный вывод: locality сама по себе не магия. Она помогает только когда CPU делает много работы на cache line. В Python loop’е, где каждый элемент уходит в PyObject, выгода теряется. Поэтому в numpy все полезные операции — vectorized (+, *, .sum(), .mean(), np.where) — делают batch-обработку в нативном коде.

Точные числа: сколько в RAM, сколько в L3, сколько в L1

Размер 1M int32 = 4 MB. На современном x86 типичный L3 = 8-32 MB, L2 = 256 KB - 1 MB, L1 = 32 KB. То есть весь массив влезает в L3, но не в L1/L2.

Когда вы делаете первый проход — данные подтягиваются из RAM в L3, в L2, в L1 (по cache line). Второй проход (если массив не вырос) — данные уже в L3, чтения дешёвые. Третий проход — может быть в L2. Это temporal locality: повторное чтение тех же данных дешевле первого.

В вашем коде это проявляется так:

import time
import numpy as np

ndarr = np.arange(10_000_000, dtype='int32')  # 40 MB — больше L3

t0 = time.perf_counter()
_ = ndarr.sum()  # первый проход — холодный кэш
t1 = time.perf_counter()
_ = ndarr.sum()  # второй — горячий
t2 = time.perf_counter()

print(f"cold: {(t1-t0)*1000:.2f} ms")
print(f"hot:  {(t2-t1)*1000:.2f} ms")

Получите что-то вроде cold: 8.20 ms, hot: 3.10 ms — разница в 2-3 раза. Это и есть «горячий vs холодный» кэш.

Сводная таблица: 1M int32

Сводка: размер и скорость на 1M элементов

Реальные числа на современном x86, CPython 3.13. Хочешь скорости — переходи на numpy и native операции.

list[int] (1M)~36 MB / sum: 5 msбуфер указателей 8 MB + объекты int 28 MB; CPython sum через python-loop
array.array('i', 1M)~4 MB / sum: 14 msплотный буфер int32, но Python sum всё равно создаёт PyLongObject на каждом шаге
numpy iter sum~4 MB / sum: 45 msndarr тоже даёт Python sum через __iter__; ещё хуже
numpy.ndarray.sum (native)~4 MB / 0.17 msвся работа в C, SIMD, без PyObject — победитель

«Spatial locality экономит время» — да, но видна эта экономия только в нативном коде. В Python loop’е её съедает overhead интерпретатора. Поэтому правило: много чисел плюс простая арифметика — переходите на numpy или pandas, и используйте vectorized операции.

Попробуй сам

Запустите этот бенч с разными N (1k, 10k, 100k, 1M, 10M) и постройте таблицу:

import time
import numpy as np

def bench(N, repeats=20):
    lst = list(range(N))
    ndarr = np.arange(N, dtype='int32')

    t0 = time.perf_counter()
    for _ in range(repeats):
        _ = sum(lst)
    t1 = time.perf_counter()
    list_ms = (t1 - t0) / repeats * 1000

    t0 = time.perf_counter()
    for _ in range(repeats):
        _ = ndarr.sum()
    t1 = time.perf_counter()
    numpy_ms = (t1 - t0) / repeats * 1000

    speedup = list_ms / numpy_ms if numpy_ms > 0 else float('inf')
    print(f"N={N:>10,}  list={list_ms:>8.3f} ms  numpy={numpy_ms:>8.3f} ms  speedup={speedup:>6.1f}x")

for N in [1_000, 10_000, 100_000, 1_000_000, 10_000_000]:
    bench(N)

Посмотрите, как растёт разница: на 1k она невелика, на 1M — десятки раз, на 10M — сотни.

PyArrow: Arrow memory model и zero-copy
Проверка знанийKnowledge check
Почему sum(array.array('i', range(1_000_000))) работает медленнее, чем ndarr.sum() на тех же данных, хотя оба буфера одинакового размера и оба лежат плотно?
ОтветAnswer
Хотя сами буферы у array.array и numpy.ndarray идентичны (4 MB плотного int32, отличная spatial locality), Python-функция sum() итерирует через Python-protocol __iter__. На каждом шаге CPython создаёт PyLongObject из значения int32 — это аллокация и refcounting в Python-loop'е. Spatial locality экономит на чтении буфера, но не отменяет overhead интерпретатора на каждое значение. ndarr.sum() — это native C-функция, она читает буфер напрямую, использует SIMD-инструкции и НЕ создаёт PyObject. Разница 50-100x — это цена 'выхода в Python' для каждого значения.

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

Результат: 0 из 0
Концептуальный
Вопрос 1 из 5. Что такое cache line и почему она важна для производительности массива?

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

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

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

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