2025, Oct 31 10:47
Как работает copy-on-write в Polars: DataFrame и столбцы
Разбираем copy-on-write в Polars на примере: что копируется, когда переиспользуются буферы, как ведут себя DataFrame и столбцы, как контролировать память.
Polars использует семантику copy-on-write: она привычна системным разработчикам, но может сбивать с толку, если вы рассчитываете на точечные изменения «на месте». Тонкость не в том, выглядят ли объекты независимыми, а в том, когда память действительно копируется, а когда буферы переиспользуются. Ниже — практический разбор, который показывает, как ведут себя DataFrame и их столбцы по мере ветвления и трансформаций.
Воспроизведение сценария
Пример начинается с простого DataFrame, затем создаются «представления» и изменяется один столбец. На каждом шаге используются новые имена переменных, чтобы было видно, когда появляется новый объект и какая память продолжает разделяться в рамках copy-on-write.
import polars as pl
base_df = pl.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]})
view_df = base_df  # Семантически это копия; под капотом ссылается на те же данные.
view_df = view_df.with_columns(
    pl.Series([7, 8, 9]).alias("b")
)  # Copy-on-write применяется только к столбцу b; a остаётся общим.
final_df = view_df  # Семантически копия; под капотом всё ещё использует буферы view_df.
final_df = final_df.with_row_index("rid")  # Добавляем временный индекс строк для условных правок.
final_df = final_df.with_columns(
    pl.when(pl.col("rid") == 0)
    .then(10)
    .otherwise(pl.col("b"))
    .alias("b")
)  # Создаёт новый Series для b, где первое значение изменено на 10.
final_df = final_df.with_columns(
    pl.when(pl.col("rid") == 1)
    .then(11)
    .otherwise(pl.col("b"))
    .alias("b")
)  # Создаёт ещё один новый Series для b, где второе значение равно 11.
final_df = final_df.drop("rid")  # Удаляем вспомогательный индексный столбец.
Что происходит на самом деле
Ключевая модель — Polars использует copy-on-write. Пока столбец не менялся, разные DataFrame, появившиеся через присваивание или преобразования, могут ссылаться на один и тот же участок памяти. В нашем примере столбец a не изменяется, поэтому base_df, view_df и final_df продолжают делить один и тот же «чанк» для a. В этом и суть оптимизации: никаких лишних копий для данных, к которым не прикасались.
Столбцы ведут себя как атомарные единицы. Любое изменение столбца порождает новый Series, а значит и новый чанк, и тот DataFrame, которому вы его присваиваете, становится новым объектом, ссылающимся уже на этот чанк. Отсюда следует, что with_columns всегда возвращает новый DataFrame: даже если меняется один элемент, модифицированный Series — это новая память. Иными словами, обновления не происходят «на месте».
Распространённое заблуждение
Нередко можно услышать: «сейчас существует только один столбец a». Если под этим подразумевается один общий чанк, которым делятся все эти DataFrame, то утверждение соответствует поведению Polars в данном примере. Для a действительно остаётся один общий чанк, и каждый DataFrame на него указывает. Важно различать: DataFrame семантически независимы, но до тех пор, пока их не мутируют, они могут переиспользовать одни и те же буферы.
Как рассуждать о copy-on-write в этом контексте
Во‑первых, обратите внимание: после создания исходного DataFrame вы ни разу не трогали a. Поэтому его память остаётся общей. Во‑вторых, каждый раз, когда вы присваиваете новый b — заменяете его свежим Series или формируете через условное выражение — для b создаётся новый чанк. Предыдущий b остаётся неизменным для любого DataFrame, который на него ссылается. Polars отслеживает владение на уровне чанков: если выражение затронет исходный буфер, чанк клонируется, иначе переиспользуется.
Проще всего ощутить это на практике с очень большими целочисленными или вещественными столбцами. На паре строк разницы по памяти почти не видно. А вот при порядках 100 млн строк 64‑битных значений заметно, копируются ли те самые ~800 МБ: всё зависит от того, клонируется столбец или разделяется. Чтобы посмотреть, сколько чанков сейчас у каждого столбца, используйте DataFrame.n_chunks — так легко проверить, что переиспользуется, а что было скопировано.
Уточнённое представление процесса
Итак, если подытожить жизненный цикл в примере, получается стройная ментальная модель: присваивание DataFrame создаёт новый дескриптор, который продолжает ссылаться на исходные буферы; изменение b порождает новый Series и новый DataFrame; последующие условные правки b снова создают новые Series; a остаётся общим, потому что его не трогали. Семантическая независимость — на уровне DataFrame, а физическое переиспользование памяти — на уровне чанков столбцов, пока мутация не потребует клонирования.
Почему это важно
Понимание этой модели позволяет рассуждать о производительности и расходе памяти без догадок. Становится ясно, почему одни операции быстры — данные не копируются, если столбец не затрагивать, — и почему выборочные обновления эффективны: клонируются только затронутые столбцы. Также проясняется, почему не стоит ожидать «наместных» изменений отдельных элементов: каждый шаг трансформации возвращает новый DataFrame и новые Series там, где это нужно, что согласуется с неизменяемостью и copy-on-write.
Практические советы
Относитесь к DataFrame и Series как к неизменяемым сущностям и мыслите чанками на уровне столбцов. Ожидайте, что with_columns возвращает новый DataFrame, а изменённые столбцы — новые Series. Если столбец не трогали во всех «копиях», он остаётся общим. Если сомневаетесь, посмотрите число чанков на столбец и проведите крупные эксперименты — так наглядно видно, когда память переиспользуется, а когда происходит клонирование.
Статья основана на вопросе на StackOverflow от user2961927 и ответе Aren.