Repository navigation
[wasm][coreclr][R2R] TensorPrimitives.IndexOfMax<double> remains interpreted and ~18x slower #134559
Description
Description
On browser-WASM CoreCLR, publishing the System.Numerics.Tensors.Tests.Perf_NumberTensorPrimitives<Double>.IndexOfMax(BufferLength: 3079) microbenchmark with ReadyToRun (R2R) leaves the closed tensor method and its vector helper interpreted. The R2R lane is ~18x slower than the interpreter-only lane; the intended outcome is a compiled SIMD tensor loop, not merely disabling SIMD in interpreted code.
The benchmark at dotnet/performance commit 99531dce calls TensorPrimitives.IndexOfMax<T> over seeded input (128 or 6*512+7 elements). An independent minimal browser CoreCLR probe with .NET 12 packages, double[3079] filled by Random(42).NextDouble(), no NaNs, returns index 1254 in both modes and reproduces the slowdown direction. This probe uses a different harness and is not the source of the ADX ratios below.
Configuration
ADX CompilationMode=wasm, RunKind=micro, RuntimeType=coreclr, target net11.0, RunQueue=Ubuntu.2204.Amd64.Viper.Perf, Ubuntu 22.04 x64, BDN 0.16.0-nightly.20260801.595. The latest build is 20260923.2 (runtime SHA 806095e5, performance SHA 99531dce). ADX exposes the queue/OS/architecture, not the CPU model or full BDN command. Interpreter and R2R lanes ran on separate, interchangeable Viper workers; the independent local probe also corroborates the slowdown direction.
The independent direct probe used SDK 12.0.100-alpha.1.26473.105, browser CoreCLR runtime and Tensors package 12.0.0-alpha.1.26473.105, and V8 15.1.182; it is not the same SDK/cohort as the ADX run.
Regression?
A specific introducing build has not been identified. The gap persists across the measured September 19/20 before-and-after builds and the latest September 23 build. The September 20 changes did not improve the benchmark's absolute R2R time.
Data
ADX default lower-is-better counter, Duration of single invocation, TestCounterResultAverage over 15 retained results:
IndexOfMax double/3079 |
Interpreter mean | R2R mean | R2R/interpreter |
|---|---|---|---|
Before: 20260919.4, runtime 229a3310 |
614,217 ns | 11,480,400 ns | 18.69x |
After: 20260920.7, runtime f8546ab2 |
643,114 ns | 11,587,246 ns | 18.02x |
Latest: 20260923.2, runtime 806095e5 |
626,058 ns | 11,736,502 ns | 18.75x |
The before/after rows use the same performance SHA d92bd6943bb130fef8a1de79d782f9022e3d260c. Latest same-build interpreter RunId: 49e37a45ea8eb9aef4a3e95c2509777c2a2fe864e0bb0f0793fae13c08b9e079; R2R RunId: 9af02c975b0d462bf710749308784002ba4fff6cee73b8d94e41f99d9d8c3ca5. Latest double/128: 26,608 ns interpreter versus 479,264 ns R2R (18.01x). Latest single/128: 25,488 ns versus 359,351 ns (14.10x). Double/3079 ADX history report.
Independent .NET 12 direct probe, five 1,000-call samples, double/3079: interpreter median ~1.13 ms/call versus R2R median ~14.80 ms/call; the different runtime and harness mean these values should not be compared numerically with ADX.
Analysis
The raw published System.Numerics.Tensors WASM R2R image contains only IndexOfMax<__Canon>, IndexOfMinMaxCore<__Canon,...>, and canonical fallback bodies; it has no closed double/float specialization or compiled vector helper. __Canon is a reference-type canonical instantiation, not the value-type double benchmark body.
There are two distinct obstacles to a compiled vector path:
- Default per-assembly Crossgen2 library roots do not include these closed value-type generic instantiations. An explicit closed-method rooting mechanism is needed (curated roots are reasonable while representative WASM PGO profiles are unavailable).
- Independently forcing
IndexOfMinMaxVectorized128Size4Plus<double,IndexOfMaxOperator<double>,ulong>with the published linked IL closure,--opt-cross-module:*and+simd128produces no R2R function. Crossgen2 reportsCanInline currently doesn't support propagation of constrained type so that we cannot reliably tell whether a SVM call can be inlinedand requires the runtime JIT. A reduced probe isolatesTInt.CreateTruncating(int)resolving toUInt64.CreateTruncating<int>in CoreLib as the first blocker; a localTOperator.Comparestatic-virtual call does compile.CorInfoImpl.ReadyToRun.csdiscards a resolved constrained target outsideVersionsWithMethodBodyand then rejects the non-shared-context SVM call. This needs version-safe cross-module resolution, not simply accepting an unguarded cross-module target. ExistingCheck_VirtualFunctionOverrideand cross-module IL-body fixups are relevant precedents (#131282).
As a diagnostic only, --inputbubble --inputbubbleref:<linked CoreLib> emits an 828-byte closed helper containing v128.load, f64x2 comparisons, and v128.bitselect, proving WASM SIMD codegen can handle the helper once its constrained call resolves. --inputbubbleref is explicitly unsupported and a manually built full image using it failed at startup; it is not a production workaround or a measured performance fix. Without the bubble flags, an otherwise matching manually built full image runs but retains the slow path.
A fix should (a) allow the CoreLib constrained static-virtual call under supported R2R versioning/fixups, (b) root the closed double/single tensor path, (c) verify the normal runnable image contains and executes the vector helper, and (d) benchmark against both the interpreter and prior R2R lane. No compiled-vector timing has been measured yet.
Note
This issue was prepared with GitHub Copilot assistance.
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsNo status
Activity
dotnet-policy-service commentedon Sep 23, 2026
Tagging subscribers to this area: @dotnet/crossgen-contrib
See info in area-owners.md if you want to be subscribed.
lewing commentedon Sep 23, 2026
cc @tannergooding
dotnet-policy-service commentedon Sep 24, 2026
Tagging subscribers to 'arch-wasm': @lewing, @pavelsavara
See info in area-owners.md if you want to be subscribed.
jkotas commentedon Sep 24, 2026
This may be fixed by #134105
lewing commentedon Sep 24, 2026
will test with main
lewing commentedon Sep 24, 2026
Thanks. I retested with a coherent current-source browser-WASM toolchain whose base includes merged #134105. It does not fix this path.
For the same seeded
double[3079]probe:1,361,820 ns/op16,073,840 ns/op(11.80x slower)1254, no NaNs in both modes656,140 nsinterpreted /2,200 nsR2RThe standard current Tensor R2R image still contains only
IndexOfMax<__Canon>andIndexOfMinMaxCore<__Canon,...>; there is no closeddoublespecialization or vector helper. With current Crossgen2, explicitly rootingIndexOfMax<double>and the concrete core succeeds, but directly rootingIndexOfMinMaxVectorized128Size4Plus<double,IndexOfMaxOperator<double>,ulong>still produces no method and reports:This occurs both with and without the WASM NYI guards. The isolated call remains the constrained
UInt64.CreateTruncating<int>static-virtual call across the CoreLib version boundary. So #134105 fixes the nondeterministic-intrinsic R2R exclusion, but the missing closed root and constrained-SVM compilation blocker remain.Note
This comment was prepared with GitHub Copilot assistance.
tannergooding commentedon Sep 24, 2026
@jkotas as best as I can tell this comes down to 3 issues...
While
IndexOfMax<T>is rooted, nothing tells R2R to specifically compileIndexOfMax<float>. This requires some machinery to go and tell R2R to actually compile for that concreteTso its in the R2R image (today we only haveIndexOfMax<__Canon>, even though no ref type was used).Once
IndexOfMax<float>is in the list of things to compile, we have a separate issue where the VM rejects calls usingstatic virtual methods, leaving them as IL -- this is theCanInline currently doesn't support propagation of constrained type so that we cannot reliably tell whether a SVM call can be inlinedmessageThe VM doesn't handle the case where the implementation is recorded in the R2R image and then disappears, because we get a MissingMethodException thrown and never turn that into a
FALSErejection allowing the generic IL fallback to be usedAt a glance, the latter two look relatively simple to handle at least for this scenario (they are probably much more complex for any potential scenario). The former is something that NAOT + trimming generally handle today, but where we don't have such machinery for R2R right now and that is likely immensely impactful to the WASM, iOS, and Android scenarios that rely on R2R+interpreter (presumably because NAOT only or NAOT+interpreter isn't a possible target and probably isn't always a desirable target).
jkotas commentedon Sep 24, 2026
The same machinery exists in R2R too. If your app calls
IndexOfMax<float>(and R2R is compiled as large version bubble - default on mobile and WASM),IndexOfMax<float>should be compiled.Looks like a crossgen bug. It should not be hard to fix for large version bubble case. The problem that the exception message talks about is related to generation of version resilient R2R.
I am not sure what you mean.
tannergooding commentedon Sep 24, 2026
The CoreCLR browser R2R target compiles per assembly with
--opt-cross-module:*, while Crossgen2 adds reference assemblies to the version bubble only with--inputbubble.But then a repro seems to indicate that the tensors R2R image only contains
IndexOfMax<__Canon>and so something is missing in the handoff here and it looks like we aren't getting--inputbubbleset for the WASM publish target. This means theCorelibreferenced byS.N.Tensorsisn't in the same bubble and so theTInt.CreateTruncatingwhich resolves toulong.CreateTruncating<int>inCorelibfails to be handled.Do you know what might be missing here? What's setting the large version bubble and allowing the input bubble to exist at all?
https://github.com/dotnet/runtime/blob/main/src/coreclr/vm/jitinterface.cpp#L14939-L14942 -- This fails with
MissingMethodExceptionif the impl recorded has been removed even if the interface could still use the DIM, for example. So I'm pretty sure we should catch that and instead reportFALSEso the stale R2R is rejected and falls back to the IL insteaddavidwrighton commentedon Sep 24, 2026
We have a cross module generics optimization switch we can use, I'll see if I can get it to work sometime this week However, I'm expecting real customers of our browser wasm experience to be using a composite mode build. (I know that the current work by @pavelsavara isn't that, but it is really the only way to get best performance for this sort of thing, especially in the wasm environment where downloading the same code multiple times is a poor decision.). And a composite build which builds all of the assemblies of the application together should already handle this situation. @lewing, could you check to see the performance of this assuming we have a composite R2R build?
lewing commentedon Sep 24, 2026
I'll have a pr up shortly to enable PublishReadyToRunComposite as an option in the wasm sdk (sorry @maraf)
lewing commentedon Sep 24, 2026
Final verification with the reviewed composite SDK package is complete.
Same Mac/V8 and browser runtime native Wasm across all three modes (
dotnet.native.wasmSHA-1 identical):Two repeat medians were
757,920 / 779,640 nsinterpreted,9,770,320 / 9,697,420 nsper-assembly, and4,240 / 4,220 nscomposite. The final reviewed SDK package republished successfully at4,040 ns/call. Every run returned index1254with no NaNs.The runnable composite image contains the closed
IndexOfMinMaxCore<double, IndexOfMaxOperator<double>>andIndexOfMinMaxVectorized128Size4Plus<double, IndexOfMaxOperator<double>, ulong>bodies; the latter contains the expectedv128.load/f64x2SIMD operations. This is not only static image evidence: a live NESM/Chrome CDP breakpoint in the composite vector helper fired duringdotnet.run(), with the concrete closed core on the stack. Removing the composite.r2r.wasmfrom a copied bundle causes startup to fail rather than silently falling back.So David's expectation is confirmed: compiling the application closure into one large composite version bubble removes the constrained-static-virtual compile gate and produces the expected native SIMD performance. The current per-assembly path remains slow for the reasons already discussed. The composite SDK/publish integration used for this test is being prepared separately.
Note
This comment was prepared with GitHub Copilot assistance.
lewing commentedon Sep 24, 2026
I think it is worth enabling composite mode builds for differential testing: #134618
I can also stand up another microbenchmark lane (and have confirmed there is capacity) with composite enabled once that lands.
MichalPetryka commentedon Sep 24, 2026
Why would interpreter be faster than per assembly r2r?
jkotas commentedon Sep 24, 2026
Interpreter->r2r calls are more expensive that Interpreter->interpreter calls currently.
[wasm][coreclr] Enable composite ReadyToRun publishing (#134618)