Skip to content

[API Proposal]: System.Runtime.Intrinsics.Wasm.RelaxedSimd #130223

Description

@lewing

Background and motivation

The WebAssembly relaxed-SIMD proposal is a finished proposal 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

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<sbyte> Swizzle(Vector128<sbyte> vector, Vector128<sbyte> indices);
    public static Vector128<byte>  Swizzle(Vector128<byte>  vector, Vector128<byte>  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<int>  ConvertToInt32(Vector128<float>  value);
    public static Vector128<uint> ConvertToUInt32(Vector128<float>  value);
    public static Vector128<int>  ConvertToInt32(Vector128<double> value);
    public static Vector128<uint> ConvertToUInt32(Vector128<double> value);

    // Multiply-add estimates — f{32x4,64x2}.relaxed_{madd,nmadd}.
    // Implementation may or may not fuse; matches Vector128.MultiplyAddEstimate.
    public static Vector128<float>  MultiplyAddEstimate        (Vector128<float>  a, Vector128<float>  b, Vector128<float>  c);
    public static Vector128<double> MultiplyAddEstimate        (Vector128<double> a, Vector128<double> b, Vector128<double> c);
    public static Vector128<float>  MultiplyAddNegatedEstimate (Vector128<float>  a, Vector128<float>  b, Vector128<float>  c);
    public static Vector128<double> MultiplyAddNegatedEstimate (Vector128<double> a, Vector128<double> b, Vector128<double> 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<sbyte>  LaneSelect(Vector128<sbyte>  left, Vector128<sbyte>  right, Vector128<sbyte>  mask);
    public static Vector128<byte>   LaneSelect(Vector128<byte>   left, Vector128<byte>   right, Vector128<byte>   mask);
    public static Vector128<short>  LaneSelect(Vector128<short>  left, Vector128<short>  right, Vector128<short>  mask);
    public static Vector128<ushort> LaneSelect(Vector128<ushort> left, Vector128<ushort> right, Vector128<ushort> mask);
    public static Vector128<int>    LaneSelect(Vector128<int>    left, Vector128<int>    right, Vector128<int>    mask);
    public static Vector128<uint>   LaneSelect(Vector128<uint>   left, Vector128<uint>   right, Vector128<uint>   mask);
    public static Vector128<long>   LaneSelect(Vector128<long>   left, Vector128<long>   right, Vector128<long>   mask);
    public static Vector128<ulong>  LaneSelect(Vector128<ulong>  left, Vector128<ulong>  right, Vector128<ulong>  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<float>  Min(Vector128<float>  left, Vector128<float>  right);
    public static Vector128<float>  Max(Vector128<float>  left, Vector128<float>  right);
    public static Vector128<double> Min(Vector128<double> left, Vector128<double> right);
    public static Vector128<double> Max(Vector128<double> left, Vector128<double> right);

    // Q15 fixed-point multiply — i16x8.relaxed_q15mulr_s.
    // Saturation on 0x8000 * 0x8000 is implementation-defined
    // (PackedSimd.MultiplyRoundedSaturateQ15 always saturates).
    public static Vector128<short> MultiplyRoundedQ15(Vector128<short> left, Vector128<short> 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<short> DotProduct   (Vector128<sbyte> left, Vector128<byte> right);
    public static Vector128<int>   DotProductAdd(Vector128<sbyte> left, Vector128<byte> right, Vector128<int> accumulator);
}

Prototype: lewing@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):

static Vector128<float> Blend(Vector128<float> a, Vector128<float> b, Vector128<float> 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):

static Vector128<byte> Pshufb(Vector128<byte> data, Vector128<byte> 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):

static Vector128<int> DotAccum(Vector128<byte> left, Vector128<sbyte> weights7bit, Vector128<int> 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, 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<double>). 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 WIP: repo consolidation scouting kick-off - make clr build locally on Windows #1Vector128.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 WIP: repo consolidation scouting kick-off - make clr build locally on Windows #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. [Analyzer] Upgrade from platform specific to cross platform intrinsics #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.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions