Skip to content

cudf-polars PythonScan support with rank-aware IO sources - #22867

Merged
rapids-bot[bot] merged 32 commits into
NVIDIA:mainfrom
madsbk:rank_aware_source
Jul 2, 2026
Merged

cudf-polars PythonScan support with rank-aware IO sources#22867
rapids-bot[bot] merged 32 commits into
NVIDIA:mainfrom
madsbk:rank_aware_source

Conversation

@madsbk

@madsbk madsbk commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Description

This PR has two parts:

  1. It implements IR::PythonScan, making it possible to use a Python function as an IO source for a cudf-polars query via polars.io.plugins.register_io_source(). On main, this path just raises NotImplementedError.
  2. It introduces a rank-aware, GPU-backed IO source through a new abstract base class, RankAwareSource. This lets you implement a custom IO source that returns cudf-polars DataFrames based on the worker's rank.

The motivation is likewise two-fold:

  1. It is generally useful to generate data directly within a query, e.g. to support a custom file format.
  2. RankAwareSource lets us implement Polars' new LazyFrame.execute() API without collecting all the GPU data to the client's host memory. We may eventually want dedicated IR in upstream Polars for this, but a prototype built on just this PR already works very well.

Limitation

We don't support a pushed-down row limit. Polars folds limit / head / tail into the PythonScan as n_rows; cudf-polars rejects that during translation (raising NotImplementedError, which falls back to CPU), because a single global row count can't be enforced across independent per-rank sources. Note the current versions of Polars never folds a limit into the scan when a predicate is also pushed so this limitation is only observed when user use something like limit() or head() with also filtering.

Example

A minimal end-to-end example: generate data from a Python function and run a GPU query over it.

import polars as pl
from polars.io.plugins import register_io_source

# 1. An IO source: a callable yielding polars DataFrames.
def source(with_columns, predicate, n_rows, batch_size):
    yield pl.DataFrame({"a": [1, 2, 3, 4, 5]})

# 2. Register it as a LazyFrame with a declared schema.
lf = register_io_source(source, schema={"a": pl.Int64})

# 3. Build and collect a query on the GPU.
result = lf.filter(pl.col("a") > 2).collect(engine=pl.GPUEngine())
print(result)
# shape: (3, 1)
# ┌─────┐
# │ a   │
# │ --- │
# │ i64 │
# ╞═════╡
# │ 3   │
# │ 4   │
# │ 5   │
# └─────┘

What happens internally:

  1. register_io_source produces a LazyFrame whose root is a Polars PythonScan node wrapping source.
  2. .collect(engine=...) hands the optimized plan to cudf-polars, which translates the PythonScan into IR::PythonScan (the predicate col("a") > 2 is pushed into it).
  3. PythonScan.do_evaluate calls source, moves each yielded frame to the GPU , validates the result against the declared schema, and applies the pushed predicate on the device.
  4. The filtered GPU frame is returned and copied back to host as the result.

xref #22917 for the gaps in Polars we need TODO

@madsbk madsbk self-assigned this Jun 12, 2026
@github-actions github-actions Bot added Python Affects Python cuDF API. cudf-polars Issues specific to cudf-polars labels Jun 12, 2026
@madsbk madsbk added improvement Improvement / enhancement to an existing function non-breaking Non-breaking change and removed Python Affects Python cuDF API. cudf-polars Issues specific to cudf-polars labels Jun 12, 2026
@madsbk
madsbk force-pushed the rank_aware_source branch from a712005 to 667df0f Compare June 12, 2026 11:28
@github-actions github-actions Bot added Python Affects Python cuDF API. cudf-polars Issues specific to cudf-polars labels Jun 12, 2026
@GPUtester GPUtester moved this to In Progress in cuDF Python Jun 12, 2026
@madsbk
madsbk force-pushed the rank_aware_source branch 3 times, most recently from 207cf5b to 3d6a238 Compare June 12, 2026 13:34
@Matt711 Matt711 self-assigned this Jun 12, 2026
@madsbk
madsbk force-pushed the rank_aware_source branch 2 times, most recently from 10e97f1 to 0181de0 Compare June 15, 2026 07:22
@Matt711 Matt711 removed their assignment Jun 15, 2026
@Matt711
Matt711 self-requested a review June 15, 2026 13:09
@madsbk
madsbk force-pushed the rank_aware_source branch 10 times, most recently from 9a46991 to 3a5a8f7 Compare June 15, 2026 20:32
@bdice
bdice removed request for a team June 29, 2026 15:40

@Matt711 Matt711 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks @madsbk I think this is in a good place. I mostly left minor suggestions. But since there are a bunch, I'll approve after you look them over.

FYI: I also opened up some sub-issues for the gaps you found in polars in #22917.

Comment thread python/cudf_polars/cudf_polars/dsl/ir.py Outdated
Comment thread python/cudf_polars/cudf_polars/dsl/ir.py
Comment thread python/cudf_polars/cudf_polars/dsl/translate.py Outdated
Comment thread python/cudf_polars/cudf_polars/dsl/ir.py Outdated
Comment thread python/cudf_polars/cudf_polars/dsl/ir.py Outdated
Comment thread python/cudf_polars/cudf_polars/dsl/ir.py
Comment thread python/cudf_polars/cudf_polars/dsl/ir.py
Comment thread python/cudf_polars/cudf_polars/dsl/ir.py
Comment thread python/cudf_polars/cudf_polars/streaming/rank_aware_source.py Outdated
Comment thread python/cudf_polars/cudf_polars/streaming/rank_aware_source.py Outdated
madsbk and others added 4 commits June 30, 2026 21:27
coderabbitai[bot]

This comment was marked as resolved.

@madsbk
madsbk requested a review from Matt711 June 30, 2026 20:40
Comment thread python/cudf_polars/cudf_polars/streaming/rank_aware_source.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.

Minor nits, looks good. Thanks!

Comment on lines +38 to +45
Each chunk is either a host `polars.DataFrame` or an already-GPU-resident
`cudf_polars.containers.DataFrame`, and a source may mix the two. Returning
GPU-resident frames skips the host-to-device copy, but such a source can only be
collected with a cudf-polars engine.

The source may yield multiple chunks, which cudf-polars combines into the scan
output (under a streaming engine the chunks are forwarded individually; see
{ref}`io-plugins-sized-chunks`).

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.

No part of the introductory documentation has previously talked about these cudf_polars.containers.DataFrame objects?

@madsbk madsbk Jul 1, 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.

Good point, updated:

Chunks are normally returned as regular host-resident `polars.DataFrame`
objects, but cudf-polars internally represents GPU-resident data using
`cudf_polars.containers.DataFrame`.

An IO source may return `cudf_polars.containers.DataFrame` objects directly.
Doing so avoids the host-to-device copy, but restricts the source to
cudf-polars engines, since the default Polars CPU engine cannot consume
`cudf_polars.containers.DataFrame` objects.

Comment on lines +167 to +176
## Threading

Under a streaming engine, cudf-polars runs IO sources on a worker thread pool.
A source is created on a worker thread, and successive chunks are pulled on
worker threads that may differ from the one that created the source and from
each other. A source must therefore not depend on thread-affine state that is
created up front and reused across chunks, for example a `sqlite3.Connection`
(which by default may only be used on the thread that opened it). Open such
resources inside the function that produces each chunk, or use a thread-safe
equivalent.

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.

Do we need this section?

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 am not sure. I think we do. This isn't obvious from the API, and users may
reasonably expect things like sqlite3.Connection objects or other bridging
libraries to work. I think it's worth stating explicitly?

Comment on lines +370 to +378
# A GPU chunk needs no copy, so its size is 0. When a predicate is applied,
# the filter briefly holds both the input and its (smaller) output, so we
# reserve double for that transient peak.
size = 0 if isinstance(chunk, DataFrame) else chunk.estimated_size()
reservation = size * 2 if ir.predicate is not None else size
with opaque_memory_usage(
await reserve_memory(context, size=reservation, net_memory_delta=size)
):
df = await ir_context.to_thread(process)

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 seems wrong for the GPU-resident input? Surely you need to reserve for the output in both cases, so:

# Reserve for the filtered output if it exists, and moving the input to device if it not already there
reservation = chunk.estimated_size() * (1 + (ir.predicate is not None) - isinstance(chunk, DataFrame))

By an abuse of casting.

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 catch, fixed: 7660aae

Comment on lines +425 to +426
rank=comm.rank,
nranks=comm.nranks,

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.

nitpick: Perhaps we should just provide the communicator to the source? That would be necessary for limit in multi-rank cases, I think.

await send_metadata(ch_out, context, ChannelMetadata(local_count=announced))
sentinel = object()
seq_num = 0
while True:

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.

Suggested change
while True:
while !ch_out.is_shutdown():

So that if the consumer doesn't want any more we don't keep making chunks.

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 this is correct, if the downstream shutsdown cout_out we should fail, which we already do.

@madsbk
madsbk force-pushed the rank_aware_source branch from d9f94a2 to acc922a Compare July 1, 2026 10:54
@madsbk

madsbk commented Jul 2, 2026

Copy link
Copy Markdown
Contributor Author

/merge

@rapids-bot
rapids-bot Bot merged commit ca1439c into NVIDIA:main Jul 2, 2026
130 checks passed
@github-project-automation github-project-automation Bot moved this from In Progress to Done in cuDF Python Jul 2, 2026
@madsbk
madsbk deleted the rank_aware_source branch July 2, 2026 06:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CMake CMake build issue cudf-polars Issues specific to cudf-polars improvement Improvement / enhancement to an existing function libcudf Affects libcudf (C++/CUDA) code. non-breaking Non-breaking change Python Affects Python cuDF API.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants