Skip to content

perf: avoid cloning every fragment descriptor in take and FTS planning - #8102

Merged
LuQQiu merged 2 commits into
lance-format:mainfrom
LuQQiu:lu/avoid-fragment-clone
Jul 30, 2026
Merged

LuQQiu merged 2 commits into
lance-format:mainfrom
LuQQiu:lu/avoid-fragment-clone

Conversation

@LuQQiu

@LuQQiu LuQQiu commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Problem

Two hot paths deep-copy the entire fragment descriptor list on every call, costing O(dataset fragments) regardless of how many fragments are actually touched:

  • do_take_rows called dataset.get_fragments() — cloning every Fragment descriptor (including DataFile path strings) — and then kept only the fragments addressed by the take.
  • plan_match_query / plan_phrase_query materialized self.dataset.fragments().to_vec() per query, even though the list is only read.

On a 126M-row dataset with 3,150 fragments this costs ~1 ms per call (~300 ns per descriptor clone+drop). Under a take-heavy workload both FTS plan build and the take pay it on every request, which showed up as a hard ~1,000 qps per-process ceiling with the machine >95% idle: thread dumps showed active threads dominated by Fragment::clone / drop_in_place<DataFile> under Scanner::plan_fts and do_take_rows.

Fix

  • do_take_rows: construct FileFragment handles only for the addressed fragment ids via get_existing_fragments_from_ids (same skip-missing semantics as the previous filter_map).
  • plan_match_query / plan_phrase_query: borrow the fragment slice (&[Fragment]); materialize only in the no-index flat fallback.

do_take_rows cloned all fragment descriptors via get_fragments() and then
kept only the addressed ones; FTS match/phrase planning deep-copied the
full fragment list per query via fragments().to_vec(). Both costs are
O(dataset fragments) per call and dominate take/plan cost on datasets
with many fragments (~1ms per call at 3150 fragments, capping a
single-node take benchmark at ~1000 qps).

Construct handles only for addressed fragments in do_take_rows
(get_existing_fragments_from_ids preserves the skip-missing semantics of
the previous filter_map), and borrow the fragment slice in
plan_match_query/plan_phrase_query, materializing only for the no-index
flat fallback.
@codecov

codecov Bot commented Jul 30, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@LuQQiu
LuQQiu requested a review from westonpace July 30, 2026 19:12
@LuQQiu
LuQQiu merged commit 07aa35d into lance-format:main Jul 30, 2026
40 of 42 checks passed
sbrunk added a commit to sbrunk/lance that referenced this pull request Aug 2, 2026
The composable scorer stack (lance-format#8092, lance-format#8093, lance-format#8094, lance-format#8131) added `compound.rs`,
a single-pass `ComposableScorer` that runs a whole compound tree against one
column's index with `Match`/`Phrase` leaves scored by a `MemBM25Scorer`. It
matches `FtsQuery` exhaustively, so the `CombinedFields` variant has to be
accounted for in multiple places.

BM25F blends `tf'`, `dl'` and `docFreq'` across columns before scoring, so it
cannot be a leaf that draws its postings from a single index; making it one
needs a multi-index leaf protocol and block-max bounds out of the MAXSCORE
scan. Gate it out instead, leaving those trees on the union/sort plan
`plan_combined_fields_query` already builds:

- `supports_compound_scorer` rejects any tree containing a `CombinedFields`
  node, at the top level or nested.
- The two `compound.rs` entry points return `Error::not_supported` rather than
  `unreachable!()`, so a future planner change fails loudly instead of
  silently misscoring.
- `collect_all_fts_columns` reports the real target columns, `contains_phrase_query`
  is false (BM25F needs no positions), `validate_fts_query_contract` has nothing
  to add (the weights are private and every constructor, `Deserialize` included,
  goes through `try_with_boosts`), and `count_fts_leaves` counts the per-column
  posting scans its partition estimate is approximating.

`test_nested_combined_fields_limit_propagation` covers the gate: it nests a
combined_fields node under MUST, SHOULD and BoostQuery, so a leak surfaces the
`not_supported` error.

Also apply lance-format#8102 to `plan_combined_fields_query`, which still deep-copied the
fragment descriptor list on every plan.
Borrow the slice like `plan_match_query` now does and materialize only the
uncovered subset the flat plan takes ownership of.
sbrunk added a commit to sbrunk/lance that referenced this pull request Aug 5, 2026
The composable scorer stack (lance-format#8092, lance-format#8093, lance-format#8094, lance-format#8131) added `compound.rs`,
a single-pass `ComposableScorer` that runs a whole compound tree against one
column's index with `Match`/`Phrase` leaves scored by a `MemBM25Scorer`. It
matches `FtsQuery` exhaustively, so the `CombinedFields` variant has to be
accounted for in multiple places.

BM25F blends `tf'`, `dl'` and `docFreq'` across columns before scoring, so it
cannot be a leaf that draws its postings from a single index; making it one
needs a multi-index leaf protocol and block-max bounds out of the MAXSCORE
scan. Gate it out instead, leaving those trees on the union/sort plan
`plan_combined_fields_query` already builds:

- `supports_compound_scorer` rejects any tree containing a `CombinedFields`
  node, at the top level or nested.
- The two `compound.rs` entry points return `Error::not_supported` rather than
  `unreachable!()`, so a future planner change fails loudly instead of
  silently misscoring.
- `collect_all_fts_columns` reports the real target columns, `contains_phrase_query`
  is false (BM25F needs no positions), `validate_fts_query_contract` has nothing
  to add (the weights are private and every constructor, `Deserialize` included,
  goes through `try_with_boosts`), and `count_fts_leaves` counts the per-column
  posting scans its partition estimate is approximating.

`test_nested_combined_fields_limit_propagation` covers the gate: it nests a
combined_fields node under MUST, SHOULD and BoostQuery, so a leak surfaces the
`not_supported` error.

Also apply lance-format#8102 to `plan_combined_fields_query`, which still deep-copied the
fragment descriptor list on every plan.
Borrow the slice like `plan_match_query` now does and materialize only the
uncovered subset the flat plan takes ownership of.
sbrunk added a commit to sbrunk/lance that referenced this pull request Aug 6, 2026
The composable scorer stack (lance-format#8092, lance-format#8093, lance-format#8094, lance-format#8131) added `compound.rs`,
a single-pass `ComposableScorer` that runs a whole compound tree against one
column's index with `Match`/`Phrase` leaves scored by a `MemBM25Scorer`. It
matches `FtsQuery` exhaustively, so the `CombinedFields` variant has to be
accounted for in multiple places.

BM25F blends `tf'`, `dl'` and `docFreq'` across columns before scoring, so it
cannot be a leaf that draws its postings from a single index; making it one
needs a multi-index leaf protocol and block-max bounds out of the MAXSCORE
scan. Gate it out instead, leaving those trees on the union/sort plan
`plan_combined_fields_query` already builds:

- `supports_compound_scorer` rejects any tree containing a `CombinedFields`
  node, at the top level or nested.
- The two `compound.rs` entry points return `Error::not_supported` rather than
  `unreachable!()`, so a future planner change fails loudly instead of
  silently misscoring.
- `collect_all_fts_columns` reports the real target columns, `contains_phrase_query`
  is false (BM25F needs no positions), `validate_fts_query_contract` has nothing
  to add (the weights are private and every constructor, `Deserialize` included,
  goes through `try_with_boosts`), and `count_fts_leaves` counts the per-column
  posting scans its partition estimate is approximating.

`test_nested_combined_fields_limit_propagation` covers the gate: it nests a
combined_fields node under MUST, SHOULD and BoostQuery, so a leak surfaces the
`not_supported` error.

Also apply lance-format#8102 to `plan_combined_fields_query`, which still deep-copied the
fragment descriptor list on every plan.
Borrow the slice like `plan_match_query` now does and materialize only the
uncovered subset the flat plan takes ownership of.
sbrunk added a commit to sbrunk/lance that referenced this pull request Aug 10, 2026
The composable scorer stack (lance-format#8092, lance-format#8093, lance-format#8094, lance-format#8131) added `compound.rs`,
a single-pass `ComposableScorer` that runs a whole compound tree against one
column's index with `Match`/`Phrase` leaves scored by a `MemBM25Scorer`. It
matches `FtsQuery` exhaustively, so the `CombinedFields` variant has to be
accounted for in multiple places.

BM25F blends `tf'`, `dl'` and `docFreq'` across columns before scoring, so it
cannot be a leaf that draws its postings from a single index; making it one
needs a multi-index leaf protocol and block-max bounds out of the MAXSCORE
scan. Gate it out instead, leaving those trees on the union/sort plan
`plan_combined_fields_query` already builds:

- `supports_compound_scorer` rejects any tree containing a `CombinedFields`
  node, at the top level or nested.
- The two `compound.rs` entry points return `Error::not_supported` rather than
  `unreachable!()`, so a future planner change fails loudly instead of
  silently misscoring.
- `collect_all_fts_columns` reports the real target columns, `contains_phrase_query`
  is false (BM25F needs no positions), `validate_fts_query_contract` has nothing
  to add (the weights are private and every constructor, `Deserialize` included,
  goes through `try_with_boosts`), and `count_fts_leaves` counts the per-column
  posting scans its partition estimate is approximating.

`test_nested_combined_fields_limit_propagation` covers the gate: it nests a
combined_fields node under MUST, SHOULD and BoostQuery, so a leak surfaces the
`not_supported` error.

Also apply lance-format#8102 to `plan_combined_fields_query`, which still deep-copied the
fragment descriptor list on every plan.
Borrow the slice like `plan_match_query` now does and materialize only the
uncovered subset the flat plan takes ownership of.
sbrunk added a commit to sbrunk/lance that referenced this pull request Aug 10, 2026
The composable scorer stack (lance-format#8092, lance-format#8093, lance-format#8094, lance-format#8131) added `compound.rs`,
a single-pass `ComposableScorer` that runs a whole compound tree against one
column's index with `Match`/`Phrase` leaves scored by a `MemBM25Scorer`. It
matches `FtsQuery` exhaustively, so the `CombinedFields` variant has to be
accounted for in multiple places.

BM25F blends `tf'`, `dl'` and `docFreq'` across columns before scoring, so it
cannot be a leaf that draws its postings from a single index; making it one
needs a multi-index leaf protocol and block-max bounds out of the MAXSCORE
scan. Gate it out instead, leaving those trees on the union/sort plan
`plan_combined_fields_query` already builds:

- `supports_compound_scorer` rejects any tree containing a `CombinedFields`
  node, at the top level or nested.
- The two `compound.rs` entry points return `Error::not_supported` rather than
  `unreachable!()`, so a future planner change fails loudly instead of
  silently misscoring.
- `collect_all_fts_columns` reports the real target columns, `contains_phrase_query`
  is false (BM25F needs no positions), `validate_fts_query_contract` has nothing
  to add (the weights are private and every constructor, `Deserialize` included,
  goes through `try_with_boosts`), and `count_fts_leaves` counts the per-column
  posting scans its partition estimate is approximating.

`test_nested_combined_fields_limit_propagation` covers the gate: it nests a
combined_fields node under MUST, SHOULD and BoostQuery, so a leak surfaces the
`not_supported` error.

Also apply lance-format#8102 to `plan_combined_fields_query`, which still deep-copied the
fragment descriptor list on every plan.
Borrow the slice like `plan_match_query` now does and materialize only the
uncovered subset the flat plan takes ownership of.
sbrunk added a commit to sbrunk/lance that referenced this pull request Aug 12, 2026
The composable scorer stack (lance-format#8092, lance-format#8093, lance-format#8094, lance-format#8131) added `compound.rs`,
a single-pass `ComposableScorer` that runs a whole compound tree against one
column's index with `Match`/`Phrase` leaves scored by a `MemBM25Scorer`. It
matches `FtsQuery` exhaustively, so the `CombinedFields` variant has to be
accounted for in multiple places.

BM25F blends `tf'`, `dl'` and `docFreq'` across columns before scoring, so it
cannot be a leaf that draws its postings from a single index; making it one
needs a multi-index leaf protocol and block-max bounds out of the MAXSCORE
scan. Gate it out instead, leaving those trees on the union/sort plan
`plan_combined_fields_query` already builds:

- `supports_compound_scorer` rejects any tree containing a `CombinedFields`
  node, at the top level or nested.
- The two `compound.rs` entry points return `Error::not_supported` rather than
  `unreachable!()`, so a future planner change fails loudly instead of
  silently misscoring.
- `collect_all_fts_columns` reports the real target columns, `contains_phrase_query`
  is false (BM25F needs no positions), `validate_fts_query_contract` has nothing
  to add (the weights are private and every constructor, `Deserialize` included,
  goes through `try_with_boosts`), and `count_fts_leaves` counts the per-column
  posting scans its partition estimate is approximating.

`test_nested_combined_fields_limit_propagation` covers the gate: it nests a
combined_fields node under MUST, SHOULD and BoostQuery, so a leak surfaces the
`not_supported` error.

Also apply lance-format#8102 to `plan_combined_fields_query`, which still deep-copied the
fragment descriptor list on every plan.
Borrow the slice like `plan_match_query` now does and materialize only the
uncovered subset the flat plan takes ownership of.
sbrunk added a commit to sbrunk/lance that referenced this pull request Aug 14, 2026
The composable scorer stack (lance-format#8092, lance-format#8093, lance-format#8094, lance-format#8131) added `compound.rs`,
a single-pass `ComposableScorer` that runs a whole compound tree against one
column's index with `Match`/`Phrase` leaves scored by a `MemBM25Scorer`. It
matches `FtsQuery` exhaustively, so the `CombinedFields` variant has to be
accounted for in multiple places.

BM25F blends `tf'`, `dl'` and `docFreq'` across columns before scoring, so it
cannot be a leaf that draws its postings from a single index; making it one
needs a multi-index leaf protocol and block-max bounds out of the MAXSCORE
scan. Gate it out instead, leaving those trees on the union/sort plan
`plan_combined_fields_query` already builds:

- `supports_compound_scorer` rejects any tree containing a `CombinedFields`
  node, at the top level or nested.
- The two `compound.rs` entry points return `Error::not_supported` rather than
  `unreachable!()`, so a future planner change fails loudly instead of
  silently misscoring.
- `collect_all_fts_columns` reports the real target columns, `contains_phrase_query`
  is false (BM25F needs no positions), `validate_fts_query_contract` has nothing
  to add (the weights are private and every constructor, `Deserialize` included,
  goes through `try_with_boosts`), and `count_fts_leaves` counts the per-column
  posting scans its partition estimate is approximating.

`test_nested_combined_fields_limit_propagation` covers the gate: it nests a
combined_fields node under MUST, SHOULD and BoostQuery, so a leak surfaces the
`not_supported` error.

Also apply lance-format#8102 to `plan_combined_fields_query`, which still deep-copied the
fragment descriptor list on every plan.
Borrow the slice like `plan_match_query` now does and materialize only the
uncovered subset the flat plan takes ownership of.
sbrunk added a commit to sbrunk/lance that referenced this pull request Aug 14, 2026
The composable scorer stack (lance-format#8092, lance-format#8093, lance-format#8094, lance-format#8131) added `compound.rs`,
a single-pass `ComposableScorer` that runs a whole compound tree against one
column's index with `Match`/`Phrase` leaves scored by a `MemBM25Scorer`. It
matches `FtsQuery` exhaustively, so the `CombinedFields` variant has to be
accounted for in multiple places.

BM25F blends `tf'`, `dl'` and `docFreq'` across columns before scoring, so it
cannot be a leaf that draws its postings from a single index; making it one
needs a multi-index leaf protocol and block-max bounds out of the MAXSCORE
scan. Gate it out instead, leaving those trees on the union/sort plan
`plan_combined_fields_query` already builds:

- `supports_compound_scorer` rejects any tree containing a `CombinedFields`
  node, at the top level or nested.
- The two `compound.rs` entry points return `Error::not_supported` rather than
  `unreachable!()`, so a future planner change fails loudly instead of
  silently misscoring.
- `collect_all_fts_columns` reports the real target columns, `contains_phrase_query`
  is false (BM25F needs no positions), `validate_fts_query_contract` has nothing
  to add (the weights are private and every constructor, `Deserialize` included,
  goes through `try_with_boosts`), and `count_fts_leaves` counts the per-column
  posting scans its partition estimate is approximating.

`test_nested_combined_fields_limit_propagation` covers the gate: it nests a
combined_fields node under MUST, SHOULD and BoostQuery, so a leak surfaces the
`not_supported` error.

Also apply lance-format#8102 to `plan_combined_fields_query`, which still deep-copied the
fragment descriptor list on every plan.
Borrow the slice like `plan_match_query` now does and materialize only the
uncovered subset the flat plan takes ownership of.
sbrunk added a commit to sbrunk/lance that referenced this pull request Aug 16, 2026
The composable scorer stack (lance-format#8092, lance-format#8093, lance-format#8094, lance-format#8131) added `compound.rs`,
a single-pass `ComposableScorer` that runs a whole compound tree against one
column's index with `Match`/`Phrase` leaves scored by a `MemBM25Scorer`. It
matches `FtsQuery` exhaustively, so the `CombinedFields` variant has to be
accounted for in multiple places.

BM25F blends `tf'`, `dl'` and `docFreq'` across columns before scoring, so it
cannot be a leaf that draws its postings from a single index; making it one
needs a multi-index leaf protocol and block-max bounds out of the MAXSCORE
scan. Gate it out instead, leaving those trees on the union/sort plan
`plan_combined_fields_query` already builds:

- `supports_compound_scorer` rejects any tree containing a `CombinedFields`
  node, at the top level or nested.
- The two `compound.rs` entry points return `Error::not_supported` rather than
  `unreachable!()`, so a future planner change fails loudly instead of
  silently misscoring.
- `collect_all_fts_columns` reports the real target columns, `contains_phrase_query`
  is false (BM25F needs no positions), `validate_fts_query_contract` has nothing
  to add (the weights are private and every constructor, `Deserialize` included,
  goes through `try_with_boosts`), and `count_fts_leaves` counts the per-column
  posting scans its partition estimate is approximating.

`test_nested_combined_fields_limit_propagation` covers the gate: it nests a
combined_fields node under MUST, SHOULD and BoostQuery, so a leak surfaces the
`not_supported` error.

Also apply lance-format#8102 to `plan_combined_fields_query`, which still deep-copied the
fragment descriptor list on every plan.
Borrow the slice like `plan_match_query` now does and materialize only the
uncovered subset the flat plan takes ownership of.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants