Repository navigation
Conversation
0a2f5f8 to
1465c70
Compare
7d12109 to
aa7bc47
Compare
|
cc @Dandandan @martin-g Should be good for another look |
aa7bc47 to
b7a06ea
Compare
|
Thanks for the review @Dandandan @martin-g , I believe I've addressed all of your feedback. |
|
Unfortunately, after the squash+force push I will have to review the whole PR again ... |
Sorry about that, the squash slipped through accidentally! |
|
No problem! |
|
Hi @martin-g and @Dandandan ! Friendly ping on this when you have a moment. I know you mentioned wanting a second person to review - let me know if there is someone specific I should tag, or if there is anything else I can do from my side to help get this across the finish line. |
b7a06ea to
3d1f008
Compare
|
starts to look good! Can you also remove the testing submodule commit / update? And fix remaining CI issues? |
8d4f0f6 to
1824111
Compare
Done, please lmk if there are still any remaining issues @Dandandan |
|
@Dandandan @martin-g when should I expect the next round of reviews on this? thanks! |
|
Thank you for your contribution. Unfortunately, this pull request is stale because it has been open 60 days with no activity. Please remove the stale label or comment or this will be closed in 7 days. |
ping |
timsaucer
left a comment
There was a problem hiding this comment.
I took a pass at it myself and I also directed an agent to review it. I've marked which comments came from the agent. It also had these suggestions:
Should fix
- 144 signature variants. L691–696 builds 18 value types × 8 k types as TypeSignature::Exact. approx_percentile_cont handles the same shape with TypeSignature::Coercible + TypeSignatureClass::Integer, which collapses k to a single coercion and accepts any integer width for free.
- Missing the types this function is most for. No Utf8View/BinaryView — the default for Parquet string reads under force_view_types — and no Dictionary. They'll be coerced to Utf8/Binary, so it works, but you eat a full cast per batch on the exact workload (top URL paths, top error codes) the PR motivates. Also absent: Decimal128/256, Boolean, Time32/64. Separately, update_batch's match (L1037–1078) has to be kept in sync with the signature list by hand; a RowConverter or ScalarValue-keyed encoding would make the type support fall out for free instead of being 150 lines of per-type to_le_bytes plus a mirrored 150 lines in build_value_array.
- Literal extraction is bespoke. L750–766 downcasts to expressions::Literal directly. crate::utils::get_scalar_value evaluates against an empty batch, so it also handles cast-wrapped or otherwise foldable literals and keeps the error text consistent with sibling functions.
- Corrupt state silently becomes NULL. build_value_array uses <[u8; N]>::try_from(..).ok() and String::from_utf8(..).ok() throughout (L830–980), so a wrong-length or non-UTF-8 item yields NULL rather than an error. The _ => fallback (L974–980) reinterprets arbitrary types as UTF-8 strings; since the signature already restricts types, that should be internal_err!.
- Hash choice. hash_item (L157–165) is byte-at-a-time FNV-1a. It's slow on long strings, and the alpha index uses hash as usize & mask (L246) — FNV's low bits are its weakest, and hashbrown::HashTable also derives its bucket index from the low bits, so you get correlated clustering in both structures. foldhash is already a dependency of this crate.
- item_heap_bytes uses raw +=/-= (L259, L290, L400, L446) after the rest of the file moved to saturating_*; a bookkeeping slip is a debug panic. Also #[derive(Clone)] re-derives Vec capacities, so a plain .clone() leaves item_heap_bytes overstated — the merge fast path works around this at L353–355 but nothing else does.
Nits
- return_type's arg_types.is_empty() → Utf8 branch (L714–724) is dead; the signature guarantees arity 2.
- SpaceSavingSummary::new(0) leaves alpha_map empty, and the at-capacity branch then does self.alpha_map.len() - 1 → underflow panic. Unreachable through accumulator() (k ≥ 1) but reachable from tests; worth a debug_assert or an explicit guard.
- LargeUtf8 → Utf8 narrowing of the return type is commented in code but not surfaced in #[user_doc]; it's user-visible.
Add a new approx_top_k(expression, k) aggregate function that returns
the approximate top-k most frequent values with their estimated counts,
using the Filtered Space-Saving algorithm.
The implementation uses a capacity multiplier of 3 (matching ClickHouse's
default) and includes an alpha map for improved accuracy by filtering
low-frequency noise before it enters the main summary.
Return type is List(Struct({value: T, count: UInt64})) ordered by count
descending, where T matches the input column type.
Closes apache#20967
- Replace HashMap<Vec<u8>, usize> with hashbrown::HashTable<(u64, usize)> to eliminate key cloning, double-hashing, and O(n) rebuild allocations - Fix merge() to use Parallel Space-Saving m1/m2 algorithm (min counter count correction) matching ClickHouse's SpaceSaving::merge() - Fix serialize() to select top-N counters and fold evicted ones into alpha_map when requested_capacity < counters.len() < target_capacity - Harden from_bytes() with validation: requested_capacity > 0, num_counters <= requested_capacity, alpha_map_size is power-of-two, and overflow-safe bounds checking throughout - Replace all raw += on count/error/alpha with saturating_add - Track item_heap_bytes incrementally for O(1) size() accounting - Pre-allocate serialize() output buffer - Add TIMEZONE_WILDCARD timestamp variants to signature - Remove unused k and data_type from state_fields/state/merge_batch - Replace all manual DataFusionError construction with exec_err!/plan_err! - Replace .unwrap() downcasts in update_batch with proper error returns - Fix test_accumulator_large_utf8_input to use DataType::LargeUtf8 - Update #[user_doc] to document approximate counts, NULL handling, supported types, and return shape - Use with_timezone() instead of arrow::compute::cast for timestamps - Adapt to upstream API changes (remove as_any from AggregateUDFImpl, use direct downcast_ref for Literal)
- Fix testing submodule pointer (reverted to match upstream main) - Fix Cargo.toml formatting (taplo) - Fix unresolved rustdoc link: [`size`] -> `size()`
1824111 to
4e651ba
Compare
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #20968 +/- ##
==========================================
+ Coverage 80.91% 81.06% +0.14%
==========================================
Files 1103 1107 +4
Lines 377219 382932 +5713
Branches 377219 382932 +5713
==========================================
+ Hits 305244 310433 +5189
- Misses 53775 54205 +430
- Partials 18200 18294 +94 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
Thank you for the thorough review @timsaucer . I have addressed the feedback and aligned the implementation closer to the original paper rather than clickhouse variant. We have already been running this in our production environment since we need to provide top-k with constant memory utilization regardless of dataset size. |
|
Thank you for your contribution. Unfortunately, this pull request is stale because it has been open 60 days with no activity. Please remove the stale label or comment or this will be closed in 7 days. |
|
Hi @timsaucer, thanks again for reviewing this PR. I’ve addressed the feedback and updated the implementation. Could you take another look when you have a chance? |
Which issue does this PR close?
Closes #20967.
Rationale
Finding the most frequently occurring values in a column is a very common analytical pattern — top error codes, most popular products, most active users, frequent URL paths, etc. The exact approach (
GROUP BY value ORDER BY COUNT(*) DESC LIMIT k) requires materializing all distinct groups, which is memory-intensive and slow on high-cardinality columns.approx_top_ksolves this with a streaming approximation algorithm that operates in bounded memory, making it practical for large-scale analytics.This function is already available in ClickHouse (
topK) and via extensions in PostgreSQL and Druid, and is a natural addition to DataFusion's existing family of approximate aggregate functions (approx_distinct,approx_median,approx_percentile_cont).What changes are included in this PR?
Core implementation (
datafusion/functions-aggregate/src/approx_top_k.rs)SpaceSavingSummary— Implements the Filtered Space-Saving algorithm (Metwally et al., 2005):CAPACITY_MULTIPLIER = 3(matching ClickHouse's default) — the summary tracksk × 3counters internallyApproxTopK—AggregateUDFImplwith#[user_doc]documentation, supporting any hashable scalar typeApproxTopKAccumulator— Accumulator withupdate_batch,merge_batch,evaluate(returnsList(Struct({value: T, count: UInt64}))ordered by count descending), andstate(serialized summary + data type)9 unit tests covering basic operations, eviction, alpha map, merge, serialization, accumulator update/evaluate, merge_batch, and a distributed merge simulation
Registration (
datafusion/functions-aggregate/src/lib.rs)expr_fnre-export, and registration inall_default_aggregate_functions()SQL logic tests (
datafusion/sqllogictest/test_files/approx_top_k.slt)Documentation
docs/source/user-guide/sql/aggregate_functions.md— TOC entry + full documentation sectiondocs/source/user-guide/expressions.md— quick-reference table entryIntegration tests
datafusion/proto/tests/cases/roundtrip_logical_plan.rs) —approx_top_k(col("a"), lit(3))datafusion/core/tests/dataframe/dataframe_functions.rs) —test_fn_approx_top_kwithinsta::assert_snapshot!Are these changes tested?
Yes:
approx_top_k.rs(algorithm correctness, serialization, accumulator lifecycle, distributed merge)approx_top_k.slt(end-to-end SQL behavior including edge cases and errors)Are there any user-facing changes?
Yes — new
approx_top_kaggregate function available in SQL and the DataFrame API: