Skip to content

[mono][interp] Fix mis-sorted SIMD intrinsic tables making entries unreachable - #132500

Merged
lewing merged 2 commits into
mainfrom
lewing-fix-packedsimd-alias-table
Aug 19, 2026
Merged

[mono][interp] Fix mis-sorted SIMD intrinsic tables making entries unreachable#132500
lewing merged 2 commits into
mainfrom
lewing-fix-packedsimd-alias-table

Conversation

@lewing

Copy link
Copy Markdown
Member

Problem

lookup_intrins() in transform-simd.c resolves method names with mono_binary_search + strcmp, which requires the tables to be in strict ASCII order. Two tables had drifted out of order, so binary search never reached certain entries: lookup returns -1, the switch falls to default: return FALSE, and the intrinsic is silently never emitted.

The failure is invisible — no crash, no assert, no test failure. The call just falls back to the managed implementation and runs orders of magnitude slower. That is why this went unnoticed.

Entries unreachable on main:

TableUnreachable entries
sri_vector128_methodsAsVector4
packedsimd_alias_methodsStore, StoreUnsafe, Subtract, SubtractSaturate

Changes

  1. sri_vector128_methods — reorder SN_AsVector, SN_AsVector4, SN_AsVector128SN_AsVector, SN_AsVector128, SN_AsVector4.
  2. packedsimd_alias_methods — move SN_Sqrt, SN_SquareRoot from after SN_SubtractSaturate to before SN_Subtract, restoring the sort invariant and making Subtract/SubtractSaturate reachable.
  3. packedsimd_alias_methodsremoveSN_Store / SN_StoreUnsafe (and the now-orphaned case block), replaced with a comment explaining why. See below.
  4. New check_intrins_sorted() under ENABLE_CHECKED_BUILD, called from lookup_intrins(), so any future mis-sort fails loudly instead of silently degrading. ENABLE_CHECKED_BUILD is on for any Debug mono build, so this gets broad coverage. Cost is at most ~46 short strcmps per SIMD resolution at transform time.

Why Store/StoreUnsafe are removed rather than sorted into place

This is the part worth reviewing carefully. Naively re-sorting the table would have activated a latent memory-corruption bug, which is presumably why nobody noticed these were dead.

The operands are reversed between the two APIs:

// interp-simd.c:799 — expects (address, vector)interp_packedsimd_store (res, addr_of_addr, vec)
→ **(v128_t**)addr_of_addr=*(v128_t*)vec;
APISignatureMatches helper?
PackedSimd.Store(T* address, Vector128<T> source)(address, vector)
Vector128.Store<T>(this Vector128<T> source, T* destination)(vector, address)❌ reversed
Vector128.StoreUnsafe<T>(this Vector128<T> source, ref T destination)(vector, address)❌ reversed

The alias path only renamescmethod_name; emit_common_simd_epilogue then assigns sregs[i] = sp[i].var in signature order with no reordering hook. The existing param_count != 2 guard is necessary but not sufficient — lookup_packedsimd_intrinsic("Store", ...) matches the Store, ANY entry regardless. The result would be vector data dereferenced as a pointer and 16 bytes written through it.

Removing the entries preserves main's behavior exactly (they are unreachable today) while making that explicit and keeping the table sorted.

Making these genuinely intrinsifiable is worth doing — measured ~6.4x on the interpreter — but it needs an operand-swap mechanism in the emit path, which is a feature rather than a bug fix and belongs in its own change.

Why the newly-reachable entries are safe

  • Subtract — identical (Vector128<T> left, Vector128<T> right) shape in both APIs. Covers D1/D2/D4/D8/R4/R8 (including nint/nuint → D4 on wasm32 via resolve_native_size).
  • SubtractSaturate — exists only for I1/U1/I2/U2. For other types packedsimd_type_matches hits default: return FALSE and falls back to managed. Structurally identical to the already-reachable AddSaturate.
  • AsVector4 — only Vector128.AsVector4(this Vector128<float>) reaches the switch; return and argument are both 16 bytes, so it emits MINT_MOV_VT 16, exactly the Unsafe.BitCast it replaces. The Plane/Quaternion/Vector2/Vector3 overloads return FALSE earlier (non-GENERICINST).
  • Neither Add nor Subtract nor SubtractSaturate has scalar overloads (only Multiply does, and SN_Multiply already guards with scalar_arg != -1), so the absent scalar_arg check is not exploitable here.

Sqrt/SquareRoot happened to be reachable despite their misplacement — the search path landed on them. Moving them is invariant hygiene, not a behavior change.

Measurements

browser-wasm / V8, interpreter, dependency-chained loops, best of 5 after 2 warmups:

OperationBeforeAfterSpeedup
Vector128.SubtractSaturate<short>28.13 ns0.84 ns33.5x
Vector128.SubtractSaturate<byte>30.73 ns0.84 ns36.6x
Vector128.Subtract<int> (static)0.69 ns0.68 nsunchanged

SubtractSaturate was running a fully-managed scalar loop and now lowers to i8x16.sub_sat / i16x8.sub_sat. Static Subtract is unchanged because its body is a - b, already intrinsified through op_Subtraction and inlined — the table entry was redundant for it.

Validation

  • Audit script replaying mono_binary_search over all 5 tables: all sorted, all entries reachable.
  • Checked build with the new assert active: builds clean, 13021/13021System.Runtime.Intrinsics tests pass on browser-wasm, assert never fires.
  • Shipping config: 0 warnings / 0 errors, 13021/13021.
  • Native osx-arm64 mono: 0 warnings / 0 errors.
  • IR dump confirms emission: simd_intrins_p_pp [80 <- 80 96], 166SubtractSaturateI2.

Note on merge order

This also touches sri_vector128_methods, which overlaps an incidental reorder in #132499. Trivial conflict; whichever merges second needs a rebase. It could not be scoped out of this change — the new sortedness assert fires on the pre-existing AsVector4 misplacement, so the fix has to land together with the assert.

Note

This pull request description was generated with the assistance of GitHub Copilot.

lewingand others added 2 commits August 18, 2026 20:57
…reachable
lookup_intrins() searches the SIMD intrinsic tables with mono_binary_search
and a strcmp comparator, which requires the tables be in strict ASCII order.
Two tables had drifted out of order, silently making entries unreachable: the
search returns -1, control falls through to "default: return FALSE", and the
intrinsic is simply never emitted. There is no crash and no test failure, only
a quiet fall back to the managed implementation - which is why this went
unnoticed.
Unreachable before this change:
sri_vector128_methods: AsVector4
packedsimd_alias_methods: Store, StoreUnsafe, Subtract, SubtractSaturate
Restoring the ordering makes Subtract, SubtractSaturate and AsVector4 emit
their intrinsics. On browser-wasm under V8, SubtractSaturate had been running
a fully managed scalar loop and now lowers to i8x16.sub_sat / i16x8.sub_sat:
Vector128.SubtractSaturate<short> 28.13 ns -> 0.84 ns (33.5x)
Vector128.SubtractSaturate<byte> 30.73 ns -> 0.84 ns (36.6x)
Store and StoreUnsafe are deliberately removed from the alias table rather
than re-sorted, because sorting them back into place would have activated a
latent memory-corruption bug that the mis-sort was masking. The alias path
only renames cmethod_name, and emit_common_simd_epilogue assigns sregs in
signature order with no reordering hook, but the operands are reversed:
PackedSimd.Store(T* address, Vector128<T> source)
Vector128.Store<T>(this Vector128<T> source, T* destination)
Vector128.StoreUnsafe<T>(this Vector128<T> source, ref T destination)
The existing param_count check does not catch this - lookup_packedsimd_intrinsic
resolves Store as ANY, so it would have matched and passed the vector where
the destination address is expected, dereferencing vector data as a pointer
and writing 16 bytes through it. Removing the entries preserves exactly the
current behavior. Making these genuinely intrinsifiable needs operand-swap
support in the alias path and is left as a follow-up.
Finally, add a check_intrins_sorted() assertion under ENABLE_CHECKED_BUILD so
a future mis-sort fails loudly instead of quietly costing performance.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 3ebb2f49-c9fc-4c28-9755-c51b9deac735
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 3 pipeline(s).
13 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: @vitek-karas, @BrzVlad, @kotlarmilos
See info in area-owners.md if you want to be subscribed.

@lewinglewing added this to the 11.0.0 milestone Aug 19, 2026

CopilotAI 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 fixes Mono interpreter SIMD intrinsic lookup reliability by restoring strict ASCII sort order for binary-searched method tables, and adds a checked-build invariant check so future table drift fails loudly instead of silently disabling intrinsics.

Changes:

  • Reorders sri_vector128_methods so AsVector4 is reachable via mono_binary_search.
  • Reorders packedsimd_alias_methods so Subtract/SubtractSaturate are reachable, and removes Store/StoreUnsafe alias entries (with an explanatory comment).
  • Adds check_intrins_sorted() under ENABLE_CHECKED_BUILD and calls it from lookup_intrins().
Suppressed comments (1)

src/mono/mono/mini/interp/transform-simd.c:74

  • Indentation in lookup_intrins uses spaces instead of the surrounding file's tab indentation, which makes the block stand out and can cause formatting churn in future diffs. Re-indent these lines to match the existing style in this file.
#ifdef ENABLE_CHECKED_BUILD
check_intrins_sorted (intrinsics, size);
#endif
guint16 *result = mono_binary_search (cmethod_name, intrinsics, size / sizeof (guint16), sizeof (guint16), &simd_intrinsic_compare_by_name);
if (result == NULL)
return -1;
else
return (int)*result;

Comment threadsrc/mono/mono/mini/interp/transform-simd.c
@pavelsavara

Copy link
Copy Markdown
Member

Which CI leg is running the new test ?

@lewing
lewing merged commit ac88b74 into mainAug 19, 2026
100 checks passed
@lewing
lewing deleted the lewing-fix-packedsimd-alias-table branch August 19, 2026 12:26
@lewing

Copy link
Copy Markdown
MemberAuthor

/backport to release/11.0-rc1

@github-actions

Copy link
Copy Markdown
Contributor

Started backporting to release/11.0-rc1 (link to workflow run)

lewing added a commit that referenced this pull request Aug 20, 2026
Rebased onto `main` now that #132500 has merged; this is a single commit
touching 3 files.
## Problem
`Vector128.Store` and `Vector128.StoreUnsafe` were deliberately left out
of the PackedSimd alias table because their operands are reversed
relative to the method they would lower to:
```
PackedSimd.Store (T* address, Vector128<T> source)
Vector128.Store (this Vector128<T> source, T* destination)
```
The alias path only renames the method, and `emit_common_simd_epilogue`
assigns sregs in signature order, so aliasing them as-is would have
passed the vector where the destination address is expected. They
therefore fell back to managed code, which routes through
`Unsafe.WriteUnaligned` — an intrinsic the interpreter does not
implement — making a documented-as-fast API roughly six times slower
than the PackedSimd equivalent.
## Fix
Add them to the alias table and swap the two sregs after the epilogue
has run. sregs are var indices consumed positionally by
`MINT_SIMD_INTRINS_P_PP` (`interp.c:6141`), so the swap is a
permutation: it changes their order, not the set of vars used, leaving
liveness and refcounting unaffected. It runs during IL→IR generation,
before every optimization pass.
It also matches what the jiterpreter already expects —
`SimdIntrinsic3.StoreANY` in `jiterpreter-trace-generator.ts` loads arg
2 as the address and arg 3 as the vector.
`Store` is registered for every element type (`ANY`), so the reorder is
guarded on the shape actually being a store: two parameters, void
return, and a raw address (`T*` or `ref T`) as the second parameter. The
three-argument `StoreUnsafe(source, destination, elementOffset)` has no
PackedSimd counterpart and is rejected by the parameter count, falling
back to managed code as before.
`System.Numerics.Vector.Store`/`StoreUnsafe` share the identical shape
and route through the same emit path, so they are lowered too.
`StoreAligned`/`StoreAlignedNonTemporal` are absent from the alias table
and continue to fall back to managed code — importantly, since
`StoreAligned` has a runtime alignment check that must throw.
### Alignment
`interp_packedsimd_store` assigned through a `v128_t*`, claiming 16-byte
alignment that neither `PackedSimd.Store` nor `Vector128.StoreUnsafe`
guarantee. It now uses `wasm_v128_store`, which stores through a
`__packed__ __may_alias__` struct. This mirrors
`interp_packedsimd_load128`'s existing use of `wasm_v128_load`, and
matches the jiterpreter's `v128_store` with an alignment hint of 1.
Misaligned access is well-defined at the wasm ISA level, so this was C
UB rather than a live bug.
## Measurements
browser-wasm under V8, interpreter, 1,000,000 iterations, 2 warmups,
best of 5, same harness before and after on the same machine:
| Operation | Before | After | Speedup |
|---|---|---|---|
| `Vector128.Store<int>` | 3.243 ns | 0.521 ns | **6.2x** |
| `Vector128.StoreUnsafe<int>` | 3.253 ns | 0.518 ns | **6.3x** |
| `PackedSimd.Store<int>` (control) | 0.485 ns | 0.527 ns | unchanged |
The control's ~8% drift bounds run-to-run noise; the 6x is far outside
it. No loop-overhead baseline is subtracted — an empty-loop control
measured *higher* than the store loops, since the jiterpreter doesn't
trace it.
## Validation
- browser-wasm `mono+libs` Release: 0 errors, 0 warnings
- native osx-arm64 `mono` Release: 0 errors, 0 warnings
- `System.Runtime.Intrinsics`: **13024/13024** passing (13021 baseline +
3 new)
- `System.Numerics.Vectors`: **7449/7449** passing
- IR (`MONO_VERBOSE_METHOD`): `simd_intrins_p_pp [74 <- 73 72], 191` —
descending sregs confirm the swap; the three-argument overload still
emits a managed `call`
- `System.Numerics.Vector<T>.StoreUnsafe` likewise emits
`simd_intrins_p_pp [19 <- 18 14], 191`
## Tests
Three tests covering `Store`, `StoreUnsafe`, and `StoreUnsafe` with an
element offset, across twelve element types, at every 16-byte alignment,
with guard bytes on both sides so a misplaced or oversized store is
caught. The element-offset arm uses `StoreUnsafe(ref *(destination - 1),
elementOffset: 1)` so a dropped offset argument shows up as a misplaced
store. They pass on an unmodified runtime as well, so they validate
behavior rather than implementation.
> [!NOTE]
> This pull request was authored with the assistance of GitHub Copilot.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 3ebb2f49-c9fc-4c28-9755-c51b9deac735
lewing added a commit that referenced this pull request Aug 20, 2026
…s making entries unreachable (#132525)
Backport of #132500 to release/11.0-rc1
/cc @lewing
## Customer Impact
- [ ] Customer reported
- [ ] Found internally
[Select one or both of the boxes. Describe how this issue impacts
customers, citing the expected and actual behaviors and scope of the
issue. If customer-reported, provide the issue number.]
## Regression
- [ ] Yes
- [ ] No
[If yes, specify when the regression was introduced. Provide the PR or
commit if known.]
## Testing
[How was the fix verified? How was the issue missed previously? What
tests were added?]
## Risk
[High/Medium/Low. Justify the indication by mentioning how risks were
measured and addressed.]
**IMPORTANT**: If this backport is for a servicing release, please
verify that:
- For .NET 8 and .NET 9: The PR target branch is `release/X.0-staging`,
not `release/X.0`.
- For .NET 10+: The PR target branch is `release/X.0` (no `-staging`
suffix).
## Package authoring no longer needed in .NET 9
**IMPORTANT**: Starting with .NET 9, you no longer need to edit a NuGet
package's csproj to enable building and bump the version.
Keep in mind that we still need package authoring in .NET 8 and older
versions.
Co-authored-by: Larry Ewing <lewing@microsoft.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 3ebb2f49-c9fc-4c28-9755-c51b9deac735
lewing added a commit that referenced this pull request Aug 20, 2026
…afe on wasm (#132575)
Manual backport of #132502 to `release/11.0-rc1`.
## Why this needed a manual backport
The automatic cherry-pick conflicted in two files:
- **`transform-simd.c`** — resolved by first landing the prerequisite,
#132525 (backport of #132500). Once that merged, this file auto-merged
with no hand edits.
- **`Vector128Tests.cs`** — a pure placement conflict. #132502's tests
were appended after `Vector128GetElementVariableOutOfRangeTest`, which
comes from #132499 and is not on this branch. The three Store tests are
self-contained, so they were appended at the end of the class instead.
No #132499 tests were pulled in.
Every added and removed line in this commit is byte-identical to
upstream `be1c680` — only hunk headers and line numbers differ.
## Depends on #132525
This backport is only effective because #132525 already merged. On
`release/11.0-rc1` before that fix, `packedsimd_alias_methods` was
mis-sorted, and since `lookup_intrins()` binary-searches the table,
`Store`, `StoreUnsafe`, `Subtract` and `SubtractSaturate` were all
unreachable. Simulating `mono_binary_search` against the pre-#132525
table confirms this — so #132502 on its own would have been a silent
no-op.
With #132525 merged, the resulting table is strictly sorted and all 49
entries resolve; `Store` and `StoreUnsafe` land at indices 29 and 30.
## Original change
`PackedSimd.Store(T* address, Vector128<T> source)` takes its operands
in the opposite order from `Vector128.Store(this Vector128<T> source, T*
destination)`, and the alias path only renames the method —
`emit_common_simd_epilogue` assigns sregs in signature order. This adds
them to the alias table and swaps the two sregs after the epilogue,
guarded on the shape actually being a store (two parameters, void
return, raw address as the second parameter). The three-argument
`StoreUnsafe(source, destination, elementOffset)` has no PackedSimd
counterpart and still falls back to managed code.
`interp_packedsimd_store` also switches from assigning through a
`v128_t*` to `wasm_v128_store`, which does not claim 16-byte alignment
that neither API guarantees.
Upstream measured ~6.2x on `Vector128.Store<int>` and ~6.3x on
`Vector128.StoreUnsafe<int>` under the interpreter.
## Local validation
- `./build.sh clr+libs -rc release` — 0 errors, 0 warnings
- `System.Runtime.Intrinsics.Tests` — **13011/13011 passing, 0 failed**,
including the three new tests: `Vector128StoreUnalignedTest`,
`Vector128StoreUnsafeUnalignedTest`,
`Vector128StoreUnsafeElementOffsetUnalignedTest`
The count is 13011 rather than upstream's 13024 because the #132499 and
#132496 tests are not on this branch.
Not validated locally: the `interp-simd.c` change is wasm-only, so a
native osx-arm64 build does not exercise it. It is byte-identical to the
upstream commit, which passed full CI.
> [!NOTE]
> This pull request was authored with the assistance of GitHub Copilot.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 3ebb2f49-c9fc-4c28-9755-c51b9deac735
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@lewing@pavelsavara@BrzVlad