Skip to content

Eliminate quadratic environment rebuilds in Scope::get - #439

Merged
stringhandler merged 2 commits into
BlockstreamResearch:masterfrom
stringhandler:st-scope-perf
Oct 13, 2026
Merged

stringhandler merged 2 commits into
BlockstreamResearch:masterfrom
stringhandler:st-scope-perf

Conversation

@stringhandler

@stringhandler stringhandler commented Oct 12, 2026 •

Copy link
Copy Markdown
Collaborator

Redo of #409

Redid this PR with signed commits. Added tvolk131 as a co-author in commit messages.

What this fixes

Compiling a function with many let bindings was quadratic in the number of bindings. Every variable read rebuilt the entire input environment from scratch: it re-folded all live bindings into a tree, converted that tree, and then searched it for the variable.

What changed

Scope now maintains its bindings incrementally: a flat list of binding patterns, a map from identifier to binding index, and per-scope start offsets. Reading a variable computes its take/drop selector directly from the binding's position: drop past the newer bindings, take into the target binding, then select within that binding's own pattern. The resulting selectors are identical to what the old code produced, so the compiled program is unchanged.

Correctness

  • The compiled program is unchanged. The pinned test-data bytecode regressions that CI already runs pass unmodified, and diffing the output of all example programs against a master build shows identical bytes throughout.
  • A randomized property test compares the new selection logic against the old algorithm over random scope stacks, covering shadowing, nested scopes, function scopes, and compound patterns.

Performance

Local benchmarking on an Apple M4 Pro, release build:

program shape codegen before codegen after
64 sequential bindings 3.0 ms 2.0 ms (-35%)
256 sequential bindings 24.7 ms 7.9 ms (-68%)
256 bindings across 32 nested scopes 20.8 ms 7.7 ms (-63%)
4096 sequential bindings 3.90 s 120 ms

Codegen speed is effectively completely linear with respect to the number of bindings now. A program with 4096 bindings (which is 64 * 64) takes 60x longer than one with 64 bindings.

Commits

Two commits. The first is the optimization. The second is a pure refactor that moves the three coupled binding fields into a ScopeBindings type with its own invariant test. Happy to drop the second if you prefer the fields on Scope.

The benchmark and profiling harness behind these numbers lives on my fork (bench/compiler-perf). Happy to send it as a separate PR if useful.

stringhandler and others added 2 commits October 12, 2026 11:39
Every variable read in codegen re-folded all live bindings into the
input pattern, re-converted it, and re-walked it to find the variable:
O(n) work per read and O(n^2) per program, even though the generated
program is only linear in size. A 4096-binding program spent ~3.9s in
codegen.

Scope now maintains its bindings incrementally: a flat list of binding
patterns, an identifier -> binding-index map, and per-scope start
offsets. Reading an identifier (get_identifier) computes its selector
directly from the binding's position: drop past newer bindings, take
into the target binding (skipped for the oldest binding, the right tip
of the input spine), then select within that binding's own pattern via
the new BasePattern::get_from. The selectors are identical to the old
fold-and-translate result, which the tests keep as a reference oracle,
so the compiled program is byte-for-byte unchanged.

- pattern.rs: factor BasePattern::get into get_from, which continues an
  accumulated selection instead of starting from an empty one
- compile/mod.rs: rework Scope's representation as above; add a
  randomized property test comparing get_identifier against the
  reference, covering main and child scopes, shadowing and compound
  patterns

Validation: the workspace test suite passes, including the pinned
test-data bytecode regressions, and 28 programs (all examples plus
generated stress shapes) produce byte-identical output to master.

Local benchmarking (release build): codegen -68% for 256 sequential
bindings, -63% for 256 bindings across 32 nested scopes, -35% at 64
bindings; a 4096-binding program goes 3.90s -> 120ms. Programs with
only a few bindings regress ~1-3% from the per-let bookkeeping.

Assisted-by: Codex (rebase)
Co-authored-by: Tommy Volk <tvolk131@gmail.com>
The three fields added by the previous commit (binding list,
identifier -> binding-index map, per-scope start offsets) form a
referential-integrity triple: the index fields are meaningless without
the binding list, and pop_scope repairs all three together. No
invariant crosses the other Scope fields.

ScopeBindings owns the triple behind a small interface
(from_root/insert/push_scope/pop_scope/position_of/as_slice), so the
invariants hold at method boundaries and pop_scope's transient
half-updated states stay unobservable. The type has no generic
parameters; all selector and type-inference logic stays in Scope.

A property test checks position_of against a naive newest-first rescan
of the bindings after every mutation. Also drops a stale "## Panics"
section from insert, a leftover from a body that could panic; the
current one cannot.

Pure refactor: tests pass and example and stress programs produce
byte-identical output.

Assisted-by: Codex (rebase)
Co-authored-by: Tommy Volk <tvolk131@gmail.com>

@KyrylR KyrylR left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

ACK f4ca6d6; successfully ran local tests

Idea and implementation are sound, though the code and test complexity worry me

I would prefer to have more "functional" approach for structuring the code, but imo the current solution does a job in fixing a bug, therefore it is good enough

@stringhandler
stringhandler merged commit 91385a2 into BlockstreamResearch:master Oct 13, 2026
15 checks passed
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