Skip to content

perf: infer each relation's schema once per build, not once per level - #250

Open
nielspardon wants to merge 3 commits into
substrait-io:mainfrom
nielspardon:perf/plan-builder-schema-memo
Open

perf: infer each relation's schema once per build, not once per level#250
nielspardon wants to merge 3 commits into
substrait-io:mainfrom
nielspardon:perf/plan-builder-schema-memo

Conversation

@nielspardon

@nielspardon nielspardon commented Aug 10, 2026

Copy link
Copy Markdown
Member

Completes #207. PR #245 removed the extension-merging half; this is the schema half.

Problem

Every verb resolves its input's schema, and plans are built as nested resolve()
closures, so an N-verb chain re-walked the whole subtree beneath it at every level.
Profiling a 40-verb project chain (45 ms total) shows the cost is not only where
the issue points:

cost centre time
the rel_anchor index in infer_plan_schema (iter_plan_rels) 30 ms
the infer_rel_schema recursion — the half #207 names 9 ms
assembling the protobuf (_plan_from) 2 ms

Indexing anchors walks every relation and every expression of the plan, to reach
the relations embedded in subqueries. Doing that per level made it the larger term,
so fixing only the recursion would have left two thirds of the cost in place.

Approach

Assigning an input's root Rel into the output relation copies it, so the schema just
inferred for that input is unreachable from the copy by identity — a plain identity
cache never hits across levels. The copy is reachable as it is made, though, and
protobuf wrappers are identity-stable, so _plan_from records that each copy carries
its input plan's output schema; infer_rel_schema consults those records before
dispatching and stops there instead of recursing through. reference and
with_execution_behavior assemble a Plan directly and record their own root; the
read builders and update are already leaves.

What is recorded is the plan, not the schema, resolved on first lookup — set,
reference and exchange never look at their input's schema, and inference can fail
where building does not, so resolving eagerly would reject plans that build today.
Only builders write to the memo: a relation's output struct can depend on ambient
correlation context (outer_schemas, anchor_scope), and anything crossing into
another context is copied on the way, so a record is only ever read back under the
context it was made in. Caching inference results wholesale would not have that
property. build_scope scopes the memo alongside the build's ExtensionCollector, so
inference used directly as a library function is untouched.

An entry keys on a live submessage, and a submessage keeps its whole plan's arena, so
entries left to accumulate would hold every intermediate plan of the build — 26 MB
against 10 MB over a 32-verb chain on a 2000-column table. Each is released once a
lookup has resolved through it, which restores flat retention with the inference counts
unchanged.

DataFrame.rename, drop and hint built their resolvers as plain closures rather
than via build_scoped, so no build scope covered them and they were the one public
path the memo could not reach. They are wrapped like every other verb.

Two supporting details: infer_plan_schema passes its anchor index as a factory that
_AnchorScope calls on the first lookup needing one, and the pairing between a record
and its relation is positional — the i-th child Rel in declaration order is the i-th
bound input, which is how every builder places them. The count is checked, and a
parametrized test over every multi-input builder pins the order, since a silent swap
would hand the level above a join its two sides' schemas the wrong way round.

Result

infer_rel_schema calls per build, and relations visited while indexing anchors:

verbs main this branch
5 15 / 15 16 / 0
10 55 / 55 36 / 0
20 210 / 210 76 / 0
40 820 / 820 156 / 0

N(N+1)/2 becomes 4N-4, and the anchor index is never built for a plan with no
id-based outer reference. Every shape follows: at 16 verbs, join+select goes 800 →
187, lateral_join+select 1088 → 251, group_by/agg 528 → 124, and rename
which no build scope used to cover — 272 → 91. Peak memory stays flat in chain length,
matching main (10.2 MB against 9.9 MB at 32 verbs over a 2000-column table).

Build time, best of 7 through the DataFrame API:

shape main @ 8 / 16 verbs this branch @ 8 / 16
with_columns 5.2 / 14.2 ms 3.2 / 6.7 ms
filter over cache() 4.5 / 12.4 ms 2.6 / 5.1 ms
join + select 17.9 / 60.6 ms 5.9 / 13.5 ms
lateral_join + select 20.4 / 66.1 ms 6.6 / 15.0 ms
group_by / agg 9.0 / 29.0 ms 3.9 / 9.1 ms

What remains per level is the protobuf copy in _plan_from, which is proportional to
the plan's byte size and inherent to assembling nested immutable messages — so
building is still quadratic in plan bytes, with a constant roughly twenty times
smaller than the term removed here.

One behavior change

join, hash_join and merge_join derived their post_join_filter output schema by
re-inferring from the input relations, passing neither input's shared-subtree list — so a
reference()-promoted (i.e. .cache()d) input, whose root is a plan-global
ReferenceRel, could not resolve. On main all three raise ReferenceRel subtree_ordinal 0 is out of range; the memo silently answered the ReferenceRel and
masked it, which would have left the memo load-bearing for correctness on an untested
path. They now combine the schemas already inferred a line above, as lateral_join
always did, so the case builds independently of the memo — and one redundant walk of both
input subtrees per join goes away with it. tests/builders/plan/test_reference.py covers
all three; it passes with the memo disabled, which is what shows the hidden dependence is
gone.

Verification

Emitted plans are unchanged, which is the property that matters most here, so it was
checked rather than assumed: 42 plans covering every builder — including the
multi-input, cache(), lateral_join and subquery shapes — are byte-identical to a
main worktree, and every example prints output identical to it — which CI does not
check, since it runs four of them and only asserts they exit cleanly
(duckdb_example still executes its plan to the same result set).

Both new cost tests fail with the memo lookup removed (528 inferences against a bound
of 216), and all six pairing tests fail with the pairing reversed.

That comparison also turned up two pre-existing coverage gaps, closed here: nothing
inferred a SortRel's schema, since sort is always terminal in the suite and had no
direct unit test, and column() was only ever called with a name, never an ordinal.
The sort branch sits in the function the memo now short-circuits, so leaving it
uncovered would mean a change there could only be caught downstream.

Closes #207

🤖 Generated with AI

Every verb resolves its input's schema and plans are built as nested resolvers, so
an N-verb chain re-walked the whole subtree beneath it at every level. Profiling a
40-verb chain put 41 of 45 ms in `infer_plan_schema`, split between the anchor index
(30 ms) and the inference recursion (9 ms); assembling the protobuf was 2 ms.

An input's root `Rel` is copied when it is assigned into the output relation, so the
schema just inferred for it is unreachable from the copy by identity. Message
wrappers are identity-stable, though, so `_plan_from` names the copies as it makes
them and records that each has its input plan's output schema; `infer_rel_schema`
stops at that boundary instead of recursing through it. The record is the plan, not
the schema, so a builder that never needs its input's schema still never causes one
to be inferred. The memo lives in the build scope, next to the ExtensionCollector.

`infer_plan_schema` also builds its rel_anchor index -- a walk of every relation and
expression in the plan -- only when an id-based outer reference asks for one.

Closes substrait-io#207
Two gaps the byte-for-byte comparison against main turned up while verifying the
schema memo, both pre-existing: nothing inferred a SortRel's schema (sort is always
terminal in the suite, and it had no direct unit test), and column() was only ever
called with a name, never an ordinal.

The sort branch sits in the function the memo now short-circuits, so leaving it
uncovered would mean a change there could only be caught downstream.
…ntion flat

Review follow-ups on the schema memo.

`join`, `hash_join` and `merge_join` derived their post_join_filter output schema by
re-inferring from the input relations, without either input's shared-subtree list in
scope -- so a `reference()`-promoted (cached) input, whose root is a plan-global
ReferenceRel, could not resolve. That raises on main; the memo happened to answer the
ReferenceRel and mask it. Combining the schemas already inferred one line above, as
`lateral_join` did, fixes it independently of the memo and drops a redundant walk of
both subtrees.

`with_execution_behavior` recorded its copied root unconditionally, which broke a Plan
carrying no relations -- it copies a caller-supplied Plan rather than assembling one, so
it has to stay total over what it accepted.

A resolved memo entry keys on a live submessage, and a submessage keeps its whole plan's
arena, so entries left to accumulate held every intermediate plan: 26 MB against main's
10 MB over a 32-verb chain on a 2000-column table. Releasing the entries for a plan's
own inputs once a lookup has resolved through it puts that back to 10 MB with the
inference counts unchanged.

`DataFrame.rename`, `drop` and `hint` built their resolvers as plain closures, so no
build scope covered them and they stayed quadratic (272 inferences at 16 verbs, the same
as main). Wrapping them in `build_scoped`, as every other verb is, brings them to 91.

Also: the anchor index is always passed as a factory rather than sometimes a dict, so a
caller cannot silently land in the wrong branch; the pairing guard raises ValueError
rather than a stripped-under-O assert; and three docstring claims that measurement
contradicted are corrected.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Plan builder does O(N²) schema re-inference and extension re-merging across verb chains

1 participant