为什么 np.sum(range(N)) 非常慢?

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 毫秒。

  • `range` 不是生成器(也不是迭代器,这经常被混淆)。但它是一个惰性迭代,如果这就是你的意思的话。它也是一个序列,这是它与迭代器的主要区别。 (2认同)

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是遗留代码,将来可能会消失。我认为这是唯一generatornp.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)


Ale*_*lex 8

从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)

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