Press "Enter" to skip to content

深入探究pandas的写时复制模式—第二部分

解释Copy-on-Write如何优化性能

Joshua Brown在Unsplash上的照片

介绍

第一篇文章解释了Copy-on-Write机制的工作原理。它突出了工作流程中引入副本的一些区域。本文将重点介绍一些优化方法,确保这不会拖慢平均工作流程。

我们利用了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中执行了防御性复制。

当启用Copy-on-Write时,所有这些防御性复制都不再存在。这影响了许多方法。完整列表可以在这里找到。

此外,选择DataFrame的列子集现在总是返回视图,而不是之前的副本。

让我们看看当我们组合其中一些方法时,这在性能方面意味着什么:

import pandas as pdimport numpy as npN = 2_000_000int_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)

这将创建一个包含30列、3种不同数据类型和200万行的DataFrame。让我们在这个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列。我们创建了对从2n列的视图,但这会使整个数组保持存活状态。我们还添加了一个具有一列的新数组,用于第一列。这将使得比必要的更多的内存保持存活状态。

这个系统直接转换到具有不同dtype的DataFrames。所有未修改的块都原样返回,只有原地修改的块被分割。

作者提供的图片

现在我们将一个新值设置到列n+1的浮点块中,以创建对列n+2m的视图。新的块将只支持列n+1

df.iloc[0, n+1] = 100.5
作者提供的图片

可以原地操作的方法

我们所看到的索引操作通常不会创建新对象;它们会原地修改现有对象,包括该对象的数据。另一组pandas方法根本不会修改DataFrame的数据。一个显著的例子是rename。rename只改变标签。这些方法可以利用上面提到的延迟复制机制。

还有另一组方法实际上可以原地完成,例如replacefillna。这些方法总是触发复制。

df2 = df.replace(...)

在不触发复制的情况下原地修改数据会修改dfdf2,这违反了Copy-on-Write规则。这是我们考虑保留这些方法的inplace关键字的原因之一。

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

这将解决这个问题。这仍然是一个开放的提案,可能会朝着不同的方向发展。也就是说,这只涉及实际更改的列;所有其他列都以视图的形式返回。这意味着,如果您的值只在一个列中找到,那么只会复制一列。

结论

我们探讨了Copy-on-Write如何改变pandas的内部行为以及如何将其转化为代码改进的优势。许多方法在使用Copy-on-Write后会变得更快,而一些与索引相关的操作则会变慢。以前,这些操作总是原地进行的,可能会产生副作用。而使用Copy-on-Write后,一个DataFrame对象的修改将不会影响到其他对象。

本系列的下一篇文章将解释如何使您的代码与Copy-on-Write兼容。此外,我们还将解释未来应该避免使用的模式。

感谢阅读。欢迎与我们分享您的想法和反馈。

Leave a Reply

Your email address will not be published. Required fields are marked *