Память и копии: когда вычисление становится дорогим

Урок про невидимую сторону вычислений: сколько памяти расходуют выражения NumPy и как избегать лишних копий.

Временный массив — промежуточный буфер, который NumPy создаёт для хранения результата подвыражения; в длинных формулах таких буферов может быть много.

Каждое подвыражение создаёт массив

Когда вы пишете result = a * 2 + b * 3, NumPy вычисляет это по шагам, и почти каждый шаг порождает новый массив в памяти. Сначала a * 2 (временный буфер), затем b * 3 (ещё один), затем их сумма (итоговый). Для маленьких массивов это незаметно, но для гигабайтных данных лишние временные копии — это и память, и время на их выделение и заполнение.

Оценим «на пальцах», сколько памяти живёт одновременно. Если a и b — массивы по 8 МБ (миллион float64), то выражение a*2 + b*3 в пике держит входы плюс несколько временных буферов того же размера:

def mb(n_elements, itemsize=8):
    return n_elements * itemsize / 1_000_000

n = 1_000_000
# a, b — входные; a*2, b*3 — временные; результат — ещё один
buffers = ["a (вход)", "b (вход)", "a*2 (врем.)", "b*3 (врем.)", "результат"]
for name in buffers:
    print("%-14s ~ %.1f МБ" % (name, mb(n)))
print("Пик памяти ~ %.1f МБ" % (mb(n) * len(buffers)))

Вывод:

a (вход)       ~ 8.0 МБ
b (вход)       ~ 8.0 МБ
a*2 (врем.)    ~ 8.0 МБ
b*3 (врем.)    ~ 8.0 МБ
результат      ~ 8.0 МБ
Пик памяти ~ 40.0 МБ

Пять буферов вместо двух исходных. На реальных данных это превращается в десятки гигабайт и может не влезть в память. Понимание, что выражения порождают временные массивы, — первый шаг к экономии.

Операции на месте экономят буферы

Как мы видели в разделе про модификацию, операции на месте (+=, *=) и параметр out= у ufunc позволяют переиспользовать существующий буфер вместо создания нового. Перепишем выражение так, чтобы минимизировать временные массивы.

import numpy as np
a = np.ones(1_000_000)
b = np.ones(1_000_000)

# Расточительно: создаёт несколько временных массивов
result = a * 2 + b * 3

# Экономно: пишем в готовый буфер, без лишних копий
out = np.empty_like(a)
np.multiply(a, 2, out=out)        # out = a*2
np.add(out, b * 3, out=out)       # out += b*3 (один временный b*3)
print(np.array_equal(result, out))

Вывод:

True

Конечно, такая ручная оптимизация уместна только в горячих местах, где память реально критична. Для обычного кода читаемое a*2 + b*3 предпочтительнее. Но знать механизм важно.

Память — частый невидимый ограничитель

Начинающие оптимизируют скорость, но на больших данных первым в стену упирается не время, а память. Программа, которая логически верна и достаточно быстра на тестовых данных, на реальных может упасть с MemoryError или начать «свопиться» на диск, замедляясь в сотни раз. Причина почти всегда — незамеченные копии и временные массивы. Поэтому привычка мысленно оценивать память не менее важна, чем оценивать скорость. Простое правило прикидки: умножьте число элементов на размер dtype (8 байт для float64) — это размер одного массива; затем посчитайте, сколько таких массивов живёт одновременно в пиковый момент (входы плюс временные плюс результат). Если сумма приближается к доступной оперативной памяти, пора оптимизировать: уменьшать dtype, переписывать выражения на операции «на месте», обрабатывать данные частями. Особенно коварны промежуточные результаты в длинных цепочках вычислений — они невидимы в коде, но реальны в памяти. Осознанная работа с памятью отличает код, который масштабируется на реальные объёмы, от кода, который работает только на игрушечных примерах.

Дорогие копии: что копирует, а что нет

Повторим ключевое из раздела про views, но с акцентом на цену. Операции, дающие view, бесплатны по памяти: срезы, reshape (обычно), транспонирование, ravel. Операции, дающие copy, выделяют новый блок: flatten, .copy(), продвинутая индексация (маски, fancy), astype, конкатенации.

Бесплатно (view)Копирует (память)
срез a[1:5]a[mask], a[[0,2]]
a.T, a.reshape(...)a.flatten(), a.copy()
a.ravel() (если можно)a.astype(...)
a[np.newaxis]np.concatenate([...])

Практический вывод: на больших данных предпочитайте view-операции, а копирующие применяйте осознанно. Например, ненужный astype или лишний .copy() в горячем цикле может удвоить расход памяти.

astype всегда копирует

Распространённое заблуждение — что astype «иногда» возвращает view. Нет: astype всегда создаёт новый массив (по умолчанию). Это логично — изменение dtype меняет itemsize, и старый блок памяти не подходит. Поэтому без нужды не приводите типы, особенно в циклах.

import numpy as np
a = np.arange(5, dtype=np.int64)
b = a.astype(np.float64)

print(np.shares_memory(a, b))   # False — это копия
b[0] = 99
print(a[0])                     # 0 — оригинал не тронут

Вывод:

False
0

Views как инструмент экономии памяти

Обратная сторона разговора о копиях — осознанное использование views для экономии. Раз срезы, reshape и транспонирование не копируют данные, их можно применять свободно для работы с подобластями больших массивов «на месте». Нужно обработать каждый второй элемент гигантского массива — a[::2] даёт представление без затрат памяти. Нужно поработать с углом матрицы — срез выделит окно в те же данные. Нужно интерпретировать буфер иначе — reshape переразметит его бесплатно. Грамотный код на NumPy активно пользуется этим: он избегает создания копий там, где достаточно представления, и создаёт копию только тогда, когда действительно нужны независимые данные. Это требует держать в голове, какие операции дают view, а какие copy (мы свели это в таблицу), но окупается возможностью работать с массивами, близкими по размеру к доступной памяти. По сути, views — это и есть механизм, позволяющий NumPy обрабатывать большие данные эффективно: вместо того чтобы плодить копии при каждом обращении к подмассиву, вы работаете с лёгкими представлениями поверх одного буфера. Понимание этого баланса — view там, где можно, copy там, где нужно — ключ к памяти-эффективному коду.

Предвыделение буфера вместо роста

Самый частый источник тихих потерь — наращивание массива по элементу через np.append в цикле. Каждый вызов создаёт новый массив и копирует всё. Сложность — квадратичная. Правильно: либо накапливать в списке Python и один раз превратить в массив, либо заранее выделить буфер нужного размера и заполнять по индексам.

# Имитация стоимости: сколько элементов копируется при росте через append
def append_copies(n):
    copied = 0
    size = 0
    for i in range(n):
        copied += size   # каждый append копирует текущее содержимое
        size += 1
    return copied

# Предвыделение: копирований нет
print("append в цикле, n=1000:", append_copies(1000), "копирований")
print("предвыделение, n=1000:", 0, "копирований")

Вывод:

append в цикле, n=1000: 499500 копирований
предвыделение, n=1000: 0 копирований

Полмиллиона лишних копирований при построении массива из тысячи элементов — наглядная цена неправильного подхода. Предвыделение через np.empty(n) или накопление в списке устраняет её полностью.

Обработка данных частями (chunking)

Когда данных слишком много для одновременной обработки, помогает классический приём — разбить их на порции (chunks) и обрабатывать по очереди, накапливая результат. Вместо того чтобы создать гигантский временный массив на все данные сразу, вы берёте кусок, считаете для него промежуточный результат, освобождаете кусок и переходите к следующему. Например, чтобы посчитать сумму огромного файла чисел, не загружая его целиком, читают и суммируют его блоками, накапливая итог в одной переменной. Этот подход меняет требование к памяти с «весь массив сразу» на «один блок плюс накопитель», что часто отличает выполнимое от невыполнимого. Многие операции (суммы, средние, минимумы, гистограммы) естественно разбиваются на блоки, потому что их можно вычислять инкрементально. Сложнее с операциями, требующими всех данных одновременно (например, сортировка или медиана), — для них существуют специальные внешние алгоритмы. Но для большинства агрегаций и поэлементных преобразований обработка частями — простой и мощный способ справиться с данными, превышающими память, не прибегая к более сложным инструментам.

Идея memmap для данных больше памяти

Когда массив не помещается в RAM целиком, NumPy предлагает np.memmap — массив, физически лежащий в файле на диске, но доступный как обычный ndarray. Чтение и запись идут «окнами», и ОС подгружает только нужные части. Это позволяет работать с терабайтными массивами, не загружая их в память. Подробности — за рамками урока, но важно знать, что такой инструмент существует для больших данных.

Подводные камни

  • Длинные выражения и пик памяти. Каждое подвыражение — временный массив; на больших данных пик может превысить RAM.
  • Лишний astype/copy в цикле. Удваивает память и время; приводите типы один раз.
  • append в цикле. Квадратичная сложность из-за постоянного копирования. Предвыделяйте или копите в списке.
  • Думать, что view бесплатен по записи. View экономит память, но запись в него меняет оригинал — это другая цена.

Профилирование: измеряйте, а не угадывайте

Последний принцип работы с производительностью — самый важный и часто игнорируемый: не оптимизируйте вслепую, измеряйте. Интуиция о том, где код тратит время и память, регулярно ошибается; «очевидное» узкое место часто оказывается дешёвым, а тормозит совсем другое. Прежде чем переписывать код ради скорости, замерьте, какая часть действительно медленная, — иначе вы потратите силы на оптимизацию того, что и так быстро. То же с памятью: прежде чем городить сложные схемы экономии, убедитесь, что память реально является проблемой на ваших объёмах. Преждевременная оптимизация делает код сложнее и багоопаснее ради выигрыша, которого может и не быть. Правильный порядок такой: сначала напишите ясный корректный код векторно (это обычно уже достаточно быстро и экономно), затем, если есть реальная проблема производительности, измерьте, где именно она, и оптимизируйте точечно это место — операциями на месте, подходящим dtype, устранением лишних копий. Чек-лист ниже — отправная точка для такого точечного анализа, а не повод переписывать всё подряд.

Лучшие практики

  • На больших данных предпочитайте view-операции (срезы, reshape) копирующим.
  • В горячих местах используйте out= и операции на месте, чтобы переиспользовать буферы.
  • Не приводите типы и не копируйте без необходимости, особенно в циклах.
  • Предвыделяйте массивы нужного размера вместо роста через append.

Итог

  • Каждое подвыражение создаёт временный массив; длинные формулы поднимают пик памяти.
  • Операции на месте и out= переиспользуют буферы и экономят память.
  • View-операции бесплатны по памяти, копирующие (flatten, astype, fancy-индексация, concatenate) — нет.
  • Предвыделение буфера устраняет квадратичные копирования при росте массива.
Проверьте себя
1. Почему длинное выражение вроде a*2 + b*3 может неожиданно потребовать много памяти на больших массивах?
AПотому что NumPy дублирует результат для надёжности
BПотому что каждое подвыражение (a*2, b*3, их сумма) создаёт отдельный временный массив, и в пике их живёт несколько
CПотому что NumPy хранит историю всех операций
DПотому что умножение всегда копирует данные дважды
2. Возвращает ли astype view, разделяющий память с исходным массивом?
AДа, если новый тип совпадает по размеру
BНет, astype по умолчанию всегда создаёт новую копию — изменение dtype меняет itemsize
CДа, astype всегда возвращает view
DТолько для целочисленных типов
3. Почему построение большого массива через np.append в цикле — это квадратичная по стоимости операция?
AПотому что append сортирует массив на каждом шаге
BПотому что каждый вызов создаёт новый массив и копирует всё текущее содержимое, и суммарно копируется порядка n²/2 элементов
CПотому что append меняет dtype на каждом шаге
DПотому что append блокирует память
Поддержать проект