Cython работает медленнее на 4 потока, чем с диапазоном

В настоящее время я пытаюсь выполнить простой пример распараллеливания цикла с помощью cython prange. Я установил OpenBlas 0.2.14 с разрешенным openmp и скомпилировал numpy 1.10.1 и scipy 0.16 из исходного кода против openblas. Для проверки производительности библиотек я использую следующий пример: http://nealhughes.net/parallelcomp2/. Замеряемые функции скопированы с сайта:

import numpy as np
from math import exp 
from libc.math cimport exp as c_exp
from cython.parallel import prange,parallel

def array_f(X):

    Y = np.zeros(X.shape)
    index = X > 0.5
    Y[index] = np.exp(X[index])

    return Y

def c_array_f(double[:] X):

    cdef int N = X.shape[0]
    cdef double[:] Y = np.zeros(N)
    cdef int i

    for i in range(N):
        if X[i] > 0.5:
            Y[i] = c_exp(X[i])
        else:
            Y[i] = 0

    return Y


def c_array_f_multi(double[:] X):

    cdef int N = X.shape[0]
    cdef double[:] Y = np.zeros(N)
    cdef int i
    with nogil, parallel():
        for i in prange(N):
            if X[i] > 0.5:
                Y[i] = c_exp(X[i])
            else:
                Y[i] = 0

    return Y

Автор кода сообщает о следующих ускорениях для 4 ядер:

from thread_demo import *
import numpy as np
X = -1 + 2*np.random.rand(10000000) 
%timeit array_f(X)
1 loops, best of 3: 222 ms per loop
%timeit c_array_f(X)
10 loops, best of 3: 87.5 ms per loop 
%timeit c_array_f_multi(X)
10 loops, best of 3: 22.4 ms per loop

Когда я запускаю этот пример на своих машинах (macbook pro с osx 10.10), я получаю следующие тайминги для экспорта OMP_NUM_THREADS=1

In [1]: from bla import *
In [2]: import numpy as np
In [3]: X = -1 + 2*np.random.rand(10000000)
In [4]: %timeit c_array_f(X)
10 loops, best of 3: 89.7 ms per loop
In [5]: %timeit c_array_f_multi(X)
1 loops, best of 3: 343 ms per loop

и для OMP_NUM_THREADS=4

In [1]: from bla import *
In [2]: import numpy as np
In [3]: X = -1 + 2*np.random.rand(10000000)
In [4]: %timeit c_array_f(X)
10 loops, best of 3: 89.5 ms per loop
In [5]: %timeit c_array_f_multi(X)
10 loops, best of 3: 119 ms per loop

Я вижу такое же поведение на машине openSuse, отсюда и мой вопрос. Как автор может получить 4-кратное ускорение, в то время как тот же код работает медленнее для 4 потоков на 2 моих системах.

Сценарий установки для создания *.c & .so также идентичен тому, что используется в блоге.

from distutils.core import setup
from Cython.Build import cythonize
from distutils.extension import Extension
from Cython.Distutils import build_ext
import numpy as np

ext_modules=[
    Extension("bla",
              ["bla.pyx"],
              libraries=["m"],
              extra_compile_args = ["-O3", "-ffast-math","-march=native", "-fopenmp" ],
              extra_link_args=['-fopenmp'],
              include_dirs = [np.get_include()]
              ) 
]

setup( 
  name = "bla",
  cmdclass = {"build_ext": build_ext},
  ext_modules = ext_modules
)

Было бы здорово, если бы кто-нибудь объяснил мне, почему это происходит.


person Martin Gal    schedule 18.10.2015    source источник


Ответы (1)


1) Важной особенностью prange (как и любого другого цикла parallel for) является то, что он активирует выполнение вне очереди, что означает, что цикл может выполняться в любом произвольном порядке. Выполнение не по порядку действительно окупается, когда у вас нет зависимости данных между итерациями.

Я не знаю внутренностей Cython, но полагаю, что если boundschecking не отключен, цикл не может выполняться произвольно, так как следующая итерация будет зависеть от того, выходит ли массив за границы на текущей итерации, следовательно, проблема становится почти последовательной, поскольку потокам придется ждать результата. Это одна из проблем с вашим кодом. На самом деле Cython дает мне следующее предупреждение:

warning: bla.pyx:42:16: Use boundscheck(False) for faster access

Итак, добавьте следующее

from cython import boundscheck, wraparound

@boundscheck(False)
@wraparound(False)
def c_array_f(double[:] X):
   # Rest of your code

@boundscheck(False)
@wraparound(False)
def c_array_f_multi(double[:] X):
   # Rest of your code

Давайте теперь синхронизируем их с вашими данными X = -1 + 2*np.random.rand(10000000).

С проверкой границ:

In [2]:%timeit array_f(X)
10 loops, best of 3: 189 ms per loop
In [4]:%timeit c_array_f(X)
10 loops, best of 3: 93.6 ms per loop
In [5]:%timeit c_array_f_multi(X)
10 loops, best of 3: 103 ms per loop

Без проверки границ:

In [9]:%timeit c_array_f(X)
10 loops, best of 3: 84.2 ms per loop
In [10]:%timeit c_array_f_multi(X)
10 loops, best of 3: 42.3 ms per loop

Эти результаты с num_threads=4 (у меня 4 логических ядра) и ускорение примерно в 2 раза. Прежде чем идти дальше, мы все еще можем сбрить еще несколько ms, объявив наши массивы непрерывными, то есть объявив X и Y с double[::1].

Непрерывные массивы:

In [14]:%timeit c_array_f(X)
10 loops, best of 3: 81.8 ms per loop
In [15]:%timeit c_array_f_multi(X)
10 loops, best of 3: 39.3 ms per loop

2) Еще важнее планирование работы. a> и именно этим страдает ваш бенчмарк. По умолчанию размеры фрагментов определяются во время компиляции, т. е. schedule=static, однако очень вероятно, что переменные среды (например, OMP_SCHEDULE) и рабочая нагрузка двух машин (вашей и той, что указана в блоге) различаются, и они планируют задания во время выполнения, динамически, управляемо и так далее. Давайте поэкспериментируем с заменой вашего prange на

for i in prange(N, schedule='static'):
    # static scheduling... 
for i in prange(N, schedule='dynamic'):
    # dynamic scheduling... 

Давайте теперь их время (только многопоточный код):

Эффект планирования:

In [23]:%timeit c_array_f_multi(X) # static
10 loops, best of 3: 39.5 ms per loop
In [28]:%timeit c_array_f_multi(X) # dynamic
1 loops, best of 3: 319 ms per loop

Вы можете воспроизвести это в зависимости от рабочей нагрузки на вашем собственном компьютере. В качестве примечания, поскольку вы просто пытаетесь измерить производительность параллельного и последовательного кода в микротесте, а не в реальном коде, я предлагаю вам избавиться от условия if-else, т.е. оставить только Y[i] = c_exp(X[i]) в цикле for. Это связано с тем, что операторы if-else также неблагоприятно влияют на прогнозирование ветвлений и выполнение не по порядку в параллельном коде. На моей машине я получаю почти 2,7-кратное ускорение по сравнению с последовательным кодом с этим изменением.

person romeric    schedule 18.10.2015
comment
Спасибо за советы. Я обновил пост в ответ. - person Neal Hughes; 21.11.2016