Skip to content

perf(flatten): a destructure defers its substitutions instead of walking the rest of the body (#2041) - #2068

Draft
raramos9 wants to merge 1 commit into
bendlang:mainfrom
raramos9:perf/2041-deferred-subs
Draft

raramos9 wants to merge 1 commit into
bendlang:mainfrom
raramos9:perf/2041-deferred-subs

Conversation

@raramos9

@raramos9 raramos9 commented Nov 6, 2026

Copy link
Copy Markdown
Contributor

This PR edits bend2/bend.ts, which AGENTS.md marks as human-written. It is a proposal for the file's owner to review, rewrite or close. Draft until that call is made.

Fixes #2041.

The problem. Each destructure (Box{x1} = x0) becomes a one-row match, and match_flatten applies its two substitutions (the constructor, then the field's own var column) with body_sub, which walked every later statement and the final expression and copied that spine on each walk: n(n+1) visits for n destructures. Every copy also stayed alive in the match_flatten frames above it until their default branch was built, so peak live memory grew as n² as well: 25.6M objects (928 MB) at the deepest point of flattening for n=3200.

The change. body_sub defers instead of walking. It pushes x <- v onto a Subs list (newest first, immutable, shared by the rows) held by a { $: "Subs" } body wrapper, in constant time. body_flatten calls body_force on the statement it is about to flatten, which applies the list to that one statement and hands the same list to its rows and its rest, to be forced when flatten reaches them. No spine is copied. subs_find finds a binder's entries through a PMap index built lazily along a list and kept on its nodes, so each node is indexed once: n destructures cost O(n log n). body_sub keeps its signature and match_flatten is unchanged.

Why the terms are the same, spans included. The eager walks changed only three things, and body_force does each the same way at the statement:

  • a scrutinee or let value that is a variable took each substitution of its binder in order, so it now takes, at each step, its binder's oldest entry newer than the last one applied (a binder can be substituted twice on a path: its own var column, then its ctr); once it is not a variable, the remaining entries wrap it;
  • the replacement is still patt_term(v, e.s), so it keeps the use's span;
  • a leaf is wrapped in every entry, oldest innermost: the same Sub nesting the walks built.

The cached index depends only on the older nodes it points to, so every branch that builds it builds the same one. Flattening does no other work differently, and its fresh-name counter is untouched.

Load and check (bend <file> --check-only, median of 7, Apple M1, 16 GB)

program main this PR
1600 destructures of nested Boxes (the issue's family) 0.24 s, 365 MB 0.06 s, 82 MB
3200 destructures 0.84 s, 1254 MB 0.10 s, 97 MB
6400 destructures 4.02 s, 3153 MB 0.21 s, 119 MB
12800 destructures of a flat recursive type 23.21 s, 7434 MB 0.66 s, 146 MB
Base and one small def 122 ms 124 ms (within run-to-run spread)

Flattening alone (in-process, median of 7): 713 ms → 13 ms at 3200, 3586 ms → 31 ms at 6400, and for each doubling past n=400 it grows 1.4 to 2.6 times where main grows 3.3 to 5.5 (operation counts: linear, plus n log n index steps). Live heap at the deepest point of flattening at 3200: 25.6M objects (928 MB) → 309k (15 MB). Base's flattening, warm: 2.62 ms → 2.66 ms.

After this PR the largest remaining cost on these programs is the parser's scope scan (358 of 439 ms of load at n=12800), which #2064 removes.

Checks

  • Differential: every top-level body_flatten result, or the error it throws, serialized with its spans and hashed, for Base and every .bend under tests/ (1853 files, 978,978 results, 24 errors), plus --check-only output and exit code: identical to main. The same over 1800 generated programs (nested matches, renaming var rows, destructures, lets, reuse of matched binders): identical.
  • Property: main's body_sub and this one, applied to 50,000 random bodies with 220,383 random substitutions (repeated real substitutions of one binder, spanless vars, Sub-wrapped scrutinees), give the same body once forced. It fails on a lookup that takes the newest entry instead of the oldest (14,787 mismatches) and on reversed leaf nesting (44,361). No .bend program can tell the first of those apart today: across the corpus and the generated programs, every binder substituted twice had its var column's identity as the older entry (558,014 cases), so this script is what guards the ordering.
  • Every test's interpreted run, and its -o t.js -o t.c -o t.bendtt output (948 / 896 / 896 files), is byte-identical to main's, except tests/proof/app/main.bend, which greets forever by design and was cut at the same 60 s timeout on both after a different number of lines. Run locally, 1842 of 1856 tests print their #| lines on both, and the same 14 fail on both (import modules that are not programs, and that loop).
  • New tests in tests/flatten/: a 48-statement destructure chain, a binder renamed by a var row and then destructured, a catch-all row destructuring under both branches of the split above it, and the error window of a binder re-matched deep in a chain.
  • gates/repo.ts: 56/56. bend.ts 45673 → 46309 ttok (cap 48000); with perf(parse): field names, array updates, namespaces and later match parameters are found without rescans #2062 and perf(parse): a name finds its binder through an index, not a scan of every local #2064 applied as well, 46481. Both apply cleanly on top, and the differential and the generated programs still match with them on both sides.
  • Not run: gates/test.ts and gates/perf.ts (no cluster access here), gates/ping.ts (no site checkout), and the Lean kernel of --verdict (Lean is not installed; the .bendtt text it checks is byte-identical, above).

A question. The property test below lives outside the repo as reviewer tooling: the allow list admits only .bend tests, and it compares against a second checkout of main. Should it become part of the repo (a gate, or a check in gates/test.ts), or stay with this PR?

subs_prop.ts
// bun subs_prop.ts <main checkout> <candidate checkout> [trials]
// main's eager body_sub and the candidate's deferred body_sub, applied to the
// same random bodies and substitution sequences, must give the same Body once
// the candidate's is fully forced (spans and Sub nesting included).
const [ref, cand, trialsArg] = process.argv.slice(2);
const A = await import(ref + "/bend2/bend.ts");
const B = await import(cand + "/bend2/bend.ts");
const trials = Number(trialsArg ?? 50000);
let seed = 42;
const rnd = () => ((seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648);
const int = (n: number) => Math.floor(rnd() * n);
const file = { str: "x".repeat(100), ns: "", al: {} };
const span = () => (rnd() < 0.3 ? undefined : { file, beg: int(50), end: 50 + int(50) });
const NB = 5;
const pvar = (i: number) => ({ $: "PVar", k: "x" + i, i, q: B.Lone(), s: span() });
const patt = (d = 0): any => rnd() < 0.6 || d > 1 ? pvar(int(NB))
  : { $: "PCtr", k: "K" + int(2), x: Array.from({ length: int(3) }, () => patt(d + 1)), s: span() };
const term = (): any => {
  const r = rnd();
  if (r < 0.6) return B.Var("x" + int(NB), int(NB), span());
  if (r < 0.8) return B.Ctr("K0", [B.Var("x1", 1, span())], span());
  return B.Sub(int(NB), patt(), B.Var("x" + int(NB), int(NB), span()));
};
const body = (d: number): any => {
  const r = rnd();
  if (d > 3 || r < 0.3) return term();
  if (r < 0.65) return { $: "Local", k: [patt()], q: B.Lone(), v: Array.from({ length: 1 + int(2) }, term), f: body(d + 1) };
  return { $: "Match", e: Array.from({ length: 1 + int(2) }, term), r: Array.from({ length: 1 + int(3) }, () => ({ p: [patt()], f: body(d + 1) })), s: span() };
};
const full = (b: any): any => {
  const f = B.body_force(b);
  if (f.$ === "Match") return { ...f, r: f.r.map((row: any) => ({ p: row.p, f: full(row.f) })) };
  if (f.$ === "Local") return { ...f, f: full(f.f) };
  return f;
};
const ser = (x: any) => JSON.stringify(x, (k, v) => (v === undefined ? "<undef>" : k === "file" ? "<file>" : v));
let bad = 0, subs = 0;
for (let n = 0; n < trials; n++) {
  const b0 = body(0);
  let e = b0, d = b0;
  for (let k = 1 + int(8); k > 0; k--) {
    const i = int(NB), v = patt();
    e = A.body_sub(e, i, v);
    d = B.body_sub(d, i, v);
    subs++;
  }
  if (ser(e) !== ser(full(d)) && bad++ < 3) console.log("mismatch at trial " + n);
}
console.log(`trials=${trials} subs=${subs} mismatches=${bad}`);
process.exit(bad === 0 ? 0 : 1);

…ing the rest of the body (bendlang#2041)

body_sub pushes onto a persistent list that body_force applies one
statement at a time, so n destructures no longer copy the spine n(n+1)
times or keep every copy alive. The flattened terms are unchanged,
spans included.
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.

[complexity, flattening] O(n^2) — Each destructure walks all later statements again

1 participant