-
Notifications
You must be signed in to change notification settings - Fork 4.6k
Implement a Top partitioner
#29106
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Implement a Top partitioner
#29106
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,111 @@ | ||
| import uuid | ||
| import apache_beam as beam | ||
| from apache_beam import pvalue | ||
| from typing import Optional, TypeVar | ||
| from typing import Tuple | ||
| from typing import Any | ||
| from typing import Callable | ||
|
|
||
| T = TypeVar('T') | ||
|
|
||
|
|
||
| class Top(beam.PTransform): | ||
| """ | ||
| A PTransform that takes a PCollection and partitions it into two | ||
| PCollections. The first PCollection contains the largest n elements of the | ||
| input PCollection, and the second PCollection contains the remaining | ||
| elements of the input PCollection. | ||
|
|
||
| Parameters: | ||
| n: The number of elements to take from the input PCollection. | ||
| key: A function that takes an element of the input PCollection and | ||
| returns a value to compare for the purpose of determining the top n | ||
| elements, similar to Python's built-in sorted function. | ||
| reverse: If True, the top n elements will be the n smallest elements of | ||
| the input PCollection. | ||
|
|
||
| Example usage: | ||
|
|
||
| >>> with beam.Pipeline() as p: | ||
| ... top, remaining = (p | ||
| ... | beam.Create(list(range(10))) | ||
| ... | partitioners.Top(3)) | ||
| ... # top will contain [7, 8, 9] | ||
| ... # remaining will contain [0, 1, 2, 3, 4, 5, 6] | ||
|
|
||
| .. note:: | ||
|
|
||
| This transform requires that the top PCollection fit into memory. | ||
|
|
||
| """ | ||
| def __init__( | ||
| self, n: int, key: Optional[Callable[[Any], Any]] = None, reverse=False): | ||
| _validate_nonzero_positive_int(n) | ||
| self.n = n | ||
| self.key = key | ||
| self.reverse = reverse | ||
|
|
||
| def expand(self, | ||
| pcoll) -> Tuple[pvalue.PCollection[T], pvalue.PCollection[T]]: | ||
| # **Illustrative Example:** | ||
| # Our goal is to return two pcollections, top and | ||
| # remaining. | ||
|
|
||
| # Suppose you want to take the top element from `[1, 2, 2]`. Since we have | ||
| # identical elements, we need to be able to uniquely identify each one, | ||
| # so we assign a unique ID to each: | ||
| # `inputs_with_ids: [(1, "A"), (2, "B"), (2, "C")]` | ||
|
|
||
| # Then we sample, e.g. | ||
| # ``` sample: [(2, "B")] ``` | ||
| # To get our goal `top` pcollection, we just strip the uuids from | ||
| # that sample. | ||
|
|
||
| # Now to get the `top` pcollection, we need to return essentially | ||
| # `inputs_with_ids` but without any of the elements fom the sample. To | ||
| # do this, we create a set from `sample`, getting `sample_ids: | ||
| # [set("B")]`. Now that we have this set, we can create our | ||
| # `remaining_with_ids` pcollection by just filtering out | ||
| # `inputs_with_ids` and checking for each element "Does this element's | ||
| # corresponding ID exist in `sample_ids`?" | ||
|
|
||
| # Finally, we just return `top` and strip the IDs as we no longer | ||
| # need them and the user doesn't care about them. | ||
| wrapped_key = lambda elem: self.key(elem[0]) if self.key else elem[0] | ||
| inputs_with_ids = (pcoll | beam.Map(_add_uuid)) | ||
| sample = ( | ||
| inputs_with_ids | ||
| | beam.combiners.Top.Of(self.n, key=wrapped_key, reverse=self.reverse)) | ||
| sample_ids = ( | ||
| sample | ||
| | beam.Map(lambda sample_list: set(ele[1] for ele in sample_list))) | ||
|
|
||
| def elem_is_not_sampled(elem, sampled_set): | ||
| return elem[1] not in sampled_set | ||
|
|
||
| remaining = ( | ||
| inputs_with_ids | ||
| | beam.Filter( | ||
| elem_is_not_sampled, | ||
| sampled_set=beam.pvalue.AsSingleton(sample_ids)) | ||
| | beam.Map(_strip_uuid)) | ||
| sample_as_pcoll = ( | ||
| sample | ||
| | beam.FlatMap(lambda x: x) | ||
| | "StripSampleIDs" >> beam.Map(_strip_uuid)) | ||
| return sample_as_pcoll, remaining | ||
|
|
||
|
|
||
| def _validate_nonzero_positive_int(n: Optional[Any]) -> None: | ||
| if not isinstance(n, int): | ||
| raise ValueError("n must be an int") | ||
| if n <= 0: | ||
| raise ValueError("n must be a positive int") | ||
|
|
||
|
|
||
| def _add_uuid(element: T) -> Tuple[T, str]: | ||
| return element, uuid.uuid4().hex | ||
|
|
||
|
|
||
| def _strip_uuid(element: Tuple[T, str]) -> T: | ||
| return element[0] | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,95 @@ | ||
| import doctest | ||
| import pytest | ||
| import unittest | ||
|
|
||
| import apache_beam as beam | ||
| from apache_beam.testing.util import assert_that | ||
| from apache_beam.testing.util import equal_to | ||
| from apache_beam.transforms import partitioners | ||
| from apache_beam.testing.test_pipeline import TestPipeline | ||
|
|
||
|
|
||
| class TopTest(unittest.TestCase): | ||
| def test_bad_n(self): | ||
| with pytest.raises(ValueError): | ||
| partitioners.Top(0) | ||
| with pytest.raises(ValueError): | ||
| partitioners.Top(-1) | ||
| with pytest.raises(ValueError): | ||
| partitioners.Top(1.5) | ||
|
|
||
| def test_empty(self): | ||
| with TestPipeline() as p: | ||
| sample, remaining = (p | ||
| | beam.Create([], reshuffle=False) | ||
| | partitioners.Top(3)) | ||
| assert_that( | ||
| sample | "CountSample" >> beam.combiners.Count.Globally(), | ||
| equal_to([0]), | ||
| label="assert0") | ||
| assert_that( | ||
| remaining | "CountRemaining" >> beam.combiners.Count.Globally(), | ||
| equal_to([0]), | ||
| label="assert1") | ||
|
|
||
| def test_Top(self): | ||
|
|
||
| with TestPipeline() as p: | ||
| sample, remaining = (p | ||
| | beam.Create([1, 1, 2, 2, 3, 3], reshuffle=False) | ||
| | partitioners.Top(3)) | ||
| assert_that( | ||
| sample | "SampleAsList" >> beam.combiners.ToList() | ||
| | "SortSample" >> beam.Map(sorted), | ||
| equal_to([[ | ||
| 2, | ||
| 3, | ||
| 3, | ||
| ]]), | ||
| label="assert0") | ||
| assert_that( | ||
| remaining | "RemainingAsList" >> beam.combiners.ToList() | ||
| | "SortRemaining" >> beam.Map(sorted), | ||
| equal_to([[1, 1, 2]]), | ||
| label="assert1") | ||
|
|
||
| def test_Top_key(self): | ||
|
|
||
| with TestPipeline() as p: | ||
| sample, remaining = (p | ||
| | beam.Create([1, 1, 2, 2, 3, 3], | ||
| reshuffle=False) | ||
| | partitioners.Top(3, key=lambda x: -x)) | ||
| assert_that( | ||
| sample | "SampleAsList" >> beam.combiners.ToList() | ||
| | "SortSample" >> beam.Map(sorted), | ||
| equal_to([[1, 1, 2]]), | ||
| label="assert0") | ||
| assert_that( | ||
| remaining | "RemainingAsList" >> beam.combiners.ToList() | ||
| | "SortRemaining" >> beam.Map(sorted), | ||
| equal_to([[2, 3, 3]]), | ||
| label="assert1") | ||
|
|
||
| def test_Top_reverse(self): | ||
|
|
||
| with TestPipeline() as p: | ||
| sample, remaining = (p | ||
| | beam.Create([1, 1, 2, 2, 3, 3], | ||
| reshuffle=False) | ||
| | partitioners.Top(3, reverse=True)) | ||
| assert_that( | ||
| sample | "SampleAsList" >> beam.combiners.ToList() | ||
| | "SortSample" >> beam.Map(sorted), | ||
| equal_to([[1, 1, 2]]), | ||
| label="assert0") | ||
| assert_that( | ||
| remaining | "RemainingAsList" >> beam.combiners.ToList() | ||
| | "SortRemaining" >> beam.Map(sorted), | ||
| equal_to([[2, 3, 3]]), | ||
| label="assert1") | ||
|
|
||
|
|
||
| class DocTest(unittest.TestCase): | ||
| def test_docs(self): | ||
| doctest.testmod(partitioners) |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we can both simplify this implementation and significantly improve performance using user state. Basically the idea would be to use bag state and:
This would have a few advantages:
There's even a few optimizations we could potentially add:
Thoughts?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for the review!
That does seem simple. I don't have a strong understanding of how user state works. Does this imply that a single worker will need to go through the entire set of inputs? Won't that become a bottleneck or am I misunderstanding how state works?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It does, though in exchange you're getting the ability to start processing your data in the rest of your pipeline before you reach the end of the window (which probably saves time/resources in most use cases, especially in streaming). I think this is a fair concern though, especially in batch; one optimization we could make to greatly reduce our bottleneck in cases where Top is small would be to add a pre-step where we filter out the non-Top elements and send them downstream to be joined with the remaining non-Top elements using a
Flatten(something like _TopPerBundle). Cases where Top is large will still end up running into a similar bottleneck even if we use a combiner since we won't be able to do much combiner lifting (local reduction before sending it over the wire).I'll also note that state is per key/window, so we're actually just talking the set of inputs for a single window and you will get parallelization with multiple windows.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is batch processing rare enough that it's not considered in "most use cases"? (Coming from a batch-only user).
I don't quite follow your suggestion.
Isn't this still doing the join that's being proposed in the current changeset?
I don't follow the distinctino between these "remaining non-Top elements" and the non-Top elements we're filtering for in the pre-step
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No, in general I think we have a pretty even spread of batch/streaming usage (though its hard to say for sure). My point was more that I think this is beneficial for most use cases and this is especially true in streaming mode (but also many and possibly most batch cases). Even in batch mode, everything I said is true, you're just also probably more likely to have scenarios where you're particularly concerned about quick fan out. I do think my suggested optimization (explained in more detail below) resolves most of these concerns though.
Basically, we're making a tradeoff, but I think the large majority of the time we can come out ahead with a stateful approach.
No, there shouldn't be a join (Flatten is the closest thing, but that's not actually a join, its just conceptually treating 2 PCollections as one). Let me try explaining again in more depth; basically your flow would be:
In this way, you don't need to do any expensive joins, you get the benefit of parallelism for
_TopPerBundle(which should reduce your cardinality byO(1000s)/NwhereNis yourTopsize in batch mode (it won't help much in streaming which usually has smaller bundles depending on the runner), and your bottleneck is on a small subset of the data._TopPerBundlewould emit 2 pcollections, the first containing the topNelements from each bundle, and the 2nd containing the remaining elements.StatefulTopPartitionerwould do the same, but over the entire input dataset.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah that makes sense! I'll take a shot at implementing that, thanks for the explanation!