Skip to content

perf: Update TableProvider to take scan projection as Option<&[usize]> - #24376

Merged
Dandandan merged 4 commits into
apache:mainfrom
Dandandan:perf/scan-projection-slice
Aug 14, 2026
Merged

Dandandan merged 4 commits into
apache:mainfrom
Dandandan:perf/scan-projection-slice

Conversation

@Dandandan

@Dandandan Dandandan commented Aug 14, 2026 •

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Rationale for this change

TableProvider::scan took its projection as Option<&Vec<usize>>. Besides being
the signature clippy::ptr_arg exists to discourage, it forced
TableProvider::scan_with_args's default body to allocate:

let projection = args.projection().map(|p| p.to_vec());
let plan = self.scan(state, projection.as_ref(), filters, limit).await?;

ScanArgs::projection() already yields Option<&[usize]>, so that Vec existed
only to be borrowed. Worse, it cannot outlive a hoisted future, so the body had to
stay an async fn — and that made it the single most expensive thing to compile
in datafusion-session
. Proving its coroutine Send walks the whole
Expr/LogicalPlan type graph, and #[async_trait]'s 'life0: 'async_trait
bounds stop rustc serving that proof from its global cache.

I measured this directly before writing the change: stubbing the body out dropped
datafusion-session's evaluate_obligation from 617ms to 16.5ms and the
whole crate from 0.86s to 0.235s, while the rest of the crate's trait solving
attributed to just 4ms. So this one default body was ~97% of what remained after
#24326.

What changes are included in this PR?

The projection becomes Option<&[usize]>, and the default body hands it straight
through, capturing only the already-boxed future it awaits:

let plan = self.scan(
    state,
    args.projection(),
    args.filters().unwrap_or(&[]),
    args.limit(),
);
Box::pin(async move { Ok(plan.await?.into()) })

Also updated, because their projections feed the same call paths:
StreamingTableExec::try_new, FilterExec::with_projection, batch_filter.

datafusion_common::project_schema now takes
Option<&T> where T: AsRef<[usize]> + ?Sized instead of
Option<&impl AsRef<[usize]>>, so it accepts a Vec or a slice — no caller of
it needs to change.

Call sites get simpler rather than noisier: Some(&vec![2, 1]) becomes
Some(&[2, 1]), and three vec! allocations in tests disappear (clippy pointed
those out on its own).

Are these changes tested?

Yes

Are there any user-facing changes?

Yes — this is a breaking change for TableProvider implementors, so it has an
upgrade-guide entry. Implementations only need the signature updated; two patterns
inside a body need adjusting:

// Before                    // After
projection.cloned()          projection.map(|p| p.to_vec())

and callers holding an Option<Vec<usize>> pass .as_deref() rather than
.as_ref(). In tree that was 47 signatures across 36 files, all mechanical.

🤖 Generated with Claude Code

`TableProvider::scan` took its projection as `Option<&Vec<usize>>`. Besides
being the signature `clippy::ptr_arg` exists to discourage, it forced
`TableProvider::scan_with_args`'s default body to allocate:

    let projection = args.projection().map(|p| p.to_vec());
    let plan = self.scan(state, projection.as_ref(), filters, limit).await?;

`ScanArgs::projection()` yields `Option<&[usize]>`, so the `Vec` existed only to
be borrowed. It also could not outlive a hoisted future, which forced the body to
stay an `async fn` -- and that made it the single most expensive thing to compile
in `datafusion-session`: proving its coroutine `Send` walks the whole
`Expr`/`LogicalPlan` type graph, which `#[async_trait]`'s `'life0: 'async_trait`
bounds stop rustc from caching (see apache#13814, apache#24325, apache#24326, apache#24329, apache#24330).

With the slice signature the default body hands the projection straight through
and only captures the already-boxed future it awaits:

    let plan = self.scan(state, args.projection(), args.filters().unwrap_or(&[]), args.limit());
    Box::pin(async move { Ok(plan.await?.into()) })

Interleaved A/B of `cargo rustc -p <crate> --lib`, 3 rounds each:

    datafusion-session   0.890s -> 0.273s   (evaluate_obligation 624.3ms -> 10.4ms)
    datafusion-catalog   1.046s -> 1.205s
    datafusion           4.088s -> 4.125s

`datafusion-session` compiles 3.3x faster and its trait solving drops 98%.
`datafusion-catalog` regresses ~10% and core is flat; both were confirmed with
the measurement order reversed, so the net is roughly -0.45s across the three.

The same shape is updated in `StreamingTableExec::try_new`,
`FilterExec::with_projection` and `batch_filter`, whose projections feed the same
call paths. `datafusion_common::project_schema` now takes
`Option<&T> where T: AsRef<[usize]> + ?Sized` so it accepts both a `Vec` and a
slice; no caller of it needs to change.

Call sites get simpler rather than noisier -- `Some(&vec![2, 1])` becomes
`Some(&[2, 1])`, and three `vec!` allocations in tests disappear.

Co-Authored-By: Claude Opus 5 <[email protected]>
@github-actions github-actions Bot added documentation Improvements or additions to documentation core Core DataFusion crate sqllogictest SQL Logic Tests (.slt) catalog Related to the catalog crate common Related to common crate functions Changes to functions implementation ffi Changes to the ffi crate physical-plan Changes to the physical-plan crate labels Aug 14, 2026
@github-actions

github-actions Bot commented Aug 14, 2026 •

Copy link
Copy Markdown

Thank you for opening this pull request!

Reviewer note: cargo-semver-checks reported the current version number is not SemVer-compatible with the changes in this pull request (compared against the base branch).

Details
     Cloning apache/main
    Building datafusion v54.1.0 (current)
       Built [  62.961s] (current)
     Parsing datafusion v54.1.0 (current)
      Parsed [   0.037s] (current)
    Building datafusion v54.1.0 (baseline)
       Built [  63.118s] (baseline)
     Parsing datafusion v54.1.0 (baseline)
      Parsed [   0.039s] (baseline)
    Checking datafusion v54.1.0 -> v54.1.0 (no change; assume patch)
     Checked [   0.926s] 223 checks: 223 pass, 31 skip
     Summary no semver update required
    Finished [ 129.093s] datafusion
    Building datafusion-catalog v54.1.0 (current)
       Built [  41.501s] (current)
     Parsing datafusion-catalog v54.1.0 (current)
      Parsed [   0.026s] (current)
    Building datafusion-catalog v54.1.0 (baseline)
       Built [  42.013s] (baseline)
     Parsing datafusion-catalog v54.1.0 (baseline)
      Parsed [   0.027s] (baseline)
    Checking datafusion-catalog v54.1.0 -> v54.1.0 (no change; assume patch)
     Checked [   0.161s] 223 checks: 223 pass, 31 skip
     Summary no semver update required
    Finished [  85.819s] datafusion-catalog
    Building datafusion-catalog-listing v54.1.0 (current)
       Built [  41.824s] (current)
     Parsing datafusion-catalog-listing v54.1.0 (current)
      Parsed [   0.012s] (current)
    Building datafusion-catalog-listing v54.1.0 (baseline)
       Built [  41.510s] (baseline)
     Parsing datafusion-catalog-listing v54.1.0 (baseline)
      Parsed [   0.013s] (baseline)
    Checking datafusion-catalog-listing v54.1.0 -> v54.1.0 (no change; assume patch)
     Checked [   0.112s] 223 checks: 223 pass, 31 skip
     Summary no semver update required
    Finished [  84.858s] datafusion-catalog-listing
    Building datafusion-cli v54.1.0 (current)
       Built [  96.145s] (current)
     Parsing datafusion-cli v54.1.0 (current)
      Parsed [   0.035s] (current)
    Building datafusion-cli v54.1.0 (baseline)
       Built [  98.219s] (baseline)
     Parsing datafusion-cli v54.1.0 (baseline)
      Parsed [   0.037s] (baseline)
    Checking datafusion-cli v54.1.0 -> v54.1.0 (no change; assume patch)
     Checked [   0.175s] 223 checks: 223 pass, 31 skip
     Summary no semver update required
    Finished [ 197.717s] datafusion-cli
    Building datafusion-common v54.1.0 (current)
       Built [  33.983s] (current)
     Parsing datafusion-common v54.1.0 (current)
      Parsed [   0.065s] (current)
    Building datafusion-common v54.1.0 (baseline)
       Built [  33.931s] (baseline)
     Parsing datafusion-common v54.1.0 (baseline)
      Parsed [   0.067s] (baseline)
    Checking datafusion-common v54.1.0 -> v54.1.0 (no change; assume patch)
     Checked [   0.996s] 223 checks: 222 pass, 1 fail, 0 warn, 31 skip

--- failure function_requires_different_generic_type_params: function now requires a different number of generic type parameters ---

Description:
A function now requires a different number of generic type parameters than it used to. Uses of this function that supplied the previous number of generic types (e.g. via turbofish syntax) will be broken.
        ref: https://doc.rust-lang.org/reference/items/generics.html
       impl: https://github.com/obi1kenobi/cargo-semver-checks/tree/v0.50.0/src/lints/function_requires_different_generic_type_params.ron

Failed in:
  function project_schema (0 -> 1 generic types) in /home/runner/work/datafusion/datafusion/datafusion/common/src/utils/mod.rs:83
  function project_schema (0 -> 1 generic types) in /home/runner/work/datafusion/datafusion/datafusion/common/src/utils/mod.rs:83

     Summary semver requires new major version: 1 major and 0 minor checks failed
    Finished [  72.874s] datafusion-common
    Building datafusion-ffi v54.1.0 (current)
       Built [  53.776s] (current)
     Parsing datafusion-ffi v54.1.0 (current)
      Parsed [   0.069s] (current)
    Building datafusion-ffi v54.1.0 (baseline)
       Built [  53.380s] (baseline)
     Parsing datafusion-ffi v54.1.0 (baseline)
      Parsed [   0.071s] (baseline)
    Checking datafusion-ffi v54.1.0 -> v54.1.0 (no change; assume patch)
     Checked [   0.398s] 223 checks: 223 pass, 31 skip
     Summary no semver update required
    Finished [ 109.340s] datafusion-ffi
    Building datafusion-functions-table v54.1.0 (current)
       Built [  40.184s] (current)
     Parsing datafusion-functions-table v54.1.0 (current)
      Parsed [   0.012s] (current)
    Building datafusion-functions-table v54.1.0 (baseline)
       Built [  41.683s] (baseline)
     Parsing datafusion-functions-table v54.1.0 (baseline)
      Parsed [   0.012s] (baseline)
    Checking datafusion-functions-table v54.1.0 -> v54.1.0 (no change; assume patch)
     Checked [   0.112s] 223 checks: 223 pass, 31 skip
     Summary no semver update required
    Finished [  83.413s] datafusion-functions-table
    Building datafusion-physical-plan v54.1.0 (current)
       Built [  40.505s] (current)
     Parsing datafusion-physical-plan v54.1.0 (current)
      Parsed [   0.158s] (current)
    Building datafusion-physical-plan v54.1.0 (baseline)
       Built [  38.781s] (baseline)
     Parsing datafusion-physical-plan v54.1.0 (baseline)
      Parsed [   0.158s] (baseline)
    Checking datafusion-physical-plan v54.1.0 -> v54.1.0 (no change; assume patch)
     Checked [   0.993s] 223 checks: 223 pass, 31 skip
     Summary no semver update required
    Finished [  82.733s] datafusion-physical-plan
    Building datafusion-session v54.1.0 (current)
       Built [  39.065s] (current)
     Parsing datafusion-session v54.1.0 (current)
      Parsed [   0.012s] (current)
    Building datafusion-session v54.1.0 (baseline)
       Built [  39.762s] (baseline)
     Parsing datafusion-session v54.1.0 (baseline)
      Parsed [   0.012s] (baseline)
    Checking datafusion-session v54.1.0 -> v54.1.0 (no change; assume patch)
     Checked [   0.252s] 223 checks: 223 pass, 31 skip
     Summary no semver update required
    Finished [  80.227s] datafusion-session
    Building datafusion-sqllogictest v54.1.0 (current)
       Built [ 101.349s] (current)
     Parsing datafusion-sqllogictest v54.1.0 (current)
      Parsed [   0.022s] (current)
    Building datafusion-sqllogictest v54.1.0 (baseline)
       Built [ 103.839s] (baseline)
     Parsing datafusion-sqllogictest v54.1.0 (baseline)
      Parsed [   0.024s] (baseline)
    Checking datafusion-sqllogictest v54.1.0 -> v54.1.0 (no change; assume patch)
     Checked [   0.124s] 223 checks: 223 pass, 31 skip
     Summary no semver update required
    Finished [ 208.564s] datafusion-sqllogictest

@github-actions github-actions Bot added the auto detected api change Auto detected API change label Aug 14, 2026
@Dandandan

Copy link
Copy Markdown
Contributor Author

@alamb for consideration - this is I think the only one that couldn't be converted due to breaking the API

`datafusion/ffi/src/tests/` is behind the `integration-tests` feature, so
`cargo check --workspace --all-targets` never compiled it and the call site was
missed. `scan.projection` is an `Option<Vec<usize>>` field, so it needs
`as_deref()` rather than `as_ref()`.

Checked with `--all-features --all-targets` per crate this time, which is what
would have caught it: datafusion, datafusion-catalog, datafusion-catalog-listing,
datafusion-session, datafusion-functions-table, datafusion-sqllogictest,
datafusion-proto, datafusion-substrait and datafusion-ffi are all clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
@codecov-commenter

codecov-commenter commented Aug 14, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 77.77778% with 12 lines in your changes missing coverage. Please review.
✅ Project coverage is 81.19%. Comparing base (ec110ce) to head (fe5f70a).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
datafusion/catalog/src/cte_worktable.rs 0.00% 4 Missing ⚠️
datafusion/catalog/src/view.rs 0.00% 4 Missing ⚠️
datafusion/core/src/dataframe/mod.rs 0.00% 3 Missing ⚠️
datafusion/session/src/table.rs 93.33% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #24376      +/-   ##
==========================================
- Coverage   81.19%   81.19%   -0.01%     
==========================================
  Files        1110     1110              
  Lines      388618   388752     +134     
  Branches   388618   388752     +134     
==========================================
+ Hits       315531   315634     +103     
- Misses      54507    54529      +22     
- Partials    18580    18589       +9     

☔ 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.

@alamb alamb 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.

Makes sense to me -- thanks @Dandandan -- I'll have claude confirm the numbers too on a separate machine

Comment thread datafusion-cli/src/functions.rs Resolved
Comment thread datafusion/session/src/table.rs Resolved
Dandandan and others added 2 commits August 14, 2026 21:14
`docs/source/library-user-guide/custom-table-providers.md` is included into
`datafusion/core/src/lib.rs` via `include_str!`, so its `TableProvider` examples
are compiled as doctests -- five `scan` signatures there still took
`Option<&Vec<usize>>`.

Also wraps the ffi call site changed in the previous commit, which exceeded the
line width once `as_ref()` became `as_deref()`.

Verified: the three doctests CI reported (lib.rs lines 1641, 1781, 1929) now pass,
`cargo test -p datafusion --doc` is 186 passed / 0 failed, and
`cargo fmt --all --check` is clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
Remove commented-out code explaining async behavior.
@alamb alamb changed the title perf: take the scan projection as Option<&[usize]> perf: Update TableProvider to take scan projection as Option<&[usize]> Aug 14, 2026
@alamb

alamb commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

FWIW I could not reproduce the reported compilation speedup

I had code run the PR's reported command, cargo rustc -p datafusion-session --lib, five times per revision. Dependencies were warmed beforehand, and datafusion/session/src/lib.rs was touched before each measured run to force only datafusion-session to recompile. Runs were interleaved baseline/PR.

run merge-base (ec110cea18) PR (2c7a7ba49a)
1 0.38s 0.39s
2 0.38s 0.38s
3 0.38s 0.38s
4 0.38s 0.39s
5 0.39s 0.38s
mean 0.382s 0.384s
median 0.380s 0.380s

Is there some other way you were testing?

@Dandandan

Copy link
Copy Markdown
Contributor Author

FWIW I could not reproduce the reported compilation speedup

I had code run the PR's reported command, cargo rustc -p datafusion-session --lib, five times per revision. Dependencies were warmed beforehand, and datafusion/session/src/lib.rs was touched before each measured run to force only datafusion-session to recompile. Runs were interleaved baseline/PR.

run merge-base (ec110cea18) PR (2c7a7ba49a)
1 0.38s 0.39s
2 0.38s 0.38s
3 0.38s 0.38s
4 0.38s 0.39s
5 0.39s 0.38s
mean 0.382s 0.384s
median 0.380s 0.380s
Is there some other way you were testing?

Yes, I tested it building the whole workspace and then looking at the contribution of datafusion-session.

@Dandandan

Dandandan commented Aug 14, 2026 •

Copy link
Copy Markdown
Contributor Author

So the difference is:

cargo clean -p datafusion-session 
CARGO_INCREMENTAL=0 cargo build -p datafusion --lib 

@alamb

alamb commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

That is a much narrower claim than the PR made. I still think Option<&[usize]> is the better signature on its own merits — it is what clippy::ptr_arg exists to discourage, and it lets the default scan_with_args body drop a Vec allocation and stop capturing it in the returned future

I think it is ok too -- I was just trying to quantify eactly how much benefit there was for the API change

@Dandandan

Dandandan commented Aug 14, 2026 •

Copy link
Copy Markdown
Contributor Author

That is a much narrower claim than the PR made. I still think Option<&[usize]> is the better signature on its own merits — it is what clippy::ptr_arg exists to discourage, and it lets the default scan_with_args body drop a Vec allocation and stop capturing it in the returned future

I think it is ok too -- I was just trying to quantify eactly how much benefit there was for the API change

Sorry this was Claude spamming a comment (I agree with the point as well though).

The difference seems to be that incremental keeps this cached (depending on what has been changed) whereas a normal build (e.g. after a clean or dependency upgrade / change...) doesn't.

@Dandandan
Dandandan added this pull request to the merge queue Aug 14, 2026
Merged via the queue into apache:main with commit b9399dc Aug 14, 2026
45 checks passed
@Dandandan
Dandandan deleted the perf/scan-projection-slice branch August 14, 2026 21:28
imtherealnaska pushed a commit to imtherealnaska/datafusion that referenced this pull request Aug 16, 2026
…ze]>` (apache#24376)

## Which issue does this PR close?

- Addresses apache#13814
- Follow-on to apache#24325, apache#24326, apache#24329, apache#24330

## Rationale for this change

`TableProvider::scan` took its projection as `Option<&Vec<usize>>`.
Besides being
the signature `clippy::ptr_arg` exists to discourage, it forced
`TableProvider::scan_with_args`'s default body to allocate:

```rust
let projection = args.projection().map(|p| p.to_vec());
let plan = self.scan(state, projection.as_ref(), filters, limit).await?;
```

`ScanArgs::projection()` already yields `Option<&[usize]>`, so that
`Vec` existed
only to be borrowed. Worse, it cannot outlive a hoisted future, so the
body had to
stay an `async fn` — and that made it **the single most expensive thing
to compile
in `datafusion-session`**. Proving its coroutine `Send` walks the whole
`Expr`/`LogicalPlan` type graph, and `#[async_trait]`'s `'life0:
'async_trait`
bounds stop rustc serving that proof from its global cache.

I measured this directly before writing the change: stubbing the body
out dropped
`datafusion-session`'s `evaluate_obligation` from **617ms to 16.5ms**
and the
whole crate from 0.86s to 0.235s, while the rest of the crate's trait
solving
attributed to just 4ms. So this one default body was ~97% of what
remained after
apache#24326.

## What changes are included in this PR?

The projection becomes `Option<&[usize]>`, and the default body hands it
straight
through, capturing only the already-boxed future it awaits:

```rust
let plan = self.scan(
    state,
    args.projection(),
    args.filters().unwrap_or(&[]),
    args.limit(),
);
Box::pin(async move { Ok(plan.await?.into()) })
```

Also updated, because their projections feed the same call paths:
`StreamingTableExec::try_new`, `FilterExec::with_projection`,
`batch_filter`.

`datafusion_common::project_schema` now takes
`Option<&T> where T: AsRef<[usize]> + ?Sized` instead of
`Option<&impl AsRef<[usize]>>`, so it accepts a `Vec` *or* a slice — no
caller of
it needs to change.

Call sites get simpler rather than noisier: `Some(&vec![2, 1])` becomes
`Some(&[2, 1])`, and three `vec!` allocations in tests disappear (clippy
pointed
those out on its own).

## Are these changes tested?

Yes
## Are there any user-facing changes?

Yes — this is a **breaking change** for `TableProvider` implementors, so
it has an
upgrade-guide entry. Implementations only need the signature updated;
two patterns
inside a body need adjusting:

```rust
// Before                    // After
projection.cloned()          projection.map(|p| p.to_vec())
```

and callers holding an `Option<Vec<usize>>` pass `.as_deref()` rather
than
`.as_ref()`. In tree that was 47 signatures across 36 files, all
mechanical.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

auto detected api change Auto detected API change catalog Related to the catalog crate common Related to common crate core Core DataFusion crate documentation Improvements or additions to documentation ffi Changes to the ffi crate functions Changes to functions implementation physical-plan Changes to the physical-plan crate sqllogictest SQL Logic Tests (.slt) v56.0.0

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants