Объяснение того, как копирование при записи оптимизирует производительность

Введение

В первом посте объяснялось, как работает механизм копирования при записи. В нем выделены некоторые области, где копии вводятся в рабочий процесс. В этом посте основное внимание будет уделено оптимизации, которая гарантирует, что это не замедлит средний рабочий процесс.

Мы используем технику, которую используют внутренние компоненты pandas, чтобы избежать копирования всего DataFrame, когда в этом нет необходимости, и, таким образом, повысить производительность.

Я являюсь частью основной команды pandas и до сих пор принимал активное участие во внедрении и улучшении CoW. Я работаю инженером по открытому коду в компании Coiled, где работаю над Dask, включая улучшение интеграции с pandas и обеспечение совместимости Dask с CoW.

Удаление защитных копий

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

df = pd.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]})
df2 = df.reset_index()
df2.iloc[0, 0] = 100

Нет необходимости копировать данные в reset_index, но возврат представления может привести к побочным эффектам при изменении результата, например. df также будет обновлен. Следовательно, защитная копия выполняется в reset_index.

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

Кроме того, выбор столбцового подмножества DataFrame теперь всегда будет возвращать представление, а не копию, как раньше.

Давайте посмотрим, что это означает с точки зрения производительности, когда мы объединяем некоторые из этих методов:

import pandas as pd
import numpy as np

N = 2_000_000
int_df = pd.DataFrame(
    np.random.randint(1, 100, (N, 10)), 
    columns=[f"col_{i}" for i in range(10)],
)
float_df = pd.DataFrame(
    np.random.random((N, 10)), 
    columns=[f"col_{i}" for i in range(10, 20)],
)
str_df = pd.DataFrame(
    "a", 
    index=range(N), 
    columns=[f"col_{i}" for i in range(20, 30)],
)

df = pd.concat([int_df, float_df, str_df], axis=1)

Это создает DataFrame с 30 столбцами, 3 различными типами данных и 2 миллионами строк. Давайте выполним следующую цепочку методов для этого DataFrame:

%%timeit
(
    df.rename(columns={"col_1": "new_index"})
    .assign(sum_val=df["col_1"] + df["col_2"])
    .drop(columns=["col_10", "col_20"])
    .astype({"col_5": "int32"})
    .reset_index()
    .set_index("new_index")
)

Все эти методы выполняют защитное копирование без включения CoW.

Производительность без CoW:

2.45 s ± 293 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

Производительность с включенным CoW:

13.7 ms ± 286 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)

Улучшение примерно в 200 раз. Я выбрал этот пример специально, чтобы проиллюстрировать потенциальные преимущества CoW. Не каждый метод будет работать намного быстрее.

Оптимизация копий, вызванных изменениями на месте

В предыдущем разделе было показано множество методов, при которых защитная копия больше не требуется. CoW гарантирует, что вы не сможете изменить два объекта одновременно. Это означает, что нам нужно создать копию, когда на одни и те же данные ссылаются два DataFrame. Давайте рассмотрим методы, позволяющие сделать эти копии максимально эффективными.

В предыдущем посте было показано, что копирование может произойти по следующим причинам:

df.iloc[0, 0] = 100

Копирование запускается, если на данные, поддерживающие df, ссылается другой DataFrame. Мы предполагаем, что наш DataFrame имеет n целочисленных столбцов, например. поддерживается одним блоком.

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

Это позволит настроить новый объект отслеживания ссылок и создать новый блок, поддерживаемый новым массивом NumPy. Этот блок больше не имеет ссылок, поэтому другая операция сможет снова изменить его на месте. При таком подходе копируется n-1 столбцов, которые нам не обязательно копировать. Чтобы избежать этого, мы используем технику, которую называем разделением блоков.

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

У этой техники есть один недостаток. Исходный массив имеет n столбцов. Мы создали представление для столбцов с 2 по n, но это сохраняет работоспособность всего массива. Мы также добавили новый массив с одним столбцом для первого столбца. Это сохранит немного больше памяти, чем необходимо.

Эта система напрямую преобразуется в DataFrames с различными типами данных. Все блоки, которые вообще не были изменены, возвращаются как есть, и разделяются только те блоки, которые были изменены на месте.

Теперь мы устанавливаем новое значение в столбец n+1 блока с плавающей запятой, чтобы создать представление для столбцов с n+2 по m. Новый блок будет поддерживать только столбец n+1.

df.iloc[0, n+1] = 100.5

Методы, которые могут работать на месте

Операции индексирования, которые мы рассмотрели, обычно не создают новый объект; они изменяют существующий объект на месте, включая данные указанного объекта. Другая группа методов pandas вообще не затрагивает данные DataFrame. Ярким примером является rename. Переименование меняет только метки. Эти методы могут использовать механизм отложенного копирования, упомянутый выше.

Существует еще одна третья группа методов, которые можно реализовать на месте, например replace или fillna. Они всегда будут запускать копирование.

df2 = df.replace(...)

Изменение данных на месте без запуска копирования приведет к изменению df и df2, что нарушает правила CoW. Это одна из причин, по которой мы рассматриваем возможность сохранения ключевого слова inplace для этих методов.

df.replace(..., inplace=True)

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

Заключение

Мы исследуем, как CoW меняет внутреннее поведение панд и как это повлияет на улучшения вашего кода. Многие методы станут быстрее с CoW, в то время как мы увидим замедление в нескольких операциях, связанных с индексированием. Раньше эти операции всегда выполнялись на месте, что могло иметь побочные эффекты. Эти побочные эффекты исчезли с CoW, и изменение одного объекта DataFrame никогда не повлияет на другой.

В следующем посте этой серии будет описано, как обновить свой код, чтобы он соответствовал CoW. Также мы объясним, каких шаблонов следует избегать в будущем.

Спасибо за чтение. Не стесняйтесь поделиться своими мыслями и отзывами о копировании при записи.