Learning Platform
Глоссарий Troubleshooting
Урок 04.02 · 26 мин
Начальный
Cache linesLocalityMemory access patterns

Что такое cache line

Когда CPU обращается к памяти, он не читает один байт. Он читает целый блок памяти, выровненный по 64-байтной границе. Этот блок называется cache line. На современных x86 и ARM это всегда 64 байта, точка.

Почему именно так? Чтение из RAM имеет огромный фиксированный overhead (~60 ns latency). Прочитать 1 байт занимает столько же времени, что прочитать 64. Поэтому архитектура CPU всегда читает 64 байта за один раз, в надежде, что соседние данные тоже скоро понадобятся.

Что физически происходит при чтении байта из RAM

CPU не имеет инструкции 'прочитай один байт из RAM'. Только 'прочитай cache line'. Один байт — мнимая абстракция.

CPU нужен1 байт по адресу XНапример, нужен int по адресу 0x10000040
Контроллер памяти читает64 байта от 0x10000000 до 0x1000003FАдрес X округляется вниз до границы 64 байт. Читается весь блок.
64 байта оседаютв L1 cache как cache lineCache line — самая мелкая единица кэширования. Не 1 байт, не 8 байт — всегда 64.
Следующие чтенияиз этих 64 байт почти бесплатныСоседние int-ы (или char-ы) уже в L1 — ~1-2 ns доступа против 60 ns

64 байта — это:

  • 16 штук 32-битных int (по 4 байта).
  • 8 штук 64-битных int / float / pointer.
  • 64 штуки char.
  • Около 4 штук Python int-объектов (28 байт каждый — не помещается ровно).

Так что когда вы делаете arr[i] (numpy int32), вы получаете не только arr[i], но и бесплатно в L1 оседают arr[i+1]..arr[i+15]. Следующее чтение arr[i+1] — ~1 ns вместо ~60 ns.

Spatial locality — пространственная локальность

Spatial locality — это свойство кода: если он обращается к адресу X, скорее всего скоро понадобится X+1, X+2, … Это золотое правило памяти: оно превращает дорогие RAM-чтения в почти бесплатные cache hits.

Хорошие примеры spatial locality:

  • Проход по массиву for x in lst или for i, x in enumerate(arr).
  • Сравнение двух соседних строк в файле.
  • Чтение полей одного объекта подряд.

Плохие примеры:

  • Random access — arr[random.randint(0, n)] каждый раз.
  • Hash-таблицы (по природе своей разбрасывают данные).
  • Linked list — каждый следующий узел может быть где угодно в памяти.

Temporal locality — временная локальность

Второе свойство — temporal locality: если код обратился к X, скорее всего скоро обратится к X снова. Cache держит недавно использованные данные — на это и расчёт.

Хорошие примеры:

  • Аккумулятор в цикле — переменная s в for x: s += x всё время в регистре.
  • Hot config / lookup table, к которой обращаются тысячи раз.
  • Активный buffer в streaming-обработке.

Плохие примеры:

  • Однократный проход по гигантскому датасету — каждый элемент посещается ровно раз, кэш не помогает (но cache line всё равно помогает!).

Демонстрация на простом примере

Сделаем эксперимент: пройдёмся по массиву последовательно vs в случайном порядке. Один и тот же big-O O(n), но разница из-за cache line будет заметна.

import timeit
import random
import array

def sequential_sum(arr, indices):
    s = 0
    for i in indices:
        s += arr[i]
    return s

n = 1_000_000
# array.array — компактный массив int32, как numpy без зависимости
arr = array.array('i', range(n))

# Случайный порядок
seq_indices = list(range(n))
random_indices = list(range(n))
random.shuffle(random_indices)

t_seq = min(timeit.repeat(
    lambda: sequential_sum(arr, seq_indices),
    number=3, repeat=3
)) / 3

t_rand = min(timeit.repeat(
    lambda: sequential_sum(arr, random_indices),
    number=3, repeat=3
)) / 3

print(f"Sequential access: {t_seq*1000:.0f} ms")
print(f"Random access:    {t_rand*1000:.0f} ms")
print(f"Slowdown:         {t_rand/t_seq:.1f}x")

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

Sequential access:  85 ms
Random access:     320 ms
Slowdown:          3.8x

Big-O одинаковый — O(n) сложений. Размер данных одинаковый. Различается только порядок доступа. Sequential получает бесплатные cache hits на соседних элементах. Random — каждый раз туда-сюда по памяти.

На Python разница ~4x — её смягчает огромный overhead интерпретатора. На numpy/C эффект может быть 20-50x на тех же данных.

WARNING

Это не «академическое любопытство». В реальных DE-системах cache locality определяет производительность пайплайнов. Например, в Spark разница между правильным partitioning (соседние данные в одной партиции) и неправильным (разбросаны) может быть 10x по wall-clock — при том же big-O.

for x in lst vs for i in range(len(lst)): lst[i]

Классический вопрос: в каких случаях быстрее одно, в каких другое?

import timeit

lst = list(range(1_000_000))

t1 = min(timeit.repeat(
    """
total = 0
for x in lst:
    total += x
""",
    setup='lst = list(range(1_000_000))',
    number=10, repeat=3
)) / 10

t2 = min(timeit.repeat(
    """
total = 0
for i in range(len(lst)):
    total += lst[i]
""",
    setup='lst = list(range(1_000_000))',
    number=10, repeat=3
)) / 10

print(f"for x in lst:        {t1*1000:.1f} ms")
print(f"for i in range(len): {t2*1000:.1f} ms")

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

for x in lst:        45.0 ms
for i in range(len): 62.0 ms

for x in lst быстрее на ~30%. Причина — он использует итератор list-а, который ходит по ob_item по порядку, идеально cache-friendly. range(len) создаёт лишний int-объект на каждой итерации и делает lst[i] через вычисление адреса.

Но: если вам нужно одновременно обращаться к lst[i] и lst[i+1] (например, для проверки соседних элементов) — выбора нет, нужны индексы. И тогда for i in range(...) правильное решение.

Cache lines и структуры данных

Это объясняет фундаментальную разницу между list (массив указателей в Python) и array.array / numpy.array (компактные массивы значений):

Python list vs array.array: что лежит в памяти

list хранит указатели на отдельные int-объекты, каждый где-то в куче. array.array хранит сами числа подряд.

Python list [1,2,3,4]header + 4 pointersСам list это 56 байт header + 32 байта на указатели (4 * 8)
int(1)@ адрес A28 байт, где-то в куче
int(2)@ адрес B28 байт, где-то ещё
int(3)@ адрес C28 байт, ещё одно место
int(4)@ адрес D28 байт, ещё одно место
array.array('i', [1,2,3,4])header + 16 байт данныхСам array.array это header + 4*4 байта на int32 значения
1, 2, 3, 4плотно в одной cache lineВсе 4 значения в одной cache line (64 байта). Все следующие чтения — cache hit.

Итог: проход по list из 1M int-ов означает 1M random RAM accesses к разным int-объектам. Проход по array.array — 250K cache line reads (по 16 int32 в каждой), всё sequentially. Разница может быть 10-50x в реальной производительности.

В Python это критично: для числовых данных всегда лучше array.array, numpy.array или встроенные функции (sum(range(n)) оптимизирован), а не list.

Попробуй сам: матрица row-major vs column-major

Самый известный пример cache effects — порядок обхода 2D-массива. В большинстве языков (C, Python, numpy с default) матрицы хранятся по строкам (row-major). То есть m[0][0], m[0][1], m[0][2], ..., m[1][0], m[1][1], ... лежат в памяти подряд.

Если обходить по строкам (внутренний цикл по столбцам) — sequential access, cache-friendly. Если обходить по столбцам (внутренний цикл по строкам) — random access, cache-unfriendly.

import timeit

N = 1000  # матрица NxN
setup = f"""
matrix = [[i*j for j in range({N})] for i in range({N})]
"""

# Обход по строкам — sequential
row_major = """
total = 0
for i in range(N):
    for j in range(N):
        total += matrix[i][j]
""".replace("N", str(N))

# Обход по столбцам — random для row-major layout
col_major = """
total = 0
for j in range(N):
    for i in range(N):
        total += matrix[i][j]
""".replace("N", str(N))

t_row = min(timeit.repeat(row_major, setup=setup, number=3, repeat=3)) / 3
t_col = min(timeit.repeat(col_major, setup=setup, number=3, repeat=3)) / 3

print(f"Row-major (cache-friendly):     {t_row*1000:.1f} ms")
print(f"Col-major (cache-unfriendly):   {t_col*1000:.1f} ms")
print(f"Slowdown:                        {t_col/t_row:.1f}x")
TIP

Угадайте перед запуском: разница 2x? 5x? 10x?

Типичный вывод (Python list of lists):

Row-major (cache-friendly):     35.0 ms
Col-major (cache-unfriendly):   58.0 ms
Slowdown:                        1.7x

На Python разница ~2x — Python list of lists это и так указатели на отдельные list-ы, layout не идеальный.

На numpy с 32-bit float разница куда драматичнее (попробуйте import numpy; m = numpy.zeros((1000, 1000))) — до 10-20x. В реальных нагрузках (свёртки нейросетей, LU-декомпозиция, BLAS-операции) это первейшее, что оптимизируют.

Шпаргалка по cache lines

Главное про cache lines

Эти правила объясняют 80% случаев 'почему один и тот же big-O даёт разную скорость'.

Cache line = 64 байтаx86/ARM standardСамая мелкая единица передачи между уровнями памяти
Sequential access -> cache hitsдо 50x быстрее randomSpatial locality превращает RAM-чтения в L1-чтения
array/numpy > list для чиселплотный layout vs указателиОдин cache miss приносит 16 int32 vs ничего полезного для list
Row-major matricesвнутренний цикл — по соседним элементамИначе random access по строкам — kills performance
Random access на больших данныхвсегда дорогоHash tables, linked lists неизбежно платят за random access — но компенсируют другим

В следующем уроке — pointer chasing: что происходит, когда CPU не может предугадать следующий адрес. Linked list, prefetcher и почему deque на самом деле хорош.

Python list: contiguous array of PyObject pointers Column store: хранение по колонкам и выгода для cache
Проверка знанийKnowledge check
Junior читает: 'numpy быстрее Python list для числовой обработки в 10-100 раз'. Какое реальное объяснение этого через cache lines?
ОтветAnswer
Python list хранит указатели на отдельные int-объекты, разбросанные по памяти. Чтение arr[i] = чтение указателя + dereference в отдельную область памяти, каждое — потенциальный cache miss. Numpy array хранит сами числа плотно подряд (для int32 — 16 чисел в одной 64-байтной cache line). Чтение arr[i] = одна загрузка приносит сам элемент и ещё 15 соседних бесплатно. На 1M элементов: list делает ~1M RAM accesses, numpy — ~62K cache line reads (1M/16). Разница в 10-20x по cache hit rate + плюс numpy выполняет операции в C без интерпретатора. Итог — 10-100x ускорение. Не "magic", а memory layout.

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

Результат: 0 из 0
Концептуальный
Вопрос 1 из 5. Какой размер cache line на современных x86/ARM процессорах?

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

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

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

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