Skip to content

Commit ae18dae

Browse files
obaltianOleksandr BaltianencukouSasha Baltian
authored
gh-64192: Add *buffersize* to imap()/imap_unordered() in multiprocessing.pool (GH-136871)
The new argument allows consuming the input iterator lazily, potentially saving memory, and allowing long/infinite iterators. It mirrors the *buffersize* argument to `concurrent.futures.Executor.map` that was added in 3.14. Co-authored-by: Oleksandr Baltian <oleksandr.baltian@maklai.com.ua> Co-authored-by: Petr Viktorin <encukou@gmail.com> Co-authored-by: Sasha Baltian <sasha.baltian@hyperexponential.com>
1 parent 9ccd5bb commit ae18dae

5 files changed

Lines changed: 305 additions & 74 deletions

File tree

Doc/library/multiprocessing.rst

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2536,7 +2536,7 @@ with the :class:`Pool` class.
25362536
Callbacks should complete immediately since otherwise the thread which
25372537
handles the results will get blocked.
25382538

2539-
.. method:: imap(func, iterable[, chunksize])
2539+
.. method:: imap(func, iterable, chunksize=1, *, buffersize=None)
25402540

25412541
A lazier version of :meth:`.map`.
25422542

@@ -2550,12 +2550,27 @@ with the :class:`Pool` class.
25502550
``next(timeout)`` will raise :exc:`multiprocessing.TimeoutError` if the
25512551
result cannot be returned within *timeout* seconds.
25522552

2553-
.. method:: imap_unordered(func, iterable[, chunksize])
2553+
The *iterable* is collected immediately rather than lazily, unless a
2554+
*buffersize* is specified to limit the number of submitted tasks whose
2555+
results have not yet been yielded. If the buffer is full, iteration over
2556+
the *iterables* pauses until a result is yielded from the buffer.
2557+
To fully utilize pool's capacity when using this feature,
2558+
set *buffersize* at least to the number of processes in pool
2559+
(to consume *iterable* as you go), or even higher
2560+
(to prefetch the next ``N=buffersize-processes`` arguments).
2561+
2562+
.. versionchanged:: next
2563+
Added the *buffersize* parameter.
2564+
2565+
.. method:: imap_unordered(func, iterable, chunksize=1, *, buffersize=None)
25542566

25552567
The same as :meth:`imap` except that the ordering of the results from the
25562568
returned iterator should be considered arbitrary. (Only when there is
25572569
only one worker process is the order guaranteed to be "correct".)
25582570

2571+
.. versionchanged:: next
2572+
Added the *buffersize* parameter.
2573+
25592574
.. method:: starmap(func, iterable[, chunksize])
25602575

25612576
Like :meth:`~multiprocessing.pool.Pool.map` except that the

Doc/whatsnew/3.16.rst

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,22 @@ math
376376
(Contributed by Jeff Epler in :gh:`150534`.)
377377

378378

379+
multiprocessing
380+
---------------
381+
382+
* Add the optional ``buffersize`` parameter to
383+
:meth:`multiprocessing.pool.Pool.imap` and
384+
:meth:`multiprocessing.pool.Pool.imap_unordered` to limit the number of
385+
submitted tasks whose results have not yet been yielded. If the buffer is
386+
full, iteration over the *iterables* pauses until a result is yielded from
387+
the buffer. To fully utilize pool's capacity when using this feature, set
388+
*buffersize* at least to the number of processes in pool (to consume
389+
*iterable* as you go), or even higher (to prefetch the next
390+
``N=buffersize-processes`` arguments).
391+
392+
(Contributed by Oleksandr Baltian in :gh:`136871`.)
393+
394+
379395
os
380396
--
381397

@@ -606,7 +622,7 @@ module_name
606622

607623

608624
Removed
609-
=======
625+
========
610626

611627
annotationlib
612628
-------------

Lib/multiprocessing/pool.py

Lines changed: 88 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,11 @@ def __init__(self, processes=None, initializer=None, initargs=(),
190190
self._ctx = context or get_context()
191191
self._setup_queues()
192192
self._taskqueue = queue.SimpleQueue()
193+
# The _taskqueue_buffersize_semaphores exist to allow calling .release()
194+
# on every active semaphore when the pool is terminating to let task_handler
195+
# wake up to stop. It's a set so that each iterator object can efficiently
196+
# deregister its semaphore when iterator finishes.
197+
self._taskqueue_buffersize_semaphores = set()
193198
# The _change_notifier queue exist to wake up self._handle_workers()
194199
# when the cache (self._cache) is empty or when there is a change in
195200
# the _state variable of the thread that runs _handle_workers.
@@ -256,7 +261,8 @@ def __init__(self, processes=None, initializer=None, initargs=(),
256261
self, self._terminate_pool,
257262
args=(self._taskqueue, self._inqueue, self._outqueue, self._pool,
258263
self._change_notifier, self._worker_handler, self._task_handler,
259-
self._result_handler, self._cache),
264+
self._result_handler, self._cache,
265+
self._taskqueue_buffersize_semaphores),
260266
exitpriority=15
261267
)
262268
self._state = RUN
@@ -382,73 +388,43 @@ def starmap_async(self, func, iterable, chunksize=None, callback=None,
382388
return self._map_async(func, iterable, starmapstar, chunksize,
383389
callback, error_callback)
384390

385-
def _guarded_task_generation(self, result_job, func, iterable):
391+
def _guarded_task_generation(self, result_job, func, iterable, sema=None):
386392
'''Provides a generator of tasks for imap and imap_unordered with
387393
appropriate handling for iterables which throw exceptions during
388394
iteration.'''
389395
try:
390396
i = -1
391-
for i, x in enumerate(iterable):
392-
yield (result_job, i, func, (x,), {})
397+
398+
if sema is None:
399+
for i, x in enumerate(iterable):
400+
yield (result_job, i, func, (x,), {})
401+
402+
else:
403+
enumerated_iter = iter(enumerate(iterable))
404+
while True:
405+
sema.acquire()
406+
try:
407+
i, x = next(enumerated_iter)
408+
except StopIteration:
409+
break
410+
yield (result_job, i, func, (x,), {})
411+
393412
except Exception as e:
394413
yield (result_job, i+1, _helper_reraises_exception, (e,), {})
395414

396-
def imap(self, func, iterable, chunksize=1):
415+
def imap(self, func, iterable, chunksize=1, *, buffersize=None):
397416
'''
398417
Equivalent of `map()` -- can be MUCH slower than `Pool.map()`.
399418
'''
400-
self._check_running()
401-
if chunksize == 1:
402-
result = IMapIterator(self)
403-
self._taskqueue.put(
404-
(
405-
self._guarded_task_generation(result._job, func, iterable),
406-
result._set_length
407-
))
408-
return result
409-
else:
410-
if chunksize < 1:
411-
raise ValueError(
412-
"Chunksize must be 1+, not {0:n}".format(
413-
chunksize))
414-
task_batches = Pool._get_tasks(func, iterable, chunksize)
415-
result = IMapIterator(self)
416-
self._taskqueue.put(
417-
(
418-
self._guarded_task_generation(result._job,
419-
mapstar,
420-
task_batches),
421-
result._set_length
422-
))
423-
return (item for chunk in result for item in chunk)
419+
return self._imap(IMapIterator, func, iterable, chunksize,
420+
buffersize=buffersize)
424421

425-
def imap_unordered(self, func, iterable, chunksize=1):
422+
def imap_unordered(self, func, iterable, chunksize=1, *, buffersize=None):
426423
'''
427424
Like `imap()` method but ordering of results is arbitrary.
428425
'''
429-
self._check_running()
430-
if chunksize == 1:
431-
result = IMapUnorderedIterator(self)
432-
self._taskqueue.put(
433-
(
434-
self._guarded_task_generation(result._job, func, iterable),
435-
result._set_length
436-
))
437-
return result
438-
else:
439-
if chunksize < 1:
440-
raise ValueError(
441-
"Chunksize must be 1+, not {0!r}".format(chunksize))
442-
task_batches = Pool._get_tasks(func, iterable, chunksize)
443-
result = IMapUnorderedIterator(self)
444-
self._taskqueue.put(
445-
(
446-
self._guarded_task_generation(result._job,
447-
mapstar,
448-
task_batches),
449-
result._set_length
450-
))
451-
return (item for chunk in result for item in chunk)
426+
return self._imap(IMapUnorderedIterator, func, iterable, chunksize,
427+
buffersize=buffersize)
452428

453429
def apply_async(self, func, args=(), kwds={}, callback=None,
454430
error_callback=None):
@@ -497,6 +473,41 @@ def _map_async(self, func, iterable, mapper, chunksize=None, callback=None,
497473
)
498474
return result
499475

476+
def _imap(self, iterator_cls, func, iterable, chunksize=1,
477+
*, buffersize=None):
478+
self._check_running()
479+
if chunksize < 1:
480+
raise ValueError(
481+
f"Chunksize must be 1+, not {chunksize}"
482+
)
483+
if buffersize is not None:
484+
if not isinstance(buffersize, int):
485+
raise TypeError("buffersize must be an integer or None")
486+
if buffersize < 1:
487+
raise ValueError("buffersize must be None or > 0")
488+
489+
result = iterator_cls(self, buffersize=buffersize)
490+
if chunksize == 1:
491+
self._taskqueue.put(
492+
(
493+
self._guarded_task_generation(result._job, func, iterable,
494+
result._buffersize_sema),
495+
result._set_length,
496+
)
497+
)
498+
return result
499+
else:
500+
task_batches = Pool._get_tasks(func, iterable, chunksize)
501+
self._taskqueue.put(
502+
(
503+
self._guarded_task_generation(result._job, mapstar,
504+
task_batches,
505+
result._buffersize_sema),
506+
result._set_length,
507+
)
508+
)
509+
return (item for chunk in result for item in chunk)
510+
500511
@staticmethod
501512
def _wait_for_updates(sentinels, change_notifier, timeout=None):
502513
wait(sentinels, timeout=timeout)
@@ -679,7 +690,8 @@ def _help_stuff_finish(inqueue, task_handler, size):
679690

680691
@classmethod
681692
def _terminate_pool(cls, taskqueue, inqueue, outqueue, pool, change_notifier,
682-
worker_handler, task_handler, result_handler, cache):
693+
worker_handler, task_handler, result_handler, cache,
694+
taskqueue_buffersize_semaphores):
683695
# this is guaranteed to only be called once
684696
util.debug('finalizing pool')
685697

@@ -690,6 +702,10 @@ def _terminate_pool(cls, taskqueue, inqueue, outqueue, pool, change_notifier,
690702
change_notifier.put(None)
691703

692704
task_handler._state = TERMINATE
705+
# Release all semaphores to wake up task_handler to stop.
706+
for buffersize_sema in tuple(taskqueue_buffersize_semaphores):
707+
buffersize_sema.release()
708+
taskqueue_buffersize_semaphores.discard(buffersize_sema)
693709

694710
util.debug('helping task handler/workers to finish')
695711
cls._help_stuff_finish(inqueue, task_handler, len(pool))
@@ -836,7 +852,7 @@ def _set(self, i, success_result):
836852

837853
class IMapIterator(object):
838854

839-
def __init__(self, pool):
855+
def __init__(self, pool, *, buffersize=None):
840856
self._pool = pool
841857
self._cond = threading.Condition(threading.Lock())
842858
self._job = next(job_counter)
@@ -846,6 +862,11 @@ def __init__(self, pool):
846862
self._length = None
847863
self._unsorted = {}
848864
self._cache[self._job] = self
865+
if buffersize is None:
866+
self._buffersize_sema = None
867+
else:
868+
self._buffersize_sema = threading.Semaphore(buffersize)
869+
self._pool._taskqueue_buffersize_semaphores.add(self._buffersize_sema)
849870

850871
def __iter__(self):
851872
return self
@@ -856,22 +877,30 @@ def next(self, timeout=None):
856877
item = self._items.popleft()
857878
except IndexError:
858879
if self._index == self._length:
859-
self._pool = None
860-
raise StopIteration from None
880+
self._stop_iterator()
861881
self._cond.wait(timeout)
862882
try:
863883
item = self._items.popleft()
864884
except IndexError:
865885
if self._index == self._length:
866-
self._pool = None
867-
raise StopIteration from None
886+
self._stop_iterator()
868887
raise TimeoutError from None
869888

889+
if self._buffersize_sema is not None:
890+
self._buffersize_sema.release()
891+
870892
success, value = item
871893
if success:
872894
return value
873895
raise value
874896

897+
def _stop_iterator(self):
898+
if self._pool is not None:
899+
# `self._pool` could be set to `None` in previous `.next()` calls
900+
self._pool._taskqueue_buffersize_semaphores.discard(self._buffersize_sema)
901+
self._pool = None
902+
raise StopIteration from None
903+
875904
__next__ = next # XXX
876905

877906
def _set(self, i, obj):

0 commit comments

Comments
 (0)