[API Proposal]: System.Runtime.Intrinsics.Wasm.RelaxedSimd
- Dominant language
- C#
- Stars
- 18.3k
- Forks
- 5.6k
- PR merge metrics
- PR metrics pending
Description
## Background and motivation
The WebAssembly [relaxed-SIMD proposal](https://github.com/WebAssembly/relaxed-simd) is a [finished proposal](https://github.com/WebAssembly/proposals/blob/main/finished-proposals.md) integrated into the Wasm 3.0 draft (WG approval 2024-07-10). It is implemented in the major engines we target: V8, SpiderMonkey, JavaScriptCore, and Wasmtime. Relaxed SIMD adds a small set of 128-bit SIMD instructions whose *observable behavior may differ across engines* — trading strict determinism for direct mapping to native SIMD (FMA, `pshufb`/`vqrdmulh`/`vdot`, hardware `min`/`max`). These are the same instructions cross-platform intrinsics like `Vector128.MultiplyAddEstimate` and `Vector128.ConvertToInt32Native` were designed to expose, and they're exactly the instructions our own numerics/ML/codec paths need for cross-platform speedups on wasm.
We already surface `System.Runtime.Intrinsics.Wasm.PackedSimd` (approved in #53730, refined in #88092). This proposal adds a sibling `System.Runtime.Intrinsics.Wasm.RelaxedSimd` class following the same shape: `IsSupported` gate + static methods that map 1:1 to spec instructions. Callers opt in explicitly by naming the class, mirroring how X86 splits `Sse2` vs `Ssse3` and Wasm splits `PackedSimd` vs relaxed. Cross-platform code should still prefer `Vector128.*` helpers where they exist — this class is the low-level surface for `PackedSimd`-style consumers.
Prior art:
- `System.Runtime.Intrinsics.Wasm.PackedSimd` — sibling class for deterministic wasm SIMD.
- `Vector128.MultiplyAddEstimate`, `Vector128.ConvertToInt32Native` — cross-platform helpers whose semantics deliberately match relaxed-SIMD instructions.
- All eight browsers/runtimes finalized support in 2023–2024; the spec is stable.
Workarounds today:
- Callers who need FMA on wasm currently write `a * b + c` and hope the JIT contracts it (Mono LLVM AOT sometimes does; interp/JIT do not). No equivalent path exists for `relaxed_swizzle`, `relaxed_dot`, `relaxed_laneselect`, or `relaxed_q15mulr` — those regress to scalar loops.
- `Vector128.MultiplyAddEstimate` on wasm currently lowers to separate `mul`+`add`. This proposal is a prerequisite for wiring `MultiplyAddEstimate` to `f32x4.relaxed_madd` on wasm.
Related: This proposal has no known duplicates. #127665 (Wasm RyuJIT SIMD + Intrinsics tracking issue) references relaxed-SIMD as future work.
## API Proposal
```csharp
namespace System.Runtime.Intrinsics.Wasm;
[CLSCompliant(false)]
public abstract class RelaxedSimd
{
public static bool IsSupported { get; }
// Swizzle — i8x16.relaxed_swizzle. Out-of-range indices produce an
// implementation-defined value (PackedSimd.Swizzle zeroes them).
public static Vector128 Swizzle(Vector128 vector, Vector128 indices);
public static Vector128 Swizzle(Vector128 vector, Vector128 indices);
// Float-to-int truncation — i32x4.relaxed_trunc_f{32x4,64x2}_{s,u}[_zero].
// NaN and out-of-range inputs may produce any of the results the wasm
// relaxed-trunc semantics permit — closest existing .NET contract is
// Vector128.ConvertToInt32Native ("platform specific behavior on overflow"),
// with the additional caveat that on wasm the specific "engine choice" is
// per-embedding rather than per-ISA. Callers wanting portable behavior
// should use PackedSimd.ConvertToInt32Saturate.
public static Vector128 ConvertToInt32(Vector128 value);
public static Vector128 ConvertToUInt32(Vector128 value);
public static Vector128 ConvertToInt32(Vector128 value);
public static Vector128 ConvertToUInt32(Vector128 value);
// Multiply-add estimates — f{32x4,64x2}.relaxed_{madd,nmadd}.
// Implementation may or may not fuse; matches Vector128.MultiplyAddEstimate.
public static Vector128 MultiplyAddEstimate (Vector128 a, Vector128 b, Vector128 c);
public static Vector128 MultiplyAddEstimate (Vector128 a, Vector128 b, Vector128 c);
public static Vector128 MultiplyAddNegatedEstimate (Vector128 a, Vector128 b, Vector128 c);
public static Vector128 MultiplyAddNegatedEstimate (Vector128 a, Vector128 b, Vector128 c);
// Lane-select — i{8,16,32,64}x{16,8,4,2}.relaxed_laneselect.
// Behavior of non-high mask bits is implementation-defined
// (PackedSimd.BitwiseSelect examines every bit).
public static Vector128 LaneSelect(Vector128 left, Vector128 right, Vector128 mask);
public static Vector128 LaneSelect(Vector128 left, Vector128 right, Vector128 mask);
public static Vector128 LaneSelect(Vector128 left, Vector128 right, Vector128 mask);
public static Vector128 LaneSelect(Vector128 left, Vector128 right, Vector128 mask);
public static Vector128 LaneSelect(Vector128 left, Vector128 right, Vector128 mask);
public static Vector128 LaneSelect(Vector128 left, Vector128 right, Vector128 mask);
public static Vector128 LaneSelect(Vector128 left, Vector128 right, Vector128 mask);
public static Vector128 LaneSelect(Vector128 left, Vector128 right, Vector128 mask);
// Min/Max — f{32x4,64x2}.relaxed_{min,max}.
// NaN and signed-zero handling is implementation-defined
// (PackedSimd.Min/Max are IEEE-conformant).
public static Vector128 Min(Vector128 left, Vector128 right);
public static Vector128 Max(Vector128 left, Vector128 right);
public static Vector128 Min(Vector128 left, Vector128 right);
public static Vector128 Max(Vector128 left, Vector128 right);
// Q15 fixed-point multiply — i16x8.relaxed_q15mulr_s.
// Saturation on 0x8000 * 0x8000 is implementation-defined
// (PackedSimd.MultiplyRoundedSaturateQ15 always saturates).
public static Vector128 MultiplyRoundedQ15(Vector128 left, Vector128 right);
// Extended integer dot products — i16x8.relaxed_dot_i8x16_i7x16_s,
// i32x4.relaxed_dot_i8x16_i7x16_add_s.
// Per the finished spec: `a` is signed, `b` is unsigned 7-bit. When any lane
// of `b` has the high bit set, that lane's product is implementation-defined
// (may be interpreted as signed or unsigned). The sum reduction is also
// implementation-defined-saturating (may or may not saturate on overflow).
public static Vector128 DotProduct (Vector128 left, Vector128 right);
public static Vector128 DotProductAdd(Vector128 left, Vector128 right, Vector128 accumulator);
}
```
Prototype: https://github.com/lewing/runtime/commit/7f99067dc5e (branch `wasm-relaxed-simd-api` on `lewing/runtime`; earlier revision at `bfebb64b7042e0f1095a4d87ff42a3504f62e122`)
## API Usage
Cross-platform saturating FMA in a hot vector loop (mirrors `Vector128.MultiplyAddEstimate` shape):
```csharp
static Vector128 Blend(Vector128 a, Vector128 b, Vector128 t)
{
if (RelaxedSimd.IsSupported)
return RelaxedSimd.MultiplyAddEstimate(a, t, RelaxedSimd.MultiplyAddEstimate(-b, t, b));
// Cross-platform helper already exists; on wasm it currently lowers to
// separate mul+add. Wiring it to RelaxedSimd is the follow-up.
return Vector128.MultiplyAddEstimate(a, t, Vector128.MultiplyAddEstimate(-b, t, b));
}
```
Byte permutation with a computed table (relaxed swizzle enables `Ssse3.Shuffle`-shaped code paths on wasm):
```csharp
static Vector128 Pshufb(Vector128 data, Vector128 indices)
{
if (RelaxedSimd.IsSupported)
return RelaxedSimd.Swizzle(data, indices); // may leave OOR lanes implementation-defined
if (PackedSimd.IsSupported)
return PackedSimd.Swizzle(data, indices); // OOR lanes are zeroed
return Vector128.Shuffle(data, indices);
}
```
INT8 dot-product (ML inference / audio codecs):
```csharp
static Vector128 DotAccum(Vector128 left, Vector128 weights7bit, Vector128 acc)
=> RelaxedSimd.IsSupported
? RelaxedSimd.DotProductAdd(left, weights7bit, acc)
: Fallback(left, weights7bit, acc);
```
## Alternative Designs
- **Name the class `Wasm.RelaxedSimd` vs prefixing every method with `Relaxed` on `PackedSimd`.** Chose a separate class so `IsSupported` gates the whole set (relaxed-SIMD requires a distinct engine feature flag), matching `X86.Sse2` vs `X86.Ssse3` and giving trimming a clean cut point. Not merging into `PackedSimd` also avoids expanding an already-large surface.
- **Reuse `Vector128.MultiplyAddEstimate` etc. rather than adding a class.** Cross-platform helpers should stay the recommended API, but users of `PackedSimd` also need direct access (e.g., to reason about codegen, or when compiling with `WasmEnableRelaxedSimd=false`). Same argument the design has already accepted for `PackedSimd` living alongside `Vector128`.
- **`Swizzle` vs `RelaxedSwizzle`.** PackedSimd already uses `Swizzle`; disambiguation happens at the class name. Same for `Min`/`Max`.
- **`MultiplyRoundedQ15` vs `MultiplyRoundedSaturateQ15`.** PackedSimd uses the latter (deterministic saturation on overflow); this class deliberately omits `Saturate` because saturation on `0x8000 * 0x8000` is the exact bit spec leaves implementation-defined. The name reflects the contract.
Open design questions worth reviewer input:
1. **`ConvertToInt32` vs `ConvertToInt32Native`.** Cross-platform `Vector128.ConvertToInt32Native` uses the `Native` suffix to mark implementation-defined OOR/NaN behavior — exactly what `i32x4.relaxed_trunc_f32x4_s` provides. Renaming to `ConvertToInt32Native` / `ConvertToUInt32Native` would make it obvious that this class supplies the wasm implementation of the cross-platform "Native" contract. **Tentative: rename to `ConvertToInt32Native` / `ConvertToUInt32Native`.**
2. **`DotProduct` operand types.** ~~The spec defines the right operand as 7-bit signed; passing an 8-bit value is implementation-defined.~~ Per the [finished spec pseudocode](https://github.com/WebAssembly/relaxed-simd/blob/main/proposals/relaxed-simd/Overview.md#relaxed-integer-dot-product), operand `a` is signed and operand `b` is unsigned-7-bit — the current prototype had these reversed. The signatures above (`sbyte left, byte right`) match the spec. Reviewers should confirm the name `DotProduct` / `DotProductAdd` conveys enough about the implementation-defined summation behavior; adding a `Saturate` suffix would be misleading (may or may not saturate).
3. **`ConvertToInt32(Vector128)`.** The wasm instruction is `i32x4.relaxed_trunc_f64x2_s_zero` — it produces four int lanes with the upper two zeroed. Cross-platform helpers spell this as `Narrow`. Consider `NarrowingConvertToInt32` for the `double` overloads (matches PackedSimd's `ConvertNarrowingSaturate` naming), or leave as an overload and document. **Tentative: keep overloaded `ConvertToInt32` and document.**
4. **`Min`/`Max` vs `MinNative`/`MaxNative`.** Same argument as #1 — `Vector128.MinNative`/`MaxNative` already document platform-specific NaN/±0 handling, which matches `f32x4/f64x2.relaxed_min/max`. Reviewers may prefer the class use the `Native` suffix for symmetry with #1 (and to reinforce that this is where `Vector128.MinNative` on wasm gets its implementation). **Tentative: keep `Min`/`Max` and rely on class-name disambiguation, matching `PackedSimd.Min/Max`.**
## Risks
- **Non-deterministic results across engines.** By design. Users opting into `RelaxedSimd.*` accept implementation-defined behavior; documented per-method.
- **Overload resolution ambiguity.** None expected: all methods are on a new class in a namespace already reserved for `Wasm.*` intrinsics.
- **TFM compatibility.** `System.Runtime.Intrinsics.Wasm` types are net5.0+; `RelaxedSimd` is a new type in that namespace with no netstandard/netfx surface. No compatibility risk.
- **Analyzer coverage.** #82486 (cross-platform intrinsics analyzer) does not yet consider relaxed-SIMD lowerings. Not a blocker; a follow-up item.
- **Binary/source breaking:** none — this is pure addition.
## Usage in dotnet/runtime
The `*Native` and `*Estimate` suffixes on cross-platform `Vector128` helpers are already documented as "platform-specific" / "may or may not fuse". Lowering them to relaxed-SIMD on wasm does not stretch that contract — it *satisfies* it (compare: `ConvertToInt32Native` maps to `cvttps2dq` on x64 and `fcvtzs` on arm64, both platform-defined for OOR/NaN; `i32x4.relaxed_trunc_f32x4_s` is the wasm equivalent). Callers who need portable behavior already use `Vector128.ConvertToInt32Saturate` / `PackedSimd.*` instead.
### Adoption in `Vector128.*` wasm lowerings
The most compelling in-tree consumers are `Vector128.ShuffleNative` and the `Vector128.*Native`/`*Estimate` helpers, whose current wasm lowerings emit portable-but-slower fallbacks even though their documented contracts already allow platform-specific behavior. Concrete examples:
| Cross-platform helper | Current wasm lowering | Relaxed target |
|---|---|---|
| `Vector128.ShuffleNative(byte)` | falls through to `PackedSimd.Swizzle` (`Vector128.cs:3534-3536`) — deterministic zero-OOR | `i8x16.relaxed_swizzle` — engine-defined OOR, matches `ShuffleNative`'s contract |
| `Vector128.MultiplyAddEstimate(float\|double)` | `FMUL`+`FADD` (two ops) in `src/mono/mono/mini/simd-intrinsics.c:2208-2244` | `f32x4/f64x2.relaxed_madd` |
| `Vector128.MultiplyAddNegatedEstimate` | falls through to managed body | `relaxed_nmadd` |
| `Vector128.ConvertToInt32Native(float)` / `ConvertToUInt32Native(float)` | falls through to managed body | `i32x4.relaxed_trunc_f32x4_{s,u}` |
| `Vector128.MinNative` / `MaxNative(float\|double)` | generic `OP_XBINOP FMIN/FMAX` | `f32x4/f64x2.relaxed_min/max` |
All the target intrinsics (`INTRINS_WASM_RELAXED_*`) are already declared and used by the `RelaxedSimd.*` class-path lowering added in this prototype; the follow-up is teaching the generic `Vector128.*` intrinsic recognizer to prefer them on wasm when the engine reports relaxed-SIMD support. RyuJIT wasm work (tracked in #127665) will get the same lowering table.
For `ShuffleNative` specifically: adopting `RelaxedSimd.Swizzle` on wasm is a behavior change for callers that today implicitly rely on `PackedSimd.Swizzle`'s zero-OOR fallback. That's within `ShuffleNative`'s documented contract, but is worth a compatibility scan of internal call sites before the companion PR lands.
### Adoption strategy
Nothing is updated in this prototype commit — following the `PackedSimd` precedent (#53730 shipped without in-tree adoption; #88092 refined based on real use). Companion PRs wiring the lowerings above are straightforward and can land alongside if reviewers prefer.
> [!NOTE]
> This API proposal was drafted with the assistance of GitHub Copilot.
Contributor guide
Research direction
Start by comparing the proposed System.Runtime.Intrinsics.Wasm.RelaxedSimd API with the existing PackedSimd surface and review the prototype commit on the wasm-relaxed-simd branch. Work through the open naming, operand-type, and overload questions with the issue discussion. Done means the API shape and semantics are agreed for implementation.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- csharp, wasm
- Domain
- backend-api-design
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100