Широковещательная арифметика NumPy — почему один метод намного более эффективен?

Этот вопрос является продолжением моего ответа в Эффективный способ вычисления матрица Вандермонда.

Вот настройка:

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

Теперь я вычислю матрицу Вандермонда двумя разными способами:

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, я могу предположить, что ответ будет связан с кешем. Первый метод намного удобнее для кэширования, чем второй. Тем не менее, я хотел бы официальное слово от кого-то с большим опытом, чем у меня.

В чем может быть причина такого резкого контраста во времени?


person cs95    schedule 14.01.2018    source источник
comment
Время транспонирования незначительно, просто меняется форма и шаги (и порядок), хотя для последующих расчетов может потребоваться копия.   -  person hpaulj    schedule 14.01.2018
comment
Малые целочисленные мощности дешевы. Поэтому накладные расходы на цикл даже в скомпилированном коде могут быть значительными. Самый внутренний (дешевый, C-непрерывный) цикл выполняется одинаковое количество раз в обоих случаях. Внешняя дорогая петля с учетом шагов 5000 против 4 раз. Просто мое обоснованное предположение.   -  person Paul Panzer    schedule 14.01.2018
comment
Я вижу ту же картину с симметричной операцией, такой как +.   -  person hpaulj    schedule 14.01.2018
comment
@hpaulj Буду признателен за ответ от вас. У вас фантастические представления о таких вещах.   -  person cs95    schedule 14.01.2018
comment
Для меня самый интересный момент из исходного вопроса заключается в том, что если x и y являются числами с плавающей запятой, относительные тайминги переключаются (m2 быстрее, чем m1).   -  person Daniel F    schedule 15.01.2018


Ответы (3)


Хотя я боюсь, что мой вывод не будет более фундаментальным, чем ваш («вероятно, кеширование»), я считаю, что могу помочь сосредоточить наше внимание с помощью набора более локализованных тестов.

Рассмотрим ваш пример проблемы:

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

Как видите, я определил набор массивов для сравнения. x1, y1 и x2, y2 соответственно соответствуют исходным тестам. ??_bc соответствуют явно широковещательным версиям этих массивов. Они используют те же данные, что и исходные, но имеют явные 0 шагов, чтобы получить соответствующую форму. Наконец, ??_cont представляют собой непрерывные версии этих широковещательных массивов, как если бы они были созданы с помощью np.tile.

Таким образом, оба массива x1_bc, y1_bc, x1_cont и 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. Чтобы быть более точным, для массивов со строками («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* просто обозначает float64 значение x, находящееся где-то в оперативной памяти. Сравните это с более медленным случаем, когда мы работаем с формой (5000,4):

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

Мое наивное мнение состоит в том, что работа с первым позволяет ЦП кэшировать большие фрагменты отдельных значений x за раз, поэтому производительность отличная. В последнем случае 0-такты заставляют ЦП прыгать по одному и тому же адресу памяти x четыре раза каждый, делая это 5000 раз. Я считаю разумным полагать, что эта настройка работает против кэширования, что приводит к общей низкой производительности. Это также согласуется с тем фактом, что смежные случаи не показывают этого падения производительности: здесь ЦП должен работать со всеми 5000 * 4 уникальными значениями float64, и кэширование может не мешать этим странным чтениям.

person Andras Deak    schedule 14.01.2018

Я тоже пытался посмотреть на 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-шаговые измерения в полные

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-шаговых массивов требует определенного времени, даже если сначала массивы явно не расширяются. А стоимость привязана к формам и, возможно, к тому, какое измерение будет расширено.

person hpaulj    schedule 14.01.2018
comment
@PaulPanzer, к сожалению, это создаст предвзятость, которая в дальнейшем исказит общее голосование;) - person kmario23; 29.01.2018

Я не уверен, что кэширование действительно является единственным наиболее влиятельным фактором.

Я также не являюсь специалистом в области информатики, поэтому я вполне могу ошибаться, но позвольте мне провести вас через пару наблюдений. Для простоты я использую вызов @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]

С точки зрения кашиста это в лучшем случае неубедительно.

Итак, давайте посмотрим на источник. После сборки текущего 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 итерациями тривиального тела цикла это будет существенным.

Я, вероятно, должен профилировать это, но моих навыков недостаточно.

person Paul Panzer    schedule 15.01.2018