Skip to content

Add resolve_and_get_from_scope (fused resolve_scope + get_from_scope) - #516

Open
Jarred-Sumner wants to merge 2 commits into
mainfrom
claude/resolve-and-get-from-scope
Open

Add resolve_and_get_from_scope (fused resolve_scope + get_from_scope)#516
Jarred-Sumner wants to merge 2 commits into
mainfrom
claude/resolve-and-get-from-scope

Conversation

@Jarred-Sumner

@Jarred-Sumner Jarred-Sumner commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #515.

In bundled ESM, every read of a module-level binding from inside a function is resolve_scope + get_from_scope: 43% of all instruction bytes in Claude Code's bytecode, and 97–99% of resolve_scope instances are immediately consumed by a get_from_scope on the resolved register. This adds resolve_and_get_from_scope and emits it for ResolveNode, call callees (f()), typeof x, and tagged templates when the resolve is static: no with scope in the function's own chain, and no with lexically enclosing the function (TaintedByWithScopeLexicallyScopedFeature). The fused instruction is the same size as get_from_scope (it already carries the resolve type in getPutInfo and localScopeDepth), and the temporary register for the resolved scope goes away.

Prior art: bugs.webkit.org #202675 (Yusuke) and #237836 / WebKit PR WebKit#1579 (Robin Morisset, Yijia Huang; implemented with checkpoints, closed unmerged in 2022).

Tiers:

  • LLInt: one fast path — resolve into a register, read from it; either half that cannot be served inline goes to one slow path (slow_path_resolve_and_get_from_scope) that redoes both.
  • Baseline: the existing resolve_scope / get_from_scope emitters and thunks are templated on the opcode and run back to back. The fused metadata lays the two ops' metadata out in order, so the thunks read the same offsets. Inline check failures redo the whole instruction in C++; thunk slow paths stay per-half (operationResolveScopeHalfForBaseline, operationGetFromScopeHalf).
  • DFG: the two lowerings (parseResolveScope, parseGetFromScope) are shared with the unfused ops and chained, so the graph is identical to before for static resolves. A dynamic resolve (unresolved property after an exit, var injection) is one GetDynamicVar that resolves through the base scope first (GetPutInfo::resolvesScopeFirstBit), so no node that clobbers exit state is followed by one that may exit within the bytecode. After a ForceOSRExit in the resolve half the get half is not parsed.
  • LOL: slow op.

A bare call through a fused resolve passes undefined as this instead of the resolved scope; JSValue::toThis maps both to the same value (undefined in strict, the global proxy in sloppy), and upstream later made the same change (ee167b). Code inside a with keeps the two instructions and the scope as this.

Size on the 20 largest CC chunks (14.9 MB of minified JS): 39.9 MB → 37.9 MB of bytecode on top of #515 (46.7 MB on main). Claude Code binary with #515 + #516: 220.4 MB → 203.1 MB; startup and footprint unchanged.

Testing: JSTests/stress/resolve-and-get-from-scope.js (global var/lexical/property, late-defined and deleted globals, typeof undeclared, TDZ, closure chains, this for sloppy/strict callees and tagged templates, with, a function nested inside with, sloppy direct eval var injection) passes in the jsc shell under default, --useJIT=false, --useDFGJIT=false, --jitPolicyScale=0 --useConcurrentJIT=false, and --useLLInt=false. Bun --bytecode round-trips over a JSTests sample. Full JSTests/stress differential (2,535 standalone files, release Bun with this WebKit vs release Bun on main, exit code + stdout compared) on linux-x64: default 2535/2535 identical, eager tier-up (jitPolicyScale=0) 2535/2535, Baseline-only 2534/2535, LLInt-only 2532/2535. The four non-identical cases are deep-recursion / termination tests that sit at the 90 s timeout under a 24-way parallel run and flipped between SIGKILL and stack-overflow SIGSEGV in both directions; rerun alone, both builds give the same exit code and time on each of them.

Comment thread Source/JavaScriptCore/dfg/DFGByteCodeParser.cpp
Comment on lines +7266 to +7278
// prediction, we'd otherwise think that it has to exit. Then when it did execute, we
// would recompile. But if we can fold it here, we avoid the exit.
if (JSValue value = m_graph.tryGetConstantClosureVar(scopeNode, ScopeOffset(inputs.operand)))
return weakJSConstant(value);

SpeculatedType prediction = SpecNone;
if (resolveType == ResolvedClosureVar) {
// ResolvedClosureVar is not used normally. It is very special internal ResolveType, mainly used for generators and private fields.
// In these variables, it can happen that we use JSEmpty as a result of op_get_from_scope (which becomes a TDZ error in normal ClosureVar).
// And this JSEmpty is still legit. The problem is that ValueProfile never tells about JSEmpty since it sees no value is stored when JSEmpty
// is stored. We workaround this very special internal use case by explicitly setting SpecEmpty when ValueProfile tells this is SpecNone.
prediction = getPredictionWithoutOSRExit();
if (prediction == SpecNone)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 The refactored parseGetFromScope() checks resolveType == ResolvedClosureVar where resolveType is derived from inputs.getPutInfo (populated from metadata.m_getPutInfo), but CodeBlock::finishCreation rewrites ResolvedClosureVar → ClosureVar in that metadata field for op_get_from_scope (CodeBlock.cpp:613-615), so this branch is now dead. The pre-refactor code deliberately checked bytecode.m_getPutInfo.resolveType() — the immutable bytecode operand, which stays ResolvedClosureVar — so generator/private-field reads that legitimately load JSEmpty now fall through to getPrediction() and insert ForceOSRExit. Fix: thread the bytecode's original resolve type (or an isResolvedClosureVar flag) through GetFromScopeInputs.

Extended reasoning...

What the bug is

The op_get_from_scope DFG lowering was extracted into parseGetFromScope() so it can be shared with the new fused opcode. Inside the ClosureVar case, the SpecEmpty workaround now reads:

ResolveType resolveType = getPutInfo.resolveType();  // getPutInfo = inputs.getPutInfo
...
if (resolveType == ResolvedClosureVar) {
    prediction = getPredictionWithoutOSRExit();
    if (prediction == SpecNone)
        prediction = SpecEmpty;
} else
    prediction = getPrediction();

At the op_get_from_scope caller, inputs.getPutInfo is populated from metadata.m_getPutInfo. But CodeBlock::finishCreation rewrites the metadata for op_get_from_scope at link time (CodeBlock.cpp:613-615):

if (bytecode.m_getPutInfo.resolveType() == ResolvedClosureVar) {
    metadata.m_getPutInfo = GetPutInfo(bytecode.m_getPutInfo.resolveMode(), ClosureVar, ...);
    break;
}

So by the time DFG parsing runs, metadata.m_getPutInfo.resolveType() is ClosureVar, never ResolvedClosureVar, and the SpecEmpty branch is unreachable.

What the pre-refactor code did

The removed code was subtle about which field it read: the outer switch used resolveType = metadata.m_getPutInfo.resolveType() (so it landed in the ClosureVar case), but the inner check read bytecode.m_getPutInfo.resolveType() — the immutable instruction operand, which is not rewritten and still says ResolvedClosureVar. The refactor collapsed both to the metadata-derived value, losing that distinction.

Step-by-step proof

  1. A generator's frame-environment slot (or a private-field brand slot) is emitted as op_get_from_scope with bytecode.m_getPutInfo.resolveType() == ResolvedClosureVar.
  2. CodeBlock::finishCreation sets metadata.m_getPutInfo to ClosureVar for that instruction.
  3. The slot legitimately holds JSEmpty (e.g., before first write). LLInt/baseline execute it; the ValueProfile records nothing because JSEmpty encodes as 0 ("no value stored"), so the profile stays SpecNone.
  4. DFG compiles: op_get_from_scope handler sets inputs.getPutInfo = metadata.m_getPutInfo → resolveType is ClosureVar.
  5. parseGetFromScope() enters the ClosureVar case; resolveType == ResolvedClosureVar is false; falls to getPrediction().
  6. getPrediction() sees SpecNone and inserts ForceOSRExit — exactly what the comment says the workaround was added to avoid.

Before this PR, step 5 would have taken the ResolvedClosureVar branch, set prediction = SpecEmpty, and emitted a normal GetClosureVar with no forced exit.

Why nothing else prevents it

The fused opcode is unaffected (op_resolve_and_get_from_scope asserts resolveType != ResolvedClosureVar and canFuseResolveAndGet excludes scoped-offset variables), so the branch's only live consumer was the standalone op_get_from_scope — which is precisely the caller that now passes the rewritten value.

Impact

This is a performance regression, not a correctness bug: DFG-compiled generators and private-field reads whose profiled value is empty will hit ForceOSRExit and recompile, undoing a deliberate fix. The workaround comment is still in the code, describing behavior that no longer happens.

Fix

Add a field to GetFromScopeInputs carrying the bytecode's original resolve type (or a bool isResolvedClosureVar), populate it from bytecode.m_getPutInfo.resolveType() at the op_get_from_scope caller, and check that field instead of the metadata-derived resolveType in the SpecEmpty branch.

Comment thread Source/JavaScriptCore/bytecode/Opcode.h
@github-actions

github-actions Bot commented Aug 25, 2026

Copy link
Copy Markdown

Preview Builds

Commit Release Date
87cc6cc0 autobuild-preview-pr-516-87cc6cc0 2026-08-25 18:30:35 UTC
04f1216b autobuild-preview-pr-516-04f1216b 2026-08-25 12:13:55 UTC
dcb82676 autobuild-preview-pr-516-dcb82676 2026-08-25 11:05:50 UTC

@Jarred-Sumner
Jarred-Sumner force-pushed the claude/resolve-and-get-from-scope branch from 94da99b to 04f1216 Compare August 25, 2026 11:35
@Jarred-Sumner
Jarred-Sumner changed the base branch from claude/bytecode-cache-packing to main August 25, 2026 11:35
@coderabbitai

coderabbitai Bot commented Aug 25, 2026

Copy link
Copy Markdown

Warning

Review limit reached

  • Run on-demand review

On-demand reviews are free for the next 26 days. After that, they cost $0.25 per reviewed file.

Or wait 58 minutes for your next included review.

View limit details

Limit details: You’ve used the included review currently available. Your 79 included PR review attempts over the past 7 days set your current allowance at 1 review per hour.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: b2d5d274-2369-4ec4-b71c-7f88c98bbc07

📥 Commits

Reviewing files that changed from the base of the PR and between 1cb96a7 and 87cc6cc.

📒 Files selected for processing (24)
  • JSTests/stress/resolve-and-get-from-scope.js
  • Source/JavaScriptCore/bytecode/BytecodeList.rb
  • Source/JavaScriptCore/bytecode/BytecodeUseDef.cpp
  • Source/JavaScriptCore/bytecode/CodeBlock.cpp
  • Source/JavaScriptCore/bytecode/Opcode.h
  • Source/JavaScriptCore/bytecompiler/BytecodeGenerator.cpp
  • Source/JavaScriptCore/bytecompiler/BytecodeGenerator.h
  • Source/JavaScriptCore/bytecompiler/NodesCodegen.cpp
  • Source/JavaScriptCore/dfg/DFGByteCodeParser.cpp
  • Source/JavaScriptCore/dfg/DFGOperations.cpp
  • Source/JavaScriptCore/jit/BaselineJITRegisters.h
  • Source/JavaScriptCore/jit/JIT.cpp
  • Source/JavaScriptCore/jit/JIT.h
  • Source/JavaScriptCore/jit/JITOperations.cpp
  • Source/JavaScriptCore/jit/JITOperations.h
  • Source/JavaScriptCore/jit/JITPropertyAccess.cpp
  • Source/JavaScriptCore/llint/LowLevelInterpreter64.asm
  • Source/JavaScriptCore/lol/LOLJIT.cpp
  • Source/JavaScriptCore/runtime/CommonSlowPaths.cpp
  • Source/JavaScriptCore/runtime/CommonSlowPaths.h
  • Source/JavaScriptCore/runtime/CommonSlowPathsInlines.h
  • Source/JavaScriptCore/runtime/FileBasedFuzzerAgent.cpp
  • Source/JavaScriptCore/runtime/GetPutInfo.h
  • Source/JavaScriptCore/runtime/PredictionFileCreatingFuzzerAgent.cpp

Comment @coderabbitai help to get the list of available commands.

let globalLet = 2;
globalThis.globalProp = 3;
function readGlobals() { return globalVar + globalLet + globalProp; }
for (let i = 0; i < 1e4; ++i)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Nit: this new stress test hardcodes 1e4 in 11 loop bounds (lines 25, 36, 43, 64, 72, 81, 91, 104, 117, 127, 135); JSTests/README.md rule 2 requires new tests to use testLoopCount so the harness can scale iterations per configuration. Replace 1e4 with testLoopCount.

Extended reasoning...

What the issue is

JSTests/stress/resolve-and-get-from-scope.js is a newly-added test file, and every one of its tier-up loops uses a hardcoded 1e4 bound:

for (let i = 0; i < 1e4; ++i)
    shouldBe(readGlobals(), 6);

This appears at 11 sites: lines 25, 36, 43, 64, 72, 81, 91, 104, 117, 127, and 135.

Why this violates a repository requirement

JSTests/README.md:20 (pulled into the directory-scoped instructions via JSTests/CLAUDE.md) states, under "New tests are required to adhere to the following rules":

  1. Use testLoopCount or wasmTestLoopCount to control how many iterations a test runs. The jsc CLI sets these based on the configuration of the test, so tests iterate enough to tier up where that matters and exit early where it doesn't.

This is not a stylistic suggestion — it is listed as a required rule for newly-added test files. The convention is widely followed: ~2200+ files under JSTests/ reference testLoopCount.

Why nothing else prevents it

The test happens to work with 1e4 because that is roughly the default tier-up threshold, but the point of testLoopCount is that the jsc shell sets it per configuration: no-JIT / cloop configurations set it low so the test exits quickly instead of wasting 10 000 iterations that will never tier up, while eager-tier configurations may set it higher/lower as needed. A hardcoded 1e4 defeats that scaling in every configuration this file is run under.

Step-by-step

  1. run-jsc-stress-tests JSTests/stress runs resolve-and-get-from-scope.js under many configurations (e.g. .no-llint, .no-cjit, .ftl-eager-no-cjit, cloop, etc.).
  2. In each configuration, the shell sets the global testLoopCount to the appropriate iteration count for that configuration.
  3. This test ignores that global and always runs each loop 1e4 times.
  4. In configurations where JIT is disabled, all 11 loops × 10 000 iterations run in the interpreter for no benefit; in configurations with lowered thresholds, 1e4 may be far more than needed to reach FTL. The test still passes — this is purely a harness-integration/convention violation, not a correctness bug.

Impact

No runtime correctness impact — the test produces the same pass/fail result either way. The impact is on test-suite hygiene: it violates a documented repository requirement for new tests and won't scale iteration count with the harness.

Fix

Replace each 1e4 with testLoopCount:

for (let i = 0; i < testLoopCount; ++i)
    shouldBe(readGlobals(), 6);

(applied to all 11 loop bounds listed above).

… instruction

A non-local read that resolves statically (no enclosing with scope) no longer spends a resolve_scope and a temporary
register on the resolved scope. The generator emits the fused op for ResolveNode, call callees, typeof, and tagged
templates; a bare call through it passes undefined as this, which is what to_this made of the scope.

LLInt: one fast path that resolves into a register and reads from it; either half that cannot be served inline goes to
one slow path that redoes both. Baseline: the resolve_scope and get_from_scope emitters and thunks are templated on the
opcode and run back to back (the fused metadata lays the two ops' metadata out in order); inline check failures redo the
whole instruction in C++. DFG: the two lowerings are shared with the unfused ops and chained; a dynamic resolve feeds a
GetDynamicVar, whose KnownCellUse edge means no exit can land between the halves. LOL treats it as a slow op.
… add a stress test

- DFG: a dynamic resolve is one GetDynamicVar that resolves first (GetPutInfo::resolvesScopeFirstBit), instead of a
  ResolveScope node (which clobbers exit state) followed by a node that may exit; after a ForceOSRExit in the resolve
  half the get half is not parsed.
- Baseline operations take the frame from the JIT operation itself; the shared helpers receive it.
- Do not fuse in code lexically inside a with statement (TaintedByWithScopeLexicallyScopedFeature): its static-looking
  scope chain resolves through the with object at runtime, which must be this for a call through it.
- Fuzzer agents know the fused op carries a value profile.
@Jarred-Sumner
Jarred-Sumner force-pushed the claude/resolve-and-get-from-scope branch from 04f1216 to 87cc6cc Compare August 25, 2026 17:56
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.

1 participant