解释Copy-on-Write如何优化性能
介绍
第一篇文章解释了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
列。我们创建了对从2
到n
列的视图,但这会使整个数组保持存活状态。我们还添加了一个具有一列的新数组,用于第一列。这将使得比必要的更多的内存保持存活状态。
这个系统直接转换到具有不同dtype的DataFrames。所有未修改的块都原样返回,只有原地修改的块被分割。
现在我们将一个新值设置到列n+1
的浮点块中,以创建对列n+2
到m
的视图。新的块将只支持列n+1
。
df.iloc[0, n+1] = 100.5
可以原地操作的方法
我们所看到的索引操作通常不会创建新对象;它们会原地修改现有对象,包括该对象的数据。另一组pandas方法根本不会修改DataFrame的数据。一个显著的例子是rename
。rename只改变标签。这些方法可以利用上面提到的延迟复制机制。
还有另一组方法实际上可以原地完成,例如replace
或fillna
。这些方法总是触发复制。
df2 = df.replace(...)
在不触发复制的情况下原地修改数据会修改df
和df2
,这违反了Copy-on-Write规则。这是我们考虑保留这些方法的inplace
关键字的原因之一。
df.replace(..., inplace=True)
这将解决这个问题。这仍然是一个开放的提案,可能会朝着不同的方向发展。也就是说,这只涉及实际更改的列;所有其他列都以视图的形式返回。这意味着,如果您的值只在一个列中找到,那么只会复制一列。
结论
我们探讨了Copy-on-Write如何改变pandas的内部行为以及如何将其转化为代码改进的优势。许多方法在使用Copy-on-Write后会变得更快,而一些与索引相关的操作则会变慢。以前,这些操作总是原地进行的,可能会产生副作用。而使用Copy-on-Write后,一个DataFrame对象的修改将不会影响到其他对象。
本系列的下一篇文章将解释如何使您的代码与Copy-on-Write兼容。此外,我们还将解释未来应该避免使用的模式。
感谢阅读。欢迎与我们分享您的想法和反馈。