Nano Hash - криптовалюты, майнинг, программирование

Увеличение времени выполнения кода NumPy с помощью NumExpr: анализ

Поскольку NumPy не использует несколько ядер, я учусь ускорять код NumPy с помощью NumExpr, так как он очень хорошо поддерживает многопоточность. Ниже приведен пример, с которым я работаю:

# input array to work with
x = np.linspace(-1, 1, 1e7)

# a cubic polynomial expr
cubic_poly = 0.25*x**3 + 0.75*x**2 + 1.5*x - 2

%timeit -n 10 cubic_poly = 0.25*x**3 + 0.75*x**2 + 1.5*x - 2
# 657 ms ± 5.04 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)

Теперь мы можем сделать то же самое, используя NumExpr:

cubic_poly_str = "0.25*x**3 + 0.75*x**2 + 1.5*x - 2"
# set number of threads to 1 for fair comparison
ne.set_num_threads(1)

%timeit -n 10 ne.evaluate(cubic_poly_str)
# 60.5 ms ± 908 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)

Как видно из таймингов, NumExpr более чем в 10 раз быстрее, даже если мы используем то же количество потоков, что и NumPy (т. е. 1).


Теперь давайте увеличим вычислительную мощность и используем все доступные потоки и наблюдаем:

# use all available threads/cores
ne.set_num_threads(ne.detect_number_of_threads())

%timeit -n 10 ne.evaluate(cubic_poly_str)
# 16.1 ms ± 82.4 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)

# sanity check
np.allclose(cubic_poly, ne.evaluate(cubic_poly_str))

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

Почему NumExpr в 10 раз быстрее даже при использовании того же количества потоков (т.е. 1)?


  • При использовании numpy некоторые промежуточные значения вычислений сохраняются в памяти (например, 1.5*x), а для таких больших x требуется время. С другой стороны, numexpr разбивает данные на небольшие фрагменты и выполняет полное вычисление без значительного хранения промежуточных значений. 14.04.2019

Ответы:


1

Ваше предположение, что ускорение происходит только/в основном за счет распараллеливания, неверно. Как уже указывал @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
Новые материалы

Кластеризация: более глубокий взгляд
Кластеризация — это метод обучения без учителя, в котором мы пытаемся найти группы в наборе данных на основе некоторых известных или неизвестных свойств, которые могут существовать. Независимо от..

Как написать эффективное резюме
Предложения по дизайну и макету, чтобы представить себя профессионально Вам не позвонили на собеседование после того, как вы несколько раз подали заявку на работу своей мечты? У вас может..

Частный метод Python: улучшение инкапсуляции и безопасности
Введение Python — универсальный и мощный язык программирования, известный своей простотой и удобством использования. Одной из ключевых особенностей, отличающих Python от других языков, является..

Как я автоматизирую тестирование с помощью Jest
Шутка для победы, когда дело касается автоматизации тестирования Одной очень важной частью разработки программного обеспечения является автоматизация тестирования, поскольку она создает..

Работа с векторными символическими архитектурами, часть 4 (искусственный интеллект)
Hyperseed: неконтролируемое обучение с векторными символическими архитектурами (arXiv) Автор: Евгений Осипов , Сачин Кахавала , Диланта Хапутантри , Тимал Кемпития , Дасвин Де Сильва ,..

Понимание расстояния Вассерштейна: мощная метрика в машинном обучении
В обширной области машинного обучения часто возникает необходимость сравнивать и измерять различия между распределениями вероятностей. Традиционные метрики расстояния, такие как евклидово..

Обеспечение масштабируемости LLM: облачный анализ с помощью AWS Fargate и Copilot
В динамичной области искусственного интеллекта все большее распространение получают модели больших языков (LLM). Они жизненно важны для различных приложений, таких как интеллектуальные..