Skip to content

Fix GroupOrdering memory descriptor double-counting - #26074

Open
kosiew wants to merge 2 commits into
apache:mainfrom
kosiew:memcalc-12-23393
Open

kosiew wants to merge 2 commits into
apache:mainfrom
kosiew:memcalc-12-23393

Conversation

@kosiew

@kosiew kosiew commented Oct 6, 2026

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Rationale for this change

GroupOrdering::size() currently counts both the outer enum descriptor and the inline active variant descriptor. Because the active variant is stored inside GroupOrdering, this double-counts inline memory for partial and full ordering states.

This change makes the GroupOrdering enum the single owner of the descriptor charge. Partial ordering continues to include retained order_indices capacity and state-owned allocations, while full ordering adds no additional descriptor charge.

What changes are included in this PR?

  • Make GroupOrdering::size() charge size_of::<GroupOrdering>() exactly once for None, Partial, and Full.
  • Rename GroupOrderingPartial::size() to heap_size() and make it report only:
    • retained order_indices allocation
    • allocations owned by the partial ordering state
  • Remove GroupOrderingFull::size(), since full ordering has no additional heap allocation to add beyond the outer enum descriptor.
  • Document the ownership boundary in GroupOrdering::size() and GroupOrderingPartial::heap_size().
  • Add deterministic size-accounting tests for none, full, and partial ordering states.

Are these changes tested?

Yes. This PR adds:

  • test_size_none, which verifies that GroupOrdering::None reports exactly size_of::<GroupOrdering>().
  • test_size_full, which verifies that full ordering reports exactly one GroupOrdering descriptor before and after new_groups, remove_groups, input_done, and reset.
  • test_size_partial_retained_allocations, which verifies that:
    • grown-and-truncated order_indices capacity remains charged
    • a variable-width ScalarValue::Utf8 sort key is included in the reported size
    • replacing the sort key charges the replacement rather than retaining the old key
    • input_done and reset drop state-owned key allocations while retaining the order_indices capacity
    • repopulating the state causes the key allocation to be charged again

The tests use deterministic size_of, vector capacity, and ScalarValue::size() expectations rather than allocator-observed byte counts.

Are there any user-facing changes?

No public API changes are introduced.

This changes internal memory accounting reported by GroupOrdering::size() so that inline descriptor memory is no longer double-counted. Grouping and aggregation behavior is otherwise unchanged by this patch.

LLM-generated code disclosure

This PR includes LLM-generated code and comments. All LLM-generated content has been manually reviewed.

kosiew added 2 commits October 6, 2026 16:19
- Enum descriptor counted once.
- Partial retained allocations preserved.
- Added None/full/partial capacity and lifecycle tests.
- Test coverage extended to cover longer sequences
- Replacement key charges only new allocation, not old key
@github-actions github-actions Bot added the physical-plan Changes to the physical-plan crate label Oct 6, 2026
@codecov-commenter

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 89.47368% with 6 lines in your changes missing coverage. Please review.
✅ Project coverage is 82.70%. Comparing base (d55153a) to head (4090508).

Files with missing lines Patch % Lines
...tafusion/physical-plan/src/aggregates/order/mod.rs 89.09% 0 Missing and 6 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #26074      +/-   ##
==========================================
- Coverage   82.70%   82.70%   -0.01%     
==========================================
  Files        1147     1147              
  Lines      447631   447680      +49     
  Branches   447631   447680      +49     
==========================================
+ Hits       370222   370262      +40     
+ Misses      54921    54919       -2     
- Partials    22488    22499      +11     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@kosiew
kosiew requested a review from comphead October 6, 2026 09:16
@kosiew
kosiew marked this pull request as ready for review October 6, 2026 09:16

@comphead comphead left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @kosiew for following on this, checking

@comphead comphead left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @kosiew. The fix is correct. size_of::<GroupOrdering>() already covers the inline variant, and both callers (grouped_hash_stream.rs and common_ordered.rs) only sum component sizes, so nothing is double counted at the call sites. size() runs once per batch over a constant-size state, so no benchmark is needed.

Inline comments cover test dedup and one pre-existing gap in heap_size. Smaller items:

  • State::size is heap-only too. Renaming it to heap_size makes the ownership rule uniform in partial.rs.
  • Spare order_indices capacity cannot arise through GroupOrdering::try_new (it clones the vec), so that part of test_size_partial_retained_allocations only guards VecAllocExt::allocated_size. If it stays, Vec::with_capacity(32) plus push(0) replaces the vec![0], extend and truncate setup and makes the capacity() > len() assert unnecessary.
  • Optional: a short comment on the None | Full(_) => 0 arm (they hold only inline state) would flag it for anyone who later adds a heap field to GroupOrderingFull, since the enum no longer delegates to it.

Comment on lines +172 to +192
#[test]
fn test_size_none() {
assert_eq!(GroupOrdering::None.size(), size_of::<GroupOrdering>());
}

#[test]
fn test_size_full() -> Result<()> {
let mut ordering = GroupOrdering::try_new(&InputOrderMode::Sorted)?;
let expected = size_of::<GroupOrdering>();
assert_eq!(ordering.size(), expected);

ordering.new_groups(&[], &[0, 1, 2], 3)?;
assert_eq!(ordering.size(), expected);
ordering.remove_groups(2);
assert_eq!(ordering.size(), expected);
ordering.input_done();
assert_eq!(ordering.size(), expected);
ordering.reset();
assert_eq!(ordering.size(), expected);
Ok(())
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One test is enough here: assert that None and a Sorted ordering both report exactly size_of::<GroupOrdering>(). None | Full(_) never reads the variant, so the new_groups, remove_groups, input_done and reset steps cannot change the result. The first Full assertion already catches the old code, and test_size_none passed before this PR too.

Comment on lines +214 to +235
ordering.remove_groups(1);
assert_eq!(ordering.size(), in_progress);

// Replacing the key charges only the new payload, not the previous key.
let replacement_key = "updated sort key with a longer payload";
let replacement_group_values: Vec<ArrayRef> =
vec![Arc::new(StringArray::from(vec![replacement_key]))];
ordering.new_groups(&replacement_group_values, &[1], 2)?;
let replaced =
expected + ScalarValue::Utf8(Some(replacement_key.to_owned())).size();
assert!(replaced > in_progress);
assert_eq!(ordering.size(), replaced);

// Completing or resetting drops the key, but retains order-index capacity.
ordering.input_done();
assert_eq!(ordering.size(), expected);
ordering.reset();
assert_eq!(ordering.size(), expected);
ordering.new_groups(&batch_group_values, &[0, 1], 2)?;
assert_eq!(ordering.size(), in_progress);
ordering.reset();
assert_eq!(ordering.size(), expected);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

heap_size is a pure function of the current state, so these steps cannot fail independently of the earlier ones:

  • remove_groups leaves sort_key untouched.
  • The key replacement block hits the same InProgress arm as the first new_groups call. Replacement itself is already covered by test_group_ordering_partial in partial.rs.
  • reset and the re-populate steps repeat the Start and InProgress assertions made above.

The spare-capacity assert, one new_groups call with the Utf8 key and the input_done assert cover every accounting term this PR touches at about half the length.

/// Returns retained heap allocations, excluding the inline descriptor
/// already counted by [`super::GroupOrdering::size`].
pub(crate) fn heap_size(&self) -> usize {
self.order_indices.allocated_size() + self.state.size()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not introduced here, so a follow-up under #23393 is fine. order_indices is charged by capacity, but State::size charges sort_key by length. get_row_at_idx collects into Result<Vec<_>>, and a std-only stand-in with a 64-byte element ends at len = 1, capacity = 4. So a one-column key likely holds 3 spare ScalarValue slots (192 bytes) that heap_size does not report, and the in_progress expectation in the new test pins that length-based value. Vec<ScalarValue> already implements DFHeapSize (capacity based), which CountGroupsAccumulator::size uses the same way. ScalarValue::size_of_vec minus the Vec header, as in nth_value.rs, is the other existing option.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

physical-plan Changes to the physical-plan crate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants