Skip to content

JIT: Remove transparent scalar/vector conversion HWINTRINSIC nodes during lowering#130444

Merged
tannergooding merged 17 commits into
dotnet:mainfrom
tannergooding:tannergooding-jit-free-scalar-conversion-hwintrinsics
Jul 14, 2026
Merged

JIT: Remove transparent scalar/vector conversion HWINTRINSIC nodes during lowering#130444
tannergooding merged 17 commits into
dotnet:mainfrom
tannergooding:tannergooding-jit-free-scalar-conversion-hwintrinsics

Conversation

@tannergooding

@tannergooding tannergooding commented Jul 10, 2026

Copy link
Copy Markdown
Member

Summary

Today, hwintrinsic lowering in the JIT specially marks CreateScalarUnsafe as contained on xarch so that no register is allocated and the value is truly "free". This is a fragile design: it produces 2-deep containment, requires special-case checks throughout LSRA/codegen, and doesn't generalize to the many other conceptually-identical "transparent" nodes (ToScalar for floating-point, GetLower/GetLower128, ToVector256Unsafe/ToVector512Unsafe, etc.).

Rather than extend that design to the other node types, this treats these nodes as effectively HIR-only and removes them during lowering, updating codegen to handle the resulting patterns directly. The nodes are not retyped (retyping would change spill size / memory-access size for certain containment cases); instead the consuming intrinsic consumes the underlying value directly, which already lives in the correct register and is implicitly consumable at full width.

As expected, the primary impact is on asserts that checked operands were an exact size/shape (e.g. requiring both operands of a TYP_SIMD32-producing intrinsic to themselves be TYP_SIMD32). Containment checks already validate that a value is at least as large as the memory access requires, so removing the transparent node does not weaken those guarantees.

Changes

Each commit is a self-contained step:

  • Remove floating-point CreateScalarUnsafe during xarch lowering, and stop the shared builders from manufacturing it in the first place.
  • Remove the intermediate CreateScalar/CreateScalarUnsafe when containing it during xarch lowering.
  • Remove the now-dead contained-CreateScalarUnsafe support across LSRA/codegen and the GenTree containability plumbing.
  • Remove ToVector256Unsafe/ToVector512Unsafe during xarch lowering.
  • Remove GetLower/GetLower128 during xarch lowering, and GetLower during arm64 lowering.
  • Remove ToVector128Unsafe and floating-point CreateScalarUnsafe during arm64 lowering.
  • Make the gtNewSimdBinOpNode / GetHWIntrinsicIdForBinOp operand-shape asserts HIR-only (gated on fgNodeThreading == NodeThreading::LIR), so a reconstructed binop in LIR may consume a size-changing SIMD reinterpret operand left behind by an elided transparent node. This keeps the aggressive elision for all hwintrinsic consumers (e.g. ConditionalSelect/Dot reconstruction) rather than falling back to a codegen-costing gate.

Deferred (need additional design work, not included here): the AsVector* family (sub-16-byte SIMD8/SIMD12 memory-access-size hazard) and floating-point ToScalar (SIMD→scalar var_type flip at ABI boundaries).

Validation

SPMI asmdiffs (all-up PR vs merge-base, Windows, Checked):

Target Contexts Overall Detail
x64 2,596,354 −23,238 bytes −21,441 FullOpts / −1,797 MinOpts; 0 regressions (all collections improved)
arm64 2,781,392 −15,796 bytes 1,570 improvements / 9 regressions (+68 bytes total, largest cluster +40)
  • Zero replay failures and zero JIT asserts across ~2.6M (x64) and ~2.8M (arm64) contexts.
  • Hot spots are the expected SIMD-heavy consumers: IndexOfAnyAsciiSearcher, Ascii.Equals/IsValidCore, ProbabilisticMap, StringSearchValues, plus Vector128/Numerics. The arm64 regressions are a few bytes of alignment/ordering noise from removing nodes and are dwarfed by the improvements.

JIT tests (priority-1, Checked, this JIT):

  • JIT/HardwareIntrinsics/General merged _r: 2551/2551 passed
  • JIT/HardwareIntrinsics/General merged _ro: 2551/2551 passed
  • JIT/SIMD: 114/114 real tests passed (the 2 Vector3Interop cases fail only due to a missing native interop DLL from a local -SkipNative build, unrelated to codegen)

Note

This pull request was authored with the assistance of GitHub Copilot.

tannergooding and others added 8 commits July 9, 2026 07:19
Floating-point CreateScalarUnsafe is a pure reinterpret: the scalar value
already resides in the lowest element of a SIMD register with the upper
elements explicitly undefined. Rather than marking such nodes contained
(which produces two-deep containment, genOperandDesc look-throughs, and
special-cased size asserts), remove the node in LIR lowering and rewire its
user to consume the underlying scalar directly.

The scalar is intentionally left at its natural (scalar) type; retyping it
would corrupt spill and memory-access sizes for other consumers. Correctness
is preserved because a register consumer sees the full SIMD register and any
memory-fold consumer is gated by the existing width-aware containment logic.

Integral scalars (and decomposed longs) still require a real movd/movq and so
keep the CreateScalarUnsafe node and fall through to standard containment.

The scalar FMA negation loop is restructured accordingly: a removed FP
CreateScalarUnsafe leaves a bare GT_NEG operand which is folded into the
negated FMA variant. A codegen store size assert is relaxed to allow a narrow
FP scalar feeding a wider SIMD local.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Fold the floating-point CreateScalarUnsafe elision into the shared
InsertNewSimdCreateScalarUnsafeNode: for a non-constant floating-point
scalar it now returns the bare operand (the scalar already occupies the
lowest element of a SIMD register with undefined upper elements, matching
the Unsafe contract) instead of manufacturing a node that lowering would
immediately remove. Integral and constant scalars still create a real
node. LowerNode is folded into the shared helper so callers no longer
lower the result themselves.

This removes the xarch-only InsertNewSimdCreateScalarUnsafeNodeIfNeeded
helper and applies the elision on arm64 as well (guarded to xarch/arm64;
wasm is excluded because its SIMD model cannot hold a bare scalar in a
v128 register). The user-level removal case is also tightened to always
remove the node, transferring the unused marker to the operand when there
is no use.

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

CreateScalar/CreateScalarUnsafe are not themselves memory operations. They
were only marked contained so codegen could look through them to the
underlying scalar, producing a second level of containment plus special
handling in LSRA and codegen.

Instead of containing such a node when it is consumed as a low-lane scalar by
a parent hardware intrinsic (exactly the set IsContainableHWIntrinsicOp already
selects), remove it in lowering and let the parent consume the scalar directly.
The scalar keeps whatever contained/regOptional state it was already given, so
codegen is unchanged (the memory operand or register is folded straight into
the parent). Decomposed longs still require a real movd/movq and keep the node.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
After lowering was changed to remove CreateScalar/CreateScalarUnsafe when a
parent consumes it as a low-lane scalar (rather than containing the node), no
CreateScalar/CreateScalarUnsafe is ever marked contained. Remove the now-dead
support for a contained CreateScalar/Unsafe:

- gentree.cpp: drop the CreateScalar/Unsafe case from isContainableHWIntrinsic.
  canBeContained() now returns false for these, so the DEBUG isContained()
  assert actively verifies none are ever contained on any target.
- instr.cpp: remove the genOperandDesc look-through that treated a contained
  CreateScalar/Unsafe as a load from memory.
- lsraxarch.cpp: remove SkipContainedUnaryOp (an identity function once no such
  node is contained) and read the operands directly.
- Refresh stale comments in instr.cpp and hwintrinsiccodegenxarch.cpp that
  referenced the removed look-through.

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

Removing a floating-point CreateScalarUnsafe during lowering can leave a bare
scalar operand feeding a SIMD binary op, such as a bitwise combine that is
formed during lowering. The scalar occupies the low element of a SIMD register
and is consumed at full register width, which matches the Unsafe contract.

Relax the operand-shape asserts in GetHWIntrinsicIdForBinOp to permit such a
floating scalar in addition to a full SIMD operand.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
These intrinsics widen a narrower vector into a wider one where the upper bits
are undefined. At the register level that is a no-op: the value already occupies
the low bits of the wider register. Remove the node during lowering and let the
consumer read the source operand directly, avoiding a register-to-register copy
when the source is still live and keeping the node out of LIR.

The consumer reads the operand from a register at its own wider size with the
upper bits undefined, which is exactly the contract of these nodes. Containment
performs its own size check, so the operand can never be widened into an
undersized contained memory operand. Relax the genCodeForStoreLclVar size assert
to allow a narrower SIMD source feeding a wider SIMD local.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
GetLower and GetLower128 narrow a wider vector by reading only its low bits,
which at the register level is a no-op: the value already occupies the low bits
of a register. Remove the node during lowering and let the consumer read the
source operand directly at its own (narrower) size, which avoids a
register-to-register copy when the source is still live and keeps the node out
of LIR.

Containment performs its own size check, and the source of these nodes is always
a full-width vector whose memory is fully allocated, so reading it at the
consumer's size is always in bounds. Extend the genCodeForStoreLclVar size assert
to also allow a wider SIMD source feeding a narrower SIMD local.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Mirror the xarch GetLower removal on arm64. GetLower narrows a Vector128 to a
Vector64 by reading its low 64 bits, which at the register level is free because
a Q register and its low D register alias the same physical register. Remove the
node during lowering so the consumer reads the source directly at its own size.

Release codegen is correct by construction: consumers size their operands from
their own node and all SIMD values share the arm64 V register class, so no
register-class mismatch or size assumption is broken. This change is code-reviewed
only; the arm64 cross-compilation toolchain is not available in the authoring
environment, so it relies on checked arm64 CI for DEBUG-assert validation.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings July 10, 2026 00:01
@github-actions github-actions Bot added the area-CodeGen-coreclr CLR JIT compiler in src/coreclr/src/jit and related components such as SuperPMI label Jul 10, 2026
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @JulieLeeMSFT, @jakobbotsch
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 updates CoreCLR JIT lowering/codegen to treat several “transparent” scalar/vector conversion intrinsics as HIR-only by eliminating them during lowering, and adjusting containment / asserts accordingly. The goal is to remove fragile 2-deep containment patterns and reduce special-case handling in LSRA/codegen while preserving operand sizing guarantees via existing containment checks.

Changes:

  • Remove floating-point Vector.CreateScalarUnsafe nodes (and other transparent widen/narrow nodes like ToVector*Unsafe, GetLower*) during lowering and have consumers directly use the underlying operand.
  • Centralize xarch containment handling for CreateScalar wrappers via ContainHWIntrinsicOperand, avoiding 2-deep containment and associated LSRA/codegen special-casing.
  • Relax/adjust debug assertions and operand handling in codegen/IR helpers to accept scalar-typed operands that physically occupy SIMD registers after lowering.
Show a summary per file
File Description
src/coreclr/jit/lsraxarch.cpp Removes LSRA “skip contained unary” helper now that transparent wrappers are eliminated during lowering.
src/coreclr/jit/lowerxarch.cpp Removes transparent HWIntrinsic nodes in LIR (CreateScalarUnsafe / ToVectorUnsafe / GetLower) and introduces ContainHWIntrinsicOperand to avoid 2-deep containment.
src/coreclr/jit/lowerwasm.cpp Stops manually lowering newly inserted CreateScalarUnsafe nodes (now lowered in the shared helper).
src/coreclr/jit/lowerarmarch.cpp Removes Vector.GetLower during lowering on arm64 and stops manually lowering inserted CreateScalarUnsafe nodes.
src/coreclr/jit/lower.h Declares the new xarch-only ContainHWIntrinsicOperand helper.
src/coreclr/jit/lower.cpp Updates InsertNewSimdCreateScalarUnsafeNode to elide floating-point reinterprets on xarch/arm64 and to lower created nodes internally.
src/coreclr/jit/instr.cpp Removes operand-desc special-casing for contained CreateScalar nodes and updates contained broadcast commentary/behavior accordingly.
src/coreclr/jit/hwintrinsiccodegenxarch.cpp Updates ToScalar/AsVector* comment to match new lowering (no CreateScalar lookthrough in genOperandDesc).
src/coreclr/jit/gentree.cpp Removes CreateScalar containability hook and relaxes DEBUG operand-shape asserts to allow scalar operands after lowering.
src/coreclr/jit/codegenxarch.cpp Relaxes DEBUG store-size assert to account for scalar/width-mismatched SIMD sources after transparent-node elimination.

Copilot's findings

  • Files reviewed: 10/10 changed files
  • Comments generated: 0

tannergooding and others added 5 commits July 9, 2026 22:40
The FP CreateScalarUnsafe, ToVector256Unsafe/ToVector512Unsafe, and
GetLower/GetLower128 removals rewired every use to the underlying operand,
including stores, returns, and call arguments. For those non-hwintrinsic
consumers the value is materialized at the node's own type and size via the
ABI, so replacing the node with a wider or narrower operand corrupts the copy
size. This produced oversized reads (e.g. a 32-byte vmovups over a 16-byte
Vector128 stack slot) that could read out of bounds and, on the SysV ABI where
vectors larger than 16 bytes are passed through memory, crash with a SIGSEGV
during the mis-sized argument copy.

Gate each removal on the consumer being a GenTreeHWIntrinsic. Such a consumer
reads the operand from a register at the intrinsic's own size (the widening
cases rely on the upper bits being undefined, which matches the Unsafe
contract) or as a contained memory operand whose size is independently
validated by containment. Non-hwintrinsic consumers keep the node so the
reinterpret is materialized at the correct size.

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

Removal of transparent SIMD reinterprets is now gated on the use being a
hwintrinsic, so a store consumer always keeps the node and its op1 matches
the store's target type/size. The relaxed genCodeForStoreLclVar assert is
therefore dead and is tightened back to the strict form. TryGetUse only
returns true with a non-null user, so the use.User() null checks at the
three lowering sites are redundant and removed.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Drop the isValidBinOpOperand lambda in favor of inlining the
'op->TypeIs(simdType) || varTypeIsFloating(op)' check directly at each
assert site.

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

The assert assumed op3 could never share the target register. That held while
the multi-element Vector.Create Insert-chain always fed a distinct CreateScalarUnsafe
def as op1, but once a floating-point CreateScalarUnsafe is elided into a bare scalar
the same enregistered value can be used as both op1 and op3 (e.g. Vector.Create(x, x)).

op3 is delay-free, so a distinct op3 interval can never land on the def register;
targetReg == op3Reg is only reachable when op3 aliases op1, which also forces
targetReg == op1Reg. In that case the leading mov is skipped, so op3 is preserved and
the ins is correct. Relax the assert to the precise safety condition accordingly.

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

These are pure register-level reinterprets: ToVector128Unsafe widens a Vector64 into a
Vector128 with undefined upper bits, and a floating-point CreateScalarUnsafe places a scalar
in the low element of a SIMD register with undefined upper elements. When the consumer is
another GenTreeHWIntrinsic it already reads the value from the low bits of the register, so
the node is a no-op and can be removed in lowering, avoiding a register-to-register copy when
the source is still live.

The underlying operand is left at its natural type - retyping it would corrupt spill and
memory-access sizes for other consumers. FusedMultiplyAddScalar is excluded because its
lowering folds a size-8 CreateScalarUnsafe wrapping a GT_NEG into the negated fused-multiply
variant. Integral CreateScalarUnsafe still needs an explicit fmov and is left unchanged.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings July 10, 2026 13:39

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.

Copilot's findings

  • Files reviewed: 10/10 changed files
  • Comments generated: 0 new

The transparent-node elision during lowering can leave a size-changing
SIMD reinterpret operand -- e.g. an elided GetLower or ToVectorXXXUnsafe --
feeding a reconstructed binop. In LIR the operand still occupies a full SIMD
register and is consumed at the node's width, and containment validates the
memory-operand size before allowing a load, so the exact-shape asserts only
need to enforce in HIR. Gate them on fgNodeThreading == NodeThreading::LIR
and treat any SIMD-typed operand as a full-vector operand in GetHWIntrinsicIdForBinOp.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings July 10, 2026 19:32

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.

Copilot's findings

  • Files reviewed: 10/10 changed files
  • Comments generated: 1

Comment thread src/coreclr/jit/gentree.cpp
@tannergooding

Copy link
Copy Markdown
Member Author

@MihuBot -nuget

Copilot AI review requested due to automatic review settings July 11, 2026 15:06

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.

Copilot's findings

  • Files reviewed: 10/10 changed files
  • Comments generated: 0 new

Comment thread src/coreclr/jit/lowerarmarch.cpp

@EgorBo EgorBo left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

per Discord: it'd be nice to run some outerloop just in case (or/and fuzzlyn) to make sure we didn't forget about some place that won't be happy with scalar. Also, AI seems to suspect Vector512.GreaterThan(Vector512.CreateScalarUnsafe(x), Zero) might run into an assert assert(isFullVectorOp(op1) || varTypeIsFloating(op1));

@tannergooding

Copy link
Copy Markdown
Member Author

/azp run runtime-coreclr jitstress-isas-x86

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines successfully started running 1 pipeline(s).

@tannergooding

Copy link
Copy Markdown
Member Author

jitstress failures are all #130212

@tannergooding
tannergooding merged commit 28abd6d into dotnet:main Jul 14, 2026
150 of 157 checks passed
@tannergooding
tannergooding deleted the tannergooding-jit-free-scalar-conversion-hwintrinsics branch July 14, 2026 00:14
@dotnet-milestone-bot dotnet-milestone-bot Bot added this to the 11.0-preview7 milestone Jul 14, 2026
eiriktsarpalis pushed a commit that referenced this pull request Jul 15, 2026
…ring lowering (#130444)

## Summary

Today, hwintrinsic lowering in the JIT specially marks
`CreateScalarUnsafe` as *contained* on xarch so that no register is
allocated and the value is truly "free". This is a fragile design: it
produces 2-deep containment, requires special-case checks throughout
LSRA/codegen, and doesn't generalize to the many other
conceptually-identical "transparent" nodes (`ToScalar` for
floating-point, `GetLower`/`GetLower128`,
`ToVector256Unsafe`/`ToVector512Unsafe`, etc.).

Rather than extend that design to the other node types, this treats
these nodes as effectively **HIR-only** and **removes them during
lowering**, updating codegen to handle the resulting patterns directly.
The nodes are not retyped (retyping would change spill size /
memory-access size for certain containment cases); instead the consuming
intrinsic consumes the underlying value directly, which already lives in
the correct register and is implicitly consumable at full width.

As expected, the primary impact is on asserts that checked operands were
an exact size/shape (e.g. requiring both operands of a
`TYP_SIMD32`-producing intrinsic to themselves be `TYP_SIMD32`).
Containment checks already validate that a value is at least as large as
the memory access requires, so removing the transparent node does not
weaken those guarantees.

## Changes

Each commit is a self-contained step:

- Remove floating-point `CreateScalarUnsafe` during xarch lowering, and
stop the shared builders from manufacturing it in the first place.
- Remove the intermediate `CreateScalar`/`CreateScalarUnsafe` when
containing it during xarch lowering.
- Remove the now-dead *contained-`CreateScalarUnsafe`* support across
LSRA/codegen and the `GenTree` containability plumbing.
- Remove `ToVector256Unsafe`/`ToVector512Unsafe` during xarch lowering.
- Remove `GetLower`/`GetLower128` during xarch lowering, and `GetLower`
during arm64 lowering.
- Remove `ToVector128Unsafe` and floating-point `CreateScalarUnsafe`
during arm64 lowering.
- Make the `gtNewSimdBinOpNode` / `GetHWIntrinsicIdForBinOp`
operand-shape asserts HIR-only (gated on `fgNodeThreading ==
NodeThreading::LIR`), so a reconstructed binop in LIR may consume a
size-changing SIMD reinterpret operand left behind by an elided
transparent node. This keeps the aggressive elision for all hwintrinsic
consumers (e.g. `ConditionalSelect`/`Dot` reconstruction) rather than
falling back to a codegen-costing gate.

Deferred (need additional design work, not included here): the
`AsVector*` family (sub-16-byte SIMD8/SIMD12 memory-access-size hazard)
and floating-point `ToScalar` (SIMD→scalar `var_type` flip at ABI
boundaries).

## Validation

**SPMI asmdiffs** (all-up PR vs merge-base, Windows, Checked):

| Target | Contexts | Overall | Detail |
|---|---|---|---|
| x64 | 2,596,354 | **−23,238 bytes** | −21,441 FullOpts / −1,797
MinOpts; **0 regressions** (all collections improved) |
| arm64 | 2,781,392 | **−15,796 bytes** | 1,570 improvements / 9
regressions (+68 bytes total, largest cluster +40) |

- **Zero replay failures and zero JIT asserts** across ~2.6M (x64) and
~2.8M (arm64) contexts.
- Hot spots are the expected SIMD-heavy consumers:
`IndexOfAnyAsciiSearcher`, `Ascii.Equals`/`IsValidCore`,
`ProbabilisticMap`, `StringSearchValues`, plus Vector128/Numerics. The
arm64 regressions are a few bytes of alignment/ordering noise from
removing nodes and are dwarfed by the improvements.

**JIT tests** (priority-1, Checked, this JIT):
- `JIT/HardwareIntrinsics/General` merged `_r`: **2551/2551 passed**
- `JIT/HardwareIntrinsics/General` merged `_ro`: **2551/2551 passed**
- `JIT/SIMD`: **114/114 real tests passed** (the 2 `Vector3Interop`
cases fail only due to a missing native interop DLL from a local
`-SkipNative` build, unrelated to codegen)

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

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
tannergooding added a commit that referenced this pull request Jul 21, 2026
Fixes #131137.

PR #130444 started removing transparent scalar/vector reinterpret
`HWINTRINSIC` nodes (`CreateScalarUnsafe`, `GetLower`/`GetLower128`,
`ToVector256Unsafe`/`ToVector512Unsafe`) during lowering when the
consumer is another `HWINTRINSIC`. That's correct in general -- the
consumer reads the value from a register at its own size -- but two x64
codegen sites keyed their decision off the *operand's* post-lowering
type, which the elision changes. Both now produce wrong code.

----------

**`ConvertToVector128Int*` / `ConvertToVector256Int*` (the reported
crash)**

These have a vector overload `(Vector128<T>)` and a pointer overload
`(T*)`, both lowering to `pmovzx*`. Codegen picked between them with
`varTypeIsSIMD(op1)`. Once an elided `CreateScalarUnsafe` leaves the
vector overload's operand scalar-typed, that proxy misfires and codegen
takes the memory-load path -- reading the scalar value as if it were an
address (the reported `NullReferenceException`; a checked JIT asserts in
`emitxarch.cpp`). Fixed by selecting the overload from the stable
`node->OperIsMemoryLoad()` metadata (aux-type driven), matching the
generic table path already used elsewhere in the file.

----------

**AVX2 gather VSIB index width**

A gather selects its VSIB index width (xmm vs ymm) from the index
operand's *own* width (`indexOp->TypeIs(TYP_SIMD32)`). An elided
`GetLower`/`ToVector*Unsafe` on the index changes that width, so the
wrong VEX.L is encoded and the hardware reads the wrong number of
indices (e.g. a `vpgatherqd` with a `GetLower()`-narrowed index gathered
4 elements instead of 2 -- a silent wrong result, not visible in the JIT
disasm since it always prints the index as `xmm`). The index width can't
be recovered in codegen, so this is fixed in lowering: the reinterpret
elision is skipped when the node is a gather's index operand, since that
width is load-bearing.

----------

I also audited the rest of `hwintrinsiccodegenxarch.cpp`, the
store-containment paths in `codegenxarch.cpp`, and `emitxarch.cpp`:
every other size/attr decision derives from node metadata
(`node->TypeGet()`, `GetSimdSize()`, `GetSimdBaseType()`), and the
`operandSize >= expectedSize` check in `IsContainableHWIntrinsicOp`
prevents a narrowed operand from being contained undersized. These two
sites were the only operand-width-driven exceptions.

Added `Runtime_131137` covering both fixed paths (Sse41/Avx2
convert-from-scalar and the gather-with-narrowed-index case); it fails
without the fix and passes with it.

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

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
lewing added a commit to lewing/runtime that referenced this pull request Jul 21, 2026
…128 (deferred dotnet#130444 float ToScalar)

lower.cpp parameter-register-to-local mapping mapped a scalar field (e.g. double at offset 0) of a
SIMD-register parameter to the register's local and retyped the LCL_VAR access to the scalar type.
On x64/arm64 the scalar overlaps the low bytes of the vector register so this is a free reinterpret;
on wasm v128 and f64 are distinct value-stack types, so 'local.get <v128>' typed double is invalid
wasm (wasm-tools: 'expected f64, found v128'). Surfaces via Vector128<T>.GetHashCode's GetElementUnsafe
inlined into GenericEqualityComparer<Vector128<double>>.GetHashCode. Guard: on wasm, skip mapping a
scalar field to a SIMD register segment so the access falls back to the memory-homed (unenregisterable)
parameter and emits a normal scalar load. This is the float-ToScalar case dotnet#130444 explicitly deferred.
lewing added a commit that referenced this pull request Jul 22, 2026
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 #130444.
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-CodeGen-coreclr CLR JIT compiler in src/coreclr/src/jit and related components such as SuperPMI

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants