Skip to content

Implement more unary polars.Expressions - #23037

Closed
mroeschke wants to merge 18 commits into
rapidsai:mainfrom
mroeschke:cudf_polars/enh/more_expressions
Closed

Implement more unary polars.Expressions#23037
mroeschke wants to merge 18 commits into
rapidsai:mainfrom
mroeschke:cudf_polars/enh/more_expressions

Conversation

@mroeschke

Copy link
Copy Markdown
Contributor

Description

xref #23015

Implements the following unary expressions

Expr.truncate
Expr.drop_nans
Expr.rechunk
Expr.search_sorted
Expr.index_of
Expr.approx_n_unique

Checklist

  • I am familiar with the Contributing Guidelines.
  • New or existing tests cover these changes.
  • The documentation is up to date with these changes.

Truncate numeric values toward zero to the requested number of decimal places.
Integer inputs are returned unchanged; float inputs are scaled by 10^decimals
(when nonzero), truncated via copy_if_else(floor(x), ceil(x), x >= 0), and
unscaled.
Drop NaN values (keeping nulls) via pylibcudf.stream_compaction.drop_nans.
Non-floating-point inputs cannot contain NaN and are returned unchanged.
rechunk is an identity passthrough on the GPU since cudf columns are always
stored as a single contiguous chunk.
Find insertion indices for one or more needles in a sorted column via
pylibcudf.search.lower_bound (side any/left) or upper_bound (side right),
selecting ascending or descending order from the descending flag. Indices are
cast to the unsigned output dtype.
Return the first index where the column equals the search value (or null if
absent). A NULL_EQUALS comparison builds the match mask so a null search value
matches null entries; the matching positions of an index sequence are filtered
and the first is returned, cast to the unsigned output dtype.
Map approx_n_unique to an exact distinct count via
pylibcudf.reduce.distinct_count with NullPolicy.INCLUDE and
NanPolicy.NAN_IS_VALID, matching Polars' n_unique semantics where null is
counted once and all NaNs compare equal. The result is returned as a length-1
unsigned column.
@mroeschke mroeschke self-assigned this Jun 30, 2026
@mroeschke
mroeschke requested a review from a team as a code owner June 30, 2026 00:26
@mroeschke
mroeschke requested a review from wence- June 30, 2026 00:26
@mroeschke mroeschke added improvement Improvement / enhancement to an existing function non-breaking Non-breaking change labels Jun 30, 2026
@github-actions github-actions Bot added Python Affects Python cuDF API. cudf-polars Issues specific to cudf-polars labels Jun 30, 2026
@mroeschke mroeschke changed the title Cudf polars/enh/more expressions Implement more unary polars.Expressions Jun 30, 2026
@GPUtester GPUtester moved this to In Progress in cuDF Python Jun 30, 2026
@coderabbitai

coderabbitai Bot commented Jun 30, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The head commit changed during the review from 578a8b0 to c5b51da.

📝 Walkthrough

Walkthrough

UnaryFunction.do_evaluate in unary.py gains six new evaluation branches: truncate, rechunk, approx_n_unique, search_sorted, index_of, and drop_nans. The corresponding function names are registered in _supported_misc_fns and two are marked pointwise. Six new test modules validate each operation on the GPU engine.

Changes

New UnaryFunction Operations

Layer / File(s) Summary
Function registration
python/cudf_polars/cudf_polars/dsl/expressions/unary.py
_supported_misc_fns gains approx_n_unique, drop_nans, index_of, search_sorted, and truncate; _pointwise_fns gains rechunk and truncate.
truncate implementation and tests
python/cudf_polars/cudf_polars/dsl/expressions/unary.py, tests/expressions/test_truncate.py
Float truncation via floor/ceil selection with 10**decimals scaling; non-float inputs pass through. Tests parametrize series shapes and decimals values [0,1,2,5].
rechunk and approx_n_unique implementation and tests
python/cudf_polars/cudf_polars/dsl/expressions/unary.py, tests/expressions/test_rechunk.py, tests/expressions/test_approx_n_unique.py
rechunk returns child column unchanged; approx_n_unique computes distinct_count including nulls/NaNs as a scalar column. Parametrized GPU tests cover nulls, NaNs, strings, and empty inputs.
search_sorted implementation and tests
python/cudf_polars/cudf_polars/dsl/expressions/unary.py, tests/expressions/test_search_sorted.py
Upper/lower bound index computation selects between lower_bound/upper_bound based on side and descending; result cast to self.dtype. Tests cover all side modes, descending order, and floats.
index_of implementation and tests
python/cudf_polars/cudf_polars/dsl/expressions/unary.py, tests/expressions/test_index_of.py
Null-safe equality mask on a literal or expression target; applies mask to index sequence and returns first match or null scalar. Tests cover integers, floats with NaN, and strings.
drop_nans implementation and tests
python/cudf_polars/cudf_polars/dsl/expressions/unary.py, tests/expressions/test_drop_nans.py
Non-float inputs pass through; floats use plc.stream_compaction.drop_nans. Tests cover NaN/None combinations, dtype variants, and empty series.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • rapidsai/cudf#22847: Previously asserted search_sorted translation is unsupported; this PR implements the GPU evaluation branch that supersedes that assertion.

Suggested reviewers

  • vyasr
  • Matt711
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 11.11% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and clearly refers to adding more unary Polars expressions.
Description check ✅ Passed The description directly lists the unary expressions implemented in this PR.
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 coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@python/cudf_polars/tests/expressions/test_index_of.py`:
- Around line 12-31: The current test only covers literal needles for
pl.col("a").index_of, so add cases that use an expression-valued needle to
exercise the UnaryFunction.do_evaluate path that evaluates value_expr and
obj_scalar. Extend test_index_of with at least one query where the needle is
another column or derived expression, and keep using assert_gpu_result_equal on
the resulting LazyFrame so regressions in the non-literal index_of branch are
caught.

In `@python/cudf_polars/tests/expressions/test_search_sorted.py`:
- Around line 14-41: The current search_sorted tests cover only normal non-null
arrays, so add cases in test_search_sorted, test_search_sorted_descending, or
nearby helpers that exercise empty inputs, single-element inputs, and
null-containing inputs to validate boundary and nullable behavior. Reuse the
existing pl.col("a").search_sorted(...) queries and assert_gpu_result_equal so
the GPU path is checked against CPU for these edge cases, especially around
UnaryFunction.do_evaluate where null ordering is hard-coded.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: c3d8345d-f780-4709-b5cb-aec917b5a0a7

📥 Commits

Reviewing files that changed from the base of the PR and between 05aeeb7 and 1378308.

📒 Files selected for processing (7)
  • python/cudf_polars/cudf_polars/dsl/expressions/unary.py
  • python/cudf_polars/tests/expressions/test_approx_n_unique.py
  • python/cudf_polars/tests/expressions/test_drop_nans.py
  • python/cudf_polars/tests/expressions/test_index_of.py
  • python/cudf_polars/tests/expressions/test_rechunk.py
  • python/cudf_polars/tests/expressions/test_search_sorted.py
  • python/cudf_polars/tests/expressions/test_truncate.py

Comment thread python/cudf_polars/tests/expressions/test_index_of.py
Comment thread python/cudf_polars/tests/expressions/test_search_sorted.py Outdated
Comment on lines +386 to +388
elif self.name == "rechunk":
(column,) = (child.evaluate(df, context=context) for child in self.children)
return column

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.

Just for my own understanding, does this actually do anything related to rechunking?

This API from polars seems very tied to its execution model, so we might not have something similar in cudf-polars, which is fine. Implementing this just to avoid fallback seems fine.

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.

Just for my own understanding, does this actually do anything related to rechunking?

It does not. Yeah this essentially is implemented as a no-op not to fallback to CPU

Comment on lines +389 to +390
elif self.name == "approx_n_unique":
(column,) = (child.evaluate(df, context=context) for child in self.children)

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 see https://docs.rapids.ai/api/libcudf/stable/classcudf_1_1approx__distinct__count. Would that be appropriate to use here? (no bindings in pylibcudf though).

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 since it's "approx"

Also FYI @wence- since he mentioned using cudf::approx_distinct_count for deciding when to use bloom filters

Comment on lines +391 to +396
count = plc.reduce.distinct_count(
column.obj,
plc.types.NullPolicy.INCLUDE,
plc.types.NanPolicy.NAN_IS_VALID,
stream=df.stream,
)

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 like something that'll need special handling for partitioned data. Is that happening anywhere? Maybe there's a fallback to single partition?

),
dtype=self.dtype,
)
elif self.name == "search_sorted":

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.

Same question about multi-partition here, and for index_of.

def test_search_sorted_nulls(
engine: pl.GPUEngine, side: Literal["any", "left", "right"]
) -> None:
lf = pl.LazyFrame({"a": [None, 1, 2, 2, 4]})

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.

Maybe parametrize this over data with nulls at the start, middle, and end?

value = value_expr.evaluate(df, context=context).obj_scalar(
stream=df.stream
)
py_value = value.to_py(stream=df.stream)

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.

Will this block df.stream entirely? I guess we need to, so that the isinstance(py_value, float) can run, but maybe we can perform that check in some other way? Does libcudf have a way to check if some scalar is nan?

Oh, but I guess then that would need to block so that we can evaluate the if condition. So really, we'd need to push that if/else into some CUDA expressions...

@mroeschke

Copy link
Copy Markdown
Contributor Author

Closing as discussed offline to open separate PRs (will take into account reviews left here)

@mroeschke mroeschke closed this Jul 7, 2026
@github-project-automation github-project-automation Bot moved this from In Progress to Done in cuDF Python Jul 7, 2026
@mroeschke
mroeschke deleted the cudf_polars/enh/more_expressions branch July 7, 2026 23:46
rapids-bot Bot pushed a commit that referenced this pull request Jul 8, 2026
I used a more barebones prompt that resembles this PR to implement #23015 and #23037, but I think it could be generally be useful to have when implementing reviewing expression support in cudf_polars.

Some of the guidelines is probably more suitable for generally cudf_polars development, but this skill is mainly a starting point that can be iterated on/split out

Authors:
  - Matthew Roeschke (https://github.com/mroeschke)

Approvers:
  - Tom Augspurger (https://github.com/TomAugspurger)

URL: #23078
@mroeschke mroeschke mentioned this pull request Jul 9, 2026
3 tasks
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

Archived in project

Development

Successfully merging this pull request may close these issues.

4 participants