fbe*_*nce 30 python performance numpy
我看过一个关于 python 中循环速度的视频,其中解释说,这样做sum(range(N))比手动循环并将变量添加在一起要快得多range,因为前者由于使用了内置函数而在 C 中运行,而在后者中求和是在(慢)Python 中完成的。numpy我很好奇添加到混合物中会发生什么。正如我预期的那样np.sum(np.arange(N)),它是最快的,但sum(np.arange(N))比np.sum(range(N))执行简单的 for 循环还要慢。
为什么是这样?
这是我用来测试的脚本,一些关于我所知道的缓慢原因的评论(主要来自视频)以及我在我的机器上得到的结果(python 3.10.0,numpy 1.21.2):
更新的脚本:
import numpy as np
from timeit import timeit
N = 10_000_000
repetition = 10
def sum0(N = N):
s = 0
i = 0
while i < N: # condition is checked in python
s += i
i += 1 # both additions are done in python
return s
def sum1(N = N):
s = 0
for i in range(N): # increment in C
s += i # addition in python
return s
def sum2(N = N):
return sum(range(N)) # everything in C
def sum3(N = N):
return sum(list(range(N)))
def sum4(N = N):
return np.sum(range(N)) # very slow np.array conversion
def sum5(N = N):
# much faster np.array conversion
return np.sum(np.fromiter(range(N),dtype = int))
def sum5v2_(N = N):
# much faster np.array conversion
return np.sum(np.fromiter(range(N),dtype = np.int_))
def sum6(N = N):
# possibly slow conversion to Py_long from np.int
return sum(np.arange(N))
def sum7(N = N):
# list returns a list of np.int-s
return sum(list(np.arange(N)))
def sum7v2(N = N):
# tolist conversion to python int seems faster than the implicit conversion
# in sum(list()) (tolist returns a list of python int-s)
return sum(np.arange(N).tolist())
def sum8(N = N):
return np.sum(np.arange(N)) # everything in numpy (fortran libblas?)
def sum9(N = N):
return np.arange(N).sum() # remove dispatch overhead
def array_basic(N = N):
return np.array(range(N))
def array_dtype(N = N):
return np.array(range(N),dtype = np.int_)
def array_iter(N = N):
# np.sum's source code mentions to use fromiter to convert from generators
return np.fromiter(range(N),dtype = np.int_)
print(f"while loop: {timeit(sum0, number = repetition)}")
print(f"for loop: {timeit(sum1, number = repetition)}")
print(f"sum_range: {timeit(sum2, number = repetition)}")
print(f"sum_rangelist: {timeit(sum3, number = repetition)}")
print(f"npsum_range: {timeit(sum4, number = repetition)}")
print(f"npsum_iterrange: {timeit(sum5, number = repetition)}")
print(f"npsum_iterrangev2: {timeit(sum5, number = repetition)}")
print(f"sum_arange: {timeit(sum6, number = repetition)}")
print(f"sum_list_arange: {timeit(sum7, number = repetition)}")
print(f"sum_arange_tolist: {timeit(sum7v2, number = repetition)}")
print(f"npsum_arange: {timeit(sum8, number = repetition)}")
print(f"nparangenpsum: {timeit(sum9, number = repetition)}")
print(f"array_basic: {timeit(array_basic, number = repetition)}")
print(f"array_dtype: {timeit(array_dtype, number = repetition)}")
print(f"array_iter: {timeit(array_iter, number = repetition)}")
print(f"npsumarangeREP: {timeit(lambda : sum8(N/1000), number = 100000*repetition)}")
print(f"npsumarangeREP: {timeit(lambda : sum9(N/1000), number = 100000*repetition)}")
# Example output:
#
# while loop: 11.493371912998555
# for loop: 7.385945574002108
# sum_range: 2.4605720699983067
# sum_rangelist: 4.509678105998319
# npsum_range: 11.85120212900074
# npsum_iterrange: 4.464334709002287
# npsum_iterrangev2: 4.498494338993623
# sum_arange: 9.537815956995473
# sum_list_arange: 13.290120724996086
# sum_arange_tolist: 5.231948580003518
# npsum_arange: 0.241889145996538
# nparangenpsum: 0.21876695199898677
# array_basic: 11.736577274998126
# array_dtype: 8.71628468400013
# array_iter: 4.303306431000237
# npsumarangeREP: 21.240833958996518
# npsumarangeREP: 16.690092379001726
Run Code Online (Sandbox Code Playgroud)
Jér*_*ard 16
np.sum(range(N))速度慢主要是因为当前的 Numpy 实现没有使用有关生成器提供的值的确切类型/内容的足够信息range(N)。一般问题的核心本质上是由于 Python 的动态类型和大整数造成的,尽管 Numpy 可以优化这种特定情况。
首先,range(N)返回一个动态类型的 Python 对象,它是一种(特殊类型的)Python 生成器。该生成器提供的对象也是动态类型的。它实际上是一个纯 Python 整数。
问题是Numpy 是用静态类型语言 C 编写的,因此它无法有效地处理动态类型的纯 Python 对象。Numpy 的策略是尽可能将此类对象转换为 C 类型。这种情况下的一个大问题是,生成器提供的整数理论上可能很大:Numpy 不知道这些值是否会溢出np.int32甚至np.int64类型。因此,Numpy 首先检测要使用的良好类型,然后使用该类型计算结果。
这个翻译过程可能非常昂贵,并且这里似乎不需要,因为所有值都由range(10_000_000). 但是,range(5_000_000_000)返回与纯 Python 整数溢出 np.int32相同的对象类型,Numpy 需要自动检测这种情况以免返回错误结果。问题还在于输入类型可以被正确识别(np.int32在我的机器上),这并不意味着输出结果将是正确的,因为在计算总和的过程中可能会出现溢出。可悲的是,我的机器上就是这种情况。
Numpy 开发人员决定弃用这种用法,并放入np.fromiter应该使用的文档中。np.fromiter有一个dtype必需的参数让用户定义要使用的良好类型。
在实践中检查此行为的一种方法是简单地使用创建一个临时列表:
tmp = list(range(10_000_000))
# Numpy implicitly convert the list in a Numpy array but
# still automatically detect the input type to use
np.sum(tmp)
Run Code Online (Sandbox Code Playgroud)
更快的实现如下:
tmp = list(range(10_000_000))
# The array is explicitly converted using a well-defined type and
# thus there is no need to perform an automatic detection
# (note that the result is still wrong since it does not fit in a np.int32)
tmp2 = np.array(tmp, dtype=np.int32)
result = np.sum(tmp2)
Run Code Online (Sandbox Code Playgroud)
第一种情况在我的机器上花费了 476 毫秒,而第二种情况则花费了 289 毫秒。请注意,这只np.sum需要 4 毫秒。因此,很大一部分时间花费在将纯 Python 整数对象转换为内部 int32 类型(更具体地说是纯 Python 整数的管理)。list(range(10_000_000))也很昂贵,因为它需要 205 毫秒。这又是由于纯Python整数的开销(即分配、释放、引用计数、可变大小整数的增量、内存间接寻址和动态类型引起的条件)以及生成器的开销。
sum(np.arange(N))速度很慢,因为sum它是一个处理 Numpy 定义的对象的纯 Python 函数。CPython解释器需要调用Numpy函数来执行基本的加法。此外,Numpy 定义的整数对象仍然是 Python 对象,因此它们需要进行引用计数、分配、释放等。更不用说 Numpy 和 CPython 在函数中添加了许多检查,旨在最终将两个本机数字加在一起。Numba 等支持 Numpy 的即时编译器可以解决这个问题。事实上,Numba 在我的机器上需要 23 毫秒来计算总和np.arange(10_000_000)(代码仍然用 Python 编写),而 CPython 解释器需要 556 毫秒。
hpa*_*ulj 10
让我们看看我能否总结一下结果。
sum可以使用任何可迭代对象,反复询问下一个值并添加它。 range是一个生成器,它很乐意提供下一个值
# sum_range: 1.4830789409988938
Run Code Online (Sandbox Code Playgroud)
从范围内创建列表需要时间:
# sum_rangelist: 3.6745876889999636
Run Code Online (Sandbox Code Playgroud)
对预先生成的列表求和实际上比对范围求和更快:
%%timeit x = list(range(N))
...: sum(x)
Run Code Online (Sandbox Code Playgroud)
np.sum旨在对数组求和。它是一个包装器np.add.reduce。
np.sum有一个弃用警告np.sum(generator),建议使用fromiter或 Python sum:
# npsum_range: 16.216972655000063
Run Code Online (Sandbox Code Playgroud)
fromiter是从生成器生成数组的最佳方法。使用np.arrayonrange是遗留代码,将来可能会消失。我认为这是唯一generator会np.array接受的。
np.array是一个通用函数,可以处理多种情况,包括嵌套数组以及转换为各种数据类型。因此,它必须处理整个输入参数,推导形状和数据类型。
# npsum_fromiterrange:3.47655400199983
Run Code Online (Sandbox Code Playgroud)
numpy 数组上的迭代比列表慢,因为它必须“拆箱”每个元素。
# sum_arange: 16.656015603000924
Run Code Online (Sandbox Code Playgroud)
同样,从数组创建列表也很慢;相同类型的 python 级别迭代。
# sum_list_arange: 19.500842117000502
Run Code Online (Sandbox Code Playgroud)
arr.tolist()相对较快,在编译代码中创建一个纯Python列表。所以速度类似于从范围内列出列表。
# sum_arange_tolist: 4.004777374000696
Run Code Online (Sandbox Code Playgroud)
np.sum数组是纯粹的numpy并且相当快。 np.sum(x)哪里x=np.arange(N)更快(大约 4 倍)
# npsum_arange: 0.2332638230000157
Run Code Online (Sandbox Code Playgroud)
np.sumfrom range 或 list 主要由首先创建数组的成本决定:
# array_basic: 16.1631146109994
# array_dtype: 16.550737804000164
# array_iter: 3.9803170430004684
Run Code Online (Sandbox Code Playgroud)
从sum的 cpython 源代码来看,sum最初似乎尝试了一条假设所有输入都是同一类型的快速路径。如果失败,它只会迭代:
/* Fast addition by keeping temporary sums in C instead of new Python objects.
Assumes all inputs are the same type. If the assumption fails, default
to the more general routine.
*/
Run Code Online (Sandbox Code Playgroud)
我并不完全确定幕后发生了什么,但很可能是 C 类型到 Python 对象的重复创建/转换导致了这些速度下降。值得注意的是, 和sum都是range用 C 实现的。
接下来的一点并不是问题的真正答案,但我想知道我们是否可以加快sumpython 的速度range,因为它range是一个相当智能的对象。
为此,我使用了专门functools.singledispatch针对该类型的内置sumrange函数;然后实现一个小函数来计算算术级数的总和。
from functools import singledispatch
def sum_range(range_, /, start=0):
"""Overloaded `sum` for range, compute arithmetic sum"""
n = len(range_)
if not n:
return start
return int(start + (n * (range_[0] + range_[-1]) / 2))
sum = singledispatch(sum)
sum.register(range, sum_range)
def test():
"""
>>> sum(range(0, 100))
4950
>>> sum(range(0, 10, 2))
20
>>> sum(range(0, 9, 2))
20
>>> sum(range(0, -10, -1))
-45
>>> sum(range(-10, 10))
-10
>>> sum(range(-1, -100, -2))
-2500
>>> sum(range(0, 10, 100))
0
>>> sum(range(0, 0))
0
>>> sum(range(0, 100), 50)
5000
>>> sum(range(0, 0), 10)
10
"""
if __name__ == "__main__":
import doctest
doctest.testmod()
Run Code Online (Sandbox Code Playgroud)
我不确定这是否完整,但它肯定比循环更快。