Skip to content

perf(web): skip scope probe and descriptor copies for plain direct-call carriers - #4651

Merged
ryansolid merged 2 commits into
solidjs:nextfrom
brenelz:perf/scope-carrier-plain
Nov 3, 2026
Merged

ryansolid merged 2 commits into
solidjs:nextfrom
brenelz:perf/scope-carrier-plain

Conversation

@brenelz

@brenelz brenelz commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

Summary

Follow-up to #4333. On the framework-tracker app's /server-side-rendered page (e18e/framework-tracker#709, rc.20), enterScopeCarrier was still 10 to 13% of server self time under load, and scopeDeferredContents was 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. bindDeferredBody ran its nativePromise probe inside scope(...), which is an AsyncLocalStorage.run per object, because Object.prototype.toString can run an authored Symbol.toStringTag getter. The frame then copied every descriptor with Object.getOwnPropertyDescriptors, which allocates one object per property plus the result.

This change makes three adjustments:

  • The scoped probe is skipped for a plain object or array (prototype Object.prototype, null or Array.prototype) that has no own Symbol.toStringTag, Symbol.asyncIterator or (for objects) Symbol.iterator. The walk then reads value[Symbol.asyncIterator] and value[Symbol.iterator] the way bindDeferredBody does, so a proxy that exposes a hook through its get trap is still bound. A per-walk check falls back to the probe for every container when Object.prototype or Array.prototype defines one of these hooks, or when Promise or ReadableStream has an own Symbol.hasInstance.
  • Slots are read with a per-key Object.getOwnPropertyDescriptor, which V8 does not allocate for here. A container whose slots hold only primitives gets no frame. It is registered in seen so that shared rows are not rescanned. A frame starts at its first slot that holds an object.
  • Descriptors are no longer copied when a frame is entered. snapshotFrames takes the snapshot for every object frame on the stack right before the walk's first bindDeferredBody call, 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, and frameShell builds 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/web against a rebuilt dist:

  • 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.tsx has six new direct-road specs, and every one passes on next and with this change. They cover:
    • a body behind leaf slots, with leaf carriers kept by identity;
    • a plain object with its own iterator hook;
    • a proxy whose hook comes from its get trap;
    • accessor and non-enumerable descriptors on a rebuild, without the getter being invoked;
    • a hook getter that makes its parent's slot non-writable mid-walk;
    • a polluted Object.prototype.
  • The frozen-slot spec now also covers a frozen array holding a generator.
  • node size.mjs in scripts/size: every scenario is within its cap and byte-identical to next, because no scenario bundles server-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:

Bench next this branch
direct call, 1,000 plain rows 0.554 ms 0.115 ms
direct call, 1,000 rows and one deferred body 0.564 ms 0.115 ms
direct call, 1,000 rows each holding an array 0.950 ms 0.237 ms

The rest of bench:server is unchanged, and no other bench imports the server-functions runtime. Two shapes from review were also checked against next. 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-solid build on npm rc.20 with only this walk spliced into server-functions/dist/server.js, because that region is byte-identical to next'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, enterScopeCarrier self 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, ownKeys and getOwnPropertyDescriptor, and index getters on arrays, which next also reads directly. One example is a proxy over a Promise, which now has its getPrototypeOf trap 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 seen costs about 0.04 ms per 1,000 unique rows. Without registration, a shared row is rescanned once per reference.

🤖 Generated with Claude Code

@changeset-bot

changeset-bot Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 3aea1c9

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 15 packages
Name Type
@solidjs/web Patch
@solidjs/babel-plugin Patch
@solidjs/diagnostics Patch
@solidjs/element Patch
@solidjs/h Patch
@solidjs/html Patch
test-integration Patch
board-example Patch
canvas-example Patch
server-dbmon-example Patch
todos-server-example Patch
@solidjs/compiler Patch
@solidjs/signals Patch
solid-js Patch
@solidjs/universal Patch

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

@codspeed

codspeed Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

Merging this PR will improve performance by ×6

1 warning

⚠️ 1 benchmark is dominated by system calls

System calls cannot be simulated consistently and are left out of the measure. The reported value understates the real cost. Switch to the Walltime instrument to measure them.

Learn more about system calls

See affected benchmarks

⚡ 2 improved benchmarks
✅ 198 untouched benchmarks
🆕 1 new benchmark
⏩ 5 skipped benchmarks1

Performance Changes

Benchmark BASE HEAD Efficiency
⚡ direct call, 1,000 plain rows 9.2 ms 1.5 ms ×6.3
⚡ direct call, 1,000 rows and one deferred body 8.8 ms 1.5 ms ×5.8
🆕 direct call, 1,000 rows each holding an array N/A 5.4 ms N/A

Tip

Curious why performance improved? Comment @codspeedbot explain why performance improved on this PR, or directly use the CodSpeed MCP with your agent.


Comparing brenelz:perf/scope-carrier-plain (3aea1c9) with next (061b013)

Open in CodSpeed

Footnotes

  1. 5 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩

@ryansolid
ryansolid merged commit b656418 into solidjs:next Nov 3, 2026
8 of 9 checks passed
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