对速度有洁癖?快来了解 Numpy 的 View 与 Copy
Numpy 之所以运算快,是有道理的,我们就在这节内容中尝试去理解 Numpy 底层的运行逻辑。其中,有一个非常重要的概念, 那就是 View 和 Copy,你会发现,有可能前几天要花 10 天处理完的数据,学完这个之后,一优化,只需要 1 小时就搞定了。
我在读博的时候,博一写的代码被博二的我嫌弃,其中的一个原因是不太清楚框架内部属性, 效率太慢了。研究 Numpy 的加速问题,也是因为我要常用 Numpy 做深度学习,强化学习的程序模拟, 一做就可能是 2 小时甚至是 1 天,而弄懂 Numpy 底层原理后,我的代码速度可以上升好几倍。
我之前在知乎上写过一篇文章关于 Numpy 加速的文章, 我认为其中有些环节很重要,也很有必要放在这个教程中。
如果你对 Numpy 运算速度有追求,我十分建议你了解接下来的内容。如果你是萌新, 目前阶段不用 Numpy 处理大数据(上百MB 的文件),那下面的内容你可以以后再作了解。
Numpy Array 和 Python List 内部结构差别¶
其实 Numpy 就是 C 的逻辑, 创建存储容器 Array
的时候是寻找内存上的一连串区域来存放, 而 Python 存放的时候则是不连续的区域, 这使得 Python 在索引这个容器里的数据时不是那么有效率。 Numpy 只需要再这块固定的连续区域前后走走就能不费吹灰之力拿到数据。下图是来自 Why Python is Slow: Looking Under the Hood, 他很好的解释了这一切.
用 Numpy 创建 Array 的时候,它使用到的是内存上的一段连续空间,而 Python List 是物理内存上的不同区域,只是它用索引将这些区域联系起来了。 正是这样的架构,使得 Numpy Array 在物理空间上可以高效排列。这就为后面理解 View 和 Copy 奠定了基础。
View 与 Copy¶
Copy 顾名思义, 会将 Array 中的数据 copy 出来存放在内存中另一个地方, 而 View 不 copy 数据, 而是给源数据加一个窗,从外面看窗户里的数据(下图)。 具体来说,view 不会新建数据,而只是在源数据上建立索引部分。 下图来自 Understanding SettingwithCopyWarning in pandas
上面说的是什么意思呢? 我们直接看代码.
简单说, a_view
的东西全部都是 a
的东西, 动 a_view
的任何地方, a
都会受到牵扯, 因为他们在内存中的位置是一模一样的, 本质上就是自己。 而 a_copy
则是将 a
copy 了一份, 然后把 a_copy
放在内存中的另外的地方, 这样改变 a_copy
, a
是不会被改变的.
那为什么要提这点呢? 因为 View 只是加了窗
,不会复制东西, 速度快! 我们来测试一下速度。 我会用下面这个功能来测试一下每个功能的运行时间,数字越小的,运行时间越快。
下面的例子中 a*=2
就是将这个 view 给赋值了, 和 a[:] *= 2
一个意思, 从头到尾没有创建新的东西。而 b = 2*b
中, 我们将 b
赋值给另外一个新建的 b
。
从运行结果来看,运行结果还是快了 10%~20% 的。
对于 view 还有一点要提, 你是不是偶尔有时候要把一个矩阵展平, 用到 np.flatten()
或者 np.ravel()
。 我们在改变数据结构 中也提到了这两个功能。 他俩虽然结果上相同,但是含义是完全不同的! 官方说如果用 ravel()
, 需要 copy 的时候才会被 copy , 我想这个时候可能是把 ravel 里面 order 转换的时候, 如 'C-type' -> 'Fortran', 而 flatten()
返回的总是一个 copy。现在你知道谁在拖你的后腿了吧!
运行了上面的测试后, 相比于 flatten()
, ravel()
简直是神速,提高了近 700 倍。
在选择数据上加速¶
选择数据的时候, 我们常会用到 view 或者 copy 的形式。现在你应该知道了, 如果能用到 view 的, 我们就尽量用 view, 避免 copy 数据。那在选择数据的时候,那些是选择了一个 view 呢? 下面举例的都是 view 的方式:
那哪些操作我们又会变成 copy 呢?
Numpy 给了我们很多很自由的方式选择数据, 这些虽然都很方便, 但是在你没必要 copy 的时候, 如果你可以避免引起 Copy 的这些操作, 你的速度可以飞起来.
在上面提到的 blog 里面, 他提到了, 如果你还是喜欢这种 fancy indexing 的形式, 我们也是可以对它加点速的。那个 blog 中指出了两种方法。
1. 使用 np.take()
, 替代用 index 选数据的方法。
上面提到了如果用 index 来选数据, 像 a_copy1 = a[[1,4,6], [2,4,6]]
, 用 take 在大部分情况中会比这样的 a_copy1
要快。
上面的数据,用 take()
有个十几倍的加速还是挺不错的。
2. 使用 np.compress()
, 替代用 mask 选数据的方法。
上面的 a_copy2 = a[[True, True], [False, True]]
这种就是用 True, False 来选择数据的。我们对比一下它和 compress()
的效率, 测试如下:
compress 方法在这种情况下,也有一个 9 倍左右的提升。已经相当不错了。
非常有用的 out 参数¶
不深入了解 numpy 的朋友, 应该会直接忽略很多功能中的这个 out 参数 (之前我从来没用过)。 不过当我深入了解了以后, 发现他非常有用! 比如下面两个其实在功能上是没差的, 不过运算时间上有差, 我觉得可能是 a=a+1
要先转换成 np.add()
这种形式再运算, 所以前者要用更久一点的时间.
如果是上面那样, 我们就会触发之前提到的 copy 原则, 这两个被赋值的 a
, 都是原来 a
的一个 copy, 并不是 a
的 view。但是在功能里面有一个 out
参数, 让我们不必要重新创建一个 a
。
我们看看使用 out
之前,它们的处理方式算不算 copy。
我上面的例子,你应该可以很明显的看出来,如果我们新建了一个 c
,用 out=c
来指定将加法结果输出到 c
, 那么我们就可以利用这种特性来减少 copy 的产生。比如下面这个例子。
从上面的例子可以看出来,如果要完完全全对比将计算结果赋值回源数据的案例,我们明显发现,使用 out
要比不使用 out
快一倍。 但是有意思的是,如果我不赋值回源数据,而是创建一个新数据 _c
,这种方法也只是比使用 out
的方法慢了一点点,但是比 a[:]
这种方法快上不少。 我想可能是 numpy 对于创造新数据的做法做了深度优化吧。
Numpy 的官方文档也对能用 out 的功能做了列举: Universal functions。
总结¶
在这里说了好几种 Numpy 加速的办法,万变不离其中,只要你明白了 view 和 copy 的概念,对于你自己的代码,优化空间总是有的。