Repository navigation
Implement fast min/max accumulator for binary / strings (now it uses the slower path) #6906
Description
Is your feature request related to a problem or challenge?
#6904 introduces some fancy new hashing and ways to implement aggregates
See related blog post: https://datafusion.apache.org/blog/2023/08/05/datafusion_fast_grouping/
min/max for strings (StringArray / LargeStringArray, etc) now uses the slower Accumulator implementation which could be made much faster
Describe the solution you'd like
I would like to implement a fast GroupsAccumulator for Min/Max
Describe alternatives you've considered
here is one potential way to implement it:
We could store the current minimum for all groups in the same Rows 🤔 and track an index into that Rows for the current minimum for each group.
This would require an extra copy of the input values, but it could probably be vectorized pretty well, as shown in the following diagram.
Sorry what I meant was something like the following where the accumulator only stored the current minimum values.
This approach would potentially end up with min_storage being full of "garbage" if many batches had new minumums, but I think we could heuristically "compact" min_storage (if it had 2*num_groups, for example) if it got too large
┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
│ Accumulator │
state
┌─────────┐ ┌─────────┐ │ ┌─────────┐ ┌─────────┐ │
│ ┌─────┐ │ │ ┌─────┐ │ │ ┌─────┐ │ │ ┌─────┐ │
│ │ A │ │ │ │ A │─┼────┐ │ │ │ D │ │ ┌─────┼─│ 1 │ │ │
│ ├─────┤ │ │ ├─────┤ │ │ │ ├─────┤ │ │ │ ├─────┤ │
│ │ B │ │ │ │ B │ │ └──┼─┼▶│ A │◀┼────┘ │ │ 0 │ │ │
│ ├─────┤ │ │ ├─────┤ │ │ └─────┘ │ │ └─────┘ │
│ │ A │ │ │ │ A │ │ │ │ │ │ │ │
│ ├─────┤ │ │ ├─────┤ │ │ │ │ │
│ │ A │ │ │ │ A │ │ │ │ │ │ │ │
│ ├─────┤ │ │ ├─────┤ │ │ │ │ │
│ │ C │ │ │ │ C │ │ │ │ │ │ │ │
│ └─────┘ │ │ └─────┘ │ │ │ │ │
└─────────┘ └─────────┘ │ └─────────┘ └─────────┘ │
input input │ min_storage: min_values │
values values Rows
(Array) (Rows) └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘
step 1: step 2: for
convert any value step 3: min value
arguments to that is a new (per group) is
Row format group tracked as an
minimum, copy index into
it to a min_storage `Rows`
second `Rows`
See #6800 (comment) for more details
Additional context
No response
Activity
[-] Implement fast min/max accumulator for strings (now it uses the slower path)[/-][+] Implement fast min/max accumulator for binary / strings (now it uses the slower path)[/+]alamb commentedon Jan 8, 2024
One observation here is that
minandmaxon strings is not that common of an operation from what it seems -- grouping on strings is more common.Maybe there is some binary usecase where it is important (e.g. embeddings 🤔 )
devanbenz commentedon Aug 26, 2024
@alamb is there anyone working on this + is this issue still relevant? I would love to tackle it as it seems like an interesting feature/optimization.
alamb commentedon Aug 26, 2024
I dont know of anyone working on this @devanbenz -- but I also don't know of any benchmarks or actual queries that use
min/maxon string columns. The place it shows up is when computing statistics when writing parquet, but I think parquet is already pretty good at this (and has its own code to compute min/max)alamb commentedon Sep 17, 2024
It actually turns out that
Min/Maxon string/binary columns are in several ClickBench queries:datafusion/benchmarks/queries/clickbench/queries.sql
Lines 22 to 23 in a08f923
datafusion/benchmarks/queries/clickbench/queries.sql
Line 29 in a08f923
I don't think
min/maxhave appeared as priorities in benchmarking before because the queries in question are doing other string heavy operations that tend to dominate. Thus the use ofGroupsAccumulatorAdaptorfor Min/Max on strings, while bad, is overshadowed by other thingsHowever, while working on #12092 it turns out that
Min/MaxonBinaryViewandStringVieware suuuuper slow. We can likely restore their speed to something similar to apache/arrow-rs#6408 but I also think this is a good time to actually makeMin/Maxon strings faster. I will write up some ideas on how to do thisalamb commentedon Sep 17, 2024
Background
(I will make a PR shortly to add this to the actual datafusion docs)
GroupsAccumulatorlogically does this:Today, String / Binary min/max values are implemented using
GroupsAccumulatorAdapterwhich results inalamb commentedon Sep 17, 2024
Potential Design
One high level idea is to build a data structure that uses the same internal format (views/buffers) as
StringViewArrayin Arrow:In this design, the current value for each group is stored in two parts (as described on arrow docs)
u128viewAs new batches are updated, each
Viewis updated if necessaryBenefits of design:
I believe (though we will have to verify it) that the conversion from MutableStringViewBuilder to just StringArray (not StringViewArray) should also be better than the current
GroupsAccumulatorAdapter. Both conversions need to copy the string bytes again into the packedStringArrayformat, but theGroupsAccumulatorAdapteralso has to allocate/free ownedStrings as wellPotential challenges
I think the trickiest part of this code, other the low level code optimizations is that as min/max values are replaced, data in the variable length buffer will become "garbage" (not reachable) thus consuming more memory than necessary:
I think this means the code will need something
GenericByteViewArray::gcrun occasionallyRandom Thoughts
Thoughts: maybe this structure (MutableStringViewBuilder??) could be upstreamed eventually
alamb commentedon Sep 17, 2024
@devanbenz I think this would be a fun and interesting project as well as valuable to DataFusion. However, I also think it is pretty advanced -- I would enjoy helping with it, but also maybe @Rachelint or @jayzhan211 are interested in helping out 🤔
devanbenz commentedon Sep 17, 2024
Great! I'll get started on this later in the week/over the weekend :) Will likely bug folks as I require help haha 😆
devanbenz commentedon Sep 17, 2024
take
alamb commentedon Sep 17, 2024
In terms of implementation, what I suggest is:
For the POC here is the reproducer I recommend:
Step 1. Get
hits_partitionedusingbench.sh:cd benchmarks ./bench.sh data clickbench_partitionedStep 2: Prepare a script with reproducer query:
Step 3: Run script (with release build of
datafusion-cli):set datafusion.execution.parquet.schema_force_view_types = true;-->Elapsed 18.431 seconds.set datafusion.execution.parquet.schema_force_view_types = false;-->Elapsed 6.427 seconds.The goal is to get
set datafusion.execution.parquet.schema_force_view_types = true;to be the same (or better) than when it is falseIf you look at the flamegraph-string-view.svg, you can see most of the time is spent doing GroupsAccumulator
Rachelint commentedon Sep 17, 2024
Seems really interesting, I am reading the related disscusions.
alamb commentedon Sep 17, 2024
BTW in case anyone is interested, I recorded a short video on how to make these flamegraphs: https://youtu.be/2z11xtYw_xs
I will add a link to that in the docs later
17 remaining items
alamb commentedon Sep 23, 2024
Yes indeed. Thank you
💯
devanbenz commentedon Sep 23, 2024
Awesome thank you @Rachelint.
I've begun working on a small POC locally with this information. It took me a bit of reading up on context to get going for sure. After reading through https://docs.rs/datafusion/latest/datafusion/physical_plan/trait.Accumulator.html and https://docs.rs/datafusion/latest/datafusion/logical_expr/trait.GroupsAccumulator.html, tracing through the code locally, and reading through the discussion a few times I now have a better understanding of where to start, the expected outcome and a path to get there.
devanbenz commentedon Sep 26, 2024
Finally got around to trying out a little POC locally. I'm getting much better results by just storing the state as
Vec<String>it's very much not polished at this moment in time but I will have some more time on Sunday + early next week to continue working on this. It is currently only implemented forMINwill need to work onMAXnext but I foresee the implementation being almost identical.Without view types:
With view types:
A 1.29% performance improvement 😎
alamb commentedon Sep 29, 2024
Nice! If you want to put up a POC / draft PR I would be more than happy to help work on it with you
devanbenz commentedon Sep 30, 2024
Sounds good I pushed up what I have so far as a draft. There are a few things I would like to do including making the accumulator more generic so that I use a single method for min or max and pass an aggregate fn similar to how other UDFs do it.
alamb commentedon Sep 30, 2024
I will check out #12677 -- I hoped to do it today but I appear to be almost out of time
jayzhan211 commentedon Oct 4, 2024
I think we can store (len, prefix, index) for string/binary view cases. We then compare the value based on len + inline or prefix first, if they are the same we look up the actual value with index. In this way, we don't have the garbage collect issue, since we store the index instead of the whole string.
@alamb @Rachelint @devanbenz
Rachelint commentedon Oct 4, 2024
I am a bit confused about where we store actual string values?
We still store it using
Vec<String>, and through copying way when constructing theStringViewarray like #6906 (comment) ?I think what @Dandandan memtioned in #6800 (comment) seems to be performance best impl?
But it is a bit hard to make it... we need to impl something like memory allocator...
alamb commentedon Oct 4, 2024
I think the garbage collection issue happens when the current "min" for a group is updated to a new value. We have to copy the new min value into the buffer and adjust the offset, but the old min value is still in the string view buffer
jayzhan211 commentedon Oct 5, 2024
My approach only works within batch not across batch. The garbage collection issue here is that whenever we got a better "min", we need to append the bytes and the old bytes is now "garbage". But given a
batch, we can get the string value by the row index of the StringArray, therefore we don't need to update the possible long bytes but update only (len, prefix, row - index) . We only store this "min" (which creates garbage) after computing the whole batch and update it to the accumulator. The more values are in the same group within the batch, the less "garbage" we created compare to the original proposed design. The best case is that the whole batch has the same group value so we just do 1 update to the accumulator, the worst case is that every group value is distinct so we need n (n is batch size and the group size of the batch) updates to the accumulator. Maybe repartition prior to the aggregation helps a lot since the same group stays together in a batch.alamb commentedon Oct 6, 2024
this makes a lot of sense (and is similar to what the current non groups accumulator does -- finds the min per batch and then updates the current overall min from that, rather than updating the min for each input row).
alamb commentedon Oct 7, 2024
I started hacking on this today: #12792. I am fighting with the generics, but I think I am pretty close.
alamb commentedon Oct 10, 2024
Ok, I think #12792 is ready to review. It has a nice 10% improvement on Q28 and avoids the slowdown when string view is enabled