Исполнитель ThreadPoolExecutor внутри ProcessPoolExecutor

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

Я использую оптимизацию роя частиц (PSO). Не вдаваясь в подробности о самом PSO, вот основная схема моего кода:

Существует класс Particle с методом getFitness(self) (который вычисляет некоторую метрику и сохраняет ее в self.fitness). Симуляция PSO имеет несколько экземпляров частиц (более 10; 100 или даже 1000 для некоторых симуляций).
Время от времени мне приходится вычислять физическую форму частиц. В настоящее время я делаю это в цикле:

for p in listOfParticles:
  p.getFitness(args)

Тем не менее, я замечаю, что пригодность каждой частицы может быть вычислена независимо друг от друга. Это делает это вычисление пригодности главным кандидатом для распараллеливания. Действительно, я мог бы сделать map(lambda p: p.getFitness(args), listOfParticles).

Теперь я легко могу сделать это с помощью futures.ProcessPoolExecutor:

with futures.ProcessPoolExecutor() as e:
  e.map(lambda p: p.getFitness(args), listOfParticles)

Поскольку побочные эффекты вызова p.getFitness хранятся в каждой частице, мне не нужно беспокоиться о получении отдачи от futures.ProcessPoolExecutor().

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

Здесь, где я сталкиваюсь с неприятностями:
На основании примеров, которые я видел, ThreadPoolExecutor работает на list. Так же как и ProcessPoolExecutor. Поэтому я не могу сделать ничего итеративного в ProcessPoolExecutor, чтобы перейти на ThreadPoolExecutor, потому что тогда ThreadPoolExecutor собирается заставить работать один объект (см. мою попытку, опубликованную ниже).
С другой стороны, я не могу сам нарезать listOfParticles, потому что я хочу, чтобы ThreadPoolExecutor делал свою собственную магию, чтобы выяснить, сколько потоков требуется.

Итак, большой вопрос (наконец-то):
Как я должен структурировать свой код так, чтобы я мог эффективно распараллеливать следующее, используя оба процесса и потоки:

for p in listOfParticles:
  p.getFitness()

Это то, что я пытался, но я не осмелился бы попытаться запустить его, потому что я знаю, что это не сработает:

>>> def threadize(func, L, mw):
...     with futures.ThreadpoolExecutor(max_workers=mw) as executor:
...             for i in L:
...                     executor.submit(func, i)
... 

>>> def processize(func, L, mw):
...     with futures.ProcessPoolExecutor() as executor:
...             executor.map(lambda i: threadize(func, i, mw), L)
...

Буду признателен за любые мысли о том, как это исправить, или даже о том, как улучшить мой подход

.В случае, если это имеет значение, я на Python3.3.2

Ответ 1

Я дам вам рабочий код, который смешивает процессы с потоками для решения проблемы, но это не то, что вы ожидаете;-) Прежде всего, нужно сделать макетную программу, которая не подвергает опасности ваши реальные данные. Экспериментируйте с чем-то безвредным. Итак, вот начало:

class Particle:
    def __init__(self, i):
        self.i = i
        self.fitness = None
    def getfitness(self):
        self.fitness = 2 * self.i

Теперь нам есть с чем поиграть. Далее некоторые константы:

MAX_PROCESSES = 3
MAX_THREADS = 2 # per process
CHUNKSIZE = 100

Попробуйте их попробовать. CHUNKSIZE будет объяснено позже.

Первый сюрприз для вас - это то, что делает моя младшая рабочая функция. Это потому, что вы чересчур оптимистичны:

Поскольку побочные эффекты вызова p.getFitness хранятся в каждой частице, мне не нужно беспокоиться о том, чтобы получить return from futures.ProcessPoolExecutor().

Увы, ничто в рабочем процессе не может повлиять на экземпляры Particle в вашей основной программе. Рабочий процесс работает с копиями экземпляров Particle, будь то с помощью реализации fork() для копирования на запись, или потому, что он работает над копией, сделанной из разметки пика Particle, проходящей через процессы.

Итак, если вы хотите, чтобы ваша основная программа увидела результаты фитнеса, вам необходимо организовать отправку информации обратно в основную программу. Поскольку я не знаю достаточно о вашей реальной программе, здесь я предполагаю, что Particle().i является уникальным целым числом, и что основная программа может легко отобразить целые числа в экземпляры Particle. Имея это в виду, нижняя рабочая функция здесь должна вернуть пару: уникальное целое число и результат пригодности:

def thread_worker(p):
    p.getfitness()
    return (p.i, p.fitness)

Учитывая, что легко распределить список Particle по потокам и вернуть список результатов (particle_id, fitness):

def proc_worker(ps):
    import concurrent.futures as cf
    with cf.ThreadPoolExecutor(max_workers=MAX_THREADS) as e:
        result = list(e.map(thread_worker, ps))
    return result

Примечания:

  • Функция, выполняемая каждым рабочим процессом.
  • Я использую Python 3, поэтому используйте list(), чтобы заставить e.map() материализовать все результаты в списке.
  • Как упоминалось в комментарии, под CPython распространение задач с привязкой к ЦП по потокам происходит медленнее, чем выполнение всего этого в одном потоке.

Осталось только написать код, чтобы развернуть список Particle для всех процессов и получить результаты. Это невозможно сделать с multiprocessing, так что я буду использовать. Я понятия не имею, может ли это сделать concurrent.futures (учитывая, что мы также смешиваем потоки), но все равно. Но поскольку я даю вам рабочий код, вы можете играть с ним и отчитываться, -)

if __name__ == "__main__":
    import multiprocessing

    particles = [Particle(i) for i in range(100000)]
    # Note the code below relies on that particles[i].i == i
    assert all(particles[i].i == i for i in range(len(particles)))

    pool = multiprocessing.Pool(MAX_PROCESSES)
    for result_list in pool.imap_unordered(proc_worker,
                      (particles[i: i+CHUNKSIZE]
                       for i in range(0, len(particles), CHUNKSIZE))):
        for i, fitness in result_list:
            particles[i].fitness = fitness

    pool.close()
    pool.join()

    assert all(p.fitness == 2*p.i for p in particles)

Примечания:

  • Я разбиваю список Particle на куски "вручную". Для чего CHUNKSIZE. Это потому, что рабочий процесс хочет, чтобы список Particle работал, и в свою очередь это потому, что хочет функция futures map(). Это хорошая идея, чтобы не работать, несмотря на то, что вы получаете реальный взмах для доллара в ответ на накладные расходы inter-process для каждого вызова.
  • imap_unordered() не дает никаких гарантий относительно порядка возврата результатов. Это дает больше свободы для организации работы максимально эффективно. И мы не заботимся о порядке здесь, так что хорошо.
  • Обратите внимание, что цикл получает результаты (particle_id, fitness) и соответственно изменяет экземпляры Particle. Возможно, ваш реальный .getfitness делает другие мутации для экземпляров Particle - не может угадать. Независимо от того, что основная программа никогда не увидит каких-либо мутаций, сделанных в рабочих "по магии", вы должны явно организовать это. В пределе вы можете вместо этого вернуть пары (particle_id, particle_instance) и заменить экземпляры Particle в основной программе. Затем они отражают все мутации, сделанные в рабочих процессах.

Удачи: -)

Фьючерсы полностью вниз

Оказывается, было очень легко заменить multiprocessing. Вот изменения. Это также (как упоминалось ранее) заменяет оригинальные экземпляры Particle, чтобы зафиксировать все мутации. Там есть компромисс, хотя: для травления экземпляра требуется "намного больше" байтов, чем травление единственного результата "пригодности". Больше сетевого трафика. Выберите свой яд; -)

Возврат мутированного экземпляра требует замены последней строки thread_worker(), например:

return (p.i, p)

Затем замените весь блок < основной следующим образом:

def update_fitness():
    import concurrent.futures as cf
    with cf.ProcessPoolExecutor(max_workers=MAX_PROCESSES) as e:
        for result_list in e.map(proc_worker,
                      (particles[i: i+CHUNKSIZE]
                       for i in range(0, len(particles), CHUNKSIZE))):
            for i, p in result_list:
                particles[i] = p

if __name__ == "__main__":
    particles = [Particle(i) for i in range(500000)]
    assert all(particles[i].i == i for i in range(len(particles)))

    update_fitness()

    assert all(particles[i].i == i for i in range(len(particles)))
    assert all(p.fitness == 2*p.i for p in particles)

Код очень похож на танец multiprocessor. Лично я бы использовал версию multiprocessing, потому что imap_unordered является ценным. Это проблема с упрощенными интерфейсами: они часто покупают простоту ценой скрытия полезных возможностей.

Ответ 2

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

Если добавление потоков влияет на вашу производительность, следующий вопрос заключается в том, можно ли повысить производительность при ручной балансировке нагрузки или автоматически. Ручным я имею в виду тщательную разбивку рабочей нагрузки на куски подобной вычислительной сложности и создание нового процессора задач на кусок, ваше решение, но с сомнением. Путем автоматического создания пула процессов/потоков и связи в рабочей очереди для новых задач, к которым вы стремитесь. На мой взгляд, первый подход является одной из парадигм Apache Hadoop, второй - процессорами рабочих очередей, такими как Celery. Первый подход может привести к тому, что некоторые задачи будут медленнее и запущены, а другие завершены, а вторая добавляет накладные расходы на коммутацию и ожидания на задание, и это вторая точка тестов производительности.

Наконец, если вы хотите иметь статический набор процессов с многопоточными файлами внутри AFAIK, вы не сможете достичь его с помощью concurrent.futures как есть и немного изменить его. Я не знаю, существуют ли существующие решения для этой задачи, но поскольку concurrent - это чистое решение python (без кода C), это легко сделать. Рабочий процессор определяется в _adjust_process_count процедура класса ProcessPoolExecutor, а подклассы и переопределение его с многопоточным подходом довольно непросто, вы просто поставьте свой собственный _process_worker на основе concurrent.features.thread

Оригинал ProcessPoolExecutor._adjust_process_count для справки:

def _adjust_process_count(self):
    for _ in range(len(self._processes), self._max_workers):
        p = multiprocessing.Process(
                target=_process_worker,
                args=(self._call_queue,
                      self._result_queue))
        p.start()
        self._processes[p.pid] = p

Ответ 3

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

import concurrent.futures
import logging
from typing import Callable, Iterable, Optional

import threadedprocess

log = logging.getLogger(__name__)


def concurrently_execute(fn: Callable, fn_args: Iterable, max_processes: Optional[int] = None, max_threads_per_process: Optional[int] = None) -> None:
    """Execute the given callable concurrently using multiple threads and/or processes."""
    # Ref: https://stackoverflow.com/a/57999709/
    if max_processes == 1:
        executor = concurrent.futures.ThreadPoolExecutor(max_workers=max_threads_per_process)
    elif max_threads_per_process == 1:
        executor = concurrent.futures.ProcessPoolExecutor(max_workers=max_processes)  # type: ignore
    else:
        executor = threadedprocess.ThreadedProcessPoolExecutor(max_processes=max_processes, max_threads=max_threads_per_process)

    if max_processes and max_threads_per_process:
        max_workers = max_processes * max_threads_per_process
        log.info("Using %s with %s processes and %s threads per process, i.e. with %s workers.", executor.__class__.__name__, max_processes, max_threads_per_process, max_workers)

    with executor:
        futures = [executor.submit(fn, *fn_args_cur) for fn_args_cur in fn_args]

    for future in concurrent.futures.as_completed(futures):
        future.result()  # Raises exception if it occurred in process worker.