Почему разница в производительности между numpy.zeros и numpy.zeros_like?

Наконец-то я нашел узкое место в моем коде, но не понимаю, в чем причина. Чтобы решить эту проблему, я изменил все свои вызовы numpy.zeros_like, чтобы вместо этого использовать numpy.zeros. Но почему zeros_like sooooo намного медленнее?

Например (обратите внимание e-05 на вызов zeros):

>>> timeit.timeit('np.zeros((12488, 7588, 3), np.uint8)', 'import numpy as np', number = 10)
5.2928924560546875e-05
>>> timeit.timeit('np.zeros_like(x)', 'import numpy as np; x = np.zeros((12488, 7588, 3), np.uint8)', number = 10)
1.4402990341186523

Но тогда странная запись в массив, созданный с помощью zeros, заметно медленнее, чем массив, созданный с помощью zeros_like:

>>> timeit.timeit('x[100:-100, 100:-100] = 1', 'import numpy as np; x = np.zeros((12488, 7588, 3), np.uint8)', number = 10)
0.4310588836669922
>>> timeit.timeit('x[100:-100, 100:-100] = 1', 'import numpy as np; x = np.zeros_like(np.zeros((12488, 7588, 3), np.uint8))', number = 10)
0.33325695991516113

Моя догадка zeros использует некоторый трюк процессора и фактически не записывает в память, чтобы выделить его. Это делается "на лету", когда она написана. Но это все еще не объясняет массовое несоответствие времени создания массива.

Я запускаю Mac OS X Yosemite с текущей версией numpy:

>>> numpy.__version__
'1.9.1'

Ответ 1

Мои тайминги в Ipython (с более простым интерфейсом timeit):

In [57]: timeit np.zeros_like(x)
1 loops, best of 3: 420 ms per loop

In [58]: timeit np.zeros((12488, 7588, 3), np.uint8)
100000 loops, best of 3: 15.1 µs per loop

Когда я смотрю на код с IPython (np.zeros_like??), я вижу:

res = empty_like(a, dtype=dtype, order=order, subok=subok)
multiarray.copyto(res, 0, casting='unsafe')

while np.zeros - это черный код - чистый скомпилированный код.

Сроки для empty:

In [63]: timeit np.empty_like(x)
100000 loops, best of 3: 13.6 µs per loop

In [64]: timeit np.empty((12488, 7588, 3), np.uint8)
100000 loops, best of 3: 14.9 µs per loop

Таким образом, дополнительное время в zeros_like находится в этом copy.

В моих тестах разница во времени назначения (x[]=1) незначительна.

Я предполагаю, что zeros, ones, empty - это все ранние скомпилированные творения. empty_like был добавлен в качестве удобства, просто нарисуйте информацию о форме и типе с ее ввода. zeros_like был написан с большим вниманием к простому обслуживанию программирования (повторное использование empty_like), чем для скорости.

np.ones и np.full также используют последовательность np.empty ... copyto и показывают аналогичные тайминги.


https://github.com/numpy/numpy/blob/master/numpy/core/src/multiarray/array_assign_scalar.c представляется файлом, который копирует скаляр (например, 0) в массив. Я не вижу использования memset.

https://github.com/numpy/numpy/blob/master/numpy/core/src/multiarray/alloc.c имеет вызовы malloc и calloc.

https://github.com/numpy/numpy/blob/master/numpy/core/src/multiarray/ctors.c - источник для zeros и empty. Оба вызова PyArray_NewFromDescr_int, но один заканчивается использованием npy_alloc_cache_zero, а другой npy_alloc_cache.

npy_alloc_cache в alloc.c вызывает alloc. npy_alloc_cache_zero вызывает npy_alloc_cache, за которым следует memset. Код в alloc.c далее путается с опцией THREAD.

Подробнее о разности calloc v malloc+memset в: Почему malloc + memset медленнее, чем calloc?

Но с кешированием и сборкой мусора, интересно, применяется ли различие calloc/memset.


Этот простой тест с пакетом memory_profile поддерживает утверждение, что zeros и empty выделяют память "на лету", а zeros_like выделяет все:

N = (1000, 1000) 
M = (slice(None, 500, None), slice(500, None, None))

Line #    Mem usage    Increment   Line Contents
================================================
     2   17.699 MiB    0.000 MiB   @profile
     3                             def test1(N, M):
     4   17.699 MiB    0.000 MiB       print(N, M)
     5   17.699 MiB    0.000 MiB       x = np.zeros(N)   # no memory jump
     6   17.699 MiB    0.000 MiB       y = np.empty(N)
     7   25.230 MiB    7.531 MiB       z = np.zeros_like(x) # initial jump
     8   29.098 MiB    3.867 MiB       x[M] = 1     # jump on usage
     9   32.965 MiB    3.867 MiB       y[M] = 1
    10   32.965 MiB    0.000 MiB       z[M] = 1
    11   32.965 MiB    0.000 MiB       return x,y,z

Ответ 2

Современная ОС распределяет память практически, т.е. память передается процессу только тогда, когда она впервые используется. zeros получает память из операционной системы, так что ОС обнуляет ее, когда она впервые используется. С другой стороны, zeros_like заполняет выделенную память нулями. Оба способа требуют примерно того же объема работы - это просто то, что при zeros_like обнуление выполняется заранее, тогда как zeros заканчивает выполнение этого на лету.

Технически, в C разница вызывает calloc vs. malloc+memset.