Широковещательная арифметика 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, я могу догадаться, что ответ будет включать кеш. Первый метод - это гораздо более дружественный кэш чем второй. Тем не менее, я хотел бы получить официальное слово от кого-то с больше опыта, чем я.
Что может быть причиной этого резкого контраста в таймингах?
Ответы
Ответ 1
Пока я боюсь, что мой вывод не будет более фундаментальным, чем ваш ( "вероятно, кеширование" ), я считаю, что могу помочь сосредоточить наше внимание на множестве локализованных тестов.
Рассмотрим пример проблемы:
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. Чтобы быть более конкретным, для массивов строк ( "С-порядок" ) конечные размеры смежны, в то время как для массивов столбцов ( "fortran-order" ) ведущие измерения являются смежными. При достаточно больших размерах arr.sum(axis=-1)
должен быть быстрее, чем arr.sum(axis=0)
для массивов с большим числом строк, выдающих или выполняющих мелкую печать.
Что здесь происходит, так это огромная разница между двумя измерениями (размер 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 раз. Я считаю разумным полагать, что эта настройка работает против кеширования, что приводит к общей плохой производительности. Это также согласуется с тем фактом, что смежные случаи не показывают этого повышения производительности: там CPU должен работать со всеми уникальными значениями 5000 * 4 float64
, и кэширование не может быть затруднено этими странными чтениями.
Ответ 2
Я тоже попытался посмотреть 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-ступенчатой структурой, даже если она явно не расширяет массивы. И стоимость привязана к фигурам, и, возможно, размер которой расширяется.
Ответ 3
Я не уверен, что кеширование - действительно самый влиятельный фактор здесь.
Я тоже не обученный компьютерный ученый, поэтому я вполне могу ошибаться, но позвольте мне провести вас через пару обсерваций.
Для простоты я использую вызов @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 из tarball вы найдете где-то в дереве почти 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.
Полезная нагрузка в нашем случае кажется достаточно худой, особенно если я правильно интерпретирую комментарий о смежной специализации и autovectorization. Теперь, если мы выполняем только 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 (@[email protected],@[email protected],@[email protected]) */
static int
[email protected][email protected][email protected][email protected][email protected][email protected](
NpyIter *iter)
{
etc.
Анализ этого не зависит от меня, но в любом случае это указатель на функцию, который должен вызываться на каждой итерации внешнего цикла, и насколько я знаю, указатели на функции не могут быть встроены, поэтому по сравнению с 4 итерациями тривиального это тело будет поддерживать.
Я должен, вероятно, профилировать это, но мои навыки недостаточны.