cudf-polars PythonScan support with rank-aware IO sources - #22867
Conversation
a712005 to
667df0f
Compare
207cf5b to
3d6a238
Compare
10e97f1 to
0181de0
Compare
9a46991 to
3a5a8f7
Compare
Co-authored-by: Matthew Murray <41342305+Matt711@users.noreply.github.com>
wence-
left a comment
There was a problem hiding this comment.
Minor nits, looks good. Thanks!
| 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`). |
There was a problem hiding this comment.
No part of the introductory documentation has previously talked about these cudf_polars.containers.DataFrame objects?
There was a problem hiding this comment.
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.
| ## 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. |
There was a problem hiding this comment.
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?
| # 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) |
There was a problem hiding this comment.
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.
| rank=comm.rank, | ||
| nranks=comm.nranks, |
There was a problem hiding this comment.
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: |
There was a problem hiding this comment.
| while True: | |
| while !ch_out.is_shutdown(): |
So that if the consumer doesn't want any more we don't keep making chunks.
There was a problem hiding this comment.
I don't think this is correct, if the downstream shutsdown cout_out we should fail, which we already do.
|
/merge |
Description
This PR has two parts:
IR::PythonScan, making it possible to use a Python function as an IO source for a cudf-polars query viapolars.io.plugins.register_io_source(). Onmain, this path just raisesNotImplementedError.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:
RankAwareSourcelets us implement Polars' newLazyFrame.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/tailinto thePythonScanasn_rows; cudf-polars rejects that during translation (raisingNotImplementedError, 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 likelimit()orhead()with also filtering.Example
A minimal end-to-end example: generate data from a Python function and run a GPU query over it.
What happens internally:
register_io_sourceproduces aLazyFramewhose root is a PolarsPythonScannode wrappingsource..collect(engine=...)hands the optimized plan to cudf-polars, which translates thePythonScanintoIR::PythonScan(the predicatecol("a") > 2is pushed into it).PythonScan.do_evaluatecallssource, moves each yielded frame to the GPU , validates the result against the declared schema, and applies the pushed predicate on the device.xref #22917 for the gaps in Polars we need TODO