Repository navigation
Keep records, tuples and spread updates that own references in frame storage - #1205
Merged
Merged
Conversation
…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>
Contributor
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
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>
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
{ kind, text, pos }tokens,{ label, parts }),const [q, r] = divmod(a, b),[key, value]pairs): tuple shapes were excluded outright,state = { ...state, count: state.count + 1 }):recordClonewas 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:
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.stackObjectFieldsaddresses each reference field from the entry block.emitStackObjectstores 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.recordCloneis 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 incall-lifetimes.return { ...s, count: s.count + 1 }). The entry stores into the zeroed caller storage withinitField(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.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):
{x, y}temporaries (control)The remaining gap to Node on the string-field workload is one
%rec.widthrecord conversion per call (the frontend widens the producer'stextparameter tostring | undefinedbecause 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.validate-errors-1.1% (faster) andhttp-api+2.4% (CI +0.1% .. +3.8%, "slower"). http-api changes in exactly one place (one%rec.widthconversion result passed tosendnow 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.Tests
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).stack-objects.test.tsfor 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).pnpm lint,pnpm format:check,pnpm test:ts7 --baselines-only; self-hosting IR build without diagnostics;tests/harness/self-hosting-native-driver.test.tspasses.🤖 Generated with Claude Code