Description
The .NET 10 compiler seems to have a performance regression when using foreach on a Span<T> field stored in a ref struct that is backed by ArrayPool<T>. The JIT produces code that is 2x slower than .NET 9 for the same pattern. The equivalent for loop is unaffected.
Here is the benchmark that repro the issue:
usingBenchmarkDotNet.Attributes;usingBenchmarkDotNet.Jobs;usingSystem;usingSystem.Buffers;usingSystem.Runtime.InteropServices;namespaceRentSpanUnmanagedPerfTests;[SimpleJob(RuntimeMoniker.Net90)][SimpleJob(RuntimeMoniker.Net10_0)][DisassemblyDiagnoser(maxDepth:3,exportDiff:true)]publicclassArrayPoolForeachRepro{privateconstintSize=1000;[Benchmark(Description="ArrayPool + Span - foreach (SLOW on .NET 10 and .NET 9)")]publicintArrayPool_Foreach(){varpool=ArrayPool<int>.Shared;vararray=pool.Rent(Size);try{varspan=newSpan<int>(array,0,Size);// Writefor(vari=0;i<Size;i++){span[i]=i*2;}// Read with foreachvarsum=0;foreach(ref readonly varvalueinspan){sum+=value;}returnsum;}finally{pool.Return(array);}}[Benchmark(Description="ArrayPool + Span - for loop (OK on .NET 10 and .NET 9)")]publicintArrayPool_ForLoop(){varpool=ArrayPool<int>.Shared;vararray=pool.Rent(Size);try{varspan=newSpan<int>(array,0,Size);// Writefor(vari=0;i<Size;i++){span[i]=i*2;}// Read with for loopvarsum=0;for(vari=0;i<span.Length;i++){sum+=span[i];}returnsum;}finally{pool.Return(array);}}[Benchmark(Description="RefStruct + Span field - foreach (SLOW on .NET 10, FAST on .NET 9)")]publicintRefStruct_Foreach(){usingvarwrapper=newSpanWrapper(Size);// Writefor(vari=0;i<Size;i++){wrapper.Span[i]=i*2;}// Read with foreachvarsum=0;varspan=wrapper.Span;foreach(ref readonly varvalueinspan){sum+=value;}returnsum;}[Benchmark(Description="RefStruct + Span field - for loop (OK on .NET 10 and .NET 9)")]publicintRefStruct_ForLoop(){usingvarwrapper=newSpanWrapper(Size);// Writefor(vari=0;i<Size;i++){wrapper.Span[i]=i*2;}// Read with for loopvarsum=0;for(vari=0;i<wrapper.Span.Length;i++){sum+=wrapper.Span[i];}returnsum;}privatereadonlyrefstructSpanWrapper{privatereadonlyint[]m_Array;publicreadonlySpan<int>Span;publicSpanWrapper(intlength){m_Array=ArrayPool<int>.Shared.Rent(length);Span=newSpan<int>(m_Array,0,length);}publicvoidDispose(){ArrayPool<int>.Shared.Return(m_Array);}}}On .NET 9, the test RefStruct_Foreach runs in ~653 ns. On .NET 10, it runs in ~1,353 ns — a 2.07x regression.
The disassembly shows that on .NET 10, the JIT aggressively inlines the entire SharedArrayPool<T>.Rent() method (bucket management, thread-static access, hash code computation, monitor operations) into the method body when ƒoreach is used on the Span field. On .NET 9, Rent and return are kept as normal calls, producing a compact method body of ~198 bytes with clean loops.
Disassembly diff is here: https://gist.github.com/ablanchet/8831824c3b81134fef9d5e1d701633ff
Configuration
- Runtimes:
- .NET 10.0.3 (10.0.3, 10.0.326.7603), X64 RyuJIT x86-64-v4 (Job: .NET 10.0(Runtime=.NET 10.0))
- .NET 9.0.11 (9.0.11, 9.0.1125.51716), X64 RyuJIT x86-64-v4 (Job: .NET 9.0(Runtime=.NET 9.0))
- OS:
- Windows 11 for disassembly export.
- Similar results on MacOS 26.2
Regression?
Yes, same exact code is running 2x slower on .NET 10 compared to .NET 9.
Data
Here are the results of the benchmarkdotnet run
| Method | Job | Runtime | Mean | Error | StdDev | Median | Code Size ||--------------------------------------------------------------------- |---------- |---------- |-----------:|---------:|---------:|-----------:|----------:|| 'ArrayPool + Span - foreach (SLOW on .NET 10 and .NET 9)' | .NET 10.0 | .NET 10.0 | 1,340.5 ns | 26.83 ns | 71.16 ns | 1,335.5 ns | 2,589 B || 'ArrayPool + Span - for loop (OK on .NET 10 and .NET 9)' | .NET 10.0 | .NET 10.0 | 673.3 ns | 13.50 ns | 30.19 ns | 686.8 ns | 2,573 B || 'RefStruct + Span field - foreach (SLOW on .NET 10, FAST on .NET 9)' | .NET 10.0 | .NET 10.0 | 1,353.4 ns | 26.92 ns | 75.49 ns | 1,364.8 ns | 2,547 B || 'RefStruct + Span field - for loop (OK on .NET 10 and .NET 9)' | .NET 10.0 | .NET 10.0 | 659.4 ns | 13.14 ns | 33.21 ns | 670.4 ns | 2,535 B || 'ArrayPool + Span - foreach (SLOW on .NET 10 and .NET 9)' | .NET 9.0 | .NET 9.0 | 1,394.6 ns | 27.74 ns | 62.05 ns | 1,406.9 ns | 2,008 B || 'ArrayPool + Span - for loop (OK on .NET 10 and .NET 9)' | .NET 9.0 | .NET 9.0 | 655.0 ns | 13.03 ns | 32.92 ns | 666.8 ns | 2,004 B || 'RefStruct + Span field - foreach (SLOW on .NET 10, FAST on .NET 9)' | .NET 9.0 | .NET 9.0 | 652.8 ns | 13.62 ns | 40.15 ns | 655.5 ns | 2,110 B || 'RefStruct + Span field - for loop (OK on .NET 10 and .NET 9)' | .NET 9.0 | .NET 9.0 | 666.7 ns | 13.36 ns | 30.16 ns | 679.8 ns | 2,110 B |
Analysis
Looking at the disassembly diff it looks like in .NET the foreach is inlining too much code compared to .NET 9, but that's probably a symptom but not the root cause.
Description
The .NET 10 compiler seems to have a performance regression when using
foreachon aSpan<T>field stored in a ref struct that is backed byArrayPool<T>. The JIT produces code that is 2x slower than .NET 9 for the same pattern. The equivalentforloop is unaffected.Here is the benchmark that repro the issue:
On .NET 9, the test
RefStruct_Foreachruns in ~653 ns. On .NET 10, it runs in ~1,353 ns — a 2.07x regression.The disassembly shows that on .NET 10, the JIT aggressively inlines the entire
SharedArrayPool<T>.Rent()method (bucket management, thread-static access, hash code computation, monitor operations) into the method body whenƒoreachis used on theSpanfield. On .NET 9,Rentandreturnare kept as normal calls, producing a compact method body of ~198 bytes with clean loops.Disassembly diff is here: https://gist.github.com/ablanchet/8831824c3b81134fef9d5e1d701633ff
Configuration
Regression?
Yes, same exact code is running 2x slower on .NET 10 compared to .NET 9.
Data
Here are the results of the benchmarkdotnet run
Analysis
Looking at the disassembly diff it looks like in .NET the foreach is inlining too much code compared to .NET 9, but that's probably a symptom but not the root cause.