Repository navigation
Conversation
Use shared ownership for logical-plan children and expression nodes to avoid recursive copying during DataFrame and Column construction. Add sharing regressions and a reproducible native PySpark benchmark. Co-authored-by: Isaac <no-reply@databricks.com>
|
cc @HyukjinKwon @viirya @dongjoon-hyun to take a look. Thanks. |
Keep benchmark handles alive with an intentionally unused local and cover their lifetime in the tooling tests. Type-check guide snippets with rustc metadata output to avoid retaining large linked executables. Run tooling regression tests in the lint workflow. Co-authored-by: Isaac <no-reply@databricks.com>
Remove the in-tree benchmark runner, its tests, and methodology document. Keep performance measurements and reproduction instructions in the independent benchmark repository and PR description. Co-authored-by: Isaac <no-reply@databricks.com>
viirya
left a comment
There was a problem hiding this comment.
Thanks for this. Sharing immutable plan/expression nodes is the right direction, and the change looks safe: immutability is enforced by the type system (there's no get_mut/make_mut path, and Expression's Arc is private), plan IDs are still assigned on the freshly built proto so shared self-joins keep distinct IDs, and none of the golden expectations change. The O(n²) → O(n) gain for chained transformations matches the benchmarks.
Most comments are inline: scope, constructor boilerplate, the two remaining boxed handles, and some redundant tests. One question that doesn't fit on a line: this changes the public Rust API (Expression becomes a handle around ExpressionNode, and LogicalPlan children become Arc) within the 4.3.0 line. Could we add a short migration note for the release notes? Keeping the plan::* helpers source-compatible through Into<Arc<LogicalPlan>> is a nice touch.
Optional: the benchmark section of the description is quite long. The main table plus a line on the unexplained core-outbound-rpc-deep-10 result would be enough for reviewers.
| } | ||
| } | ||
|
|
||
| impl From<ExpressionNode> for Expression { |
There was a problem hiding this comment.
This impl is only used in tests; production code spells out Expression::new(ExpressionNode::X(...)) about 200 times (plus about 80 in tests). Could we either use .into() at call sites or add a few constructors for the common cases (e.g. Expression::literal, Expression::column, Expression::function(name, args))? That would shrink this diff a lot and keep future call sites readable.
There was a problem hiding this comment.
Added Expression::literal, Expression::column, and Expression::function(name, args) and updated the common production and test call sites. Less common variants use the existing From<ExpressionNode> conversion through .into() or Expression::from. Distinct-function flags, metadata, and bound-column plan IDs are preserved.
| /// and matching use this type; the public Column API continues to use Expression. | ||
| #[derive(Debug, Clone, PartialEq)] | ||
| pub enum Expression { | ||
| pub enum ExpressionNode { |
There was a problem hiding this comment.
Expression is now pointer-sized, so the two remaining Box<Expression>s, DirectShufflePartitionId(Box<Expression>) (L68) and CaseWhen::else_expr: Option<Box<Expression>> (L749), are just an extra allocation and indirection. Since this PR already changes the public representation, could we make these DirectShufflePartitionId(Expression) and Option<Expression> now rather than breaking the API a second time later? The boxed struct variants (Alias(Box<Alias>) etc.) still make sense for keeping the node size down; these two don't.
There was a problem hiding this comment.
Removed both extra boxes: DirectShufflePartitionId now takes an Expression, and CaseWhen::else_expr is Option<Expression>. Updated the CASE and shuffle construction/serialization paths; the selected golden tests pass. The migration note also calls out these representation changes.
| "2021", | ||
| "--crate-type", | ||
| "bin", | ||
| "--emit=metadata", |
There was a problem hiding this comment.
The change itself looks fine, since metadata emission still runs type and borrow checking. But this, the new lint.yml step, and scripts/tests/test_check_doc_snippets.py aren't related to plan/expression sharing. Could they go in a separate PR/JIRA so this one stays focused and the CI fix gets its own history entry?
There was a problem hiding this comment.
Removed the docs-checker change, lint workflow change, and tooling tests from this PR's aggregate diff. The extracted CI fix is prepared as a separate follow-up and is being kept locally.
|
|
||
|
|
||
| class CheckDocSnippetsTests(unittest.TestCase): | ||
| def test_type_checks_without_linking_a_native_executable(self): |
There was a problem hiding this comment.
This pins the exact rustc argv, so it fails on any harmless flag change but can't catch a snippet that should fail to compile. test_checks_all_rust_snippets_even_after_a_failure covers behavior that predates this PR. If the tooling tests stay, I'd keep only what guards the new behavior. Either way, this belongs with the separate CI change.
There was a problem hiding this comment.
The tooling tests are now outside this PR. In the local CI follow-up, replaced the argv assertion with compiler-backed checks: a valid snippet with an unresolved foreign symbol type-checks without native linking, while type-error and moved-value snippets fail. Removed the test for the pre-existing continue-after-failure behavior.
| } | ||
|
|
||
| #[test] | ||
| fn binary_literal_payload_survives_without_being_cloned() { |
There was a problem hiding this comment.
This and udf_clones_share_command_bytes_and_argument_lifetimes (L168) follow directly from node sharing: if the clone shares the node (already asserted by clones_and_arithmetic_chains_share_existing_nodes), whatever the node owns is shared too. Could we drop them, or fold the UDF proto encoding check into an existing test?
There was a problem hiding this comment.
Dropped both redundant ownership/lifetime tests. The existing UDF serialization test already checks command bytes and eval type, so that coverage remains without another node-sharing test.
| assert_eq!(function.arguments.len(), 2); | ||
| assert_eq!(function.arguments[0], source.to_proto()); | ||
| assert_eq!(function.arguments[1], source.to_proto()); | ||
| function.arguments[0].expr_type = None; |
There was a problem hiding this comment.
to_proto() returns an owned proto tree, so mutating one argument can't affect the other or the source expression; this part can't fail. The equality checks above it already show that the shared occurrence expands into two full subtrees.
There was a problem hiding this comment.
Removed the owned-proto mutation assertions. Kept the equality checks that verify a shared expression occurrence expands into two complete protobuf subtrees.
| } | ||
|
|
||
| #[test] | ||
| fn long_chain_shares_each_previous_root() { |
There was a problem hiding this comment.
This repeats the single-step Arc::ptr_eq assertion from transformations_and_clones_share_ancestors 256 times, and local_relation_payload_is_not_cloned_by_transformations (L2702) follows from that same pointer equality. Similarly, the per-builder sharing tests in readwriter.rs, merge.rs, streaming.rs and table_arg.rs could be one test. The plan-ID uniqueness test for shared self-joins is the important one here; I'd keep that and trim the rest.
There was a problem hiding this comment.
Removed the repeated deep-plan and local-payload tests. Consolidated writer, V2 writer, streaming, merge, and table-argument sharing into related_builders_share_the_source_plan in dataframe.rs. Kept the single-step ancestor-sharing checks and the shared self-join plan-ID uniqueness test.
Simplify expression construction, remove redundant handle boxes, trim sharing tests, document Rust migration, and extract the unrelated docs-checker CI change. Co-authored-by: Isaac <no-reply@databricks.com>
|
Thanks for the review. Addressed the feedback in ff6fddd: added common expression constructors, removed the two redundant expression boxes, trimmed/consolidated sharing tests, and added the Rust 4.3 migration note in Validation: 424 Rust tests passed; all core integration targets compiled with and without Live-server/official parity suites and benchmarks were not rerun for this cleanup. Workspace Clippy is blocked locally by the missing WASM target, and focused core/binding Clippy still reports existing lint failures, as recorded in the updated description. |
Type-check documentation snippets without linking one executable per block. Keep the Spark 4.2 test environment on pandas 2.x and retain individual pytest diagnostics, including retries and timeouts. Co-authored-by: Isaac <no-reply@databricks.com>
viirya
left a comment
There was a problem hiding this comment.
Thanks for the updates. All the earlier points are addressed: the constructors removed the boilerplate cleanly (distinct/plan-id/metadata flags are preserved, golden expectations unchanged), the two extra boxes are gone, the migration note reads well, and the trimmed test set keeps the important self-join plan-ID check. The compiler-backed snippet tests are a nice improvement over the argv assertion.
One thing: 092c199 brings the docs-snippet change, the lint.yml step and the tooling tests back into this PR, along with the pandas <3.0 pin and the parity-log retention in run_official_tests.py. They look right to me, and I assume the pandas pin is needed to get parity CI green at all, but none of it is about plan/expression sharing. Could they land as their own JIRA/PR first, with this one rebased on top? Otherwise they get squashed under this title. Not blocking if that's impractical.
Minor, optional: related_builders_share_the_source_plan asserts exact Arc::strong_count values, so an extra internal clone in any builder would fail it without being a bug; checking Arc::ptr_eq against each builder's plan would be sturdier.
### What changes were proposed in this pull request? Follow up on [the review of #102](#102 (review)) for [SPARK-60549](https://issues.apache.org/jira/browse/SPARK-60549). Replace exact `Arc::strong_count` assertions in `related_builders_share_the_source_plan` with direct `LogicalPlan` pointer comparisons. Cover both batch writers, the streaming writer, the merge writer, `TableArg`, and its clone. Four crate-private accessors, compiled only for tests, expose the builders' plan references. ### Why are the changes needed? An extra `Arc` handle can break an exact-count assertion even when every builder still shares the original plan. Pointer comparisons directly check that each builder retains the source allocation and reject separately allocated copies. ### Does this PR introduce _any_ user-facing change? No. ### How was this patch tested? - `cargo fmt --all --check` passed. - 392 release-mode Rust tests passed: 368 library tests and 24 expression-sharing/golden integration tests. - All core integration targets compiled in release mode, both with and without `wasm-udf`. - Five CI-tooling tests passed. - Workspace Clippy was blocked by the locally unavailable `wasm32-unknown-unknown` target. A core-only Clippy run reached existing `clippy::approx_constant` errors in unchanged tests; neither Clippy run passed. - The live-server and full official parity suites were not rerun for this test-only change. ### Was this patch authored or co-authored using generative AI tooling? Generated-by: OpenAI Codex (GPT-6) This pull request and its description were written by Isaac. Closes #104 from dbtsai/codex/shared-plan-pointer-identity. Authored-by: DB Tsai <dbtsai@dbtsai.com> Signed-off-by: Hyukjin Kwon <hyukjin.kwon@databricks.com>
What changes were proposed in this pull request?
Share immutable logical-plan and expression nodes instead of recursively cloning
their ancestors while building DataFrames and Columns.
Arc<LogicalPlan>.Expressionan immutable shared handle aroundExpressionNode.Common builders use
Expression::literal,Expression::column, andExpression::function; other nodes use theFrom<ExpressionNode>conversion.DirectShufflePartitionIdand
CaseWhen::else_expr.output, argument order, and execution-time plan-ID assignment.
tests. Add a Rust API migration note in
docs/compatibility.md.CI follow-ups type-check documentation snippets without linking an executable per
block, constrain Spark 4.2 tests and workers to pandas 2.x, and retain per-file
pytest diagnostics. Benchmark code lives in
pyspark-connect-benchmark.
Why are the changes needed?
Extending and reusing a plan or expression currently copies existing subtrees.
Sharing immutable nodes avoids that repeated work. Expression and Column clones
become O(1) with respect to subtree size.
Fresh benchmark rerun on 2026-10-27 UTC, after the review cleanups, using
separately built release wheels from base
c41ff82and PR headff6fddd3.The separate
pyspark-connect-benchmark
harness is pinned to
1bbdec9.
These results supersede the earlier measurements of
b45e5d6; old samples arenot pooled with this rerun.
The breadth stage covered 39 cases with six attempts per client/case. A separate
confirmation stage repeated eight selected cases with 18 attempts per client/case.
Each stage used two rounds with the Rust baseline/candidate execution slots
swapped. The tables compare the two Rust builds, not Rust against official Python.
Confirmation wall-time medians (18 samples per Rust version):
chain-withColumn-256linear-expression-64increment-filter-depth256core-outbound-rpc-expression-10core-outbound-rpc-deep-10core-outbound-rpc-deep-100core-outbound-rpc-expression-5prebuilt-schema-depth256The earlier 15.3% wall-time slowdown in
core-outbound-rpc-deep-10was notreproduced: both confirmation rounds favor the PR, but their ranges overlap
widely, so this is not evidence of a statistically significant speedup.
The schema-depth256 timing remains mixed (-0.9% and +2.2% across rounds), rather
than a convincing timing regression. Expression-5 outbound also reverses
direction between rounds. These are not direct old-head/current-head comparisons,
so differences from the earlier run cannot be attributed solely to review cleanups.
Two cold-construction slowdown signals remain in the breadth stage:
core-construct-deep-10core-construct-expression-5Both have higher wall-time medians in both breadth rounds. Each has only six
samples per Rust version and overlapping ranges; neither was included in the
confirmation stage. They need targeted confirmation, and should not be dismissed
as noise or interpreted as established regressions.
Memory measurements (before -> after, MiB):
core-construct-expression-10/ breadthcore-outbound-rpc-expression-10/ confirmationprebuilt-schema-depth256/ confirmationAdditional peak RSS is the median of each sample's sampled peak minus its own
post-setup baseline, not the difference of two medians or total process memory.
Memory improvement is not universal: the schema case uses more incremental RSS
despite its slightly lower absolute process peak. RSS sampling targets 2 ms and
can miss short spikes; zero observed growth does not mean zero allocation.
These are client-side measurements against canned localhost gRPC responses on
an Apple M3 Max (64 GiB), macOS arm64, Python 3.12.14 and Rust 1.99.1, using
identical release build settings and dependencies for the two Rust versions.
Core one-shot cases include imports/first-use work inside timing; outbound cases
also include plan construction, encoding, transport, and response handling.
Matched warmups and RPC counters were checked for all Rust pairs, and source,
wheel, native-extension, dependency, and harness identities were audited.
Results measure plan and expression sharing together, not either Arrow-handoff
experiment or isolated protobuf encoding. They do not establish real-server
performance, statistical significance for small changes, API parity, or universal
gains. Breadth and confirmation samples are reported separately. Raw reports have
not been published in the harness repository.
Does this PR introduce any user-facing change?
Yes, for direct Rust users of the public representation:
Expressionbecomes a shared handle; its enum variants move toExpressionNode. Construct nodes using the common constructors or.into(),and match through
expression.node().LogicalPlanchild fields change fromBox<LogicalPlan>toArc<LogicalPlan>. Plan helpers accept owned/shared nodes throughInto<Arc<LogicalPlan>>.DirectShufflePartitionIdtakesExpressionandCaseWhen::else_exprtakesOption<Expression>.The 4.3 migration note in
docs/compatibility.mdincludes a construction andmatching example. Ordinary Column, DataFrame, and Python APIs are unchanged.
The wire protocol is unchanged: serialization still expands shared occurrences
into independent protobuf trees. Recursive serialization/destruction limits
remain, and flat CASE/window containers can still copy collections of handles.
How was this patch tested?
Benchmark rerun at
ff6fddd3:PySpark 4.2.0 reference's known
Exceeded upb_DecodeOptions_MaxDepthfailuresin
plan-expression-32-schema-firstandplan-expression-32-collect-empty.The benchmark launcher retained exit status 1; the full run is not reported
as green.
are excluded from the measured results.
No additional live-server or official parity suite was run for this benchmark.
After the review cleanup:
tests, including seven expression-sharing tests. The selected golden target
also reports 12 explicitly ignored tests; these are not counted as passes.
wasm-udf.its 108 exported classes showed no changed members or text signatures.
example; Rust formatting and
git diff --checkpassed.Full official parity and live-server suites were not rerun locally for this
cleanup. Local workspace Clippy is blocked by the missing
wasm32-unknown-unknowntarget; focused core/binding Clippy also reports existinglint failures.
CI follow-up at
092c199:Ruff checks, Rust formatting, and
git diff --checkpassed.release mode, both with and without
wasm-udf. The 108-class Python API snapshotis unchanged. Workspace Clippy still reports existing advisory lint errors.
test profile: 61 failed and 20 passed. Spark 4.2 imports
DateOffsetfrompandas.tseries.frequencies, which pandas 3.1 removed. With pandas 2.3.3, all81 utility tests passed through both the official and Rust transports.
failures before the next stateful-streaming file exceeded its 360-second cap.
The attempt was stopped during its retry; this is not a full green parity run.
In the completed Arrow Python UDF file, all 319 tests passed, versus 205 failures
in the previous CI environment.
pending. Local checks used macOS arm64, Python 3.12, and Java 21, rather than
CI's Ubuntu/Python 3.11/Java 17 environment.
Was this patch authored or co-authored using generative AI tooling?
Yes.
This pull request and its description were written by Isaac.