numpy.add.at медленнее, чем добавление на месте?

Исходя из одного из моих более ранних сообщений, я заметил, что операция np.add.at(A, indices, B) является намного медленнее, чем A[indices] += B.

def fast(A):
    n = A.shape[0]
    retval = np.zeros(2*n-1)
    for i in range(n):
        retval[slice(i,i+n)] += A[i, :]
    return retval
def slow(A):
    n = A.shape[0]
    retval = np.zeros(2*n-1)
    for i in range(n):
        np.add.at(retval, slice(i,i+n), A[i, :])
    return retval
def slower(A):
    n = A.shape[0]
    retval = np.zeros(2*n-1)
    indices = np.arange(n)
    indices = indices[:,None] + indices
    np.add.at(retval, indices, A) # bottleneck here
    return retval

Мои тайминги:

A = np.random.randn(10000, 10000)

timeit(lambda: fast(A), number=10) # 0.8852798199995959
timeit(lambda: slow(A), number=10) # 56.633683917999406
timeit(lambda: slower(A), number=10) # 57.763389584000834

Очевидно, что использование __iadd__ намного быстрее. Однако документация для np.add.at утверждает:

Выполняет небуферизованную операцию на месте над операндом «а» для элементов, указанных «индексами». Для сложения ufunc этот метод эквивалентен a[indices] += b, за исключением того, что результаты накапливаются для элементов, индексированных более одного раза.


Почему np.add.at такой медленный?

Каков вариант использования этой функции?


person Kevin    schedule 29.04.2021    source источник
comment
Отвечает ли это на ваш вопрос? Производительность операций Numpy на месте и Почему =a*100 почти в два раза быстрее, чем a*=100?.   -  person Jérôme Richard    schedule 29.04.2021
comment
@ JérômeRichard Не совсем так, я думал, что ufunc.at эквивалентно операциям на месте. То, что вы связали, - это сравнение между A += B и A + B. Я могу понять, почему последний здесь работает медленнее, потому что он должен выделять временный массив.   -  person Kevin    schedule 29.04.2021
comment
Может показаться, что np.add.at без необходимости использует расширенную индексацию, которая создает копию данных. Кто-нибудь может подтвердить эту теорию?   -  person Kevin    schedule 29.04.2021
comment
add.at полезен, когда в индексируемом массиве есть дубликаты, а += не соответствует итеративной альтернативе. Это должно быть описано в документах.   -  person hpaulj    schedule 29.04.2021
comment
Хорошо, спасибо, так что он может использовать повторяющиеся индексы, потому что add.at — это итеративное решение?   -  person Kevin    schedule 29.04.2021
comment
Все в numpy повторяется, но быстрые вещи делают это скрытно в скомпилированном коде. add.at дает понять, что у += есть проблемы, потому что решение буферизовано. Фактически temp=a[idx]+b, за которым следует a[idx]=temp. add.at делает это без буферизации, поэтому могут накапливаться повторяющиеся изменения. Это медленнее, чем с буферизацией, но все же быстрее, чем итерация на уровне Python.   -  person hpaulj    schedule 29.04.2021
comment
@hpaulj Я отредактировал свой пост и добавил метод slower, который я считаю допустимым вариантом использования для add.at. Как видите, итерацию на уровне Python можно удалить, используя повторяющиеся индексы. Хотя это выглядит более читабельно, кажется, что это не быстрее, чем использование итерационных решений на уровне Python. Это потому, что fast и slow используют slice?   -  person Kevin    schedule 29.04.2021
comment
Я добавил тестовый пример, который ближе к вашему, но для меньшего массива. Ваш slower может работать медленнее из-за проблем с управлением памятью.   -  person hpaulj    schedule 30.04.2021


Ответы (1)


add.at предназначен для случаев, когда индексы содержат дубликаты, а += не дает желаемого результата.

In [44]: A = np.zeros(5,int); idx = np.array([0,1,1,2,2,2,3,3,3,3])
In [45]: A[idx]+=1
In [46]: A
Out[46]: array([1, 1, 1, 1, 0])    # the duplicates in idx are ignored

С add.at:

In [47]: A = np.zeros(5,int); idx = np.array([0,1,1,2,2,2,3,3,3,3])
In [48]: np.add.at(A, idx, 1)
In [49]: A
Out[49]: array([1, 2, 3, 4, 0])

Тот же результат, что и при явной итерации:

In [50]: A = np.zeros(5,int); idx = np.array([0,1,1,2,2,2,3,3,3,3])
In [51]: for i in idx: A[i]+=1
In [52]: A
Out[52]: array([1, 2, 3, 4, 0])

Некоторые тайминги:

In [53]: %%timeit A = np.zeros(5,int); idx = np.array([0,1,1,2,2,2,3,3,3,3])
    ...: A[idx]+=1
3.65 µs ± 13.2 ns per loop (mean ± std. dev. of 7 runs, 100000 loops each)

In [54]: %%timeit A = np.zeros(5,int); idx = np.array([0,1,1,2,2,2,3,3,3,3])
    ...: np.add.at(A, idx, 1)
6.47 µs ± 24.8 ns per loop (mean ± std. dev. of 7 runs, 100000 loops each)

In [55]: %%timeit A = np.zeros(5,int); idx = np.array([0,1,1,2,2,2,3,3,3,3])
    ...: np.add.at(A, idx, 1)
    ...: for i in idx: A[i]+=1
15.6 µs ± 41.5 ns per loop (mean ± std. dev. of 7 runs, 100000 loops each)

add.at медленнее, чем +=, но лучше, чем итерация Python.

Мы могли бы протестировать такие варианты, как A[:4]+1, A[:4]+=1 и т. д.

В любом случае, не используйте вариант add.at, если он вам не нужен.

редактировать

Ваш пример немного упрощен:

In [108]: x = np.zeros(2*10-1)
     ...: for i in range(10):
     ...:     x[i:i+10] += 1
     ...: 
In [109]: x
Out[109]: 
array([ 1.,  2.,  3.,  4.,  5.,  6.,  7.,  8.,  9., 10.,  9.,  8.,  7.,
        6.,  5.,  4.,  3.,  2.,  1.])

Итак, вы добавляете значения к перекрывающимся срезам. Мы могли бы заменить срезы массивом:

In [110]: x = np.zeros(2*10-1)
     ...: for i in range(10):
     ...:     x[np.arange(i,i+10)] += 1
     ...: 
In [111]: x
Out[111]: 
array([ 1.,  2.,  3.,  4.,  5.,  6.,  7.,  8.,  9., 10.,  9.,  8.,  7.,
        6.,  5.,  4.,  3.,  2.,  1.])

Не нужно добавлять свой «медленный» случай, add.at с кусочками, потому что индексы не имеют дубликатов.

Создание всех индексов. += не работает из-за буферизации

In [112]: idx=np.arange(10); idx=(idx[:,None]+idx).ravel()
In [113]: y=np.zeros(2*10-1)
     ...: y[idx]+=1
In [114]: y
Out[114]: 
array([1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1.,
       1., 1.])

add.at решает, что:

In [115]: y=np.zeros(2*10-1)
     ...: np.add.at(y, idx, 1)
In [116]: y
Out[116]: 
array([ 1.,  2.,  3.,  4.,  5.,  6.,  7.,  8.,  9., 10.,  9.,  8.,  7.,
        6.,  5.,  4.,  3.,  2.,  1.])

И полная итерация Python:

In [117]: y=np.zeros(2*10-1)
     ...: for i in idx: y[i]+=1
In [118]: 
In [118]: y
Out[118]: 
array([ 1.,  2.,  3.,  4.,  5.,  6.,  7.,  8.,  9., 10.,  9.,  8.,  7.,
        6.,  5.,  4.,  3.,  2.,  1.])

Теперь немного таймингов.

Базовая линия:

In [119]: %%timeit
     ...: x = np.zeros(2*10-1)
     ...: for i in range(10):
     ...:     x[i:i+10] += 1
     ...: 
50.5 µs ± 177 ns per loop (mean ± std. dev. of 7 runs, 10000 loops each)

Расширенная индексация несколько замедляет это:

In [120]: %%timeit
     ...: x = np.zeros(2*10-1)
     ...: for i in range(10):
     ...:     x[np.arange(i,i+10)] += 1
     ...: 
75.2 µs ± 79.9 ns per loop (mean ± std. dev. of 7 runs, 10000 loops each)

Если бы это сработало, один += с расширенной индексацией был бы самым быстрым:

In [121]: %%timeit
     ...: idx=np.arange(10); idx=(idx[:,None]+idx).ravel()
     ...: y=np.zeros(2*10-1)
     ...: y[idx]+=1
     ...: 
     ...: 
17.5 µs ± 693 ns per loop (mean ± std. dev. of 7 runs, 10000 loops each)

Полная итерация Python примерно такая же, как и в случае с зацикленным аранжем:

In [122]: %%timeit
     ...: idx=np.arange(10); idx=(idx[:,None]+idx).ravel()
     ...: y=np.zeros(2*10-1)
     ...: for i in idx: y[i]+=1
     ...: 
     ...: 
76.3 µs ± 2.51 µs per loop (mean ± std. dev. of 7 runs, 10000 loops each)

add.at медленнее, чем плоский +=, но все же лучше, чем базовая линия:

In [123]: %%timeit
     ...: idx=np.arange(10); idx=(idx[:,None]+idx).ravel()
     ...: y=np.zeros(2*10-1)
     ...: np.add.at(y, idx,1)
     ...: 
     ...: 
29.4 µs ± 21.2 ns per loop (mean ± std. dev. of 7 runs, 10000 loops each)

В моем меньшем тесте лучше всего показал себя ваш slower. Но возможно, что он не так хорошо масштабируется, как базовый/быстрый. Ваш indices намного больше. Часто для очень больших массивов итерация по одному измерению выполняется быстрее из-за снижения нагрузки на управление памятью.

person hpaulj    schedule 29.04.2021
comment
Спасибо. Да, похоже, в конечном итоге это проблема управления памятью, которую np.add.at имеет для больших массивов. Приятно получить это подтверждение. - person Kevin; 01.05.2021