为什么 `np.sum(range(N))` 很慢?

30

我看了一个有关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


4
numpy 是否专门为 numpy 进行了优化,而不是与内置的 Python 函数一起使用,就像它的设计一样?例如,在 sum(np.arange(N)) 的情况下,numpy 范围必须首先转换为 Python 数据结构,然后进行求和,类似地,对于 np.sum,也许需要将 range 转换为 numpy 理解的类型,但我不确定。 - Matiiss
4
您可以在此处查看cpython sum实现(https://github.com/python/cpython/blob/79bc5e1dc6f87149240bded3654574b24168f1ac/Python/bltinmodule.c#L2408-L2597),并且numpy函数在此处(https://github.com/numpy/numpy/blob/b235f9e701e14ed6f6f6dcba885f7986a833743f/numpy/core/fromnumeric.py#L2123-L2260)(尽管这是一个包装器函数)。您可以在godbolt上查看所有函数的“dis”输出(https://godbolt.org/z/h5G4Ezx68)。我无法确定具体原因,可能是因为cpython(`sum`和`range`)完全在C中运行。 - Alex
3
根据你的评论和np.sum的源代码中的一条评论,我添加了几个其他的测试。我猜在range上调用np.sum隐含地涉及到转换为np.array,这似乎是非常低效的转换,除非明确告诉numpy正在使用生成器。观察转换时间(最后三行)以及使用fromiter如何改变运行时,这可以解释为什么np.sum(range(N))很慢。现在我唯一不明白的是为什么sum(np.arange(N))这么慢。 - fbence
2
我认为sum(np.arange(N))会很慢,因为你正在创建一个numpy整数数组,而sum将把它从numpy表示转换为Py_Long - Alex
1
@hpaulj,确实是4,参见sum7v2和注释。 - fbence
显示剩余2条评论
3个回答

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.fromiternp.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)

一个更快的实现如下所示:
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)

第一种情况在我的机器上需要476毫秒,而第二种情况需要289毫秒。请注意np.sum仅需要4毫秒。因此,大部分时间都花在将纯Python整数对象转换为内部int32类型(更具体地说是纯Python整数管理)上。 list(range(10_000_000))也很昂贵,因为它需要205毫秒。这又是由于纯Python整数的开销(即分配,释放,引用计数,增加可变大小整数,内存间接以及由于动态类型而导致的条件),以及生成器的开销sum(np.arange(N))很慢,因为sum是一个在Numpy定义的对象上工作的纯Python函数。此外,Numpy定义的整数对象仍然是Python对象,因此它们受到引用计数,分配,释放等影响。更不用说Numpy和CPython在旨在最终只将两个本机数字相加的函数中添加了许多检查。 像Numba这样的Numpy感知即时编译器可以解决这个问题。实际上,Numba在我的机器上仅需要23毫秒即可计算np.arange(10_000_000)的总和(代码仍写成Python),而CPython解释器需要556毫秒。

2
range 不是生成器(也不是迭代器,这常常会引起混淆)。但如果你的意思是它是一种惰性可迭代对象,那么没错。它也是一个序列,这是它与迭代器最主要的区别。 - wjandrea

10

让我们看看我能否总结一下结果。

sum可以与任何可迭代对象一起使用,重复请求下一个值并将其相加。range是一个生成器,它很乐意提供下一个值。

# sum_range:          1.4830789409988938

从范围中创建列表需要时间:

# sum_rangelist:      3.6745876889999636

使用预生成的列表进行求和比使用范围进行求和更快:

%%timeit x = list(range(N))
    ...: sum(x)

np.sum旨在对数组进行求和,它是np.add.reduce的包装器。

np.sum(generator)已被弃用,建议使用fromiter或Python的sum函数。

# npsum_range:        16.216972655000063
fromiter是从生成器创建数组的最佳方法。 在range上使用np.array 是遗留代码,并且可能在未来被弃用。我认为这是唯一一个generator,可以被np.array接受。 np.array是一个通用函数,可以处理许多情况,包括嵌套数组和转换为不同的数据类型。因此,它必须处理整个输入参数,推断形状和dtype。
# npsum_fromiterrange:3.47655400199983

使用NumPy数组进行迭代比使用列表更慢,因为它需要“拆箱”每个元素。

# sum_arange:         16.656015603000924

同样,从数组中制作列表也很慢;需要进行类似于Python级别迭代的操作。

# sum_list_arange:    19.500842117000502

arr.tolist() 相对较快,它在编译代码中创建一个纯 Python 列表。因此速度类似于从范围创建列表。

# sum_arange_tolist:  4.004777374000696

numpy中的np.sum函数是一个纯粹的函数,非常快速。当数组为x=np.arange(N)时,使用np.sum(x)会更快(约快4倍)。

# npsum_arange:       0.2332638230000157
< p > 从范围或列表中的< code >np.sum< /code >受创建数组的成本支配:< /p>
# array_basic:        16.1631146109994
# array_dtype:        16.550737804000164
# array_iter:         3.9803170430004684

8

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.
*/

我不完全确定发生了什么,但很可能是重复创建/转换C类型到Python对象导致这些减速。值得注意的是,sumrange都是用C实现的。
这部分并不是问题的答案,但我想知道是否可以加速Python中rangesum函数,因为range是一个相当智能的对象
为此,我使用了functools.singledispatch覆盖了内置的sum函数,专门针对range类型;然后实现了一个小函数来计算等差数列的和
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()

我不确定这是否完整,但它肯定比循环快。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接