Skip to content

perf(arrow): buffer positional-delete runs to drop per-row key allocation - #3015

Open
anoopj wants to merge 2 commits into
apache:mainfrom
anoopj:perf-posdel-string-alloc
Open

perf(arrow): buffer positional-delete runs to drop per-row key allocation#3015
anoopj wants to merge 2 commits into
apache:mainfrom
anoopj:perf-posdel-string-alloc

Conversation

@anoopj

@anoopj anoopj commented Aug 16, 2026

Copy link
Copy Markdown
Member

Which issue does this PR close?

What changes are included in this PR?

parse_positional_deletes_record_batch_stream is allocating and hashing a fresh path string per row even though a positional delete file repeats the same data-file path across all of its rows (the records are sorted by (file_path, pos)).

This PR fixes the inefficiency. Behavior is unchanged: positions are still inserted one-by-one, and a path that recurs merges into its existing delete vector, so results are identical regardless of ordering.

Microbenchmark filling HashMap<String, RoaringTreemap> from 500k rows in 8192-row batches: one data file 32.9 -> 8.5 ns/row (3.9x); 50 data files 36.8 -> 10.0 ns/row (3.7x).

Are these changes tested?

Added tests, existing test coverage is there also

AI Disclosure

Assisted with Claude code

anoopj added 2 commits August 16, 2026 10:41
…tion

parse_positional_deletes_record_batch_stream called
result.entry(file_path.to_string()) for every deleted row, allocating
and hashing a fresh path string per row even though a positional
delete file repeats the same data-file path across all of its rows
(the records are sorted by (file_path, pos)).

Within each Arrow batch, buffer the contiguous run of positions for one
path and merge it into the map in a single entry lookup, so the key is
allocated and hashed once per run instead of once per row. Grouping is
per batch, not across the whole stream (the run buffer is scoped to the
batch), so a path spanning batches is flushed once per batch it appears
in. Behavior is unchanged: positions are still inserted one-by-one (no
ordering precondition), and a path that recurs merges into its existing
delete vector, so results are identical regardless of ordering. Also
drops an unused schema binding.

Microbenchmark filling HashMap<String, RoaringTreemap> from 500k rows
in 8192-row batches: one data file 32.9 -> 8.5 ns/row (3.9x); 50 data
files 36.8 -> 10.0 ns/row (3.7x).
…ted cases

Replace the single grouping test with two: one spec-compliant case (rows sorted by (file_path, pos), covering multi-position runs, several files in a batch, and a run spanning the batch boundary), and one deliberately spec-noncompliant unsorted case that proves the reader does not depend on the spec sort order. A path split into non-contiguous runs still merges into one delete vector rather than dropping positions. The non-compliance is called out in the test name and doc comment so the input is not mistaken for a realistic one.
@anoopj
anoopj force-pushed the perf-posdel-string-alloc branch from 3fb1647 to 721b42e Compare August 16, 2026 21:00
@mbutrovich
mbutrovich self-requested a review August 17, 2026 12:50

@mbutrovich mbutrovich left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

First pass, thanks @anoopj!

The benchmark numbers in the description measure filling a bare HashMap<String, RoaringTreemap>, not parse_positional_deletes_record_batch_stream itself. The function's downcast, iteration, and validation overhead aren't part of that loop, so the ns/row numbers likely overstate the end-to-end win. Re-running the benchmark through the actual function would give a more representative number.

Comment on lines +417 to +420
let delete_vector = result.entry(file_path.to_string()).or_default();
for &pos in positions {
delete_vector.insert(pos);
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

DeleteVector::insert_positions (delete_vector.rs:56) already bulk-appends a sorted slice into the underlying RoaringTreemap in one call, and it's currently unused (#[allow(dead_code)]). Since a run here is ascending in the common spec-compliant case, calling insert_positions first and falling back to this per-element loop only on Err would get the append-based speedup for the case your benchmark targets, while keeping the non-compliant-run handling your test at line 1007 covers.

Comment on lines +369 to +370
let mut run_path: Option<&str> = None;
let mut run_positions: Vec<u64> = Vec::new();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

run_positions is declared inside the while loop, so it drops and rebuilds from empty every batch, discarding the capacity it grew to from the previous batch's runs. run_path needs to stay batch-scoped since it borrows the batch's StringArray, but run_positions is owned and could be hoisted above the loop, with a .clear() added after the final flush at line 399 to match.

file_path: &str,
positions: &[u64],
) {
if positions.is_empty() {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This guard can't be hit: both call sites only run after run_path was set to Some, which always happens in the same iteration as a push onto run_positions. Since this is a caller-guaranteed invariant rather than a real precondition, debug_assert!(!positions.is_empty()) would document it instead of silently swallowing a case that can't occur.

.or_default()
.insert(pos as u64);
if run_path != Some(file_path) {
if let Some(run_path) = run_path {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This binding shadows the outer run_path, which is reassigned two lines down at line 392, two different bindings sharing one name. Renaming this one to prev_path would make the reassignment read as updating the loop state rather than the just-matched value.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

perf: avoid per-row path allocation when parsing positional delete files

2 participants