Ваше предположение, что ускорение происходит только/в основном за счет распараллеливания, неверно. Как уже указывал @Brenlla, львиная доля ускорения numexpr обычно происходит за счет лучшего использования кеша. Однако есть и некоторые другие причины.
Во-первых, numpy и numexpr не оценивают одно и то же выражение:
- numpy вычисляет
x**3
и x**2
как pow(x,3)
и pow(x,2)
.
- numexpr позволяет оценить его как
x**3=x*x*x
и x**2=x*x
.
pow
сложнее, чем одно или два умножения, и поэтому намного медленнее, сравните:
ne.set_num_threads(1)
%timeit ne.evaluate("0.25*x**3 + 0.75*x**2 + 1.5*x - 2")
# 60.7 ms ± 1.2 ms, base line on my machine
%timeit 0.25*x**3 + 0.75*x**2 + 1.5*x - 2
# 766 ms ± 4.02 ms
%timeit 0.25*x*x*x + 0.75*x*x + 1.5*x - 2
# 130 ms ± 692 µs
Теперь numexpr всего в два раза быстрее. Я предполагаю, что версия pow
была связана с процессором, а версия с умножением больше связана с памятью.
Numexpr в основном сияет, когда данные большие - больше, чем L3-кэш (например, 15 МБ на моей машине), который приведен в вашем примере, так как x
составляет около 76 МБ:
- numexp оценивает поблочно, т. е. все операции оцениваются для блока, и каждый блок соответствует (по крайней мере) L3-кэшу, что максимально увеличивает использование кеша. ТОЛЬКО после завершения одного блока оценивается другой блок.
- numpy оценивает одну операцию за другой со всеми данными, поэтому данные удаляются из кеша, прежде чем их можно будет использовать повторно.
Мы можем посмотреть на кэш-промахи, используя, например, valgrind
(см. скрипты в приложении к этому посту):
>>> valgrind --tool=cachegrind python np_version.py
...
...
==5676== D refs: 1,144,572,370 (754,717,376 rd + 389,854,994 wr)
==5676== D1 misses: 220,844,716 (181,436,970 rd + 39,407,746 wr)
==5676== LLd misses: 217,056,340 (178,062,890 rd + 38,993,450 wr)
==5676== D1 miss rate: 19.3% ( 24.0% + 10.1% )
==5676== LLd miss rate: 19.0% ( 23.6% + 10.0% )
....
Интересная часть для нас - LLd-misses
(т.е. промахи L3, см. здесь информацию об интерпретации вывода) - около 25 % промахов при доступе на чтение.
Тот же анализ для numexpr показывает:
>>> valgrind --tool=cachegrind python ne_version.py
...
==5145== D refs: 2,612,495,487 (1,737,673,018 rd + 874,822,469 wr)
==5145== D1 misses: 110,971,378 ( 86,949,951 rd + 24,021,427 wr)
==5145== LLd misses: 29,574,847 ( 15,579,163 rd + 13,995,684 wr)
==5145== D1 miss rate: 4.2% ( 5.0% + 2.7% )
==5145== LLd miss rate: 1.1% ( 0.9% + 1.6% )
...
Только 5% прочтений промахиваются!
Однако у numpy также есть некоторые преимущества: под капотом numpy использует mkl-процедуры (по крайней мере, на моей машине), а numexpr — нет. Таким образом, numpy использует упакованные SSE-операции (movups
+mulpd
+addpd
), а numexpr использует скалярные версии (movsd
+mulsd
).
Это объясняет 25%-й процент промахов версии numpy: одно чтение составляет 128 бит (movups
), что означает, что после 4 чтений обрабатывается строка кэша (64 байта) и возникает промах. Его можно увидеть в профиле (например perf
в Linux):
32,93 │ movups 0x10(%r15,%rcx,8),%xmm4
1,33 │ movups 0x20(%r15,%rcx,8),%xmm5
1,71 │ movups 0x30(%r15,%rcx,8),%xmm6
0,76 │ movups 0x40(%r15,%rcx,8),%xmm7
24,68 │ movups 0x50(%r15,%rcx,8),%xmm8
1,21 │ movups 0x60(%r15,%rcx,8),%xmm9
2,54 │ movups 0x70(%r15,%rcx,8),%xmm10
каждому четвертому movups
требуется больше времени, потому что он ожидает доступа к памяти.
Numpy блестит для меньших размеров массивов, которые подходят к кешу L1 (но тогда львиная доля приходится на накладные расходы, а не на сами вычисления, которые быстрее в numpy — но это не играет большой роли):
x = np.linspace(-1, 1, 10**3)
%timeit ne.evaluate("0.25*x*x*x + 0.75*x*x + 1.5*x - 2")
# 20.1 µs ± 306 ns per loop (mean ± std. dev. of 7 runs, 10000 loops each)
%timeit 0.25*x*x*x + 0.75*x*x + 1.5*x - 2
# 13.1 µs ± 125 ns per loop (mean ± std. dev. of 7 runs, 100000 loops each)
В качестве примечания: было бы быстрее оценить функцию как ((0.25*x + 0.75)*x + 1.5)*x - 2
.
Оба из-за меньшего использования ЦП:
# small x - CPU bound
x = np.linspace(-1, 1, 10**3)
%timeit ((0.25*x + 0.75)*x + 1.5)*x - 2
# 9.02 µs ± 204 ns
и меньше обращений к памяти:
# large x - memory bound
x = np.linspace(-1, 1, 10**7)
%timeit ((0.25*x + 0.75)*x + 1.5)*x - 2
# 73.8 ms ± 3.71 ms
Объявления:
А np_version.py
:
import numpy as np
x = np.linspace(-1, 1, 10**7)
for _ in range(10):
cubic_poly = 0.25*x*x*x + 0.75*x*x + 1.5*x - 2
В ne_version.py
:
import numpy as np
import numexpr as ne
x = np.linspace(-1, 1, 10**7)
ne.set_num_threads(1)
for _ in range(10):
ne.evaluate("0.25*x**3 + 0.75*x**2 + 1.5*x - 2")
18.04.2019