Быстрая очередь только для чтения с числовыми массивами

У меня есть многопроцессорное задание, в котором я выполняю очередь с использованием только массивов numpy, как часть конвейера производителя.

В настоящее время они маринуются, потому что это поведение по умолчанию multiprocessing.Queue, которое замедляет производительность.

Есть ли какой-нибудь pythonic способ передать ссылки на разделяемую память вместо того, чтобы травить массивы?

К сожалению, массивы генерируются после запуска потребителя, и нет простого способа обойти это. (Таким образом, глобальный переменный подход был бы уродливым...).

[Обратите внимание, что в следующем коде мы не ожидаем вычисления h (x0) и h (x1) параллельно. Вместо этого мы видим, что h (x0) и g (h (x1)) вычисляются параллельно (например, конвейерная обработка в ЦП).]

from multiprocessing import Process, Queue
import numpy as np

class __EndToken(object):
    pass

def parrallel_pipeline(buffer_size=50):
    def parrallel_pipeline_with_args(f):
        def consumer(xs, q):
            for x in xs:
                q.put(x)
            q.put(__EndToken())

        def parallel_generator(f_xs):
            q = Queue(buffer_size)
            consumer_process = Process(target=consumer,args=(f_xs,q,))
            consumer_process.start()
            while True:
                x = q.get()
                if isinstance(x, __EndToken):
                    break
                yield x

        def f_wrapper(xs):
            return parallel_generator(f(xs))

        return f_wrapper
    return parrallel_pipeline_with_args


@parrallel_pipeline(3)
def f(xs):
    for x in xs:
        yield x + 1.0

@parrallel_pipeline(3)
def g(xs):
    for x in xs:
        yield x * 3

@parrallel_pipeline(3)
def h(xs):
    for x in xs:
        yield x * x

def xs():
    for i in range(1000):
        yield np.random.uniform(0,1,(500,2000))

if __name__ == "__main__":
    rs = f(g(h(xs())))
    for r in rs:
        print r

Ответ 1

Обмен памятью между потоками или процессами

Использовать многопоточность вместо многопроцессорной обработки

Поскольку вы используете numpy, вы можете воспользоваться тем фактом, что глобальная блокировка интерпретатора выпущена во время вычисления numpy. Это означает, что вы можете выполнять параллельную обработку со стандартными потоками и разделяемой памятью вместо многопроцессорной и межпроцессной коммуникации. Вот версия вашего кода, настроенная на использование threading.Thread и Queue.Queue вместо многопроцессорной обработки. Процесс и многопроцессорность. Queue. Это пропускает numpy ndarray через очередь без травления. На моем компьютере это работает примерно в 3 раза быстрее, чем ваш код. (Тем не менее, это примерно на 20% быстрее, чем серийная версия вашего кода. Я предложил некоторые другие подходы дальше.)

from threading import Thread
from Queue import Queue
import numpy as np

class __EndToken(object):
    pass

def parallel_pipeline(buffer_size=50):
    def parallel_pipeline_with_args(f):
        def consumer(xs, q):
            for x in xs:
                q.put(x)
            q.put(__EndToken())

        def parallel_generator(f_xs):
            q = Queue(buffer_size)
            consumer_process = Thread(target=consumer,args=(f_xs,q,))
            consumer_process.start()
            while True:
                x = q.get()
                if isinstance(x, __EndToken):
                    break
                yield x

        def f_wrapper(xs):
            return parallel_generator(f(xs))

        return f_wrapper
    return parallel_pipeline_with_args

@parallel_pipeline(3)
def f(xs):
    for x in xs:
        yield x + 1.0

@parallel_pipeline(3)
def g(xs):
    for x in xs:
        yield x * 3

@parallel_pipeline(3)
def h(xs):
    for x in xs:
        yield x * x

def xs():
    for i in range(1000):
        yield np.random.uniform(0,1,(500,2000))

rs = f(g(h(xs())))
%time print sum(r.sum() for r in rs)  # 12.2s

Хранить массивы numpy в общей памяти

Другим вариантом, близким к запрошенному, будет продолжение использования пакета многопроцессорности, но передача данных между процессами с использованием массивов, хранящихся в общей памяти. В приведенном ниже коде создается новый класс ArrayQueue. Объект ArrayQueue должен быть создан до появления подпроцессов. Он создает и управляет пулом массивов numpy, поддерживаемых общей памятью. Когда массив результатов помещается в очередь, ArrayQueue копирует данные из этого массива в существующий массив разделяемой памяти, а затем передает идентификатор массива разделяемой памяти через очередь. Это намного быстрее, чем отправка всего массива через очередь, поскольку он позволяет избежать травления массивов. Это имеет сходную производительность с поточной версией выше (примерно на 10% медленнее) и может масштабироваться лучше, если глобальная блокировка интерпретатора является проблемой (т.е. Вы запускаете много кода на Python в функциях).

from multiprocessing import Process, Queue, Array
import numpy as np

class ArrayQueue(object):
    def __init__(self, template, maxsize=0):
        if type(template) is not np.ndarray:
            raise ValueError('ArrayQueue(template, maxsize) must use a numpy.ndarray as the template.')
        if maxsize == 0:
            # this queue cannot be infinite, because it will be backed by real objects
            raise ValueError('ArrayQueue(template, maxsize) must use a finite value for maxsize.')

        # find the size and data type for the arrays
        # note: every ndarray put on the queue must be this size
        self.dtype = template.dtype
        self.shape = template.shape
        self.byte_count = len(template.data)

        # make a pool of numpy arrays, each backed by shared memory, 
        # and create a queue to keep track of which ones are free
        self.array_pool = [None] * maxsize
        self.free_arrays = Queue(maxsize)
        for i in range(maxsize):
            buf = Array('c', self.byte_count, lock=False)
            self.array_pool[i] = np.frombuffer(buf, dtype=self.dtype).reshape(self.shape)
            self.free_arrays.put(i)

        self.q = Queue(maxsize)

    def put(self, item, *args, **kwargs):
        if type(item) is np.ndarray:
            if item.dtype == self.dtype and item.shape == self.shape and len(item.data)==self.byte_count:
                # get the ID of an available shared-memory array
                id = self.free_arrays.get()
                # copy item to the shared-memory array
                self.array_pool[id][:] = item
                # put the array id (not the whole array) onto the queue
                new_item = id
            else:
                raise ValueError(
                    'ndarray does not match type or shape of template used to initialize ArrayQueue'
                )
        else:
            # not an ndarray
            # put the original item on the queue (as a tuple, so we know it not an ID)
            new_item = (item,)
        self.q.put(new_item, *args, **kwargs)

    def get(self, *args, **kwargs):
        item = self.q.get(*args, **kwargs)
        if type(item) is tuple:
            # unpack the original item
            return item[0]
        else:
            # item is the id of a shared-memory array
            # copy the array
            arr = self.array_pool[item].copy()
            # put the shared-memory array back into the pool
            self.free_arrays.put(item)
            return arr

class __EndToken(object):
    pass

def parallel_pipeline(buffer_size=50):
    def parallel_pipeline_with_args(f):
        def consumer(xs, q):
            for x in xs:
                q.put(x)
            q.put(__EndToken())

        def parallel_generator(f_xs):
            q = ArrayQueue(template=np.zeros(0,1,(500,2000)), maxsize=buffer_size)
            consumer_process = Process(target=consumer,args=(f_xs,q,))
            consumer_process.start()
            while True:
                x = q.get()
                if isinstance(x, __EndToken):
                    break
                yield x

        def f_wrapper(xs):
            return parallel_generator(f(xs))

        return f_wrapper
    return parallel_pipeline_with_args


@parallel_pipeline(3)
def f(xs):
    for x in xs:
        yield x + 1.0

@parallel_pipeline(3)
def g(xs):
    for x in xs:
        yield x * 3

@parallel_pipeline(3)
def h(xs):
    for x in xs:
        yield x * x

def xs():
    for i in range(1000):
        yield np.random.uniform(0,1,(500,2000))

print "multiprocessing with shared-memory arrays:"
%time print sum(r.sum() for r in f(g(h(xs()))))   # 13.5s

Параллельная обработка образцов вместо функций

Код выше всего на 20% быстрее, чем однопоточная версия (12.2s против 14.8s для серийной версии, показанной ниже). Это потому, что каждая функция запускается в одном потоке или процессе, и большая часть работы выполняется с помощью xs(). Время выполнения для приведенного выше примера почти такое же, как если бы вы просто запустили %time print sum(1 for x in xs()).

Если ваш реальный проект имеет гораздо больше промежуточных функций и/или они сложнее, чем те, которые вы показали, тогда рабочая нагрузка может быть лучше распределена между процессорами, и это может и не быть проблемой. Однако, если ваша рабочая нагрузка действительно похожа на предоставленный вами код, вы можете захотеть реорганизовать свой код, чтобы выделить один образец для каждого потока вместо одной функции для каждого потока. Это будет выглядеть как код ниже (показаны как потоковые, так и многопроцессорные версии):

import multiprocessing
import threading, Queue
import numpy as np

def f(x):
    return x + 1.0

def g(x):
    return x * 3

def h(x):
    return x * x

def final(i):
    return f(g(h(x(i))))

def final_sum(i):
    return f(g(h(x(i)))).sum()

def x(i):
    # produce sample number i
    return np.random.uniform(0, 1, (500, 2000))

def rs_serial(func, n):
    for i in range(n):
        yield func(i)

def rs_parallel_threaded(func, n):
    todo = range(n)
    q = Queue.Queue(2*n_workers)

    def worker():
        while True:
            try:
                # the global interpreter lock ensures only one thread does this at a time
                i = todo.pop()
                q.put(func(i))
            except IndexError:
                # none left to do
                q.put(None)
                break

    threads = []
    for j in range(n_workers):
        t = threading.Thread(target=worker)
        t.daemon=False
        threads.append(t)   # in case it needed later
        t.start()

    while True:
        x = q.get()
        if x is None:
            break
        else:
            yield x

def rs_parallel_mp(func, n):
    pool = multiprocessing.Pool(n_workers)
    return pool.imap_unordered(func, range(n))

n_workers = 4
n_samples = 1000

print "serial:"  # 14.8s
%time print sum(r.sum() for r in rs_serial(final, n_samples))
print "threaded:"  # 10.1s
%time print sum(r.sum() for r in rs_parallel_threaded(final, n_samples))

print "mp return arrays:"  # 19.6s
%time print sum(r.sum() for r in rs_parallel_mp(final, n_samples))
print "mp return results:"  # 8.4s
%time print sum(r_sum for r_sum in rs_parallel_mp(final_sum, n_samples))

Резьбовая версия этого кода только немного быстрее, чем первый пример, который я дал, и только на 30% быстрее, чем серийная версия. Это не столько ускорение, сколько я ожидал; может быть, Python все еще частично увяз в GIL?

Многопроцессорная версия выполняется значительно быстрее, чем исходный многопроцессорный код, в первую очередь потому, что все функции объединяются вместе в один процесс, а не в очереди (и травления) промежуточных результатов. Тем не менее, он все еще медленнее, чем серийная версия, потому что все массивы результатов должны быть маринованными (в рабочем процессе) и незакрашенными (в основном процессе), прежде чем они будут возвращены imap_unordered. Однако, если вы можете организовать его так, чтобы ваш конвейер возвращал агрегированные результаты вместо полных массивов, тогда вы можете избежать накладных расходов на травление, а версия многопроцессорности - быстрее: примерно на 43% быстрее, чем серийная версия.

ОК, теперь для полноты, здесь вариант второго примера, который использует многопроцессорность с вашими исходными функциями генератора вместо более тонких функций, показанных выше. Это использует некоторые трюки для распространения образцов среди нескольких процессов, что может сделать его непригодным для многих рабочих процессов. Но использование генераторов, по-видимому, немного быстрее, чем использование более тонких функций, и этот метод может привести вас к ускорению на 54% по сравнению с серийной версией, показанной выше. Однако это доступно только в том случае, если вам не нужно возвращать полные массивы из рабочих функций.

import multiprocessing, itertools, math
import numpy as np

def f(xs):
    for x in xs:
        yield x + 1.0

def g(xs):
    for x in xs:
        yield x * 3

def h(xs):
    for x in xs:
        yield x * x

def xs():
    for i in range(1000):
        yield np.random.uniform(0,1,(500,2000))

def final():
    return f(g(h(xs())))

def final_sum():
    for x in f(g(h(xs()))):
        yield x.sum()

def get_chunk(args):
    """Retrieve n values (n=args[1]) from a generator function (f=args[0]) and return them as a list. 
    This runs in a worker process and does all the computation."""
    return list(itertools.islice(args[0](), args[1]))

def parallelize(gen_func, max_items, n_workers=4, chunk_size=50):
    """Pull up to max_items items from several copies of gen_func, in small groups in parallel processes.
    chunk_size should be big enough to improve efficiency (one copy of gen_func will be run for each chunk)
    but small enough to avoid exhausting memory (each worker will keep chunk_size items in memory)."""

    pool = multiprocessing.Pool(n_workers)

    # how many chunks will be needed to yield at least max_items items?
    n_chunks = int(math.ceil(float(max_items)/float(chunk_size)))

    # generate a suitable series of arguments for get_chunk()
    args_list = itertools.repeat((gen_func, chunk_size), n_chunks)

    # chunk_gen will yield a series of chunks (lists of results) from the generator function, 
    # totaling n_chunks * chunk_size items (which is >= max_items)
    chunk_gen = pool.imap_unordered(get_chunk, args_list)

    # parallel_gen flattens the chunks, and yields individual items
    parallel_gen = itertools.chain.from_iterable(chunk_gen)

    # limit the output to max_items items 
    return itertools.islice(parallel_gen, max_items)


# in this case, the parallel version is slower than a single process, probably
# due to overhead of gathering numpy arrays in imap_unordered (via pickle?)
print "serial, return arrays:"  # 15.3s
%time print sum(r.sum() for r in final())
print "parallel, return arrays:"  # 24.2s
%time print sum(r.sum() for r in parallelize(final, max_items=1000))


# in this case, the parallel version is more than twice as fast as the single-thread version
print "serial, return result:"  # 15.1s
%time print sum(r for r in final_sum())
print "parallel, return result:"  # 6.8s
%time print sum(r for r in parallelize(final_sum, max_items=1000))

Ответ 2

Ваш пример, похоже, не работает на моем компьютере, хотя это может быть связано с тем, что я запускаю окна (проблемы травления ничего не в пространстве имен __main__ (что-то украшено))... будет что-то вроде эта помощь? (вам нужно будет положить пакет и распаковать внутри каждого из f(), g() и h())

Примечание * Я не уверен, что это будет на самом деле быстрее. Просто ударьте то, что предложили другие.

from multiprocessing import Process, freeze_support
from multiprocessing.sharedctypes import Value, Array
import numpy as np

def package(arr):
    shape = Array('i', arr.shape, lock=False)

    if arr.dtype == float:
        ctype = Value('c', b'd') #d for double #f for single
    if arr.dtype == int:
        ctype = Value('c', b'i') #if statements could be avoided if data is always the same
    data = Array(ctype.value, arr.reshape(-1),lock=False)

    return data, shape

def unpack(data, shape):
    return np.array(data[:]).reshape(shape[:])

#test
def f(args):
    print(unpack(*args))

if __name__ == '__main__':
    freeze_support()

    a = np.array([1,2,3,4,5])
    a_packed = package(a)
    print('array has been packaged')

    p = Process(target=f, args=(a_packed,))
    print('passing to parallel process')
    p.start()

    print('joining to parent process')
    p.join()
    print('finished')

Ответ 3

Просмотрите Проект для многопроцессорных проектов, который позволяет избежать стандартной зависимости multiprocessing от травления. Это должно позволить вам обойти как неэффективность травления, так и предоставить вам доступ к общей памяти для общих ресурсов только для чтения. Обратите внимание, что, хотя Pathos близок к развертыванию в пакете полного пакета, в промежутке времени я бы рекомендовал установить с помощью pip install git+https://github.com/uqfoundation/pathos