Skip to content

Keep records, tuples and spread updates that own references in frame storage - #1205

Merged
ctate merged 3 commits into
mainfrom
opt/02-value-records
Oct 18, 2026
Merged

ctate merged 3 commits into
mainfrom
opt/02-value-records

Conversation

@cramforce

Copy link
Copy Markdown
Contributor

Summary

Frame storage for non-escaping records (#stack-objects, record-results) only covered shapes whose fields were numbers, booleans and inline scalar unions. Three very common idioms therefore always allocated, even when every use only read fields:

  • records with string, array, record or class fields ({ kind, text, pos } tokens, { label, parts }),
  • tuples (const [q, r] = divmod(a, b), [key, value] pairs): tuple shapes were excluded outright,
  • same-shape spread updates (state = { ...state, count: state.count + 1 }): recordClone was neither a frame-storage source nor a projection, so even an all-number state cloned on the heap every update.

This change extends the existing projection proof and result-slot machinery to those cases:

  • Closed record and tuple shapes qualify regardless of field types (frameRecordShape). Tuples are element records: destructuring and element reads project them; every array-like use is a whole-value use that keeps the heap path.
  • The frame owns the reference fields. stackObjectFields addresses each reference field from the entry block. emitStackObject stores null into them and hands them to an owner before anything is stored: the binding's lexical scope for locals (ownScopeFields, released on scope exit, break/continue/return and unwind like the binding itself), the statement frame for call-argument temporaries. An exception while the record is being built (a throwing field expression, a throwing producer after a partial fill) releases exactly the fields stored so far. Interned string literal fields need no owner.
  • Reassigned bindings build the new value apart (its fields owned by the statement), load the old field values, copy the struct over the binding's slot, disown the temporary's fields and release the old values.
  • Spread updates: a recordClone is a frame-storage source (copies the source's fields with retains, skips overridden fields, then initializes the overrides in source order), and a spread of a binding is a projection in call-lifetimes.
  • Result-slot entries now cover producers that return such records, tuples, or a spread of a binding (return { ...s, count: s.count + 1 }). The entry stores into the zeroed caller storage with initField (each field at most once per path; literal and override names are required to be distinct), so no old-value release is emitted. Returned-parameter copies stay limited to scalar shapes so a copy never drops the undeclared source keys a checked conversion may keep beside a record.
  • Kept on the heap: published (worker-guarded) shapes, whose field guards treat the immortal header as a frozen object (this also closes the latent case for scalar shapes), and switch-case bindings of reference-owning shapes (the cases share one scope).

Not in this change: nested records stay heap-allocated inside a frame record ({ pos: { x, y } } keeps the inner records), class instances with reference fields keep the heap path, and arrays of records are unchanged (no contiguous storage).

Evidence

Sandbox VM (8 vCPU x86_64), base = origin/main 8620fa5, interleaved runs.

Microbenchmarks (in-process ms, median of 11; 20M iterations each):

workload base candidate change node
scalar {x, y} temporaries (control) 23.2 23.0 -1% 33
records with string fields + producer 593 269 -55% 57
nested records (outer frame-stored, inner still heap) 513 320 -38% 33
spread state updates 234 77 -67% 590
tuple returns + destructuring 311 129 -59% 614
arrays of records (unchanged path) 102 102 -1% ~300

The remaining gap to Node on the string-field workload is one %rec.width record conversion per call (the frontend widens the producer's text parameter to string | undefined because the caller passes an array element read), and on the nested workload the inner records.

scripts/bench-runtime.mjs --layouts=4 --runs=16 (all workloads; candidate/baseline medians):

  • value-records (new workload in this PR: token records with string fields, a spread-updating reducer, destructured tuple returns): 287.1 -> 236.7 ms, -17.5% (95% CI -31.7% .. -13.1%), node 340 ms. In-process components: tokenizer 97 -> 94 ms, reducer 82 -> 59 ms, tuples 83 -> 67 ms.
  • Every other workload is neutral (geomean -0.4%), except validate-errors -1.1% (faster) and http-api +2.4% (CI +0.1% .. +3.8%, "slower"). http-api changes in exactly one place (one %rec.width conversion result passed to send now uses frame storage, one heap record per request fewer); a re-run of http-api alone gave +4.0% with CI -1.2% .. +9.1% (neutral), and the pre-review run of this change gave -1.0%. I read this as server-workload noise, but it is the one number to watch.
  • Binary sizes: unchanged (within 0.2%).

Tests

  • New corpus programs, Node vs native byte-identical in the plain and sanitized (ASan + RC audit) lanes: 5960-frame-records-own-references.ts (strings/arrays/records/class instances/optional fields; loops, break/continue/return, throwing field expressions and producers, try/finally, switch cases, escaping/captured/printed records), 5961-frame-spread-updates.ts (spread updates with string/array fields, reducers returning spreads, throwing overrides, override evaluation order, aliases/identity/history), 5962-frame-tuples.ts (number and string tuples, producer tuples, swaps, escaping tuples).
  • Unit tests in stack-objects.test.ts for frame storage of reference-owning records and tuples (no heap allocation, owned fields start empty and are released, literal fields unowned) and for spread updates (result-slot entries, copy then release of the replaced fields).
  • Corpus slice (record|tuple|spread|frame|destruct|object-lit|clone|reducer|state, 226 programs) plain and sanitized: only the three failures that also fail on base in the sandbox (2283-structured-clone, 2976-http-static-incoming-state, 5761-crypto-hash-stream-states).
  • Full plain differential corpus (2868 programs): 63 failures, all of which fail identically on base in the sandbox (Node-version and environment dependent programs). No new failures.
  • pnpm lint, pnpm format:check, pnpm test:ts7 --baselines-only; self-hosting IR build without diagnostics; tests/harness/self-hosting-native-driver.test.ts passes.

🤖 Generated with Claude Code

…storage

Frame storage for non-escaping records was limited to number, boolean and
inline-union fields. Records with string, array, record or class fields,
tuples, and same-shape spread updates (`{ ...state, count }`) were always
heap-allocated, even when every use only projected them.

Closed record and tuple shapes now qualify regardless of field types. The
frame owns the reference fields: they start empty in entry-addressed slots
and are registered before anything is stored, with the binding's lexical
scope for locals and with the statement frame for call-argument
temporaries, so an exception while the record is being built releases
exactly what was stored. Interned string literal fields need no owner.
A reassigned binding builds the new value apart, copies it over its slot
and then releases the replaced fields. Result-slot entries cover producers
returning such records, tuples and spreads of a binding, and store into
the zeroed caller storage without releasing an old value. A spread of a
binding is a projection for the lifetime analysis. Published shapes and
switch-case bindings of reference-owning shapes keep heap storage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vercel

vercel Bot commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
scriptc Ready Ready Preview, v0 Oct 18, 2026 1:24pm UTC

A for-of over a tuple producer's result only reads its elements, so the
tuple now lives in frame storage and the producer runs through its
result-slot entry. The test still checks that the receiver is computed
once and that no array snapshot is allocated, whichever entry runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Resolve the emitter.ts import conflict by keeping both RESULT_DST_ID
(frame-stored result slots) and specializeCallbackCalls (#1206).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ctate
ctate merged commit f2e03bd into main Oct 18, 2026
66 checks passed
@ctate
ctate deleted the opt/02-value-records branch October 18, 2026 13:44

This branch was successfully deployed

1 active deployment
Preview — ed5aa104 Deployed Oct 18, 2026 by vercel[bot]
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