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
Add into_grouping_map for efficient group-and-fold operations #465
Conversation
let acc = destination_map.remove(&key); | ||
if let Some(op_res) = operation(acc, &key, val) { | ||
destination_map.insert(key, op_res); | ||
} |
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.
Do we really need to remove
and insert
back into destination_map
? Couldn't we facilitate the entry
-API?
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.
Using FnMut(Option<R>, ...)
forces the caller to deal with Option
, i.e. the caller has to deal with Some
/None
, essentially checking the same thing that is already checked in aggregate
in terms of existence in destination_map
. It may be ok, but we could avoid these checks in the callers by having two functions (one for the None
-case, one for the Some
-case).
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.
Do we really need to
remove
andinsert
back intodestination_map
? Couldn't we facilitate theentry
-API?
remove
and insert
are needed if we want to pass an owned accumulator to the closure, like the normal Iterator::fold
. The alternative is passing a mutable reference but this is more restricting for the end user.
Maybe I should add a second aggregate
function that provides mutable access instead? This would allow a more efficient implementation in some cases, like in GroupingMap::collect
(currently it duplicates aggregate
's code).
Using
FnMut(Option<R>, ...)
forces the caller to deal withOption
, i.e. the caller has to deal withSome
/None
, essentially checking the same thing that is already checked inaggregate
in terms of existence indestination_map
. It may be ok, but we could avoid these checks in the callers by having two functions (one for theNone
-case, one for theSome
-case).
Wouldn't this just move the check from the closure to the aggregate
function?
Performance wise it should remain the same.
But what if the caller wants to mutate an external variable in both the case the map contains the value or not? It can't do that with 2 closures.
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 was thinking about this, in particular since issue #467. I made a couple of basic benchmarks and the diffeference in speed is not negligible. For example on a simple collect
of 1_000_000 i32
grouped by their reminder by 10 or 10_000, the entry version performed 2x better! But at what cost?
- I had to add another closure for the init.
- The
init
closure can't take a reference to thekey
as parameter becauseEntry::or_insert_with_key
is unstable. Not sure but there may be a way to do it anyway. - I had to remove the
key
parameter from the other closure as well - I had to pass a mutable reference to the accumulator instead of an owned value because the
Entry
api gives me only that.
The problem is that the current approach move a lot the accumulator and makes two lookup in the map, one for remove
and one for insert
. Moving the accumulator is required if we don't want to restrict the API, so I wondered if I could use the OccupiedEntry
and VacantEntry
API to make only one lookup, but unfortunately currently it's impossible. If OccupiedEntry
had a way to remove the entry, returning the value, but also returning the just freed VacantEntry
it would be perfect, but there's no such API. I tried to imitate it using unsafe
and MaybeUninit
and this is the result:
https://gist.github.com/SkiFire13/12a61302e35ffe52c0337e0959fb15f6
(Edit: updated the gist with a version that uses less unsafe
code and also reduces by half the number of writes to the map in case of an OccupiedEntry)
On the previous example it runs 1.6x faster than the current approach but 1.3 slower than the normal entry
. On another example (count the number of occurences of a bunch of i32
s) it performed 2.5x the current approach and the same as the normal entry. This could actually be used to unify the implementation with #468, however I really don't like that I had to use unsafe
for this. There's also the problem that MaybeUninit
wasn't stable in rust 1.32.0
Or maybe should I just add an aggregate_mut
/aggregate_update
(has anyone better names?) that provides a restricted but faster API using entry
?
I think you came to a sensible decision, even if it means that users have to consider the impossible I think that ideally
In general, I think we should quickcheck-test that the new functions are equivalent to
If we decide to accept this, I would second this. |
Another idea I had was to add a new
Nice idea, I'm definitely gonna add this. |
I was wondering in the issues section and notice #358 which made me realize |
#467 brought up this: Is the (As of now, we either have to specify a |
Sorry I haven't yet had time to thoroughly review all this. :(
@phimuemue Itertools has deprecated/changed/removed plenty of things in the past. I'm fine with making breaking changes, especially if also we take the opportunity to finally remove some long-deprecated methods that conflict with
Possible alternative: defining a |
Maybe change the |
I don't think this is really the case. It's more like the iterator items are the keys and the values are ingnored (they could be Little side note: the real case
Coupled with @sollyucko proposals it would be possible, but I see 2 problems:
Ps: I'm currently on vacation without my pc so I can't write any code. I'll think about all of this better when I'll get back at home. Edit: I forgot to say that @sollyucko proposal by itself makes sense, but I personally can't decide between that and the current approach. Other opinions are welcome :) |
@SkiFire13 sorry for the delay on this. Do you feel like it's ready to merge? If so, I'll merge it! |
There's still #465 (comment) which is a performance issue. I haven't decided yet whether:
Note that if we go for the 1st or 3rd options we could replace them with So the final question here is: do you want to discard performance, complexity in user interface or no- |
Of those three options, I'm most inclined to keep things as-is and merge. Its performance issues can be eventually be resolved with A fourth option: add an pub fn aggregate<FO, R>(self, mut operation: FO) -> HashMap<K, R>
where
R: Default,
FO: FnMut(Option<R>, &K, V) -> Option<R>,
{
let mut destination_map = HashMap::new();
for (key, val) in self.iter {
let entry = destination_map.entry(key);
match entry {
Entry::Occupied(mut entry) => {
let acc = entry.insert(R::default());
if let Some(op_res) = operation(Some(acc), entry.key(), val) {
entry.insert(op_res);
}
},
Entry::Vacant(entry) => {
if let Some(op_res) = operation(None, entry.key(), val) {
entry.insert(op_res);
}
},
}
}
destination_map
} |
I feel like adding a |
Great, let's merge this as-is, then. bors r+ |
Build succeeded: |
Adds two functions on the
Itertools
trait,into_grouping_map
andinto_grouping_map_by
.into_grouping_map
expects an iter of(K, V)
where theK
will be used as key andV
as value.into_grouping_map_by
expect only an iter ofV
as values, the keys will be calculated using the provided functions.Both of them return a
GroupingMap
, which is just a wrapper on an iterator. Since it introduces a lot of related methods I thought it would be better to separate them from theItertools
trait. This also prevents duplicating every method for the_by
version.All of these functions have in common the fact they perform efficient group-and-fold operations without allocating temporary vecs like you would normally do if you used
into_group_map
+into_iter
+map
+collect::<HashMap<_, _>>()
.Here's the possible issues I can see, I would like to hear some feedback before trying to fix any of them:
grouping_by
which is the java/kotlin equivalent, but later changed it tointo_grouping_map
to match the already existinginto_group_map
and to differentiate fromgroup_by
;fold_first
: the equivalent function in theItertools
trait isfold1
but there's an unstable stdlib function that does the same thing and is calledfold_first
. I decided to be consistent with the stdlib;minmax
return type is the already existingMinMaxResult
, but theNoElements
variant is never returned. I didn't want to duplicate that struct thinking it could cause confusion (and if I did what name could I have chosen?);sum
andproduct
: They dont' use theSum
andProduct
traits but instead requireV: Add<V, Output=V>
andV: Mul<V, Output=V>
. They're pretty much a wrapper aroundfold_first
. I don't really know if they're meaningful or if I should just remove them;scan
function. Even though it is an iterator adapter I could sort of "collect" it into aVec
(more like extend) but I don't really see an use for this;no integration tests for theadded;_by
and_by_key
versions ofmin
,max
andminmax
. To be fair I was a bit lazy, but I also couldn't find any integration test for the normalminmax_by
andminmax_by_key
so I though it was fine;into_group_map
;into_group_map
in terms ofinto_grouping_map
?Related issues: #457, #309
Related PR: #406 (in particular relates to the
into_group_map_by_fold
function that was proposed but later removed)