Skip to content

JIT: Improve parameter register field extraction and reconstruction#131174

Open
lewing wants to merge 13 commits into
dotnet:mainfrom
lewing:wasm-r2r-simd-scalar-extract
Open

JIT: Improve parameter register field extraction and reconstruction#131174
lewing wants to merge 13 commits into
dotnet:mainfrom
lewing:wasm-r2r-simd-scalar-extract

Conversation

@lewing

@lewing lewing commented Jul 21, 2026

Copy link
Copy Markdown
Member

Summary

Improve both directions of struct field handling for incoming and outgoing ABI registers:

  • extract scalar fields directly from incoming parameter-register locals
  • reconstruct an 8-byte floating-point register directly from two promoted float fields

The urgent WASM correctness fix has been split into #131237. This PR remains the broader JIT exploration for native code quality and the generalized implementation.

Changes

  • Use gtNewSimdGetElementNode for aligned scalar fields contained in SIMD parameter registers.
  • Reinterpret aligned native scalar floating-point carriers as SIMD values for direct lane extraction. The x64 double-to-second-float case emits movshdup; ARM64 emits dup.
  • Retain integer-carrier shift/narrow/bitcast handling for misaligned scalar fields and targets without the direct SIMD path.
  • Keep ARM32 on the existing fallback because scalar extraction can require TYP_LONG nodes after long decomposition.
  • Synchronize Promotion::MapsToParameterRegister with lowering eligibility.
  • Teach xarch field-list lowering to reconstruct exactly two float fields in one 8-byte floating-point ABI register with NI_X86Base_UnpackLow, producing unpcklps instead of stack stores and reloads for arguments and returns.
  • Remove the experimental induced-promotion suppression heuristic; induced accesses are again costed like normal accesses.

Code generation

For a constructed { float X; float Y; } value:

Path Before After
Return 22 bytes / PerfScore 6.25 4 bytes / PerfScore 2.00
Argument forwarding 35 bytes / PerfScore 9.75 22 bytes / PerfScore 6.75

Both paths now use one unpcklps and avoid the spill/reload sequence.

Native Linux x64 BenchmarkDotNet results (AMD EPYC 7763):

Method Stack baseline insertps prototype unpcklps
Return pair 8.286 ns 4.199 ns 2.737 ns
Construct + pass 9.402 ns 5.362 ns 4.384 ns

The HFA forwarding stress methods demonstrate the remaining architectural issue Jakob described: old promotion can extract fields and the backend then inserts them back-to-back. The insertion support removes the reconstruction spill, but eliminating the no-op extract/insert pair requires ABI-based promotion or forward substitution and is intentionally not papered over with a promotion heuristic in this PR.

SuperPMI evidence

The archived insertps run (build 1522492) directly established the backend capability's scope:

  • Linux x64 overall improved from +21,757 bytes in the extraction-only run to +2,966 bytes.
  • All 100 production contexts containing insertps improved, netting -4,643 bytes with no remaining field-list spill.
  • PMI: 50 improvements, zero regressions; crossgen2: 48 improvements and five small regressions from other extraction paths.
  • All 34 insertps-containing regressions were contrived HFA/ABI stress contexts with redundant extract/insert pairs.
  • ARM64 and Browser/WASM were unchanged, matching the intended SysV x64 ABI scope.

The full archived analysis and representative diffs are available at https://gist.github.com/lewing/59292126500746b46abd297f609cfad4. Fresh CI for the unpcklps refinement is pending.

Validation

  • Debug ARM64 JIT build and functional test
  • Debug and Checked x64 JIT builds
  • JIT/opt/Unsafe/Unsafe extraction regression tests
  • JIT/Directed/StructABI/FieldListFloatInsertion functional and x64 disassembly checks for arguments and returns
  • Existing HFA suite with the Checked x64 JIT
  • Native Linux x64 matched-JIT BenchmarkDotNet comparison
  • ISA-toggle checks (DOTNET_EnableSSE41=0, DOTNET_EnableHWIntrinsic=0)
  • JIT formatting
  • Independent multi-model reviews of extraction, exact field-list shape validation, and unpcklps lowering

Note

This PR was authored with the assistance of GitHub Copilot.

The parameter-register-to-local mapping in lowering maps a scalar field of an
unenregisterable parameter to the local created for the containing register
segment, retyping the local access to the field's scalar type. On x64/arm64 a
scalar overlaps the low bytes of the vector register, so reading the register
local at scalar width is a free, correct reinterpret.

On wasm this is invalid: a v128 and a scalar (f64/f32/...) are distinct
value-stack types with no implicit reinterpret, so 'local.get <v128>' typed as
a scalar produces invalid wasm (wasm-tools: 'expected f64, found v128'). This
surfaces when crossgen2 compiles, e.g., Vector128<T>.GetHashCode (whose
GetElementUnsafe reads a scalar lane of the vector) inlined into
GenericEqualityComparer<Vector128<double>>.GetHashCode.

Guard the mapping on wasm: skip mapping a scalar field to a SIMD register
segment so the access falls back to the (memory-homed) unenregisterable
parameter and a normal scalar load is emitted. This is the floating-point
ToScalar / SIMD->scalar var_type case noted as deferred in dotnet#130444.
Copilot AI review requested due to automatic review settings July 21, 2026 22:31
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 5 pipeline(s).
11 pipeline(s) were filtered out due to trigger conditions.
There may be pipelines that require an authorized user to comment /azp run to run.

@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @agocke
See info in area-owners.md if you want to be subscribed.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR adjusts JIT lowering for the wasm target to avoid inducing an invalid scalar read from a SIMD (v128) register-local when the accessed parameter field is a non-SIMD scalar type. The change is localized to the parameter-register-local mapping logic and is guarded to affect only wasm.

Changes:

  • Add a TARGET_WASM-guarded check in Lowering::FindInducedParameterRegisterLocals to skip mapping when the ABI segment is SIMD but the field access is scalar.
  • Preserve existing behavior for non-wasm targets and for SIMD-typed field accesses.

Comment thread src/coreclr/jit/lower.cpp Outdated
@AndyAyersMS

Copy link
Copy Markdown
Member

Is this on top of #130866? Else how do we have a V128 parameter?

I agree with Tanner, would be nice to use f64x2.extract_lane 0 here or similar to avoid having to spill and reload.

@lewing

lewing commented Jul 21, 2026

Copy link
Copy Markdown
Member Author

Yes — this is effectively on top of #130866 (it's in a wasm R2R prototype branch that has #130866 merged, which materializes `Vector128` as a wasm `v128` parameter — hence the v128 register parameter here). Agreed on `f64x2.extract_lane 0` (and the general extract/insert for the sibling paths) being the better, spill-free lowering. Let me look at doing it that way in this PR rather than the memory fallback.

Note

This comment was authored with the assistance of GitHub Copilot.

Per review feedback (thanks @tannergooding, @AndyAyersMS): rather than skipping the
parameter-register-local mapping for a scalar field of a SIMD register (which forces a spill/reload
through the memory-homed parameter), extract the lane directly from the SIMD register local via
gtNewSimdGetElementNode. This lowers to an explicit lane extract (e.g. f64x2.extract_lane 0) with no
spill. The GetElement node is lowered by the subsequent per-block lowering pass.

> [!NOTE]
> This change was authored with the assistance of GitHub Copilot.
Copilot AI review requested due to automatic review settings July 21, 2026 23:36

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 1 out of 1 changed files in this pull request and generated 2 comments.

Comment thread src/coreclr/jit/lower.cpp Outdated
Comment thread src/coreclr/jit/lower.cpp Outdated
lewing added a commit to lewing/runtime that referenced this pull request Jul 22, 2026
…tnet#131174)

Extract the scalar lane directly from the SIMD register local instead of a memory spill/reload.
lewing added a commit to lewing/runtime that referenced this pull request Jul 22, 2026
…tnet#131174)

Extract the scalar lane directly from the SIMD register local instead of a memory spill/reload.
lewing added 2 commits July 21, 2026 20:10
Use direct SIMD lane extraction and scalar bit manipulation for fields carried in floating-point parameter registers. Keep physical promotion eligibility synchronized and retain the ARM32 fallback.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 22214765-2cd2-4951-b778-8793730f2bc1
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 22214765-2cd2-4951-b778-8793730f2bc1
Copilot AI review requested due to automatic review settings July 22, 2026 01:24
@lewing lewing changed the title JIT: Fix wasm scalar-field extraction from a SIMD-register parameter JIT: Improve parameter register field extraction Jul 22, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.

Copilot AI review requested due to automatic review settings July 22, 2026 01:33
@lewing

lewing commented Jul 22, 2026

Copy link
Copy Markdown
Member Author

Latest round before narrowing to fp

Latest CI is net strongly positive:

Linux x64: −65,271 bytes
Linux ARM64: −66,308 bytes
WASM: −178 bytes
HFA stress regressions are gone; CoreCLR x64 is now −23,396 bytes
However, the guard overreached into integer-register parameters, regressing AsyncHelpers.Await 73→106 bytes, Range.Equals 27→38, and NativeAOT by +434 bytes.

I narrowed the guard locally to floating/SIMD registers only. HFA02/HFA03 remain exactly at baseline, integer paths are unaffected, and x64 Checked/ARM64 tests pass.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.

Comment thread src/coreclr/jit/promotion.cpp Outdated
Comment thread src/coreclr/jit/promotion.cpp Outdated
lewing added 2 commits July 22, 2026 17:27
Drop the induced-access suppression that only helped contrived HFA forwarding cases and restore direct promotion costing.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 22214765-2cd2-4951-b778-8793730f2bc1
Use a SIMD lane insertion for two float fields carried in one floating-point ABI register, avoiding spill/reload sequences for arguments and returns.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 22214765-2cd2-4951-b778-8793730f2bc1
Copilot AI review requested due to automatic review settings July 22, 2026 22:35
@lewing lewing changed the title JIT: Improve parameter register field extraction JIT: Improve parameter register field extraction and reconstruction Jul 22, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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/opt/Unsafe/Unsafe.cs:80

  • The construction of doubleValue assumes little-endian layout (low 32 bits at byte offset 0, high 32 bits at byte offset 4). On big-endian targets this swaps the two floats, so UnsafeAsSecondFloat_Double(doubleValue) will not return 2.5f and the test becomes endian-dependent. Build doubleValue with a BitConverter.IsLittleEndian swap so byte offset 4 always contains the 2.5f bits.
            double doubleValue = BitConverter.Int64BitsToDouble(
                ((long)BitConverter.SingleToInt32Bits(2.5f) << 32) | (uint)BitConverter.SingleToInt32Bits(1.25f));
            if (UnsafeAsSecondFloat_Double(doubleValue) != 2.5f)
                return 0;

            float expectedMisaligned = BitConverter.Int32BitsToSingle((int)(BitConverter.DoubleToInt64Bits(doubleValue) >> 16));
            if (UnsafeAsMisalignedFloat_Double(doubleValue) != expectedMisaligned)

lewing added a commit that referenced this pull request Jul 23, 2026
## Summary

Fixes a WASM codegen bug where extracting a scalar field from a
SIMD-register parameter leaks a `v128` where a scalar (`f64`/`f32`/...)
is expected, producing invalid WASM:

```
type mismatch: expected f64, found v128
```

`Lowering::FindInducedParameterRegisterLocals` maps a scalar field of an
unenregisterable parameter to the local created for the containing
register segment. Native targets can read the overlapping low scalar
value by retyping that local access, but WASM has distinct `v128` and
scalar value-stack types and cannot reinterpret them implicitly.

This is the floating-point `ToScalar` / SIMD-to-scalar `var_type`
transition at ABI boundaries noted as deferred in #130444.

## Fix

When the mapped incoming register local is SIMD and the field is scalar,
use `gtNewSimdGetElementNode` under `TARGET_WASM`. For lane zero this
becomes `ToScalar`, which WASM lowers to the appropriate `extract_lane`
operation rather than retyping a `v128` local or spilling through
memory.

The byte offset is required to be scalar-lane aligned before deriving
the lane index. Every non-WASM target retains the existing path
unchanged.

## Validation

- `./build.sh clr.jit -rc Debug`
- `PATH="/opt/homebrew/bin:$PATH" ./build.sh -os browser -c Debug
-subset clr+libs`
  - Confirmed `lower.cpp` compiled into the WASM JIT.
- `python3 src/coreclr/scripts/jitformat.py -r . -o osx -a arm64`

There is currently no WASM R2R validation leg in CI suitable for a
focused regression test. The existing generalized extraction tests from
#131174 were intentionally not included in this narrowly scoped WASM
correctness fix.

> [!NOTE]
> This PR was authored with the assistance of GitHub Copilot.

Copilot-Session: e48939ab-dc73-4630-a8ca-72c3b926133a
Represent two scalar floats as an xarch unpack-low operation, producing smaller and faster code than lane insertion while preserving the exact ABI layout.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 22214765-2cd2-4951-b778-8793730f2bc1
Copilot AI review requested due to automatic review settings July 23, 2026 03:21

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.

Resolve the lower.cpp conflict by retaining the generalized parameter register extraction path, which subsumes the landed WASM-specific fix, while preserving the lane-alignment assertion from main.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 22214765-2cd2-4951-b778-8793730f2bc1
Copilot AI review requested due to automatic review settings July 23, 2026 04:49

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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/opt/Unsafe/Unsafe.cs:89

  • The new bit-pattern construction/expectations for doubleValue and expectedMisaligned assume little-endian layout (via << 32 and >> 16). This will miscompute expected values on big-endian targets, causing false failures. It also makes the misaligned expectation dependent on integer shifting rather than the actual in-memory byte layout being tested.
            double doubleValue = BitConverter.Int64BitsToDouble(
                ((long)BitConverter.SingleToInt32Bits(2.5f) << 32) | (uint)BitConverter.SingleToInt32Bits(1.25f));
            if (UnsafeAsSecondFloat_Double(doubleValue) != 2.5f)
                return 0;

            float expectedMisaligned = BitConverter.Int32BitsToSingle((int)(BitConverter.DoubleToInt64Bits(doubleValue) >> 16));
            if (UnsafeAsMisalignedFloat_Double(doubleValue) != expectedMisaligned)
                return 0;

            Vector128<float> floatVector = Vector128.Create(1.0f, 2.0f, 3.0f, 4.0f);
            if (UnsafeAsInt_Vector128(floatVector) != BitConverter.SingleToInt32Bits(3.0f))
                return 0;

            Vector128<double> doubleVector = Vector128.Create(5.0, 6.0);
            if (UnsafeAsSecondDouble_Vector128(doubleVector) != 6.0)
                return 0;

Use the lowering helper that removes scalar operands when CreateScalarUnsafe folds to a vector constant, preventing unmarked unused LIR values. Cover constant float-pair arguments in the directed StructABI test.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 22214765-2cd2-4951-b778-8793730f2bc1
Copilot AI review requested due to automatic review settings July 23, 2026 08:20

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.

@lewing

lewing commented Jul 24, 2026

Copy link
Copy Markdown
Member Author

Build 1523081 measures the final candidate after merging the WASM extraction fix from main, replacing
insertps with unpcklps, and fixing the folded-constant LIR lifetime bug:

  • The Checked-JIT Found an unmarked unused value assertion is gone.
  • Linux x64 replayed 2,796,950 contexts with zero missed contexts in both base and diff.
  • Linux x64 code size is -3,941 bytes overall.
  • Production-oriented x64 collections are -6,234 bytes:
    • benchmarks PGO: -41 bytes, 2 improvements and zero regressions
    • libraries crossgen2: -735 bytes, 48 improvements and five regressions
    • libraries PMI: -854 bytes, 54 improvements and zero regressions
    • tiered library tests: -66 bytes, four improvements and zero regressions
    • non-tiered library tests: -4,473 bytes, 74 improvements, one regression, and one unchanged context
    • real-world: -65 bytes, one improvement and one regression
  • Across those production-oriented collections, 184 contexts improved and seven regressed.
  • CoreCLR tests are +2,293 bytes, with the regressions dominated by the existing HFA/ABI stress shapes.
  • The previously asserting Runtime_31615 methods now replay successfully and improve. TestV3B is
    -25 bytes and reduces JIT instructions by 5.99%.

Representative production improvements in the final run include:

  • ComplexFloat.op_Addition: -24 bytes, from 53 to 29 bytes; PerfScore 17.50 to 10.50
  • RectangleF.FromLTRB: -36 bytes
  • MathF.SinCos / Single.SinCos: -19 bytes
  • ShapePileBenchmark.Initialize: -33 bytes
  • RagdollTubeBenchmark.AddRagdoll: -32 bytes

The final ComplexFloat.op_Addition sequence has no stack frame and reconstructs the return pair directly:

- sub       rsp, 24
- vmovsd    qword ptr [rsp+0x10], xmm0
- vmovsd    qword ptr [rsp+0x08], xmm1
+ vmovaps   xmm2, xmm0
+ vmovshdup xmm0, xmm0
+ vmovaps   xmm3, xmm1
+ vmovshdup xmm1, xmm1
+ vaddss    xmm2, xmm2, xmm3
  vaddss    xmm0, xmm0, xmm1
- vmovss    dword ptr [rsp], xmm0
- vmovss    dword ptr [rsp+0x04], xmm1
- vmovsd    xmm0, qword ptr [rsp]
+ vunpcklps xmm0, xmm2, xmm0

Code size falls from 53 to 29 bytes, instruction count from 11 to 8, and PerfScore from 17.50 to 10.50.
Compared with the earlier insertps prototype, the packing instruction is three bytes smaller while retaining
the same modeled PerfScore.

The complete throughput result is small but exposes two distinct costs:

  • CoreCLR tests round to +0.01% JIT instructions.
  • Linux x64 production collections round to +0.00%.
  • The large per-method regressions, up to +83.86%, are the same Average*_HFA02 stress methods that extract
    fields from an incoming carrier and reconstruct the carrier before forwarding it.
  • Linux and Windows ARM64 crossgen2 and PMI round to +0.01%. Their most affected contexts use about 18.3%
    more JIT instructions, while PhysicallyPromotedFields rises 5.32% / 5.26% in crossgen2 and 1.74% / 1.70%
    in PMI.
  • Windows x64 and x86 also round to +0.01% despite having no asm diffs. This is consistent with a small
    compiler-throughput cost from the additional parameter-promotion eligibility analysis, independent of
    generated code.
  • Browser WASM and Linux ARM have no significant aggregate throughput differences.
  • The large HFA values are compiler-work regressions in contrived stress contexts; none of these numbers
    measure runtime throughput.

Cross-architecture asm totals are favorable overall, with one new ARM32 caveat:

Target Code-size delta
Browser WASM -178 bytes
Linux ARM +396 bytes
Linux ARM64 -2,704 bytes
Linux x64 -3,941 bytes
Windows ARM64 -2,416 bytes
macOS ARM64 No diffs
Windows x64 No diffs
Windows x86 No diffs

The ARM64 improvements mostly remove spills while extracting incoming SIMD/register fields. A small number of
Vector64 and large Vector512 cases regress, but both ARM64 aggregates remain strongly positive. Windows x64 has
no diffs because its ABI does not pass this 8-byte struct shape in an FP register carrier.

Linux ARM's +396 bytes are concentrated in a few hard-float Vector3 cases:

  • RayTracer.GetNaturalColor: +50 bytes in the benchmark corpus and +270 bytes in CoreCLR tests
  • nativeCall_PInvoke_Vector3Arg: +48 bytes in each of two CoreCLR test contexts
  • Average19_HFA02: +4 bytes
  • Runtime_128373.ProblematicBody: -22 bytes in each of two contexts
  • MathHelper.Equal(Plane, Plane): -24 bytes

The ARM32 guard still rejects extraction forms that would require late TYP_LONG nodes. These changes come
from legal, aligned scalar-float mappings: physical promotion splits hard-float HFA parameters into individual
field locals, but long lifetimes and calls can make LSRA spill those fields separately. That exposes a promotion
cost-model limitation rather than the unsupported late-decomposition path.

Representative diffs

All examples in this section are from final-candidate build 1523081.

Production tuple return: System.Single.SinCos

libraries_tests_no_tiered_compilation, context 461027.dasm:

- sub      rsp, 24
  call     [<unknown method>]
- vmovss   xmm0, dword ptr [rsp+0x14]
- vmovss   xmm1, dword ptr [rsp+0x10]
+ vmovss   xmm0, dword ptr [rsp+0x04]
+ vmovss   xmm1, dword ptr [rsp]
+ vunpcklps xmm0, xmm0, xmm1
- add      rsp, 24
+ add      rsp, 8

The field-list spill is removed. Code size improves from 54 to 35 bytes, instruction count from 11 to 9,
and PerfScore from 11.50 to 9.25.

Production two-register return: RectangleF.FromLTRB

libraries.crossgen2, context 226292.dasm:

- sub      rsp, 24
- vmovss   dword ptr [rsp+0x08], xmm0
- vmovss   dword ptr [rsp+0x0C], xmm1
- vsubss   xmm0, xmm2, xmm0
- vmovss   dword ptr [rsp+0x10], xmm0
- vsubss   xmm0, xmm3, xmm1
- vmovss   dword ptr [rsp+0x14], xmm0
- vmovsd   xmm0, qword ptr [rsp+0x08]
- vmovsd   xmm1, qword ptr [rsp+0x10]
+ vsubss   xmm2, xmm2, xmm0
+ vsubss   xmm3, xmm3, xmm1
+ vunpcklps xmm0, xmm0, xmm1
+ vunpcklps xmm1, xmm2, xmm3

The stack frame and both reconstruction spills disappear. Code size improves from 53 to 17 bytes,
instruction count from 11 to 5, and PerfScore from 15.50 to 9.00.

Benchmark extraction plus reconstruction: ComplexFloat.op_Addition

benchmarks.run_pgo, context 86696.dasm:

- sub      rsp, 24
- vmovsd   qword ptr [rsp+0x10], xmm0
- vmovsd   qword ptr [rsp+0x08], xmm1
+ vmovaps  xmm2, xmm0
+ vmovshdup xmm0, xmm0
+ vmovaps  xmm3, xmm1
+ vmovshdup xmm1, xmm1
+ vaddss   xmm2, xmm2, xmm3
  vaddss   xmm0, xmm0, xmm1
- vmovss   dword ptr [rsp], xmm0
- vmovss   dword ptr [rsp+0x04], xmm1
- vmovsd   xmm0, qword ptr [rsp]
+ vunpcklps xmm0, xmm2, xmm0

The method becomes frameless. Code size improves from 53 to 29 bytes, instruction count from 11 to 8,
and PerfScore from 17.50 to 10.50.

Constant-folding regression: Runtime_31615.TestV3B

coreclr_tests, context 248085.dasm, now replays successfully after the LIR lifetime fix:

  • Code size improves by 25 bytes, from 83 to 58 bytes.
  • JIT instructions improve by 5.99%, from 223,949 to 210,530.
  • The earlier Checked-JIT Found an unmarked unused value assertion is absent.

ARM32 regression: nativeCall_PInvoke_Vector3Arg

coreclr_tests, context 130326.dasm, shows the ARM32 promotion cost-model limitation. The baseline homes the
six incoming hard-float registers once. The changed JIT creates six promoted scalar locals, spills the incoming
registers, and then copies them to a second stack area before the P/Invoke:

- ; Lcl frame size = 84
+ ; Lcl frame size = 108
- sub     sp, 84
+ sub     sp, 108
- vstr    s0, [r9+0x40]
- vstr    s1, [r9+0x44]
- vstr    s2, [r9+0x48]
- vstr    s3, [r9+0x34]
- vstr    s4, [r9+0x38]
- vstr    s5, [r9+0x3C]
+ vstr    s0, [r9+0x18]
+ vstr    s1, [r9+0x14]
+ vstr    s2, [r9+0x10]
+ vstr    s3, [r9+0x0C]
+ vstr    s4, [r9+0x08]
+ vstr    s5, [r9+0x04]
+ vldr    s0, [r9+0x18]
+ vstr    s0, [r9+0x30]
+ vldr    s1, [r9+0x14]
+ vstr    s1, [r9+0x2C]
+ vldr    s2, [r9+0x10]
+ vstr    s2, [r9+0x28]
+ vldr    s3, [r9+0x0C]
+ vstr    s3, [r9+0x24]
+ vldr    s4, [r9+0x08]
+ vstr    s4, [r9+0x20]
+ vldr    s5, [r9+0x04]
+ vstr    s5, [r9+0x1C]

Code size increases by 48 bytes (+10.30%). The mapping itself is legal on ARM32; the loss comes from promoted
field lifetimes crossing calls and spilling, which the current promotion profitability estimate does not model.

Remaining limitation: HFA forwarding

coreclr_tests, Average8_HFA02, context 252676.dasm, is a representative regression. The baseline forwards
eight incoming two-float carriers directly. The changed JIT extracts both lanes from every carrier and then
reconstructs the same eight carriers immediately before the call:

- mov       rax, qword ptr [(reloc)]
  vmovsd    xmm0, qword ptr [rbp-0x68]
- vmovsd    xmm1, qword ptr [rbp-0x70]
- vmovsd    xmm2, qword ptr [rbp-0x78]
- vmovsd    xmm3, qword ptr [rbp-0x80]
- vmovsd    xmm4, qword ptr [rbp-0x88]
- vmovsd    xmm5, qword ptr [rbp-0x90]
- vmovsd    xmm6, qword ptr [rbp-0x98]
- vmovsd    xmm7, qword ptr [rbp-0xA0]
+ vmovaps   xmm1, xmm0
+ vmovshdup xmm0, xmm0
+ vmovsd    xmm2, qword ptr [rbp-0x70]
+ vmovaps   xmm3, xmm2
+ vmovshdup xmm2, xmm2
+ vmovsd    xmm4, qword ptr [rbp-0x78]
+ vmovaps   xmm5, xmm4
+ vmovshdup xmm4, xmm4
+ vmovsd    xmm6, qword ptr [rbp-0x80]
+ vmovaps   xmm7, xmm6
+ vmovshdup xmm6, xmm6
+ vmovsd    xmm8, qword ptr [rbp-0x88]
+ vmovaps   xmm9, xmm8
+ vmovshdup xmm8, xmm8
+ vmovsd    xmm10, qword ptr [rbp-0x90]
+ vmovaps   xmm11, xmm10
+ vmovshdup xmm10, xmm10
+ vmovsd    xmm12, qword ptr [rbp-0x98]
+ vmovaps   xmm13, xmm12
+ vmovshdup xmm12, xmm12
+ vmovsd    xmm14, qword ptr [rbp-0xA0]
+ vmovaps   xmm15, xmm14
+ vmovshdup xmm14, xmm14
+ mov       rax, qword ptr [(reloc)]
+ vunpcklps xmm0, xmm1, xmm0
+ vunpcklps xmm1, xmm3, xmm2
+ vunpcklps xmm2, xmm5, xmm4
+ vunpcklps xmm3, xmm7, xmm6
+ vunpcklps xmm4, xmm9, xmm8
+ vunpcklps xmm5, xmm11, xmm10
+ vunpcklps xmm6, xmm13, xmm12
+ vunpcklps xmm7, xmm15, xmm14
  call      rax

Code size increases from 226 to 334 bytes (+108, +47.79%), instruction count from 52 to 76, PerfScore from
57.75 to 75.75, and JIT instructions by 83.84%. The final form still improves on the extraction-only stage
(496 bytes) and the earlier insertps prototype (346 bytes), and it eliminates reconstruction spills. The
remaining cost is the redundant extract/unpack pair; eliminating it requires ABI-based promotion or forward
substitution.

Final representation

For the exact two-float pair, the final xarch implementation uses:

unpcklps xmm0, xmm1

This is three bytes smaller per packing site than the earlier insertps prototype and is faster in the
native x64 benchmark.

What the local matched-JIT comparison proves

The same Release/Checked runtime layout was run with JITs built from:

  • baseline 674ceb560af (promotion heuristic removed, before insertion)
  • changed 202dda1796d (direct float-pair insertion)

Code generation for struct { float X; float Y; }:

Shape Baseline insertps prototype unpcklps alternative
Return 22 bytes, PerfScore 6.25 7 bytes, PerfScore 2.00 4 bytes, PerfScore 2.00
Construct + pass 35 bytes, PerfScore 9.75 25 bytes, PerfScore 6.75 22 bytes, PerfScore 6.75

The functional and disassembly tests pass for arguments and returns. ARM64 is behaviorally unchanged because its HFA ABI uses two separate scalar FP register segments.

BenchmarkDotNet 0.16 preview, local x64 under Rosetta:

  • Short run (insertps): return ratio 0.58; pass ratio 0.84.
  • Short run (unpcklps): return ratio 0.78; pass ratio 0.75.
  • A medium run was bimodal under Rosetta and is not reliable enough to rank return performance.

Native Linux x64 Codespace benchmark (AMD EPYC 7763, x86-64-v3, MediumRun):

Method Baseline insertps unpcklps
Return pair 8.286 ns 4.199 ns (0.51x) 2.737 ns (0.33x)
Construct + pass 9.402 ns 5.362 ns (0.57x) 4.384 ns (0.47x)

The return distributions had some multimodality, but their medians preserve the same ordering:
7.559 ns baseline, 4.128 ns insertps, and 2.742 ns unpcklps. The argument baseline was stable
(9.402 ns, 0.035 ns standard deviation), and both direct forms remained substantially faster.

The native reports are archived under native-x64-benchmark/.

The benchmark result is strong enough to distinguish the designs:

  • Both direct register-packing forms substantially outperform the stack baseline.
  • unpcklps is faster than insertps for both measured shapes and has the smaller encoding.
  • This supports the backend capability itself while favoring a different IR/instruction selection than the first prototype.

Reconstruction-site prevalence

Mining the stage-2 Linux x64 raw assembly corpus found 72 changed methods whose diff still contained a
Spilled local for field list:

  • 36 CoreCLR stress methods, dominated by HFA forwarding.
  • 14 library PMI methods.
  • 12 library crossgen2 methods.
  • 9 non-tiered library-test methods.
  • 1 benchmark method.

The production set includes ComplexFloat, PointF, SizeF, and RectangleF methods. Most already net-improve
from extraction, but the remaining spill local shows that direct reconstruction is a recurring backend shape,
not only an HFA stress artifact. The mined records are in s2-field-list-spills.json.

The build-1522492 insertion footprint is in build-1522492-insertps-methods.json.

ISA and representation

  • The runtime's x64 and default R2R baseline is x86-64-v2, which includes SSE4.1. insertps is therefore legal baseline code.
  • DOTNET_EnableSSE41=0 and DOTNET_EnableHWIntrinsic=0 still emitted insertps; these switches control exposed managed intrinsic support, not removal of x86-64-v2 baseline instructions from internal JIT codegen.
  • unpcklps expresses the exact packing operation more directly, uses an older baseline instruction, and saves three code bytes per packing site.
  • Native x64 benchmarking confirms the xarch-only unpcklps representation is stronger than insertps.

Remaining architectural limitation

In HFA forwarding stress cases, old promotion extracts fields from an incoming carrier and the backend then inserts them back. Direct insertion removes the reconstruction spill, but it cannot remove the redundant extract/insert pair. Eliminating that pair requires ABI-based promotion or forward substitution, as Jakob described.

Full breakdown at https://gist.github.com/lewing/59292126500746b46abd297f609cfad4

@lewing

lewing commented Jul 24, 2026

Copy link
Copy Markdown
Member Author

Marking ready for review but there is no urgency on my side, the wasm blocker has been resolved separately.

@lewing
lewing marked this pull request as ready for review July 24, 2026 18:05
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 5 pipeline(s).
11 pipeline(s) were filtered out due to trigger conditions.
There may be pipelines that require an authorized user to comment /azp run to run.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants