Skip to content

Fix quadratic term construction under nested binders - #726

Merged
jfeser merged 3 commits into
masterfrom
eb-defdata-typeof-cache
Jul 28, 2026
Merged

Fix quadratic term construction under nested binders#726
jfeser merged 3 commits into
masterfrom
eb-defdata-typeof-cache

Conversation

@eb8680

@eb8680 eb8680 commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

The regression

#716 ("Add cached interpretations and use for typeof") made defdata compute the
full type analysis of every term it builds, in order to pick the right term
constructor. That interacts badly with binders: when an operation binds variables,
defdata renames them throughout the body, which rebuilds the body by re-entering
defdata via apply. Re-entering defdata recomputes binders and renames each
child again at every level, so nesting binders re-traverses the subtree once per
level of nesting.

The changes

  • effectful/ops/syntax.py: evaluate_with_renaming now rebuilds nodes with
    _build_term instead of re-entering defdata. The subterm was already renamed
    when it was first built, so the rebuild only needs to re-dispatch each node, not
    redo binder analysis and renaming.
  • _build_term is defdata minus the renaming step, and is shared: defdata
    itself ends with return _build_term(__dispatch, op, *args_, **kwargs_), and the
    rebuild installs functools.partial(_build_term, __dispatch) as the apply rule.

The change does not alter any observable result.

Benchmark

tests/test_ops_syntax.py::test_bench_nested_binder_construction builds a term
of 10 nested let-style binders, each variable used in the body below it. The
existing test_bench_term_construction builds a binder-free term, so it never
reaches this path and stays fast even when nested construction blows up.

Measured on this branch vs. the same test with the effectful/ change reverted
(mean of pytest-benchmark rounds):

benchmark before after
test_bench_nested_binder_construction 572.8 ms 17.2 ms (33x)
test_bench_term_construction 3.39 ms 3.50 ms (unchanged)

An earlier revision of this PR also seeded defdata's already-computed type
analysis into the per-term evaluation cache, which bought a further ~1.8x on
nested construction and ~1.5x on binder-free construction. That was dropped at
review: it reached past _evaluate_term, which owns that cache, and it is a
constant factor rather than the asymptotic fix (with and without, nested
construction is quadratic in depth, at a flat ~1.8x ratio from depth 10 to 40).

Testing

Full test suite: 18749 passed, 1 skipped, 2108 xfailed. mypy and ruff clean
on the changed files.

Split out of #724 for review.

🤖 Generated with Claude Code

`defdata` rebuilds a subterm when it renames bound variables. Re-entering
`defdata` for the rebuild recomputed binders and renamed children again at
every level, so nested binders re-traversed the subtree once per level.
Rebuild with a single-pass `_reconstruct` instead, and seed each freshly
built term's `_typeof` cache with the type analysis `defdata` already
computed, so a parent's `_typeof` on a new child is a cache hit.

Adds `test_bench_nested_binder_construction`; the existing
`test_bench_term_construction` builds a binder-free term and never reaches
this path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@eb8680
eb8680 requested a review from jfeser July 28, 2026 15:25
@eb8680
eb8680 marked this pull request as ready for review July 28, 2026 15:25
@eb8680
eb8680 marked this pull request as draft July 28, 2026 16:08
@eb8680
eb8680 marked this pull request as ready for review July 28, 2026 16:08
Comment thread effectful/ops/syntax.py Outdated
Comment thread effectful/ops/semantics.py Outdated
eb8680 and others added 2 commits July 28, 2026 14:37
Remove `_seed_typeof`. It is not what fixes the quadratic regression --
`_evaluate_term` already caches a node's typeof result whenever `_typeof` is
called on it, so by the time `defdata` finishes a node every descendant is
cached and the parent's `_typeof` on the fresh child is one level deep. The
asymptotic fix is the `apply` rule, which stops the rebuild from re-entering
`defdata` and re-running `__fvs_rule__` + renaming at every level. Seeding was
a constant factor (~1.8x on nested construction, ~1.5x binder-free) bought by
writing into a cache that `_evaluate_term` owns. `effectful/ops/semantics.py`
is now unchanged by this branch.

With the seeding gone, the rebuild rule and the tail of `defdata` are
identical, so both become the module-level `_build_term`: `defdata` minus the
renaming step. It takes `__dispatch` as a parameter rather than closing over
it, so it lives at module level instead of being rebuilt on every `defdata`
call; the `functools.partial` is allocated only on the renaming path.

`test_bench_nested_binder_construction`: 572.8 ms before the branch, 17.2 ms
now (33x). `test_bench_term_construction` returns to its baseline 3.5 ms,
giving up the constant factor that the removed seeding provided.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jfeser
jfeser self-requested a review July 28, 2026 19:10

@jfeser jfeser left a comment

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.

Fine to merge once CI passes.

@jfeser
jfeser merged commit 1958e4a into master Jul 28, 2026
29 checks passed
@jfeser
jfeser deleted the eb-defdata-typeof-cache branch July 28, 2026 19:51
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.

2 participants