Skip to content

Poor performance/code-gen with struct enumerators, especially when containing ReadOnlySpan #71510

Closed

Description

@Chicken-Bones

Description

Struct enumerators have a lot of potential for high-performance iteration of custom structures. I've seen 'struct-linq' discussed in several places. The following is a simple but practical example.

Try changing the type of arr to T[] and inds to ReadOnlySpan<int> and observe differences in stack spill and x86.

I'd love a way to write this code fully with spans and have it be performant.

Even the T[] and int[] version doesn't compare to the equivalent hand-written version (Test2). It looks like loop-cloning doesn't see the enumerator as eligible.

public ref struct FilteredSpanEnumerator<T>
{
	private readonly ReadOnlySpan<T> arr;
	private readonly int[] inds;

	private T current;
	private int i;

	[MethodImpl(MethodImplOptions.AggressiveInlining)]
	public FilteredSpanEnumerator(ReadOnlySpan<T> arr, int[] inds) {
		this.arr = arr;
		this.inds = inds;
		current = default;
		i = 0;
	}

	public T Current => current;

	[MethodImpl(MethodImplOptions.AggressiveInlining)]
	public bool MoveNext() {
		if (i >= inds.Length)
			return false;

		current = arr[inds[i++]];
		return true;
	}

	public FilteredSpanEnumerator<T> GetEnumerator() => this;

	public static void Test() => Test3(new T[100], new int[] {0, 5, 6, 12, 47, 99});

	public static void Test(ReadOnlySpan<T> elements, int[] inds) {
		foreach (var s in new FilteredSpanEnumerator<T>(elements, inds)) {
			Console.WriteLine(s);
		}
	}

	public static void Test2(ReadOnlySpan<T> elements, ReadOnlySpan<int> inds) {
		int i = 0;
		while (i < inds.Length) {
			Console.WriteLine(elements[inds[i++]]);
		}
	}
}

Configuration

.NET 6.0.6, Win x64

Analysis

Probably a duplicate of a number of issues like #68797 #9977 related to struct promotion and loop cloning heuristics.

category:cq
theme:structs
skill-level:expert
cost:large
impact:medium

Activity

  1. ghost added
    untriagedNew issue has not been triaged by the area owner
    on Jun 30, 2022
  2. added
    area-CodeGen-coreclrCLR JIT compiler in src/coreclr/src/jit and related components such as SuperPMI
    on Jul 1, 2022
  3. added this to Runtime Infra and removed this from Runtime Infraon Jul 1, 2022
  4. JulieLeeMSFT commented on Jul 5, 2022

    @JulieLeeMSFT
    Member

    cc @BruceForstall for further triaging.

    Probably a duplicate of a number of issues like #68797 #9977 related to struct promotion and loop cloning heuristics.

  5. added
    needs-further-triageIssue has been initially triaged, but needs deeper consideration or reconsideration
    and removed
    untriagedNew issue has not been triaged by the area owner
    on Jul 5, 2022
  6. added this to the 8.0.0 milestone on Jul 5, 2022
  7. BruceForstall commented on Jul 5, 2022

    @BruceForstall
    Contributor

    Maybe also related? #9304

  8. removed
    needs-further-triageIssue has been initially triaged, but needs deeper consideration or reconsideration
    on Jul 5, 2022
  9. removed their assignment
    on Jul 5, 2022
  10. Chicken-Bones commented on Jul 5, 2022

    @Chicken-Bones
    ContributorAuthor

    Maybe also related? #9304

    CollectionsMarshal.AsSpan allows for avoiding the versioning overhead and size vs length that list enumerator has, but the "critical block (fork/join)." issue is probably related.

    Additional reasoning about lengths and bound checks is a generally valuable feature, not applicable in this example, but consider a Zip enumerator that initializes int len = a.Length < b.Length ? a.Length : b.Length; and iterates from i = 0 to len

    Eliminating bounds checks can be key in some hot loop scenarios and there's no way to use unsafe with managed types so we're trying to make the most of Span

  11. Chicken-Bones commented on Mar 31, 2023

    @Chicken-Bones
    ContributorAuthor

    Adding to this, the x86 produced by ReadOnlySpan<T>.Enumerator is significantly worse/longer than the for-i loop version (which is produced by the compiler when using foreach on a span directly). This means that structs/classes which essentially wrap an array or span cannot achieve peak performance with code like

    public ReadOnlySpan<T>.Enumerator GetEnumerator() => _span.GetEnumerator();

    Instead, such wrappers must force the caller to convert them to a Span/Array explicitly in order to use foreach

  12. self-assigned this
    on Apr 4, 2023
  13. jakobbotsch commented on Apr 4, 2023

    @jakobbotsch
    Member

    @Chicken-Bones Thanks for the example. The problem with the existing JIT is, as you mention, essentially the same as #68797 -- the existing struct promotion the JIT supports cannot handle recursive promotion, so we do not enregister the fields of FilteredSpanEnumerator.

    I am working on more general handling for struct promotion in #76928, with a (by default disabled) prototype coming in #83388. The prototype can handle the recursive patterns like in this case. I've turned your code into a benchmark here that shows good results at around 2.5x the speed.

    If you have more examples then please feel free to share them. It is very helpful to have as many concrete cases as possible.

  14. Chicken-Bones commented on Apr 4, 2023

    @Chicken-Bones
    ContributorAuthor

    Thanks, this is great to see! The other benchmark I'd like to provide is simple:

    ref struct SpanWrapper<T>
    {
        public ReadOnlySpan<T> span;
        public ReadOnlySpan<T>.Enumerator GetEnumerator() => span.GetEnumerator();
    }

    and then compare foreach (var s in span) to foreach (var s in spanWrapper)

  15. jakobbotsch commented on Apr 25, 2023

    @jakobbotsch
    Member

    Thanks! I benchmarked that and it's better, and approaches the ReadOnlySpan<T> version with tiered PGO. The benchmark is the following:

    Benchmark code
    [BenchmarkCategory(Categories.Runtime)]
    public class SpanWrapper
    {
        private int[] _array;
    
        [GlobalSetup]
        public void Setup()
        {
            _array = Enumerable.Range(0, 10000).ToArray();
        }
    
        [Benchmark]
        public int BaselineSum()
        {
            return SumSpan(_array);
        }
    
        [Benchmark]
        public int WrapperSum()
        {
            return SumSpanWrapper(new SpanWrapper<int> { Span = _array });
        }
    
        [MethodImpl(MethodImplOptions.NoInlining)]
        private int SumSpan(ReadOnlySpan<int> span)
        {
            int sum = 0;
            foreach (int val in span)
                sum += val;
    
            return sum;
        }
    
        [MethodImpl(MethodImplOptions.NoInlining)]
        private int SumSpanWrapper(SpanWrapper<int> spanWrapper)
        {
            int sum = 0;
            foreach (int val in spanWrapper)
                sum += val;
    
            return sum;
        }
    }
    
    public ref struct SpanWrapper<T>
    {
        public ReadOnlySpan<T> Span;
        public ReadOnlySpan<T>.Enumerator GetEnumerator() => Span.GetEnumerator();
    }

    The results are:

    Method Mean Error StdDev Median Min Max Allocated
    BaselineSum 2.122 us 0.0032 us 0.0029 us 2.122 us 2.116 us 2.128 us -
    WrapperSum (no physical promotion) 6.166 us 0.0195 us 0.0182 us 6.167 us 6.121 us 6.191 us -
    WrapperSum (with physical promotion) 4.241 us 0.0148 us 0.0139 us 4.245 us 4.216 us 4.257 us -
    WrapperSum (physical promotion + tiered PGO) 2.823 us 0.0550 us 0.0589 us 2.832 us 2.710 us 2.909 us -
    WrapperSum (physical promotion + synthetic PGO) 4.242 us 0.0081 us 0.0072 us 4.243 us 4.227 us 4.255 us -

    The inner loop of the baseline is

    G_M10672_IG03:  ;; offset=000FH
           mov      r9d, r8d
           add      eax, dword ptr [rcx+4*r9]
           inc      r8d
           cmp      r8d, edx
           jl       SHORT G_M10672_IG03

    The inner loop without physical promotion of the wrapped version is:

    G_M19652_IG06:  ;; offset=004DH
           jmp      SHORT G_M19652_IG08
                                                    ;; size=2 bbWeight=1 PerfScore 2.00
    G_M19652_IG07:  ;; offset=004FH
           mov      edx, dword ptr [rsp+50H]
           cmp      edx, dword ptr [rsp+48H]
           jae      SHORT G_M19652_IG11
           mov      rcx, bword ptr [rsp+40H]
           add      eax, dword ptr [rcx+4*rdx]
                                                    ;; size=18 bbWeight=2 PerfScore 16.00
    G_M19652_IG08:  ;; offset=0061H
           mov      edx, dword ptr [rsp+50H]
           inc      edx
           cmp      edx, dword ptr [rsp+48H]
           jge      SHORT G_M19652_IG10
                                                    ;; size=12 bbWeight=8 PerfScore 34.00
    G_M19652_IG09:  ;; offset=006DH
           mov      dword ptr [rsp+50H], edx
           jmp      SHORT G_M19652_IG07

    With physical promotion (DOTNET_JitEnablePhysicalPromotion=1) the generated code is:

    G_M19652_IG06:  ;; offset=004DH
           mov      edx, dword ptr [rsp+48H]
           mov      ecx, -1
           jmp      SHORT G_M19652_IG08
                                                    ;; size=11 bbWeight=1 PerfScore 3.25
    G_M19652_IG07:  ;; offset=0058H
           cmp      ecx, edx
           jae      SHORT G_M19652_IG11
           mov      r8, bword ptr [rsp+40H]
           mov      r9d, ecx
           add      eax, dword ptr [r8+4*r9]
                                                    ;; size=16 bbWeight=2 PerfScore 11.00
    G_M19652_IG08:  ;; offset=0068H
           inc      ecx
           cmp      ecx, edx
           jge      SHORT G_M19652_IG10
                                                    ;; size=6 bbWeight=8 PerfScore 12.00
    G_M19652_IG09:  ;; offset=006EH
           jmp      SHORT G_M19652_IG07

    Seems like this misses one important physical promotion (the [rsp+40H]). With tiered PGO enabled (DOTNET_TieredPGO=1) we realize that promotion is important and then the codegen is:

    G_M19652_IG05:  ;; offset=0040H
           cmp      r8d, ecx
           jae      SHORT G_M19652_IG08
           mov      r9d, r8d
           add      eax, dword ptr [rdx+4*r9]
                                                    ;; size=12 bbWeight=999.00 PerfScore 4495.50
    G_M19652_IG06:  ;; offset=004CH
           inc      r8d
           cmp      r8d, ecx
           jge      SHORT G_M19652_IG07
           jmp      SHORT G_M19652_IG05
                                                    ;; size=10 bbWeight=1000.00 PerfScore 3500.00

    Which still has an unnecessary bounds check but is very close to the ideal version in performance.

    With synthetic PGO instead of tiered PGO (DOTNET_JitSynthesizeCounts=1, not available in release JIT builds) we do get the extra promotion as well, but we seem to end up with some odd unoptimal flow:

    G_M19652_IG05:  ;; offset=0040H
           cmp      r8d, ecx
           jae      SHORT G_M19652_IG09
           mov      r9d, r8d
           add      eax, dword ptr [rdx+4*r9]
                                                    ;; size=12 bbWeight=36.00 PerfScore 162.00
    G_M19652_IG06:  ;; offset=004CH
           inc      r8d
           cmp      r8d, ecx
           jl       SHORT G_M19652_IG08
                                                    ;; size=8 bbWeight=80.00 PerfScore 120.00
    G_M19652_IG07:  ;; offset=0054H
           add      rsp, 56
           ret
                                                    ;; size=5 bbWeight=80.00 PerfScore 100.00
    G_M19652_IG08:  ;; offset=0059H
           jmp      SHORT G_M19652_IG05
                                                    ;; size=2 bbWeight=4.16 PerfScore 8.32
    G_M19652_IG09:  ;; offset=005BH
           call     CORINFO_HELP_RNGCHKFAIL
           int3

    (cc @AndyAyersMS in case you are interested)

    @Chicken-Bones Are you ok with me adding your benchmarks to our collection of benchmarks at https://github.com/dotnet/performance/, under the MIT license? It will allow us to track the performance of these scenarios better.

  16. Chicken-Bones commented on Apr 25, 2023

    @Chicken-Bones
    ContributorAuthor

    Yes absolutely, please do!

  17. added
    Priority:2Work that is important, but not critical for the release
    on Jun 20, 2023
  18. ghost added
    in-prThere is an active PR which will close this issue when it is merged
    on Jun 27, 2023
  19. ghost removed
    in-prThere is an active PR which will close this issue when it is merged
    on Jun 29, 2023
  20. ghost locked as resolved and limited conversation to collaborators on Jul 29, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Priority:2Work that is important, but not critical for the releasearea-CodeGen-coreclrCLR JIT compiler in src/coreclr/src/jit and related components such as SuperPMItenet-performancePerformance related issue

Type

No type

Projects

No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions