Repository navigation
Closed
Closed
Copy link
Labels
bugSomething isn't workingSomething isn't workinghelp wantedExtra attention is neededExtra attention is neededperformanceMake DataFusion fasterMake DataFusion faster
Description
Describe the bug
I encountered a slow query, which I already mentioned in Discord but got no answer. I went ahead and created a repro case with data I can share that showcases this slow query.
Plans:
DataFrame()
+---------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| plan_type | plan |
+---------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| logical_plan | Projection: df.name, df.group, CASE WHEN array_element(markets, Int64(1)) IS NOT NULL THEN markets ELSE List() END AS CASE WHEN markets[Int64(1)] IS NOT NULL THEN markets ELSE NULL END |
| | Aggregate: groupBy=[[df.name, df.group]], aggr=[[array_agg(market) AS markets]] |
| | Projection: df.name, df.group, CASE WHEN df.market IS NOT NULL AND df.price IS NOT NULL THEN named_struct(Utf8("market"), CAST(df.market AS Dictionary(UInt16, Utf8)), Utf8("price"), df.price) ELSE Struct({market:,price:}) END AS market |
| | TableScan: df projection=[name, group, market, price] |
| physical_plan | ProjectionExec: expr=[name@0 as name, group@1 as group, CASE WHEN array_element(markets@2, 1) IS NOT NULL THEN markets@2 END as CASE WHEN markets[Int64(1)] IS NOT NULL THEN markets ELSE NULL END] |
| | AggregateExec: mode=FinalPartitioned, gby=[name@0 as name, group@1 as group], aggr=[markets], ordering_mode=Sorted |
| | SortExec: expr=[name@0 ASC NULLS LAST, group@1 ASC NULLS LAST], preserve_partitioning=[true] |
| | CoalesceBatchesExec: target_batch_size=8192 |
| | RepartitionExec: partitioning=Hash([name@0, group@1], 10), input_partitions=10 |
| | AggregateExec: mode=Partial, gby=[name@0 as name, group@1 as group], aggr=[markets], ordering_mode=Sorted |
| | ProjectionExec: expr=[name@0 as name, group@1 as group, CASE WHEN market@2 IS NOT NULL AND price@3 IS NOT NULL THEN named_struct(market, CAST(market@2 AS Dictionary(UInt16, Utf8)), price, price@3) END as market] |
| | DataSourceExec: file_groups={10 groups: [, ...]}, projection=[name, group, market, price], output_ordering=[name@0 ASC NULLS LAST, group@1 ASC NULLS LAST], file_type=parquet |
| | |
+---------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
Hyperfine reports:
Benchmark 1: uv run test_polars.py sample-1m.parquet
Time (mean ± σ): 300.2 ms ± 104.0 ms [User: 538.6 ms, System: 140.1 ms]
Range (min … max): 249.2 ms … 585.3 ms 10 runs
Warning: The first benchmarking run for this command was significantly slower than the rest (585.3 ms). This could be caused by (filesystem) caches that were not filled until after the first run. You should consider using the '--warmup' option to fill those caches before the actual benchmark. Alternatively, use the '--prepare' option to clear the caches before each timing run.
Benchmark 2: uv run test_datafusion.py sample-1m.parquet
Time (mean ± σ): 1.320 s ± 0.062 s [User: 4.919 s, System: 0.258 s]
Range (min … max): 1.264 s … 1.444 s 10 runs
Summary
uv run test_polars.py sample-1m.parquet ran
4.40 ± 1.54 times faster than uv run test_datafusion.py sample-1m.parquet
To Reproduce
See https://github.com/valkum/polars-datafusion-comparison/tree/main for full repro case.
Expected behavior
No response
Additional context
I am not sure if a similar file and task is part of the benchmarks. As the 1m sample file is 14MB in size, thus it makes no sense to include such a file in the git repo. A smaller sample file (e.g. 10k) only results a 1.49x difference by Polars. Feel free to store a generated file to include this in the benchmarks.
Metadata
Metadata
Assignees
Labels
bugSomething isn't workingSomething isn't workinghelp wantedExtra attention is neededExtra attention is neededperformanceMake DataFusion fasterMake DataFusion faster
Activity
alamb commentedon Sep 5, 2025
Thank you @valkum
I tried the reproducer locally and I do also see the difference reported. I rewrote the query into the equivalent SQL as that was easier to profile for me:
Here is the input:
repo.zip
When I did some profiling with samply
samply record -- ~/Software/datafusion-cli/datafusion-cli-49.0.0 -f report.sqlHalf the time is spent in the array_agg implementation
So I think a lot of the difference would be fixed with a better array_agg implementation
here is one idea of how to do it:
[-]Slow aggregrate query, Polars is 4 times faster for equal query[/-][+]Slow aggregrate query with `array_agg`, Polars is 4 times faster for equal query[/+]valkum commentedon Sep 5, 2025
Thanks a lot for your investigation. I am OOO until Wednesday. I have to check if I have enough time to work on an updated attempt. But I certainly want to give it a try.
alamb commentedon Sep 6, 2025
A
GroupsAccumulatorwill be non trivial for ArrayAgg, I recommend you start with a simple type like Int64 first, and then we can make it generic for all primitives and then other types.This query accumulates arrays of structs 🤔
valkum commentedon Sep 6, 2025
Yes. We have a one to many relationship of some data and it doesn't make sense to store this normalized in a different file. My understanding of the parquet format, or rather dremel encoding, is that this should work rather good.
This is an intermediate step on our way to rework and modernize our data pipelines. So in the end this might not even be used very long, but we have this intermediate input file right now, and I thought it would be a good test to see if datafusion is fit for our task.
Omega359 commentedon Sep 12, 2025
There was a previous attempt at a GroupsAccumulator for ArrayAgg (#11096) but the performance of that PR was mixed.
alamb commentedon Sep 13, 2025
I don't know enough about how polars is implemented, but they may also use the
ListViewtype (similar to StringView, but for lists) which would make copying / aggregating lists potentially much fasterduongcongtoai commentedon Sep 20, 2025
I am looking for some topic about performance optimizing, and would like to take this ticket
duongcongtoai commentedon Sep 22, 2025
convert_to_state is invoked when skip partial aggregation is enabled at runtime (which make partial aggregation does nothing), but this "do-nothing" cost is a bit too much. I think as you mention we can try leverage the zero-copy functionality of ListArrays, which reference the original input values and adding some offsets of ones. For example given the input
[1,2,3,4,5,....]
Then
convert_to_stateis expected to return [1],[2],[3],[4],[5] ....valkum commentedon Sep 23, 2025
I haven't had time to start on this properly. If you have the time, feel free to look into this. I am looking forward to your results.
duongcongtoai commentedon Sep 26, 2025
I did a tweak inside that function and it looks good now, still performance is not that impressive, i'll take a look at other functions like
evaluateandupdate_batchduongcongtoai commentedon Sep 27, 2025
I also notice that
AccumulatorState::sizetakes up to 30% of CPU4 remaining items
duongcongtoai commentedon Sep 29, 2025
Yes i'm working on it
vegarsti commentedon Sep 29, 2025
Awesome!
duongcongtoai commentedon Sep 30, 2025
Indeed polar use binary view to build the agg result
duongcongtoai commentedon Sep 30, 2025
array agg for the specific query mentioned in this issue does not work so well with existing nullstate method which works only with primitive types, as well as this attemp, the struct can be any combination of subfield (not to mention nested field), in this case it is a dictionary field and a utf8 field. Slicing row of a struct arrays can be expensive, i wonder how polar store the data of this column during their execution, or do they consider the column as a struct type at all
alamb commentedon Oct 1, 2025
What I would suggest we do initially in DataFusion is:
interleavekernel)That approach requires no slicing and will work for any arbitrary array type
This design will require 2x the input size at peak (for the input and the inprogress output) but I think is probably significantly better than using the Accumulators (which also holds each input array entirely, but also has a bunch of individual allocations per row to store the ScalarValues
Here is a ascii art idea:
GroupsAccumulator State
GroupsAccumulator output (emit):
duongcongtoai commentedon Oct 2, 2025
Thank you @alamb for the detailed explanation. I'll try this out maybe tmr
duongcongtoai commentedon Oct 3, 2025
There was significant improvement after using
interleave, but there is still some overflowing error writing to Parquet (so i changed the script to evaluating in-memory), i'm fixing that and push for review soonduongcongtoai commentedon Oct 3, 2025
looks related: #17445
duongcongtoai commentedon Nov 7, 2025
I applied a patch in arrow-rs to fix dictionary overflow error and here is the performance improvement
ArrayAgg ran within the first 600-700ms, the rest is the time for writing Parquet file, which is another point for improvement.
With this result, i think this PR is good to push for review: #17915
array_aggis very slow #20465 as a duplicate of this issueperf: Optimize `array_agg()` using `GroupsAccumulator` (#20504)
array_aggis very slow #20465 as a duplicate of this issueperf: Optimize `array_agg()` using `GroupsAccumulator` (apache#20504)