Skip to content

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

Open
tvolk131 wants to merge 2 commits into
BlockstreamResearch:masterfrom
tvolk131:pr/codegen-scope-perf
Open

Eliminate quadratic environment rebuilds in Scope::get#409
tvolk131 wants to merge 2 commits into
BlockstreamResearch:masterfrom
tvolk131:pr/codegen-scope-perf

Conversation

@tvolk131

@tvolk131 tvolk131 commented Sep 2, 2026

Copy link
Copy Markdown

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.

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.
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.
@tvolk131
tvolk131 requested a review from delta1 as a code owner September 2, 2026 01:04
@tvolk131 tvolk131 changed the title compile: eliminate quadratic environment rebuilds in Scope::get Eliminate quadratic environment rebuilds in Scope::get Sep 2, 2026
@apoelstra

Copy link
Copy Markdown
Contributor

Can you move the new ScopeBindings into its own module/file?

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