Repository navigation
perf(web): skip scope probe and descriptor copies for plain direct-call carriers - #4651
Conversation
🦋 Changeset detectedLatest commit: 3aea1c9 The changes in this PR will be included in the next version bump. This PR includes changesets to release 15 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Merging this PR will improve performance by ×61 warning
Performance Changes
Tip Curious why performance improved? Comment Comparing Footnotes
|
Summary
Follow-up to #4333. On the framework-tracker app's
/server-side-renderedpage (e18e/framework-tracker#709, rc.20),enterScopeCarrierwas still 10 to 13% of server self time under load, andscopeDeferredContentswas 13.7% inclusive. The page's loader is a'use server'query()that returns 1,000{ id, name }rows, and nothing in that result is ever bound.Two costs remained for each plain object in a direct-call result.
bindDeferredBodyran itsnativePromiseprobe insidescope(...), which is anAsyncLocalStorage.runper object, becauseObject.prototype.toStringcan run an authoredSymbol.toStringTaggetter. The frame then copied every descriptor withObject.getOwnPropertyDescriptors, which allocates one object per property plus the result.This change makes three adjustments:
Object.prototype,nullorArray.prototype) that has no ownSymbol.toStringTag,Symbol.asyncIteratoror (for objects)Symbol.iterator. The walk then readsvalue[Symbol.asyncIterator]andvalue[Symbol.iterator]the waybindDeferredBodydoes, so a proxy that exposes a hook through itsgettrap is still bound. A per-walk check falls back to the probe for every container whenObject.prototypeorArray.prototypedefines one of these hooks, or whenPromiseorReadableStreamhas an ownSymbol.hasInstance.Object.getOwnPropertyDescriptor, which V8 does not allocate for here. A container whose slots hold only primitives gets no frame. It is registered inseenso that shared rows are not rescanned. A frame starts at its first slot that holds an object.snapshotFramestakes the snapshot for every object frame on the stack right before the walk's firstbindDeferredBodycall, the first point where authored code can run. Until then nothing authored has run, so live reads equal the entry-time snapshot. Slots read after the snapshot come from it, andframeShellbuilds the shell from it, as before.The walk keeps #4333's behavior: deferred bodies are bound, cycles resolve to rebuilt shells, frozen carriers and non-plain prototypes are left alone, accessors are not invoked, non-writable and non-enumerable slots keep their authored values, and identity is kept when nothing binds.
How did you test this change?
Commands were run in
packages/webagainst a rebuiltdist:npx vitest run: 1,669 passed.npx vitest run --config vite.config.server.mjs: 1,949 passed, 3 expected failures.npx vitest run --config vite.config.hydrate.mjs: 754 passed, 10 expected failures.server-functions-nested-deferred-scope.spec.tsxhas six new direct-road specs, and every one passes onnextand with this change. They cover:gettrap;Object.prototype.node size.mjsinscripts/size: every scenario is within its cap and byte-identical tonext, because no scenario bundlesserver-functions/dist/server.js.npx vitest bench --run --config vite.config.server-bench.mjs test/server/direct-call-result.bench.tsx(local Mac, means of 2 interleaved runs) gave these results. The third case is new:The rest of
bench:serveris unchanged, and no other bench imports the server-functions runtime. Two shapes from review were also checked againstnext. 2,000 references to one 2,000-key row went from 0.50 to 0.12 ms, and 2,000 references to one 8-key row went from 0.035 to 0.036 ms. A 20,000-level[previous, cursor()]chain went from 21.1 to 14.7 ms.End to end, I ran the framework-tracker
app-solidbuild on npm rc.20 with only this walk spliced intoserver-functions/dist/server.js, because that region is byte-identical tonext's. Autocannon ran at 10 connections against/server-side-rendered, in 3 interleaved 20-second runs on a local Mac. Throughput went from 375, 379 and 376 req/s to 430, 430 and 463 req/s, a mean of 377 to 441 req/s (+17%), and p50 latency went from 26 to 23 ms. In a CPU profile,enterScopeCarrierself time fell from 12.7% to 1.9%. The HTML is identical once the per-request random UUIDs are masked.Open questions
Authored code that runs before the walk's first scope probe can still see live descriptors instead of the entry snapshot. Only two shapes do that: proxy traps such as
getPrototypeOf,ownKeysandgetOwnPropertyDescriptor, and index getters on arrays, whichnextalso reads directly. One example is a proxy over a Promise, which now has itsgetPrototypeOftrap called once outside the scope before the scoped probe. Reading array elements through descriptors would close the array case, but it cost 0.04 ms per 1,000 plain rows and doubled the nested-array case, so I left it out.Registering leaf rows in
seencosts about 0.04 ms per 1,000 unique rows. Without registration, a shared row is rescanned once per reference.🤖 Generated with Claude Code