广播的NumPy算法 - 为什么一种方法的性能更高?

时间:2018-01-14 19:11:50

标签: python performance numpy linear-algebra numpy-broadcasting

  

这个问题是对我的回答的跟进   Efficient way to compute the Vandermonde matrix

以下是设置:

x = np.arange(5000)  # an integer array
N = 4

现在,我将以两种不同的方式计算Vandermonde matrix

m1 = (x ** np.arange(N)[:, None]).T

而且,

m2 = x[:, None] ** np.arange(N)

完整性检查:

np.array_equal(m1, m2)
True

这些方法完全相同,但其性能不是:

%timeit m1 = (x ** np.arange(N)[:, None]).T
42.7 µs ± 271 ns per loop (mean ± std. dev. of 7 runs, 10000 loops each)

%timeit m2 = x[:, None] ** np.arange(N)
150 µs ± 995 ns per loop (mean ± std. dev. of 7 runs, 10000 loops each)

所以,第一种方法,尽管最后需要换位,但仍然比第二种方法快3倍以上

唯一的区别是,在第一种情况下,较小的数组被广播,而在第二种情况下,它是较大的

  

所以,通过对numpy如何运作的相当正确的理解,我可以猜出答案   将涉及缓存。第一种方法是更多缓存友好   比第二个。但是,我喜欢某个人的官方消息   比我更多的经验。

在时间上形成鲜明对比的原因是什么?

3 个答案:

答案 0 :(得分:4)

我也试着看np.ascontiguousarray

In [132]: Y1 = np.ascontiguousarray(Y)
In [134]: Y1.strides
Out[134]: (16, 4)
In [135]: X1 = np.ascontiguousarray(X)
In [136]: X1.shape
Out[136]: (1000, 4)

In [137]: timeit X1+Y1 4.66 µs ± 161 ns per loop (mean ± std. dev. of 7 runs, 100000 loops each) 将0个跨步维度转换为完整维度

{{1}}

使用完整阵列进行操作更快:

{{1}}

因此,使用0跨步数组会有一些时间损失,即使它没有先显式扩展数组。成本与形状有关,可能与哪个尺寸有关。

答案 1 :(得分:2)

虽然我担心我的结论不会比你的结论更为重要("可能是缓存"),但我相信我可以通过一系列更加本地化的测试来帮助我们集中注意力

考虑您的示例问题:

M,N = 5000,4
x1 = np.arange(M)
y1 = np.arange(N)[:,None]
x2 = np.arange(M)[:,None]
y2 = np.arange(N)
x1_bc,y1_bc = np.broadcast_arrays(x1,y1)
x2_bc,y2_bc = np.broadcast_arrays(x2,y2)
x1_cont,y1_cont,x2_cont,y2_cont = map(np.ascontiguousarray,
                                      [x1_bc,y1_bc,x2_bc,y2_bc])

如您所见,我定义了一堆数组进行比较。 x1y1x2y2分别对应于您的原始测试用例。 ??_bc对应于这些数组的显式广播版本。它们与原始数据共享数据,但它们具有明确的0步幅以获得适当的形状。最后,??_cont是这些广播数组的连续版本,就像用np.tile构造一样。

因此,x1_bcy1_bcx1_conty1_cont都有(4, 5000)形状,但前两个都是零步,后两个是连续的数组。对于所有意图和目的,取任何这些相应的数组对的权力应该给我们相同的连续结果(如评论中提到的hpaulj,转换本身基本上是免费的,所以我将忽略最外层转置如下)。

以下是与原始支票对应的时间:

In [143]: %timeit x1 ** y1
     ...: %timeit x2 ** y2
     ...: 
52.2 µs ± 707 ns per loop (mean ± std. dev. of 7 runs, 10000 loops each)
96 µs ± 858 ns per loop (mean ± std. dev. of 7 runs, 10000 loops each)

以下是显式广播数组的时间:

In [144]: %timeit x1_bc ** y1_bc
     ...: %timeit x2_bc ** y2_bc
     ...: 
54.1 µs ± 906 ns per loop (mean ± std. dev. of 7 runs, 10000 loops each)
99.1 µs ± 1.51 µs per loop (mean ± std. dev. of 7 runs, 10000 loops each

同样的事情。这告诉我,由于从索引表达式到广播数组的转换,差异不是以某种方式。这主要是预期的,但检查不会受到伤害。

最后,连续数组:

In [146]: %timeit x1_cont ** y1_cont
     ...: %timeit x2_cont ** y2_cont
     ...: 
38.9 µs ± 529 ns per loop (mean ± std. dev. of 7 runs, 10000 loops each)
45.6 µs ± 390 ns per loop (mean ± std. dev. of 7 runs, 10000 loops each)

差异的很大一部分消失了!

那我为什么检查这个?如果在python中使用大型尾随维度的向量化操作,则可以使用CPU缓存,这是一般的经验法则。更具体地说,对于row-major(" C-order"),数组尾随维度是连续的,而对于column-major(" fortran-order")数组,前导维度是连续的。对于足够大的维度,arr.sum(axis=-1)应该比{-1}}更快,因为行主要的numpy数组给出或者进行了一些精细打印。

这里发生的是两个维度(分别为4和5000)之间存在巨大差异,但两个转置案例之间的巨大性能不对称仅发生在广播案例中。我不可否认的是,广播使用0步来构建适当大小的视图。这些0步骤意味着在更快的情况下,对于长arr.sum(axis=0)数组,内存访问看起来像这样:

x

其中[mem0,mem1,mem2,...,mem4999, mem0,mem1,mem2,...,mem4999, ...] # and so on 仅表示mem*float64位于RAM中的某处。将此与我们正在处理形状x的较慢情况相比较:

(5000,4)

我天真的想法是,使用前者允许CPU一次缓存[mem0,mem0,mem0,mem0, mem1,mem1,mem1,mem1, mem2,mem2,mem2,mem2, ...] 个别值的更大块,因此性能非常好。在后一种情况下,0步幅使CPU在x的相同内存地址上跳四次,每次执行5000次。我觉得有理由相信这种设置可以防止缓存,导致整体性能不佳。这也符合以下事实:连续的案例不会显示这种性能损失:CPU必须处理所有5000 * 4唯一x值,并且这些值可能不会阻碍缓存怪异的读物。

答案 2 :(得分:2)

我不相信缓存真的是这里最具影响力的因素。

我也不是一位训练有素的计算机科学家,所以我可能错了,但让我带你走过几个神迹。 为简单起见,我使用@ hpaulj的电话' +'显示与' **'基本相同的效果。

我的工作假设是外环的开销,我认为它比连续的可矢量化的最内环更昂贵。

因此,让我们首先减少重复的数据量,因此缓存不太可能产生太大影响:

>>> from timeit import repeat
>>> import numpy as np
>>> 
>>> def mock_data(k, N, M):
...     x = list(np.random.randint(0, 10000, (k, N, M)))
...     y = list(np.random.randint(0, 10000, (k, M)))
...     z = list(np.random.randint(0, 10000, (k, N, 1)))
...     return x, y, z
...   
>>> k, N, M = 500, 5000, 4
>>>
>>> repeat('x.pop() + y.pop()', setup='x, y, z = mock_data(k, M, N)', globals=globals(), number=k)
[0.017986663966439664, 0.018148145987652242, 0.018077059998176992]
>>> repeat('x.pop() + y.pop()', setup='x, y, z = mock_data(k, N, M)', globals=globals(), number=k)
[0.026680009090341628, 0.026304758968763053, 0.02680662798229605]

这两个场景都有连续数据,相同数量的添加但具有5000外部迭代的版本要慢得多。当我们带回缓存虽然在试验中差异保持大致相同但比率变得更加明显:

>>> repeat('x[0] + y[0]', setup='x, y, z = mock_data(k, M, N)', globals=globals(), number=k)
[0.011324503924697638, 0.011121788993477821, 0.01106808998156339]
>>> repeat('x[0] + y[0]', setup='x, y, z = mock_data(k, N, M)', globals=globals(), number=k)
[0.020170683041214943, 0.0202067659702152, 0.020624138065613806]

回到原来的"外额"我们看到,在非连续的长维度情况下,我们的情况会变得更糟。由于我们不得不读取连续场景中的数据,因此无法通过未缓存的数据来解释这些数据。

>>> repeat('z.pop() + y.pop()', setup='x, y, z = mock_data(k, M, N)', globals=globals(), number=k)
[0.013918839977122843, 0.01390116906259209, 0.013737019035033882]
>>> repeat('z.pop() + y.pop()', setup='x, y, z = mock_data(k, N, M)', globals=globals(), number=k)
[0.0335254140663892, 0.03351909795310348, 0.0335453050211072]

此外,双方都可以从试用缓存中获益:

>>> repeat('z[0] + y[0]', setup='x, y, z = mock_data(k, M, N)', globals=globals(), number=k)
[0.012061356916092336, 0.012182610924355686, 0.012071475037373602]
>>> repeat('z[0] + y[0]', setup='x, y, z = mock_data(k, N, M)', globals=globals(), number=k)
[0.03265167598146945, 0.03277428599540144, 0.03247103898320347]

从高速缓存的角度来看,这最多是不确定的。

让我们来看看来源。 在从tarball构建当前NumPy之后,您将在树中的某处找到大约15000行计算机生成的代码,该文件名为' loops.c'。这些循环是ufuncs的最内层循环,与我们的情况最相关的位似乎是

#define BINARY_LOOP\
    char *ip1 = args[0], *ip2 = args[1], *op1 = args[2];\
    npy_intp is1 = steps[0], is2 = steps[1], os1 = steps[2];\
    npy_intp n = dimensions[0];\
    npy_intp i;\
    for(i = 0; i < n; i++, ip1 += is1, ip2 += is2, op1 += os1)

/*
 * loop with contiguous specialization
 * op should be the code working on `tin in1`, `tin in2` and
 * storing the result in `tout * out`
 * combine with NPY_GCC_OPT_3 to allow autovectorization
 * should only be used where its worthwhile to avoid code bloat
 */
#define BASE_BINARY_LOOP(tin, tout, op) \
    BINARY_LOOP { \
        const tin in1 = *(tin *)ip1; \
        const tin in2 = *(tin *)ip2; \
        tout * out = (tout *)op1; \
        op; \
    }

etc.

我们案例中的有效负载似乎足够精简,特别是如果我正确解释有关连续专业化和自动向量化的评论。现在,如果我们只进行4次迭代,那么开销与有效负载的比率开始变得有点麻烦,并且它不会在这里结束。

在文件ufunc_object.c中,我们找到以下代码段

/*
 * If no trivial loop matched, an iterator is required to
 * resolve broadcasting, etc
 */

NPY_UF_DBG_PRINT("iterator loop\n");
if (iterator_loop(ufunc, op, dtypes, order,
                buffersize, arr_prep, arr_prep_args,
                innerloop, innerloopdata) < 0) {
    return -1;
}

return 0;

实际循环看起来像

    NPY_BEGIN_THREADS_NDITER(iter);

    /* Execute the loop */
    do {
        NPY_UF_DBG_PRINT1("iterator loop count %d\n", (int)*count_ptr);
        innerloop(dataptr, count_ptr, stride, innerloopdata);
    } while (iternext(iter));

    NPY_END_THREADS;

innerloop是我们在上面看到的内部循环。 iternext会带来多少开销?

为此我们需要转到我们找到的文件nditer_templ.c.src

/*NUMPY_API
 * Compute the specialized iteration function for an iterator
 *
 * If errmsg is non-NULL, it should point to a variable which will
 * receive the error message, and no Python exception will be set.
 * This is so that the function can be called from code not holding
 * the GIL.
 */
NPY_NO_EXPORT NpyIter_IterNextFunc *
NpyIter_GetIterNext(NpyIter *iter, char **errmsg)
{

etc.

此函数返回一个函数指针,指向预处理的其中一个

/* Specialized iternext (@const_itflags@,@tag_ndim@,@tag_nop@) */
static int
npyiter_iternext_itflags@tag_itflags@_dims@tag_ndim@_iters@tag_nop@(
                                                      NpyIter *iter)
{

etc.

解析这个问题超出了我的意思,但无论如何它都是一个函数指针,必须在外部循环的每次迭代中调用,并且据我所知,函数指针不能内联,因此与一个普通的4次迭代相比循环体这将是可持续的。

我应该对此进行描述,但我的技能不足。