簡體   English   中英

廣播的NumPy算法 - 為什么一種方法的性能更高?

[英]Broadcasted NumPy arithmetic - why is one method so much more performant?

這個問題是我以高效的方式計算Vandermonde矩陣的答案。

這是設置:

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

現在,我將以兩種不同的方式計算Vandermonde矩陣

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如何工作的相當不錯的理解,我可以猜測答案將涉及緩存。 第一種方法比第二種方法更加緩存友好。 但是,我想要一個比我更有經驗的人的官方消息。

在時間上形成鮮明對比的原因是什么?

我也試着看一下broadcast_arrays

In [121]: X,Y = np.broadcast_arrays(np.arange(4)[:,None], np.arange(1000))
In [122]: timeit X+Y
10.1 µs ± 31.1 ns per loop (mean ± std. dev. of 7 runs, 100000 loops each)
In [123]: X,Y = np.broadcast_arrays(np.arange(1000)[:,None], np.arange(4))
In [124]: timeit X+Y
26.1 µs ± 30.6 ns per loop (mean ± std. dev. of 7 runs, 10000 loops each)
In [125]: X.shape, X.strides
Out[125]: ((1000, 4), (4, 0))
In [126]: Y.shape, Y.strides
Out[126]: ((1000, 4), (0, 4))

np.ascontiguousarray將0 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跨步數組會有一些時間損失,即使它沒有先顯式擴展數組。 成本與形狀有關,可能與哪個尺寸有關。

雖然我擔心我的結論不會比你的結論更為重要(“可能是緩存”),但我相信我可以通過一系列更加本地化的測試來幫助我們集中注意力。

考慮你的示例問題:

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構造np.tile

因此, x1_bcy1_bcx1_conty1_cont都具有形狀(4, 5000) y1_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緩存,這是一般的經驗法則。 更具體地說,對於行主(“C順序”)數組,尾隨維度是連續的,而對於列主要(“fortran-order”)數組,前導維度是連續的。 對於足夠大的尺寸, arr.sum(axis=-1)應該比arr.sum(axis=0)快,因為行主要numpy數組給出或采取一些精細打印。

這里發生的是兩個維度(分別為4和5000)之間存在巨大差異,但兩個轉置案例之間的巨大性能不對稱僅發生在廣播案例中。 我不可否認的是,廣播使用0步來構建適當大小的視圖。 這些0步驟意味着在更快的情況下,對於長x數組,內存訪問看起來像這樣:

[mem0,mem1,mem2,...,mem4999, mem0,mem1,mem2,...,mem4999, ...] # and so on

mem*只表示坐在RAM中某處的xfloat64值。 將此與我們使用shape (5000,4)的較慢情況進行比較:

[mem0,mem0,mem0,mem0, mem1,mem1,mem1,mem1, mem2,mem2,mem2,mem2, ...]

我天真的想法是,使用前者允許CPU一次緩存x的各個值的更大塊,因此性能很好。 在后一種情況下,0步幅使CPU在x的相同存儲器地址上跳躍四次,這樣做5000次。 我覺得有理由相信這種設置可以防止緩存,導致整體性能不佳。 這也與連續案例沒有表現出這種性能影響的事實一致:CPU必須處理所有5000 * 4唯一的float64值,並且這些奇怪的讀取可能不會阻止緩存。

我不相信緩存真的是這里最具影響力的因素。

我也不是一個訓練有素的計算機科學家,所以我可能錯了,但讓我帶你走過幾個神跡。 為簡單起見,我使用@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次迭代相比將是可持續的。

我應該對此進行描述,但我的技能不足。

暫無
暫無

聲明:本站的技術帖子網頁,遵循CC BY-SA 4.0協議,如果您需要轉載,請注明本站網址或者原文地址。任何問題請咨詢:yoyou2525@163.com.

 
粵ICP備18138465號  © 2020-2024 STACKOOM.COM