Learning Platform
Глоссарий Troubleshooting
Урок 04.03 · 26 мин
Начальный
Pointer chasingPrefetcherLinked list

Что такое pointer chasing

Pointer chasing — это паттерн доступа к памяти, при котором каждое следующее обращение зависит от текущего. CPU не может узнать следующий адрес заранее — должен сначала прочитать текущее значение, потом из него взять следующий указатель.

Самый каноничный пример — обход linked list:

class Node:
    def __init__(self, val, next=None):
        self.val = val
        self.next = next

# Линкед-лист 1 -> 2 -> 3 -> 4 -> ...
def sum_linked(head):
    s = 0
    node = head
    while node:
        s += node.val      # читаем val
        node = node.next   # читаем next — адрес следующего
    return s

CPU выполняет это так:

  1. Прочитать node.val -> прибавить к s.
  2. Прочитать node.next -> получить адрес следующего узла.
  3. Прочитать next.val (но адрес мы узнали только сейчас).
  4. Прочитать next.next.
  5. И так далее.

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

Pointer chasing: сериализованное чтение

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

Node 1val + next ptrАдрес 0x1000
Node 2val + next ptrАдрес 0x8000 — где угодно
Node 3val + next ptrАдрес 0x12000 — снова не предсказать
Node 4val + next ptrКаждый узел — потенциальный cache miss

Sequential access: prefetcher творит чудеса

Контраст — обход массива:

arr = [1, 2, 3, 4, 5, 6, 7, 8]
for x in arr:
    process(x)

CPU видит: «обращения идут по адресам X, X+8, X+16, X+24, …». Это арифметическая прогрессия. CPU включает prefetcher — отдельный аппаратный блок, который начинает асинхронно подтягивать будущие cache lines, пока вы работаете с текущей.

К тому моменту, когда вам понадобится arr[i+8], его cache line уже в L1. Чтение бесплатное.

Prefetcher работает наперёд

CPU видит арифметическую прогрессию и догадывается, что нужно подтащить заранее. На pointer chasing prefetcher слепой.

CPU читает arr[0]Загрузка cache line с arr[0..7]Первое чтение из RAM — 60 ns latency
PrefetcherВидит pattern: +8 байтЗамечает арифметическую прогрессию и начинает наперёд тащить arr[8..15]
CPU использует arr[1..7]L1 hit, ~1 nsПока работает с текущей cache line, prefetcher уже работает
CPU достигает arr[8]Уже в L1!Prefetcher успел — нет cache miss

Когда prefetcher работает хорошо (предсказуемый pattern, не слишком большие данные) — sequential access становится практически бесплатным относительно RAM access. Это разница между 60 ns на элемент и ~1 ns на элемент = до 60x speedup.

Замер: list (массив) vs deque (linked list of blocks) vs class-based linked list

В Python нет «настоящего» linked list в stdlib. collections.deque — это двусвязный список блоков (каждый блок ~64 элемента), что делает его компромиссом между чистым linked list и массивом. Для демонстрации pointer chasing смастерим linked list сами:

import timeit
from collections import deque

class Node:
    __slots__ = ('val', 'next')
    def __init__(self, val):
        self.val = val
        self.next = None

def make_linked(n):
    head = Node(0)
    cur = head
    for i in range(1, n):
        cur.next = Node(i)
        cur = cur.next
    return head

def sum_linked(head):
    s = 0
    node = head
    while node:
        s += node.val
        node = node.next
    return s

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

def sum_deque(d):
    s = 0
    for x in d:
        s += x
    return s

N = 1_000_000
linked_head = make_linked(N)
lst = list(range(N))
dq = deque(range(N))

t_linked = min(timeit.repeat(lambda: sum_linked(linked_head), number=3, repeat=3)) / 3
t_list   = min(timeit.repeat(lambda: sum_list(lst), number=3, repeat=3)) / 3
t_deque  = min(timeit.repeat(lambda: sum_deque(dq), number=3, repeat=3)) / 3

print(f"linked list (pointer chasing): {t_linked*1000:>6.0f} ms")
print(f"deque (blocks of ~64):         {t_deque*1000:>6.0f} ms")
print(f"list (array of pointers):      {t_list*1000:>6.0f} ms")
print(f"linked / list ratio:           {t_linked/t_list:.1f}x")

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

linked list (pointer chasing):    280 ms
deque (blocks of ~64):             52 ms
list (array of pointers):          45 ms
linked / list ratio:               6.2x

Big-O идентичен — все O(n) на обход. Но реальная разница до 6x.

Почему deque близок к list? Потому что внутри deque — двусвязный список блоков по ~64 элемента. Pointer chase происходит только раз в 64 элемента, остальные 63 — sequential внутри блока. Это компромисс, который хорошо работает.

NOTE

Python list — это массив указателей на int-объекты. Указатели лежат plотно (cache-friendly), но сами int-ы — где попало в куче. Поэтому обход list тоже частично pointer chase. Для чисто sequential access в Python берите array.array или numpy — там значения лежат подряд, без указателей.

Когда prefetcher не помогает

Prefetcher хороший, но не магия. Он не помогает в случаях:

  1. Random access — обращения по случайным адресам. Pattern непредсказуем.
  2. Pointer chasing — следующий адрес зависит от данных, prefetcher не знает.
  3. Слишком большие шаги (strides) — если читаете каждый 1000-й элемент массива, между чтениями prefetcher не видит «соседства».
  4. Слишком много одновременных потоков memory accesses — prefetcher имеет ограниченное число in-flight prefetches.

Это объясняет, почему hash tables (dict, set) на больших размерах внезапно проседают: hash function разбрасывает ключи случайно, prefetcher слепнет.

Pointer chasing в реальном Python

Где в Python мы платим за pointer chasing, хотя не пишем linked list руками?

  • list of objects — обход for obj in objects: process(obj.field) это chase через ссылку на obj в куче.
  • dict[key].field — двойной chase: указатель в dict-bucket, потом dereference value.
  • Атрибуты классаobj.attr это словарь __dict__ (или slot), снова pointer.
  • Recursive data structures — JSON-like dict, tree-структуры.

В DE это критично — pandas DataFrame внутри строит компактные колонки (numpy-arrays), чтобы избежать pointer chasing. df['col'].sum() быстрый — потому что значения в колонке лежат подряд. for row in df.iterrows(): row['col'] медленный — потому что каждая строка восстанавливается как dict-объект.

Замер: dict iteration vs array iteration

import timeit

N = 1_000_000

dct = {i: i for i in range(N)}
lst = list(range(N))
arr = list(dct.values())   # тоже list, но получен из dict

t_dict = min(timeit.repeat(
    """
total = 0
for v in dct.values():
    total += v
""",
    globals=globals(), number=3, repeat=3
)) / 3

t_list = min(timeit.repeat(
    """
total = 0
for v in lst:
    total += v
""",
    globals=globals(), number=3, repeat=3
)) / 3

print(f"dict.values() iter: {t_dict*1000:.0f} ms")
print(f"list iter:          {t_list*1000:.0f} ms")
print(f"slowdown:           {t_dict/t_list:.2f}x")

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

dict.values() iter:  55 ms
list iter:           45 ms
slowdown:            1.2x

Разница есть, но небольшая — потому что начиная с Python 3.6 dict хранит данные в compact form: внутренний массив values тоже относительно cache-friendly. На более старых Python разница была больше.

Попробуй сам: эффект размера блока

Сделайте свою «псевдо-linked list» с блоками разного размера и измерьте, как меняется производительность:

import timeit

def make_blocks(n, block_size):
    """Сделать pseudo-linked list of blocks"""
    blocks = []
    for i in range(0, n, block_size):
        blocks.append(list(range(i, min(i + block_size, n))))
    return blocks  # list of lists

def sum_blocks(blocks):
    s = 0
    for block in blocks:
        for x in block:
            s += x
    return s

N = 1_000_000
for block_size in [1, 4, 16, 64, 256, 1024]:
    blocks = make_blocks(N, block_size)
    t = min(timeit.repeat(lambda: sum_blocks(blocks), number=3, repeat=3)) / 3
    print(f"block_size={block_size:>5}: {t*1000:>5.0f} ms, blocks={len(blocks):>7}")
TIP

Угадайте перед запуском: при каком размере блока время выйдет на плато?

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

block_size=    1:  280 ms, blocks=1000000  -- классический pointer chase, кошмар
block_size=    4:  100 ms, blocks= 250000  -- уже сильно лучше
block_size=   16:   58 ms, blocks=  62500  -- хорошо
block_size=   64:   45 ms, blocks=  15625  -- одна cache line на блок -- оптимум
block_size=  256:   42 ms, blocks=   3906  -- едва быстрее
block_size= 1024:   42 ms, blocks=    977  -- плато

Видите, как размер блока 64 даёт почти идеальную производительность? Это не случайно — это размер cache line! deque в Python использует ~64 элемента на блок именно поэтому. Это прямое наблюдение того, как 64 байта определяют дизайн структур данных.

Главное правило для DE

В DE pointer chasing проявляется в самых неожиданных местах:

  • Spark UDF на pyspark — каждый row передаётся как Python dict, что pointer-chase кошмарный. Поэтому groupBy().agg() (нативный) на порядки быстрее map(custom_function).
  • JSON парсинг наивно — каждый объект — отдельный dict с pointers. orjson ускоряет в 5x именно через лучше layout.
  • Pandas iteration vs vectorizationfor i in range(len(df)) это chase, df['col'] * 2 — numpy vectorization.

Правило: если можно представить данные как плотный массив значений — представляйте. Если нет — используйте структуры с блоками (deque, B-tree, chunked array) вместо чистого linked list.

В следующем уроке — branch prediction: ещё один способ, которым CPU обманывает медленную RAM. Классический эксперимент с sorted vs unsorted массивом.

Tuple: неизменяемый contiguous layout malloc и free: как аллокатор размещает объекты в куче
Проверка знанийKnowledge check
В чём фундаментальная разница между обходом array (O(n)) и обходом linked list (тоже O(n)), хотя big-O одинаковый?
ОтветAnswer
Big-O одинаковый, но cache behavior радикально разный. Array — sequential memory access, prefetcher CPU видит арифметическую прогрессию адресов и начинает асинхронно подтягивать будущие cache lines пока работает с текущей. К моменту обращения к arr[i+8] cache line уже в L1 — чтение ~1ns. Linked list — pointer chasing: следующий адрес узла можно узнать только прочитав поле next текущего узла, prefetcher слеп, каждый узел может быть в любом месте RAM — потенциальный cache miss ~60ns. Разница в реальной скорости 5-50x на одинаковом big-O. Это иллюстрирует, почему "big-O недостаточно" и почему этот курс идёт до железа.

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

Результат: 0 из 0
Концептуальный
Вопрос 1 из 5. Что такое pointer chasing?

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

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

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

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