Skip to content

PERF: Optimize cudf-polars stable hashing - #23582

Open
TomAugspurger wants to merge 4 commits into
NVIDIA:mainfrom
TomAugspurger:tom/stable-hash-perf
Open

PERF: Optimize cudf-polars stable hashing#23582
TomAugspurger wants to merge 4 commits into
NVIDIA:mainfrom
TomAugspurger:tom/stable-hash-perf

Conversation

@TomAugspurger

Copy link
Copy Markdown
Contributor

Description

This PR improves the performance of Node.get_stable_id() and its callers, most notably lower_ir_graph_with_node_map. The old _expand_hashable didn't populate / cache the stable hash values of its children, so lower_ir_graph_with_node_map had to repeatedly rewalk subtrees and recompute hash values.

Here's an impossible to read flamegraph of lower_ir_graph_with_node_map on a benchmark that looks like tpc-h query 8. Just notice the shape (very nested):

image

Now, we go child first. This lets us reuse the cached value from node.get_stable_id().

image

On main, lower_ir_graph_with_node_map took 55-75ms for me on tpc-h query 8 at sf-1k. On this branch, it takes ~5-6ms.

This PR improves the performance of `Node.get_stable_id()` and its
callers, most notably `lower_ir_graph_with_node_map`.

The old `_expand_hashable` didn't populate / cache the stable hash
values of its children, so `lower_ir_graph_with_node_map` had to
repeatedly rewalk subtrees and recompute hash values.

Now, we go child first. This lets us reuse the cached value from
node.get_stable_id().
@TomAugspurger
TomAugspurger requested a review from a team as a code owner August 7, 2026 14:58
@TomAugspurger
TomAugspurger requested a review from Matt711 August 7, 2026 14:58
@TomAugspurger TomAugspurger added improvement Improvement / enhancement to an existing function non-breaking Non-breaking change cudf-polars Issues specific to cudf-polars labels Aug 7, 2026
@github-actions github-actions Bot added the Python Affects Python cuDF API. label Aug 7, 2026
@GPUtester GPUtester moved this to In Progress in cuDF Python Aug 7, 2026
@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Summary by CodeRabbit

  • Bug Fixes

    • Improved consistency of identifiers for equivalent query plans and nested query structures.
    • Stable identifiers now reflect literal values and Python types, distinguishing otherwise similar literals.
    • Increased reliability when comparing structurally equivalent plans across repeated operations.
  • Tests

    • Added coverage for stable identifiers across equivalent plans and literals.
    • Verified that differing values, types, and child plans produce distinct or consistent identifiers as appropriate.

Walkthrough

Stable node IDs now use cached structural digests. Literal IDs now depend on frozen content rather than object identity. Tests cover digest derivation, equivalent plans, and differing literal values.

Changes

Stable hashing

Layer / File(s) Summary
Structural and content-based hashing
python/cudf_polars/cudf_polars/dsl/nodebase.py, python/cudf_polars/cudf_polars/dsl/expressions/literal.py
Nodes compute cached bottom-up MD5 digests. Literals freeze nested values before hashing.
Stable-ID validation
python/cudf_polars/tests/dsl/test_nodebase.py, python/cudf_polars/tests/expressions/test_literal.py
Tests validate digest-to-ID derivation, equivalent child IDs, and content-based literal IDs.

Estimated code review effort: 3 (Moderate) | ~15 minutes

Suggested reviewers: matt711

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 28.57% 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 clearly and concisely describes the main performance improvement to cudf-polars stable hashing.
Description check ✅ Passed The description explains the stable hashing optimization, its cause, implementation approach, and reported performance impact.
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/cudf_polars/dsl/nodebase.py`:
- Around line 40-42: Update the fallback hashing logic used by
Literal.get_hashable() and LiteralColumn.get_hashable() so it does not encode
process-dependent object identities such as id(self.value). Canonically
serialize supported values into stable bytes, preserving identical
get_stable_id() results for equivalent plans across processes while retaining
distinct encodings for different value types.
- Around line 32-34: Update the Node hashing logic around Node.get_stable_id()
so parent hashing incorporates each child’s full internal digest rather than
only its truncated 32-bit ID. Preserve the 32-bit conversion only when producing
the external stable ID, ensuring distinct child subtrees remain distinguishable
in SerializablePlan.nodes and lower_ir_graph_with_node_map.
🪄 Autofix

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: a602f4fc-81af-430f-87d7-28fb96df2a81

📥 Commits

Reviewing files that changed from the base of the PR and between bbeea4b and 77599ce.

📒 Files selected for processing (2)
  • python/cudf_polars/cudf_polars/dsl/nodebase.py
  • python/cudf_polars/tests/dsl/test_nodebase.py

Comment thread python/cudf_polars/cudf_polars/dsl/nodebase.py Outdated
Comment thread python/cudf_polars/cudf_polars/dsl/nodebase.py

@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

🧹 Nitpick comments (1)
python/cudf_polars/cudf_polars/dsl/expressions/literal.py (1)

110-115: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Complete the is_equal docstring.

Document the other parameter and the boolean return value. The current docstring only describes behavior.

As per coding guidelines, “all public API methods have complete docstrings documenting parameters, return values, and behavior.”

🤖 Prompt for 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.

In `@python/cudf_polars/cudf_polars/dsl/expressions/literal.py` around lines 110 -
115, Complete the public LiteralColumn.is_equal docstring by documenting the
other parameter, the boolean return value, and its existing content-based
equality behavior; do not change the implementation.

Source: Coding guidelines

🤖 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/cudf_polars/dsl/expressions/literal.py`:
- Around line 33-34: Update the dict-handling branch in _freeze_for_hash to
canonicalize keys with a type-aware, comparable encoding before sorting, so
heterogeneous keys such as integers and strings cannot trigger TypeError while
preserving distinct key identities for Literal.get_stable_id().

In `@python/cudf_polars/tests/expressions/test_literal.py`:
- Around line 175-195: Extend test_literal_stable_id_is_content_based and
test_literal_column_stable_id_is_content_based to cover nested list/dictionary
scalar values, empty Series, all-null Series, and single-element Series,
asserting equal IDs for equivalent values and distinct IDs for differing values.
Add mixed-type Series coverage where the supported Polars dtype permits it,
including nulls or heterogeneous elements as appropriate, to exercise
_freeze_for_hash boundary and recursive handling.

---

Nitpick comments:
In `@python/cudf_polars/cudf_polars/dsl/expressions/literal.py`:
- Around line 110-115: Complete the public LiteralColumn.is_equal docstring by
documenting the other parameter, the boolean return value, and its existing
content-based equality behavior; do not change the implementation.
🪄 Autofix

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: 5967bfb6-f7b6-4b3e-a39e-bb6285e61f6d

📥 Commits

Reviewing files that changed from the base of the PR and between 931f017 and a3a536d.

📒 Files selected for processing (4)
  • python/cudf_polars/cudf_polars/dsl/expressions/literal.py
  • python/cudf_polars/cudf_polars/dsl/nodebase.py
  • python/cudf_polars/tests/dsl/test_nodebase.py
  • python/cudf_polars/tests/expressions/test_literal.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • python/cudf_polars/cudf_polars/dsl/nodebase.py

Comment on lines +33 to +34
if isinstance(value, dict):
return tuple(sorted((k, _freeze_for_hash(v)) for k, v in value.items()))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Handle heterogeneous dictionary keys without ordering them directly.

A dictionary can contain keys such as 1 and "1". sorted then compares these keys and raises TypeError. This prevents Literal.get_stable_id() from completing.

Use a type-aware canonical key encoding before sorting, or reject unsupported dictionary key types during construction.

🤖 Prompt for 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.

In `@python/cudf_polars/cudf_polars/dsl/expressions/literal.py` around lines 33 -
34, Update the dict-handling branch in _freeze_for_hash to canonicalize keys
with a type-aware, comparable encoding before sorting, so heterogeneous keys
such as integers and strings cannot trigger TypeError while preserving distinct
key identities for Literal.get_stable_id().

Comment on lines +175 to +195
def test_literal_stable_id_is_content_based():
a = Literal(DataType(pl.Int64()), 42)
b = Literal(DataType(pl.Int64()), 42)
c = Literal(DataType(pl.Int64()), 43)
assert a.get_stable_id() == b.get_stable_id()
assert a.get_stable_id() != c.get_stable_id()
# Distinct Python types with the same dtype stay distinct via value.
assert (
Literal(DataType(pl.String()), "42").get_stable_id()
!= Literal(DataType(pl.Int64()), 42).get_stable_id()
)


def test_literal_column_stable_id_is_content_based():
from cudf_polars.dsl.expressions.literal import LiteralColumn

a = LiteralColumn(DataType(pl.Int64()), pl.Series([1, 2, 3], dtype=pl.Int64()))
b = LiteralColumn(DataType(pl.Int64()), pl.Series([1, 2, 3], dtype=pl.Int64()))
c = LiteralColumn(DataType(pl.Int64()), pl.Series([1, 2, 4], dtype=pl.Int64()))
assert a.get_stable_id() == b.get_stable_id()
assert a.get_stable_id() != c.get_stable_id()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Add stable-ID coverage for recursive and boundary values.

These tests cover scalar literals and a non-empty, non-null integer Series only. Add cases for nested list or dictionary values, empty Series, all-null Series, and single-element Series. Add mixed-type coverage when the Polars dtype supports it.

This validates the new _freeze_for_hash path and null or size boundaries.

As per coding guidelines, “Ensure test files provide comprehensive edge case coverage (empty, all-null, single-element, mixed types).”

🤖 Prompt for 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.

In `@python/cudf_polars/tests/expressions/test_literal.py` around lines 175 - 195,
Extend test_literal_stable_id_is_content_based and
test_literal_column_stable_id_is_content_based to cover nested list/dictionary
scalar values, empty Series, all-null Series, and single-element Series,
asserting equal IDs for equivalent values and distinct IDs for differing values.
Add mixed-type Series coverage where the supported Polars dtype permits it,
including nulls or heterogeneous elements as appropriate, to exercise
_freeze_for_hash boundary and recursive handling.

Source: Coding guidelines

Comment thread python/cudf_polars/cudf_polars/dsl/expressions/literal.py Outdated

def _expand_hashable(obj: Any) -> Any:
"""Expand nested Node instances to their hashable form."""
def _update_stable_hasher(hasher: Any, obj: Hashable) -> None:

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.

Perhaps a nicer way of doing this is something like the following.

The pre-order traversal of an expression uniquely defines it. A non-leaf object is therefore uniquely defined by the structure induced by:

[(type(n), n._non_child) for n in traversal([root])]

I think?

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.

Ohh I like the idea of using its position in the traversal. Let me see how that works.

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 wasn't immediately able to get this to work :/

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
python/cudf_polars/cudf_polars/dsl/expressions/literal.py (1)

103-108: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Separate local identity from stable hashing.

id(self.value) makes LiteralColumn IDs change after pickle reconstruction and across processes. Provide a process-independent stable representation while retaining identity-based hashing for local replacement. Add a cross-process serialization test.

🤖 Prompt for 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.

In `@python/cudf_polars/cudf_polars/dsl/expressions/literal.py` around lines 103 -
108, Separate LiteralColumn.get_hashable()’s local replacement identity from the
representation used for serialization or stable hashing, replacing
id(self.value) in the cross-process path with a deterministic
process-independent value while preserving identity-based behavior locally. Add
a serialization test that pickles and reconstructs a LiteralColumn, including
across process boundaries, and verifies the stable representation remains
unchanged.
🧹 Nitpick comments (1)
python/cudf_polars/cudf_polars/dsl/expressions/literal.py (1)

103-104: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Complete the get_hashable() docstring.

The method returns a tuple containing the expression type, self.dtype.plc_type, and id(self.value). It does not compute a content hash. Add a Returns section and document the process-local identity behavior.

Proposed documentation
-        """Compute a hash of the column."""
+        """Return the hashable identity key for this literal column.
+
+        Returns
+        -------
+        Hashable
+            Tuple containing the expression type, PLC dtype, and
+            process-local identity of ``self.value``.
+        """

As per coding guidelines, all public API methods must document parameters, return values, and behavior.

🤖 Prompt for 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.

In `@python/cudf_polars/cudf_polars/dsl/expressions/literal.py` around lines 103 -
104, Update the public Literal.get_hashable() docstring to describe that it
returns a tuple of the expression type, self.dtype.plc_type, and id(self.value),
rather than computing a content hash. Add a Returns section and document that
the value identity is process-local; no implementation changes are needed.

Source: Coding guidelines

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

Outside diff comments:
In `@python/cudf_polars/cudf_polars/dsl/expressions/literal.py`:
- Around line 103-108: Separate LiteralColumn.get_hashable()’s local replacement
identity from the representation used for serialization or stable hashing,
replacing id(self.value) in the cross-process path with a deterministic
process-independent value while preserving identity-based behavior locally. Add
a serialization test that pickles and reconstructs a LiteralColumn, including
across process boundaries, and verifies the stable representation remains
unchanged.

---

Nitpick comments:
In `@python/cudf_polars/cudf_polars/dsl/expressions/literal.py`:
- Around line 103-104: Update the public Literal.get_hashable() docstring to
describe that it returns a tuple of the expression type, self.dtype.plc_type,
and id(self.value), rather than computing a content hash. Add a Returns section
and document that the value identity is process-local; no implementation changes
are needed.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: d0934a44-918d-4b99-96b6-b807620f88a1

📥 Commits

Reviewing files that changed from the base of the PR and between a3a536d and 150aaa0.

📒 Files selected for processing (2)
  • python/cudf_polars/cudf_polars/dsl/expressions/literal.py
  • python/cudf_polars/tests/expressions/test_literal.py
💤 Files with no reviewable changes (1)
  • python/cudf_polars/tests/expressions/test_literal.py

Comment on lines +181 to +185
# Distinct Python types with the same dtype stay distinct via value.
assert (
Literal(DataType(pl.String()), "42").get_stable_id()
!= Literal(DataType(pl.Int64()), 42).get_stable_id()
)

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.

Those have different dtypes, so at the very least this comment is wrong.

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

None yet

Development

Successfully merging this pull request may close these issues.

3 participants