[wasm] Report GC locals held across a safepoint as pinned#131402
Conversation
|
Azure Pipelines: Successfully started running 6 pipeline(s). 10 pipeline(s) were filtered out due to trigger conditions. There may be pipelines that require an authorized user to comment /azp run to run. |
89191ca to
d70dfec
Compare
There was a problem hiding this comment.
Pull request overview
This PR adjusts the CoreCLR JIT’s wasm-specific GC reporting so that certain GC locals that can be held on the wasm operand stack across a safepoint are reported as pinned (via a wasm-only local flag), avoiding stale references after relocation. It also adds a JIT regression test that reproduces the issue under browser wasm R2R.
Changes:
- Add a wasm-only local-descriptor bit (
lvWasmReportPinned) and use it during GC slot encoding to emitGC_SLOT_PINNEDwhen appropriate. - Extend
fgWasmSpillRefsto stop exempting non-address-exposedGT_LCL_VARand instead mark eligible locals for pinned reporting when they are live across a call. - Add the
Runtime_131373regression test project and test case, forcing crossgen on the browser leg.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| src/coreclr/jit/compiler.h | Adds wasm-only lvWasmReportPinned bit on LclVarDsc. |
| src/coreclr/jit/fgwasm.cpp | Marks certain locals as “report pinned” when held across calls; adds operand-check helper. |
| src/coreclr/jit/gcencode.cpp | Uses lvWasmReportPinned (wasm-only) to set GC_SLOT_PINNED when encoding stack GC slots. |
| src/tests/JIT/Regression/JitBlue/Runtime_131373/Runtime_131373.csproj | New regression test project; forces crossgen for TargetOS=browser. |
| src/tests/JIT/Regression/JitBlue/Runtime_131373/Runtime_131373.cs | New regression test intended to reproduce the stale-ref hole under wasm R2R. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
src/tests/JIT/Regression/JitBlue/Runtime_131373/Runtime_131373.cs:20
- This test is specific to wasm R2R (browser) but currently runs the full allocation/compacting-GC loop on every target. That can add noticeable time to non-browser JIT regression runs while providing little additional coverage. Consider gating the test with ConditionalFact so it only executes on the browser leg where it can actually reproduce the issue.
[Fact]
public static void TestEntryPoint()
Alternative to dotnet#131374, which reports every on-frame GC slot pinned. This pins only the locals whose operand-stack copy actually spans a safepoint. On wasm a GC reference loaded from its frame home onto the operand stack is a copy the GC can neither see nor update, so it goes stale if the referent is relocated at an intervening call. fgWasmSpillRefs handles that for values with no home by spilling them to pinned slots, but it skips non-address-exposed GT_LCL_VAR on the grounds that such a local "won't be mutated between its def and its use" -- true of IR mutation, but not of relocation. Stop skipping those locals, and instead of spilling them to a second slot, report the home they already have as pinned. Reuse of lvPinned is deliberately avoided: it is a source-level concept with roughly fifteen consumers, all of which run before this phase, and setting it here breaks the lvPinned => !lvTracked invariant asserted in gcencode.cpp and gcinfo.cpp. A wasm-only lvStackPinned bit, read only when encoding GC info, says just the thing that needs saying. Locals the call consumes directly are excluded: there is no safepoint between loading one and the call, and the home keeps the referent reachable meanwhile. Measured against the same unfixed baseline on a browser R2R composite: pinned slots size dotnet#131374 (pin all) 141,029 (100%) +22,512 this change 21,161 (15.0%) +14,256 spill to new slots n/a (bounded) +2,028,932 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2d24becc-f8f3-40bf-b13b-5dd1df662196
d70dfec to
a673984
Compare
|
Tagging subscribers to 'arch-wasm': @lewing, @pavelsavara |
| if (!fgWasmIsOperandOf(tree, def)) | ||
| { | ||
| JITDUMP("Pinning V%02u, held across call\n", def->AsLclVarCommon()->GetLclNum()); | ||
| dsc->lvStackPinned = true; |
There was a problem hiding this comment.
Why can't we just set this inside the existing LCL_VAR check?
There was a problem hiding this comment.
Ah ok, I see we do not push the def in that case. Can you just do that?
- Remove
LCL_VARhandling below - In the loop over
defsbelow, handleLCL_VARby setting this new bit, and then skip the spill logic - The FIXME below should be addressed and I think it should be done before the spilling. After a call the call arguments are the callees problem, not ours.
| // A non-address-exposed GT_LCL_VAR is not exempt: the copy pushed onto the operand stack | ||
| // goes stale if the referent moves, so record it and pin its home at the next call. |
There was a problem hiding this comment.
| // A non-address-exposed GT_LCL_VAR is not exempt: the copy pushed onto the operand stack | |
| // goes stale if the referent moves, so record it and pin its home at the next call. | |
| // We have a ref that may be live across a future call. |
Alternative to #131374. Same bug, same test, narrower fix — opened as a draft so the two can be compared side by side rather than argued in the abstract.
Background
On wasm a GC reference loaded from its frame home onto the operand stack is a copy the GC can neither see nor update, so it goes stale if the referent is relocated at an intervening call.
fgWasmSpillRefsalready handles this for values with no home by spilling them to pinned slots, but it skips non-address-exposedGT_LCL_VAR:That holds for IR mutation but not for relocation, which is where the hole comes from.
What this does
Stop skipping those locals; instead of spilling them to a second slot, report the home they already have as pinned.
lvPinnedis deliberately not reused. As @AndyAyersMS noted, it has side effects — it is a source-level concept with ~15 consumers, all of which run before this phase, and setting it here breaks thelvPinned => !lvTrackedinvariant asserted atgcencode.cpp:4135andgcinfo.cpp:608. That is not hypothetical: a variant doing exactly that fails 8 assertions (EC=133) during Emit GC+EH tables under a checked crossgen of CoreLib, where this change isEC=0. A wasm-onlylvStackPinnedbit, consulted only when encoding GC info, says just the thing that needs saying and nothing more.Locals the call consumes directly are excluded — there is no safepoint between loading one and the call, and the home keeps the referent reachable meanwhile.
Versus #131374
@jakobbotsch's objection to #131374 was that it reports everything pinned always, trading GC behaviour for code size. This addresses that directly. Measured on a browser R2R composite against a common unfixed baseline, with pinning as the only variable:
Same denominator, so the comparison is exact: 85% of the pins #131374 takes are unnecessary, and targeting is slightly smaller rather than costing anything.
One caveat, stated plainly: this reduces the number of pins, not their duration. The spill approach zeroes its slots at end of block, releasing the pin; a report-pinned bit on a real local holds until the local is overwritten or the frame returns. On the fragmentation axis the ordering is spill (bounded) < this (6.7× fewer, unbounded) < #131374 (unbounded). GC-pressure measurement could not separate this from #131374 on the available workloads — within noise — so the static pin count is offered as the defensible proxy rather than a runtime claim.
Validation
Browser R2R rig, all variants sharing an identical base so pinning is the sole variable:
ec=13/3; this changeec=1003/3.RuntimeCustomAttributeDataNRE, 5 seeds, System.Text.Json 12166 discovered: 0 NREs on all five. So "held across a call" is not too narrow a criterion — it catches everything the encoder-wide version did.EC=0, no assertions.Test
Runtime_131373is unchanged from #131374. Four shapes look correct here but silently false-pass, which is worth recording:obj.Field = Call()lowers toSTOREIND(ADD(LCL_VAR obj, offset), CALL)because the field is at a non-zero offset; that byrefADDhas no home and was already spilled. Arefparameter store has noADD.fgMorphArgsspills an earlier argument to a temp when a later one contains a call, so the load happens after the safepoint.Only one of #131374 and this should be taken.
Note
This change was authored with GitHub Copilot.