Загружаем научный разбор
Подготавливаем текст, источники и редакционные примечания без изменения разметки страницы.
Подготавливаем текст, источники и редакционные примечания без изменения разметки страницы.
Автор: Казачкин Даниил Михайлович · Обновлено
Почему срез может менять исходный массив и удерживать большой буфер: views, strides, advanced indexing и проверяемый эксперимент.
Исследователь выбирает несколько строк большой таблицы измерений, нормализует их и замечает, что исходные данные тоже изменились. В другой программе небольшой сохранённый срез неожиданно удерживает в памяти весь исходный массив. Оба эффекта могут быть следствием одного устройства: объект массива и буфер его данных — разные сущности.
В NumPy несколько массивов способны описывать разные способы чтения одного буфера. Это экономит копирование, но меняет модель владения данными. Для корректного кода важно знать не только значения, форму и тип элементов, но и то, разделяется ли память с другими объектами и кто имеет право её менять.
Статья Harris и соавторов, Nature 2020, описывает NumPy как основу программирования с массивами и научной экосистемы Python. Это работа об устройстве и роли инструмента, а не эксперимент, доказывающий одинаковое ускорение для любого Python-кода. Первичная публикация.
Для конкретной семантики срезов используется руководство NumPy о копиях и представлениях. Представление использует тот же буфер с другими метаданными, копия получает собственные данные. Базовая индексация срезом создаёт представление; advanced indexing обычно создаёт копию. Некоторые преобразования формы требуют копирования в зависимости от расположения элементов.
Создадим шесть целых чисел. Один результат получим обычным срезом, второй — выбором двух индексов, третий — явной копией. Затем изменим элементы и проверим последствия. Для запуска нужен установленный NumPy; большой набор данных и ускоритель не требуются.
import numpy as np
original = np.arange(6, dtype=np.int64)
view = original[1:4]
selected = original[[1, 3]]
independent = view.copy()
assert np.shares_memory(original, view)
assert not np.shares_memory(original, selected)
assert not np.shares_memory(original, independent)
view[0] = 99
selected[0] = -5
independent[1] = -7
assert original.tolist() == [0, 99, 2, 3, 4, 5]
every_second = original[::2]
assert every_second.strides[0] == 2 * original.itemsize
print(original.tolist())
print(every_second.tolist()) # [0, 2, 4]Первая запись в view изменила original, потому что оба объекта указывают на соответствующую область одного буфера. Изменения selected и independent туда не попали. Вторая проверка показывает шаг в памяти: чтобы перейти к следующему элементу every_second, нужно пропустить два исходных элемента. Этот шаг называется stride и измеряется в байтах.
Срез с большим шагом не обязан занимать новый плотный буфер. Поэтому малый размер логически видимых данных ещё не означает, что процесс удерживает мало памяти. Если небольшой view остаётся единственной живой ссылкой на огромный исходный буфер, буфер всё равно нужен. Явная небольшая копия иногда уменьшает длительное удержание памяти, хотя сама операция копирования имеет цену.
Представим обработку каждого второго столбца большой матрицы. Представление позволяет не выделять новый буфер сразу, но последующие операции могут работать с неудобным расположением данных. Если один и тот же набор вычислений повторяется много раз, единственная подготовительная копия в подходящее расположение иногда окупается. Это гипотеза для измерения, а не универсальное правило ускорения.
Сравнивать нужно полную операцию: подготовку данных, повторные проходы и освобождение памяти. Замер только одной строки создания среза покажет преимущество представления, но не стоимость следующего тяжёлого вычисления. Также фиксируйте dtype: переход с 64-битного числа на 32-битное меняет не только объём памяти, но потенциально и точность результата.
Отдельно учитывайте временные массивы. Выражение из нескольких арифметических операций может создавать промежуточные результаты, даже если начальный вход был представлением. Векторизация убирает часть работы интерпретатора, однако не означает отсутствия выделений памяти. Полезный вопрос при профилировании — какие буферы одновременно живы на пике, а не сколько переменных видно в исходном коде.
Если функция возвращает массив, определите её договорённость: результат независим или разделяет память с входом? Для внутреннего вычислительного конвейера представление может быть ожидаемым и полезным. Для публичной функции, чей вызывающий код свободно меняет результат, скрытое разделение памяти становится источником ошибок. Документируйте контракт и проверяйте именно последствия изменения.
Не следует заменять эту проверку предположением по имени метода или размеру массива. Сложные цепочки reshape, transpose и индексации меняют условия. На небольших входах используйте shares_memory и собственные проверки значений, а на реальной нагрузке — профиль памяти. Так можно отличить неправильное разделение буфера от удерживающей ссылки в другой части приложения.
Наш опыт показывает семантику нескольких операций с обычным числовым массивом. Он не измеряет быстродействие BLAS, GPU или конкретного алгоритма машинного обучения. Для массивов объектов поверх буфера существуют дополнительные Python-объекты, и простая оценка по itemsize не описывает всю память. Осознанный выбор между копией и представлением полезен в подготовке датасетов, численных расчётах и обработке изображений: корректность владения данными становится таким же явным требованием, как правильность самой формулы.
Самостоятельный русскоязычный разбор ЯдроКода. Описания первоисточников отделены от авторских учебных примеров и инженерных выводов. Материал не является переводом или перепечаткой.
Учебные данные, расчёты, таблицы и программные примеры созданы для этой публикации. Иллюстрации и программный код из первоисточников не воспроизводятся.