Почему NumPyum в 10 раз медленнее, чем оператор +?

Я заметил, что очень странно, что np.sum в 10 раз медленнее, чем рукописная сумма.

np.sum с осью:

p1 = np.random.rand(10000, 2)
def test(p1):
    return p1.sum(axis=1)
%timeit test(p1)

186 µs ± 4.21 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)

np.sum без оси:

p1 = np.random.rand(10000, 2)
def test(p1):
    return p1.sum()
%timeit test(p1)

17.9 µs ± 236 ns per loop (mean ± std. dev. of 7 runs, 10000 loops each)

+:

p1 = np.random.rand(10000, 2)
def test(p1):
    return p1[:,0] + p1[:,1]
%timeit test(p1)

15.8 µs ± 328 ns per loop (mean ± std. dev. of 7 runs, 100000 loops each)

Умножение:

p1 = np.random.rand(10000, 2)
def test(p1):
    return p1[:,0]*p1[:,1]
%timeit test(p1)

15.7 µs ± 701 ns per loop (mean ± std. dev. of 7 runs, 10000 loops each)

Я не вижу причин для этого. Есть идеи почему? Моя маленькая версия - 1.15.3.

ОБНОВЛЕНИЕ: с 10000000:

np.sum (with axis): 202 ms (5 x)
np.sum (without axis): 12 ms
+ : 46 ms (1 x)
* : 44.3 ms 

Так что я думаю, что в некоторой степени есть игра наверху...

Ответы

Ответ 1

Основное различие заключается в больших накладных расходах при расчете a.sum(axis=1). Вычисление сокращения (в данном случае sum) не является тривиальным вопросом:

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

Я обсуждал темы выше более подробно, например здесь и здесь.

Однако все это не нужно и не лучше, чем наивное суммирование, если добавить только два элемента - вы получите тот же результат, но с гораздо меньшими издержками и быстрее.

Только для 1000 элементов затраты на вызов функциональности numpy, вероятно, выше, чем на самом деле выполнение этих 1000 сложений (или умножений в этом отношении, потому что на современных процессорах конвейерные сложения/умножения имеют одинаковую стоимость) -as, как вы можете видеть, что для 10 ^ 4 время работы только примерно в 2 раза выше, что является верным признаком того, что накладные расходы играют большую роль для 10 ^ 3! В этом ответе более подробно рассматривается влияние накладных расходов и пропусков кэша.

Давайте посмотрим на результат профилировщика, чтобы увидеть, справедлива ли приведенная выше теория (я использую perf):

Для a.sum(axis=1):

  17,39%  python   umath.cpython-36m-x86_64-linux-gnu.so       [.] reduce_loop
  11,41%  python   umath.cpython-36m-x86_64-linux-gnu.so       [.] pairwise_sum_DOUBLE
   9,78%  python   multiarray.cpython-36m-x86_64-linux-gnu.so  [.] npyiter_buffered_reduce_iternext_ite
   9,24%  python   umath.cpython-36m-x86_64-linux-gnu.so       [.] DOUBLE_add
   4,35%  python   python3.6                                   [.] _PyEval_EvalFrameDefault
   2,17%  python   multiarray.cpython-36m-x86_64-linux-gnu.so  [.] _aligned_strided_to_contig_size8_src
   2,17%  python   python3.6                                   [.] lookdict_unicode_nodummy
   ...

Затраты на использование reduce_loop, pairwise_sum_DOUBLE доминируют.

Для a[:,0]+a[:,1]):

   7,24%  python   python3.6                                   [.] _PyEval_EvalF
   5,26%  python   python3.6                                   [.] PyObject_Mall
   3,95%  python   python3.6                                   [.] visit_decref
   3,95%  python   umath.cpython-36m-x86_64-linux-gnu.so       [.] DOUBLE_add
   2,63%  python   python3.6                                   [.] PyDict_SetDef
   2,63%  python   python3.6                                   [.] _PyTuple_Mayb
   2,63%  python   python3.6                                   [.] collect
   2,63%  python   python3.6                                   [.] fast_function
   2,63%  python   python3.6                                   [.] visit_reachab
   1,97%  python   python3.6                                   [.] _PyObject_Gen

Как и следовало ожидать: накладные расходы Python играют большую роль, используется простой DOUBLE_add.


При вызове a.sum() меньше издержек

  • на этот раз reduce_loop вызывается не для каждой строки, а только один раз, что означает значительно меньшие накладные расходы.
  • новые результирующие массивы не создаются, больше нет необходимости записывать 1000 дублей в память.

так что можно ожидать, что a.sum() быстрее (несмотря на то, что 2000, а не 1000 добавлений должны быть сделаны - но, как мы видели, это в основном накладные расходы и реальная работа - дополнения не несут ответственности за большая доля времени работы).


Получение данных путем запуска:

perf record python run.py
perf report

и

#run.py
import numpy as np
a=np.random.rand(1000,2)

for _ in range(10000):
  a.sum(axis=1)
  #a[:,0]+a[:,1]

Ответ 2

Хорошо для .sum() с осью против без, с осью должен генерировать массив с плавающей запятой до вашего ввода, с элементом для каждой строки. Это означает, что он должен вызывать Reduce() 10000 раз по оси = 1. Без аргумента оси он вычисляет сумму каждого элемента в единственном числе с плавающей запятой, которое является всего одним вызовом для уменьшения через плоское представление массива.

Я не уверен, почему функция добавления вручную работает быстрее, и мне не хочется копаться в исходном коде, но я думаю, что у меня есть довольно хорошее предположение. Я полагаю, что это связано с необходимостью выполнять уменьшение по оси = 1 для каждой строки, поэтому необходимо 10 000 отдельных вызовов для уменьшения. В функции ручного добавления разделение оси выполняется только один раз при определении параметров функции "+", и затем каждый элемент столбцов разделения может добавляться вместе параллельно.