| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
Sorry, something went wrong.
Sorry, something went wrong.
|
Most changes to Python require a NEWS entry. Add one using the blurb_it web app or the blurb command-line tool. If this change has little impact on Python users, wait for a maintainer to apply the skip news label instead. |
Sorry, something went wrong.
|
Most changes to Python require a NEWS entry. Add one using the blurb_it web app or the blurb command-line tool. If this change has little impact on Python users, wait for a maintainer to apply the skip news label instead. |
Sorry, something went wrong.
|
Most changes to Python require a NEWS entry. Add one using the blurb_it web app or the blurb command-line tool. If this change has little impact on Python users, wait for a maintainer to apply the skip news label instead. |
Sorry, something went wrong.
|
Most changes to Python require a NEWS entry. Add one using the blurb_it web app or the blurb command-line tool. If this change has little impact on Python users, wait for a maintainer to apply the skip news label instead. |
Sorry, something went wrong.
|
Most changes to Python require a NEWS entry. Add one using the blurb_it web app or the blurb command-line tool. If this change has little impact on Python users, wait for a maintainer to apply the skip news label instead. |
Sorry, something went wrong.
|
Most changes to Python require a NEWS entry. Add one using the blurb_it web app or the blurb command-line tool. If this change has little impact on Python users, wait for a maintainer to apply the skip news label instead. |
Sorry, something went wrong.
|
Thanks for the PR. First, I think this is a big behavior change for Executor. I think we need to discuss it in the https://discuss.python.org/ first. In my personal opinion, I think this is not a good choice to add the buffersize argument to the api. For now, the API design is based on the original map API. I think this argument will bring more inconsistent into the codebase. And BTW, even if we need the buffersize argument, I think it's not reasonable to forbids the usage of both timeout and buffersize at the same time
|
Sorry, something went wrong.
|
Hi @Zheaoli, thank you for your comment!
You mean big alternative behavior, right? (the default behavior when ommitting buffersize remaining unchanged)
Fair, I will start a thread there and ping you.
I'm not sure to get it, could you detail that point? 🙏🏻
You are completely right, makes more sense! I have fixed that (commit) |
Sorry, something went wrong.
For me, the basic map API's behavior is when we put an infinite iterator, the result would be infinite and only stop when the iterator has been stoped. I think we need to keep the same behavior between map and executor.map |
Sorry, something went wrong.
|
Hi @Zheaoli
There may be a misunderstanding here, the goal of this PR is precisely to make Executor.map closer to the builtin map behavior, i.e. make it lazier. (map and current executor.map do not have the same behavior) I will recap the behaviors so that everybody is on the same page: built-in mapinfinite_iterator = itertools.count(0)
# a `map` instance is created and the func and iterable are just stored as attributes
mapped_iterator = map(str, infinite_iterator)
# retrieves the first element of its input iterator, applies
# the transformation and returns the result
assert next(mapped_iterator) == "0"
# the next element in the input iterator is the 2nd
assert next(infinite_iterator) == 1
# one can next infinitely
assert next(mapped_iterator) == "2"
assert next(mapped_iterator) == "3"
assert next(mapped_iterator) == "4"
assert next(mapped_iterator) == "5"
...Executor.map without buffersize (= current Executor.map)infinite_iterator = itertools.count(0)
# this line runs FOREVER, trying to iterate over input iterator until exhaustion
mapped_iterator = executor.map(str, infinite_iterator)⏫ this line will run forever because it collects the entire input iterable eagerly, in order to build the entire future results list fs = [self.submit(fn, *args) for args in zip(*iterables)] which requires infinite time and memory. Executor.map with buffersizeinfinite_iterator = itertools.count(0)
# retrieves the first 2 elements (=buffersize) and submits 2 tasks for them
mapped_iterator = executor.map(str, infinite_iterator, buffersize=2)
# retrieves the 3rd element of input iterator and submits a task for it,
# then wait for the oldest future in the buffer to complete and returns the result
assert next(mapped_iterator) == "0"
# the next element of the input iterator is the 4th
assert next(infinite_iterator) == 3
# one can next infinitely while only a buffer of finite not-yet-yielded future results is kept in memory
assert next(mapped_iterator) == "1"
assert next(mapped_iterator) == "2"
assert next(mapped_iterator) == "4"
assert next(mapped_iterator) == "5"
...noteI used the example of an infinite input iterator because this is an example where current Executor.map is just unusable at all. But even for finite input iterables, if a developer writes mapped_iterator = executor.map(fn, iterable), they often don’t want the iterable to be eagerly exhausted right away, but rather to be iterated at the same rate as mapped_iterator. This PR's proposal is to allow them to do so by setting a buffersize. |
Sorry, something went wrong.
|
hey @rruuaanng, fyi I have applied your requested changes regarding the integration of unit tests into existing class 🙏🏻 |
Sorry, something went wrong.
| args_iter = iter(zip(*iterables)) | ||
| if buffersize: | ||
| fs = collections.deque( | ||
| self.submit(fn, *args) for args in islice(args_iter, buffersize) |
There was a problem hiding this comment.
Isn't buffersize empty? Can you introduce it? (Forgive me for not understanding it).
Sorry, something went wrong.
There was a problem hiding this comment.
absolutely np, thank you for taking the time to review my proposal. To be sure to understand the question well, what do you mean by "Isn't buffersize empty?"
Sorry, something went wrong.
There was a problem hiding this comment.
Hey @rruuaanng , I have reworked the PR's description, I hope it makes things clearer!
Sorry, something went wrong.
|
Hey @NewUserHa @AA-Turner @serhiy-storchaka, this may interest you given your recent activity on #14221 🙏🏻 |
Sorry, something went wrong.
| if ( | ||
| buffersize | ||
| and (executor := executor_weakref()) | ||
| and (args := next(args_iter, None)) |
There was a problem hiding this comment.
args may be empty, so you need to check for args is not None
Sorry, something went wrong.
There was a problem hiding this comment.
Are you refering to the case where one call executor.map(func) without any input iterable?
Sorry, something went wrong.
There was a problem hiding this comment.
Yes. You can't always assume that func needs an input (or do you?)
Sorry, something went wrong.
There was a problem hiding this comment.
you are right! But in such a case we don't enter the while fs: (fs being empty in that case), right?
Sorry, something went wrong.
There was a problem hiding this comment.
@picnixz I have added unit tests checking the behavior with multiple input iterables and without any input iterables.
Sorry, something went wrong.
There was a problem hiding this comment.
But in such a case we don't enter the while fs.
Not necessarily. What I meant is that you call executor.map with an input iterable that yields args = () everytime.
Note that it also doesn't hurt to check is not None because it's probably slightly faster since otherwise you need to call __bool__ on the args being yielded.
Sorry, something went wrong.
There was a problem hiding this comment.
So for example a call like executor.map(func, [()])? In such a call we get iterables = ([()],) and args_iter = iter(zip(*([()],))) and next(args_iter,) will be ((),) (not ()). You may have missed the ziping in your reasoning?
In term of pure readability of the code I struggle to have an opinion, do you feel that (args := next(args_iter, None)) is not None is more natural?
Sorry, something went wrong.
There was a problem hiding this comment.
You may have missed the ziping in your reasoning?
I did :) Sorry, my bad!
do you feel that (args := next(args_iter, None)) is not None is more natural?
I feel it would at least help avoiding questions like mine! (and it would still be probably slightly better performance wise but this claim is just my gut feeling).
Sorry, something went wrong.
There was a problem hiding this comment.
@picnixz oh yes I see... I have renamed args_iter into a more self-explanatory zipped_iterables, do you think it would be enough to avoid the confusion?
(Because I am scared that the addition of is not None may misslead some of our fellow pythonistas wondering "wait, why is this not None check necessary here, what am I missing here 🤔?")
Sorry, something went wrong.
There was a problem hiding this comment.
Personally, I like having the is not None just so that I don't have to wonder what's args_iter is precisely yielding. I can assume that it's yielding a tuple-like object, but I don't necessarily know the shape of that tuple. So is not None discriminates probable items and the sentinel value. So I'd say it's still pythonic.
Performance-wise it should be roughly the same (one checks that the tuple's size != 0 and the other just compares if it's the None singleton but both are essentially a single comparison).
Now up to you. If others didn't observe (like me) that args_iter never yields an empty tuple, then it's probably better to keep the is not None check for clarity.
Sorry, something went wrong.
| if ( | ||
| buffersize | ||
| and (executor := executor_weakref()) | ||
| and (args := next(args_iter, None)) |
There was a problem hiding this comment.
Are you refering to the case where one call executor.map(func) without any input iterable?
Sorry, something went wrong.
No problem, but please refrain from force pushing. Everything will be squash-merged in the end.
https://devguide.python.org/getting-started/pull-request-lifecycle/#quick-guide Thank you! |
Sorry, something went wrong.
|
@hugovk ok, will merge main instead of rebasing next time, thanks for the pointer! 🙏🏻 |
Sorry, something went wrong.
|
Hi @gpshead, whenever you get a chance, your feedback on this would be really appreciated 🙏🏻 |
Sorry, something went wrong.
There was a problem hiding this comment.
This looks great to me. One detail: Lets make buffersize a keyword only argument.
Sorry, something went wrong.
|
@gpshead Thank you for your review, I’m glad the proposal makes sense to you too 🙏🏻 .
Because they are preceded by *iterables, chunksize/timeout/buffersize are already keyword-only args, or am I missing something? |
Sorry, something went wrong.
|
@ebonnal I've introduced conflicts I think due to the rewording of the start-method section. Could you fix them please? TiA |
Sorry, something went wrong.
There was a problem hiding this comment.
Except for the conflicts to change, I'm fine with this changeset. We could also do the same for Pool.map for multiprocessing but in a follow-up PR if you want (I don't know if the task will be easy or relevant btw)
Sorry, something went wrong.
Merged main ✅
@picnixz Thank you for all your support on this PR, I will follow up on the Pool-side. |
Sorry, something went wrong.
Co-authored-by: Bénédikt Tran <10796600+picnixz@users.noreply.github.com>
|
I'll rerun the CI tomorrow if it still fails, don't worry |
Sorry, something went wrong.
There was a problem hiding this comment.
LGTM. 👍
Sorry, something went wrong.
There was a problem hiding this comment.
I think the new line I wanted you to add has been gobbled by another commit :(
Sorry, something went wrong.
Co-authored-by: Bénédikt Tran <10796600+picnixz@users.noreply.github.com>
|
I'll merge this tomorrow or on Friday (today is review's day!) |
Sorry, something went wrong.
|
Thank you for the contribution! |
Sorry, something went wrong.
|
Thank you @picnixz @gpshead @serhiy-storchaka for making this change go through! 🙏🏻 |
Sorry, something went wrong.
| # reverse to keep finishing order | ||
| fs.reverse() | ||
| while fs: | ||
| if ( |
There was a problem hiding this comment.
@ebonnal I believe you got this part slightly wrong, "off-by-one". IIUC, the number of pending futures cannot be larger than buffsize. However, after the initial submission of buffsize tasks before, here in this branch you are appending an EXTRA task to the queue, and now you have buffsize + 1 tasks that have potentially not yielded yet.
Fortunately, looks like the fix is trivial: you simply have to yield first, next append to the queue:
diff --git a/Lib/concurrent/futures/_base.py b/Lib/concurrent/futures/_base.py
index d98b1ebdd58..3b9ccf4d651 100644
--- a/Lib/concurrent/futures/_base.py
+++ b/Lib/concurrent/futures/_base.py
@@ -628,17 +628,17 @@ def result_iterator():
# reverse to keep finishing order
fs.reverse()
while fs:
+ # Careful not to keep a reference to the popped future
+ if timeout is None:
+ yield _result_or_cancel(fs.pop())
+ else:
+ yield _result_or_cancel(fs.pop(), end_time - time.monotonic())
if (
buffersize
and (executor := executor_weakref())
and (args := next(zipped_iterables, None))
):
fs.appendleft(executor.submit(fn, *args))
- # Careful not to keep a reference to the popped future
- if timeout is None:
- yield _result_or_cancel(fs.pop())
- else:
- yield _result_or_cancel(fs.pop(), end_time - time.monotonic())
finally:
for future in fs:
future.cancel()
Sorry, something went wrong.
There was a problem hiding this comment.
Hi @dalcinl,
TL;DR: fyi we have #131467 that is open and tackling this "off-by-one" situation. Would be great to get your review there! 🙏🏻
I have not proposed this variation at first because I think it makes sense as an optional follow up PR given that it integrates slightly less smoothly into existing logic.
You will notice that it is not "just" moving the yield before the enqueue, let's see why on a simple scenario, explaining the 3 behaviors:
it = executor.map(fn, iterable, buffersize=buffersize)
# point A
next(it)
# point B
next(it)
# point Cpro: buffersize tasks in buffer between two calls to next
con: while waiting for the next result we have buffersize+1 tasks in buffer
pro: never exceed buffersize
con: between two calls to next we have only buffersize - 1 tasks in buffer
pros:
Let me know if it makes sense 🙏🏻
Sorry, something went wrong.
| Back | FazBrowse Home | New Git URL |
Context recap (#74028)
Let's consider that we have an input iterable and N = len(iterable).
Current concurrent.futures.Executor.map is $O(N)$ in space (unecessarily expensive on large iterables, completely impossible to use on infinite iterables):
The call results: Iterator = executor.map(func, iterable) iterates over all the elements of the iterable, submitting $N$ tasks to the executor (futures collected into a list of size $N$). Following calls to next(results) take the oldest future from the list (FIFO), then wait for its result and return it.
Proposal: add an optional buffersize param
With this proposal, the call results: Iterator = executor.map(func, iterable, buffersize=b) will iterate only over the first $b$ elements of iterable, submitting $b$ tasks to the executor (futures stored in the buffer deque) and then will return the results iterator.
Calls to next(results) will get the next input element from iterable and submit a task to the executor for it (enqueuing another future), then wait for the oldest future in the buffer queue to complete (FIFO), then return the result.
Benefits:
Why a new PR
It turns out it is very similar to the initial work of @MojoVampire in #707 back in 2017 (followed up by @graingert in #18566 and @Jason-Y-Z in #114975): use a queue of fixed size to hold the not-yet-yielded future results.
In addition this PR:
📚 Documentation preview 📚: https://cpython-previews--125663.org.readthedocs.build/