Skip to content

Add engine.execute() returning a PersistedQueryResult - #23114

Merged
rapids-bot[bot] merged 21 commits into
NVIDIA:mainfrom
madsbk:engine-execute
Jul 16, 2026
Merged

Add engine.execute() returning a PersistedQueryResult#23114
rapids-bot[bot] merged 21 commits into
NVIDIA:mainfrom
madsbk:engine-execute

Conversation

@madsbk

@madsbk madsbk commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Description

This PR adds an execute() API to the streaming cudf-polars engines (SPMD, Dask, and Ray) that returns a PersistedQueryResult. Instead of gathering the output to the client as a single DataFrame, each rank's partition stays GPU-resident in the process that produced it.

PersistedQueryResult.lazy() converts the persisted result back into a LazyFrame, allowing additional queries to be chained without an intermediate host round-trip:

import polars as pl
from cudf_polars.engine.ray import RayEngine

with RayEngine() as engine:
    # Runs on the GPU, the result stays there (no host copy).
    result = engine.execute(
        pl.scan_parquet("/data/dataset/*.parquet")
        .filter(pl.col("amount") > 100)
        .group_by("customer_id")
        .agg(pl.col("amount").sum())
    )

    # Chain more work off the persisted result, still on the GPU.
    df = (
        result.lazy()
        .sort("amount")
        .head(10)
        .collect(engine=engine)
    )

This provides the foundation for supporting Polars' LazyFrame.execute() / QueryResult APIs on GPU engines while keeping intermediate results GPU-resident and rank-local.

The implementation is built entirely on the existing RankAwareSource I/O-plugin infrastructure. Each rank's output partition is exposed through a registered scan source, and workers read only their local partition when the result is collected. Reads are move-on-read, so a PersistedQueryResult can only be collected once (see the design note).

Limitation

We expose this as engine.execute() because there is currently no way to hook into Polars' own pl.LazyFrame.execute(engine=...) call. This is one of the Polars gaps tracked in #22917.

The Approach

The lifecycle of a persisted result spans three phases. Each rank keeps its own output partition GPU-resident in a process-local store keyed by (query_id, rank) (see cudf_polars.engine.rank_local_store), so nothing crosses the process boundary until the caller explicitly collects.

Execute (engine.execute(lf) -> PersistedQueryResult):

  • The engine translates lf to IR, creates a query_id, and asks the backend to execute the query on each rank.
  • Each rank evaluates its partition and stores the surviving output locally.
  • The engine returns a PersistedQueryResult referencing the producing ranks.

Collect (PersistedQueryResult.lazy() -> collect(engine=...)):

  • PersistedQueryResult.lazy() exposes the stored partitions as a Polars IO-plugin LazyFrame so downstream query nodes can chain onto it normally.
  • Collecting with the producing engine scans each owning rank and removes its partition from the rank-local store.

Release (GC finalizer / PersistedQueryResult.release() / context manager):

  • A live result holds a weakref.finalize that calls PersistedBackend.drop_persisted, broadcasting a store drop for the query_id to every rank. It is idempotent and never raises, so a collected, released, or reset result all clean up safely.

@madsbk madsbk self-assigned this Jul 6, 2026
@madsbk madsbk added improvement Improvement / enhancement to an existing function non-breaking Non-breaking change labels Jul 6, 2026
@github-actions github-actions Bot added Python Affects Python cuDF API. cudf-polars Issues specific to cudf-polars labels Jul 6, 2026
@GPUtester GPUtester moved this to In Progress in cuDF Python Jul 6, 2026
@madsbk
madsbk force-pushed the engine-execute branch 11 times, most recently from 012f980 to 3d4580f Compare July 7, 2026 11:04
@madsbk
madsbk marked this pull request as ready for review July 7, 2026 11:06
@madsbk
madsbk requested a review from a team as a code owner July 7, 2026 11:06
@madsbk
madsbk requested a review from nirandaperera July 7, 2026 11:06
@NVIDIA NVIDIA deleted a comment from copy-pr-bot Bot Jul 7, 2026
@coderabbitai

coderabbitai Bot commented Jul 7, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: d6cdb034-2685-4243-a6a2-c72d9c2de2b3

📥 Commits

Reviewing files that changed from the base of the PR and between 8db4297 and 4b99b43.

📒 Files selected for processing (2)
  • docs/cudf/source/cudf_polars/execute.md
  • python/cudf_polars/cudf_polars/engine/rank_local_store.py
🚧 Files skipped from review as they are similar to previous changes (2)
  • docs/cudf/source/cudf_polars/execute.md
  • python/cudf_polars/cudf_polars/engine/rank_local_store.py

📝 Walkthrough

Summary by CodeRabbit

  • New Features
    • Added experimental query execution that returns GPU-resident results for SPMD, Dask, and Ray engines.
    • Persisted results can be lazily collected, chained into additional queries, projected, and filtered.
    • Added explicit result release and context-manager support for deterministic GPU memory cleanup.
  • Bug Fixes
    • Improved handling and presentation of unsupported operations.
    • Corrected duplicated partition handling across distributed ranks.
  • Documentation
    • Added comprehensive execution and persisted-results guidance, including usage limitations and supported engines.
  • Tests
    • Added extensive coverage for execution, chaining, cleanup, failures, and distributed behavior.

Walkthrough

This PR adds experimental engine.execute() support to cudf-polars, returning GPU-resident persisted results across SPMD, Dask, and Ray. It adds rank-local storage, lazy persisted sources, replicated-output handling, cleanup, serialization helpers, documentation, and tests.

Changes

Persisted execute() pipeline

Layer / File(s) Summary
Translation and GPU output handling
python/cudf_polars/cudf_polars/dsl/translate.py, .../callback.py, .../engine/core.py
Centralizes unsupported-operation errors, preserves GPU DataFrames from rank execution, and adds replicated-output helpers.
Persisted result storage and loading
.../engine/rank_local_store.py, .../engine/persisted_result.py
Adds rank-local partition storage, lazy loading, one-shot consumption, cleanup, and PersistedQueryResult.
Replication metadata
.../streaming/rank_aware_source.py, .../streaming/actor_graph/*
Adds duplication signaling to rank-aware sources and channel metadata, with explicit SPMD conversion back to Polars.
SPMD, Dask, and Ray backends
.../engine/spmd.py, .../engine/dask.py, .../engine/ray.py
Adds persisted execution, engine methods, rank/worker/actor cleanup, and replicated-output handling.
Serializable executor configuration
.../utils/config.py, .../streaming/benchmarks/utils.py
Adds shared removal of process-local executor state before serialization.
Documentation
docs/cudf/source/cudf_polars/*, docs/cudf/source/conf.py
Documents the execute workflow, API, navigation, and Sphinx reference exclusions.
Validation and fixtures
python/cudf_polars/tests/*
Adds persisted-result, lifecycle, duplicated-output, distributed cleanup, configuration, and environment-specific tests.

Estimated code review effort: 4 (Complex) | ~75 minutes

Possibly related issues

Possibly related PRs

  • rapidsai/cudf#23179: Overlaps in engine/core.py execution entry points while adding Quent tracing context passthrough.

Suggested reviewers: wence-, tomaugspurger, nirandaperera

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: adding engine.execute() that returns a PersistedQueryResult.
Description check ✅ Passed The description is directly related to the change and accurately describes the new persisted-result execute API.
Docstring Coverage ✅ Passed Docstring coverage is 80.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

coderabbitai[bot]

This comment was marked as resolved.

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

Should we have some kind of base .execute() method on StreamingEngine? Same for drop_persisted?

Comment thread docs/cudf/source/cudf_polars/execute.md Outdated
Comment thread docs/cudf/source/cudf_polars/execute.md Outdated
Comment thread docs/cudf/source/cudf_polars/execute.md
Comment thread docs/cudf/source/conf.py
Comment thread python/cudf_polars/cudf_polars/engine/core.py
Comment thread python/cudf_polars/cudf_polars/engine/dask.py Outdated
Comment thread python/cudf_polars/cudf_polars/engine/rank_local_store.py Outdated
Comment thread python/cudf_polars/cudf_polars/engine/rank_local_store.py Outdated
Comment thread python/cudf_polars/cudf_polars/engine/rank_local_store.py

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

Small things

Comment thread docs/cudf/source/cudf_polars/execute.md Outdated
Comment thread docs/cudf/source/cudf_polars/execute.md
Comment thread docs/cudf/source/cudf_polars/execute.md Outdated
Comment thread docs/cudf/source/cudf_polars/execute.md Outdated
Comment thread docs/cudf/source/cudf_polars/execute.md Outdated
Comment thread docs/cudf/source/cudf_polars/execute.md Outdated

## Chaining into another `execute()`

`result.lazy()` is an ordinary `LazyFrame`, so you can feed it (or further work

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.

Note, it's not really an ordinary LazyFrame because you can't collect it on the CPU, no?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

good point, updated to:

`result.lazy()` supports the usual `LazyFrame` API, so you can add further
operations and pass the resulting query directly to `engine.execute()` without
collecting it first.

Only materialization is special. Like any persisted result, it must run on the
engine that produced it, as described above, and cannot be collected directly
on the host. The query executes on the GPU, and its output remains there as a
new persisted result. This makes it useful for building multi-step pipelines
without transferring intermediate results to host memory:

Comment thread docs/cudf/source/cudf_polars/execute.md Outdated
Comment on lines +91 to +92
`LazyFrame` twice, or to use it in an operation that reads it multiple times
(such as a self-join), raises a `RuntimeError`. Call `engine.execute()` again

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.

This is bad (the self-join thing). I guess the problem is that Polar's common subplan elimination doesn't see that these are the same thing twice?

@madsbk madsbk Jul 15, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yes, CSE will sometimes collapse the duplicate scan, so a self-join can happen to work, but not in the general case. Support for repeated reads is tracked in #23115.

Comment on lines +124 to +125
# The process-global set of per-engine stores, keyed by uid
_stores: dict[str, RankLocalStore] = {}

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.

Question: can we somehow have this thing tied to an engine, rather than being process-global?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I don't think so. I initially explored several ways to keep the store on the engine, but persisted partitions are loaded by PersistedSource, which runs during query execution in the worker deep inside the Polars IR.

That callback receives only the serialized scan arguments, the engine uid and query_id. It has no reference to the engine object, which lives in the client process.

@madsbk
madsbk requested review from TomAugspurger and wence- July 15, 2026 12:09

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

I think all my questions have been addressed.

@madsbk

madsbk commented Jul 16, 2026

Copy link
Copy Markdown
Contributor Author

/merge

@rapids-bot
rapids-bot Bot merged commit 8474239 into NVIDIA:main Jul 16, 2026
130 checks passed
@github-project-automation github-project-automation Bot moved this from In Progress to Done in cuDF Python Jul 16, 2026
@madsbk
madsbk deleted the engine-execute branch July 16, 2026 18:18
galipremsagar added a commit to galipremsagar/cudf that referenced this pull request Jul 17, 2026
…ction

DataFrame.from_table lost its num_rows parameter in NVIDIA#23234 (row counts
are now inferred from the pylibcudf table, which carries them even for
zero-column tables), but the duplicated-output path added in NVIDIA#23114
still passed num_rows=0, breaking mypy on every PR and raising
TypeError at runtime on that path. empty_like already produces a
0-row table (including for zero-column inputs), so the argument was
redundant.
rapids-bot Bot pushed a commit that referenced this pull request Jul 17, 2026
…utput path (#23303)

`DataFrame.from_table` lost its `num_rows` parameter in #23234 (row counts are now inferred from the pylibcudf table, which carries them even for zero-column tables), but the duplicated-output path added in #23114 still passes `num_rows=0`. The two PRs merged around the same time, so this surfaced only after both landed: mypy now fails on every PR's `check-style` job (`engine/core.py:840: Unexpected keyword argument "num_rows"`), and the path would raise `TypeError` at runtime.

`plc.copying.empty_like` already produces a 0-row table (including for zero-column inputs, verified), so dropping the argument preserves the intended "freshly-allocated empty same-schema frame" semantics exactly.

Authors:
  - GALI PREM SAGAR (https://github.com/galipremsagar)

Approvers:
  - Matthew Roeschke (https://github.com/mroeschke)
  - Lawrence Mitchell (https://github.com/wence-)

URL: #23303
davidwendt pushed a commit to wjxiz1992/cudf that referenced this pull request Jul 21, 2026
…utput path (NVIDIA#23303)

`DataFrame.from_table` lost its `num_rows` parameter in NVIDIA#23234 (row counts are now inferred from the pylibcudf table, which carries them even for zero-column tables), but the duplicated-output path added in NVIDIA#23114 still passes `num_rows=0`. The two PRs merged around the same time, so this surfaced only after both landed: mypy now fails on every PR's `check-style` job (`engine/core.py:840: Unexpected keyword argument "num_rows"`), and the path would raise `TypeError` at runtime.

`plc.copying.empty_like` already produces a 0-row table (including for zero-column inputs, verified), so dropping the argument preserves the intended "freshly-allocated empty same-schema frame" semantics exactly.

Authors:
  - GALI PREM SAGAR (https://github.com/galipremsagar)

Approvers:
  - Matthew Roeschke (https://github.com/mroeschke)
  - Lawrence Mitchell (https://github.com/wence-)

URL: NVIDIA#23303
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cudf-polars Issues specific to cudf-polars improvement Improvement / enhancement to an existing function non-breaking Non-breaking change Python Affects Python cuDF API.

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

5 participants