繁体   English   中英

为什么使用单元素视图更快地重复 numpy 数组访问?

[英]Why is repeated numpy array access faster using a single-element view?

我在另一个 SO 线程中看到,可以使用arr[index:index+1]创建数组arr的单元素视图。 这对我很有用,因为我需要重复设置一个(可能很大~100k 个条目)数组的几个值。 但在我刚刚使用这种方法之前,我想确保创建视图的额外工作不会花费太多时间。 令人惊讶的是,我发现如果您至少访问一个索引约 10 次,那么使用该视图已经更好了。 使用索引或单元素视图重复访问 numpy 数组所花费的时间比较

此 plot 的数据是通过对两种方法进行定时创建的(在 python 3.10 中):

#!/bin/python3
# https://gist.github.com/SimonLammer/7f27fd641938b4a8854b55a3851921db

from datetime import datetime, timedelta
import numpy as np
import timeit

np.set_printoptions(linewidth=np.inf, formatter={'float': lambda x: format(x, '1.5E')})

def indexed(arr, indices, num_indices, accesses):
    s = 0
    for index in indices[:num_indices]:
        for _ in range(accesses):
            s += arr[index]

def viewed(arr, indices, num_indices, accesses):
    s = 0
    for index in indices[:num_indices]:
        v = arr[index:index+1]
        for _ in range(accesses):
            s += v[0]
    return s

N = 11_000 # Setting this higher doesn't seem to have significant effect
arr = np.random.randint(0, N, N)
indices = np.random.randint(0, N, N)

options = [1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377, 610, 987, 1597, 2584, 4181, 6765, 10946]
for num_indices in options:
    for accesses in options:
        print(f"{num_indices=}, {accesses=}")
        for func in ['indexed', 'viewed']:
            t = np.zeros(5)
            end = datetime.now() + timedelta(seconds=2.5)
            i = 0
            while i < 5 or datetime.now() < end:
                t += timeit.repeat(f'{func}(arr, indices, num_indices, accesses)', number=1, globals=globals())
                i += 1
            t /= i
            print(f"  {func.rjust(7)}:", t, f"({i} runs)")

这些观察对我来说非常违反直觉。 为什么viewed速度快于indexed (每个索引超过 10 次访问)?


编辑 1


编辑 2

我可以复制 Jérôme Richard 的发现。 罪魁祸首是索引数据类型(python int vs. numpy int):

>>> import timeit
>>> timeit.timeit('arr[i]', setup='import numpy as np; arr = np.random.randint(0, 1000, 1000); i = np.random.randint(0, len(arr), 1)[0]', number=20000000)
1.618339812999693
>>> timeit.timeit('arr[i]', setup='import numpy as np; arr = np.random.randint(0, 1000, 1000); i = np.random.randint(0, len(arr), 1)[0]; i = int(i)', number=20000000)
1.2747555710002416

由于num_indices对观察到的性能没有显着影响,我们可以通过丢弃该参数(即设置为 1)来简化问题。 由于只有大accesses很重要,我们还可以通过仅考虑像 10946 这样的大值来简化问题。 在不影响基准的情况下,也可以简化index的使用。 同样的事情也适用于return语句。 简化的问题现在是我们得到这个的原因(在 CPython 3.10.5 上重现):

import numpy as np

def indexed(arr, index):
    s = 0
    for _ in range(10946): s += arr[index]

def viewed(arr, index):
    s = 0
    v = arr[index:index+1]
    for _ in range(10946): s += v[0]

N = 11_000
arr = np.random.randint(0, N, N)
indices = np.random.randint(0, N, N)

# mean ± std. dev. of 7 runs, 1000 loops each
%timeit indexed(arr, indices[0])       # 1.24 ms ± 22.3 µs per loop
%timeit viewed(arr, indices[0])        # 0.99 ms ± 4.34 µs per loop

现在,放缓的根源非常有限。 它只与arr[index]v[0]有关。 同样重要的是要注意arrv基本上属于同一类型,而index0不属于同一类型 实际上, index if 类型为np.int640是 PyLong object。 问题是Numpy 项目类型比内置项目类型慢得多 简而言之,在这两种情况下,CPython 函数都由 Numpy 调用,但是PyLong对象的计算速度比基于PyNumber的通用数据类型(由 CPython 本身)更快。 Numpy 检查也是有序的,因此第一种类型也可以更快。 有关详细信息,请参阅“* 更深入的分析与讨论*”部分。

要解决此问题,您只需将 Numpy 类型转换为内置类型

import numpy as np

def indexed(arr, index):
    s = 0
    nativeIndex = int(index)  # <------------------------------
    for _ in range(10946): s += arr[nativeIndex]

def viewed(arr, index):
    s = 0
    v = arr[index:index+1]
    for _ in range(10946): s += v[0]

N = 11_000
arr = np.random.randint(0, N, N)
indices = np.random.randint(0, N, N)

# mean ± std. dev. of 7 runs, 1000 loops each
%timeit indexed(arr, indices[0])       # 981 µs ± 4.6 µs per loop
%timeit viewed(arr, indices[0])        # 989 µs ± 5.3 µs per loop
# The difference is smaller than the sum of the standard deviations 
# so the gap is clearly not statistically significant anymore.

此外,请注意,这两个函数的几乎所有时间都是纯开销 Numpy 不是为进行标量访问而设计的,而是针对矢量化访问进行了优化。 v[0]这样的幼稚表达式会导致处理器完成大量工作:需要解释表达式,需要分配一个新的引用计数 object,需要调用 Numpy 的几个(内部)函数许多开销(包装、动态类型检查、内部迭代器配置)。 低级分析器报告了数十个 function 调用被调用,并且在现代 x86 处理器上应该占用不超过 1 个吞吐量周期的东西浪费了 250-300 个周期。


更深入的分析与讨论

当计算表达式arr[nativeIndex] (或v[0] )时,CPython 解释器最终会调用 Numpy 函数,尤其是通用array_subscript 这个 function 调用prepare_index在操作数中进行一些类型检查。 此 function 使用非内置类型较慢。 一个原因是PyArray_PyIntAsIntp调用 CPython 类型检查函数,并且用于检查PyLong类型的代码比“涵盖所有内容”的更通用的替代方法更快(包括np.int64 ,引用代码中的注释)。 此外,有级联的检查和类型导致提前中断往往也会导致更快的检查阶段(因为所有 CPython function 调用的累积成本)。 因此,Numpy 开发人员倾向于将更常见的情况放在首位。 话虽这么说,这并不是那么简单,因为检查每种类型的成本是不一样的(所以先放慢一个普通类型的检查速度可能会减慢所有其他情况),并且有时代码中存在依赖关系。 另请注意,CPython 接口阻止 Numpy 进行进一步优化(CPython 在内置函数中所做的)并因此使更通用的类型也慢一些。 事实上,这就是让内置 function 更快的原因(尽管这通常不是唯一的原因)。


相关文章: 为什么np.sum(range(N))很慢?

更新:我不能再复制这个答案的时间了。 也许我在设置步骤中做了一些改变这些结果的事情; 或者他们只是巧合。

>>> arr = np.random.randint(0, 1000, 1000)
>>> i = 342
>>> def a3(i): return arr[i]
...
>>> def b3(i): return arr[342]
...
>>> def c3(i): return arr[0]
...
>>> t = timeit.repeat('a3(i)', globals=globals(), number=100000000); print(t, np.mean(t), np.median(t))
[17.449311104006483, 17.405843814995023, 17.91914719599299, 18.123263651999878, 18.04744581299019] 17.789002315996914 17.91914719599299
>>> t = timeit.repeat('b3(i)', globals=globals(), number=100000000); print(t, np.mean(t), np.median(t))
[17.55685576199903, 18.099313585989876, 18.032570399998804, 18.153590378991794, 17.628647994992207] 17.894195624394342 18.032570399998804
>>> t = timeit.repeat('c3(i)', globals=globals(), number=100000000); print(t, np.mean(t), np.median(t))
[17.762766532003297, 17.826293045000057, 17.821444382003392, 17.618322997994255, 17.488862683996558] 17.703537928199513 17.762766532003297

时间差异似乎是由加载变量与加载常量引起的。

import numpy as np
import dis
arr = np.random.randint(0, 1000, 1000)

def a3(i):
    return arr[i]
def b3(i):
    return arr[342]
def c3(i):
    return arr[0]

这些函数的区别只是用i3420索引数组的方式。

>>> dis.dis(a3)
  2           0 LOAD_GLOBAL              0 (arr)
              2 LOAD_FAST                0 (i)
              4 BINARY_SUBSCR
              6 RETURN_VALUE
>>> dis.dis(b3)                                                                   
  2           0 LOAD_GLOBAL              0 (arr)
              2 LOAD_CONST               1 (342)
              4 BINARY_SUBSCR
              6 RETURN_VALUE
>>> dis.dis(c3)                                                                   
  2           0 LOAD_GLOBAL              0 (arr)
              2 LOAD_CONST               1 (0)
              4 BINARY_SUBSCR
              6 RETURN_VALUE

可变索引比常数索引慢 (~8%),而常数索引 0 仍然快 (~5%)。 访问索引 0 ( c3 ) 处的数组比变量索引 ( a3 ) 快 (~13%)。

>>> t = timeit.repeat('a3(i)', globals=globals(), number=10000000); print(t, np.mean(t), np.median(t))
[1.4897515250049764, 1.507482559987693, 1.5573357169923838, 1.581711255988921, 1.588776800010237] 1.5450115715968422 1.5573357169923838
>>> t = timeit.repeat('b3(i)', globals=globals(), number=10000000); print(t, np.mean(t), np.median(t))
[1.4514476449985523, 1.427873961001751, 1.4268056689907098, 1.4114146630017785, 1.442651974997716] 1.4320387825981016 1.427873961001751
>>> t = timeit.repeat('c3(i)', globals=globals(), number=10000000); print(t, np.mean(t), np.median(t))
[1.357518576012808, 1.3500928360008402, 1.3615708220022498, 1.376022889991873, 1.3813936790102161] 1.3653197606035974 1.3615708220022498

感谢 u/jtclimb https://www.reddit.com/r/Numpy/comments/wb4p12/comment/ii7q53s/?utm_source=share&utm_medium=web2x&context=3


编辑 1:使用timeit.repeatsetup参数反驳了这个假设。

>>> t=timeit.repeat('arr[i]', setup='import numpy as np; arr = np.random.randint(0,10000,1000000); i = 342', number=10000000); print(np.around(t, 5), np.mean(t), np.median(t))
[0.7697  0.76627 0.77007 0.76424 0.76788] 0.7676320286031114 0.7678760859998874
>>> t=timeit.repeat('arr[0]', setup='import numpy as np; arr = np.random.randint(0,10000,1000000); i = 342', number=10000000); print(np.around(t, 5), np.mean(t), np.median(t))
[0.76836 0.76629 0.76794 0.76619 0.7682 ] 0.7673966443951941 0.7679443680099212

暂无
暂无

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM