Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enumerable.Index/Select((T, int)) iterator classes #102314

Closed
wants to merge 1 commit into from

Conversation

Emik03
Copy link

@Emik03 Emik03 commented May 16, 2024

This PR addresses #102252 by replacing the iterator methods for Enumerable.Index and Enumerable.Select (Func<TSource, int, TResult> overload) with iterator classes, which allows the number of elements to be extracted using Enumerable.TryGetNonEnumeratedCount.

There are two considerations to this change to focus on, which is how it affects binary size and enumeration performance compared to before.

Binary Size

To make up for the added iterator class, Size compromises by reusing the iterator class for Index, which has a slight overhead in delegate invocation. This happens to perfectly balance out, keeping the file size the same.

Before After Delta
Size 136.2kB 136.2kB 0
Speed 170.5kB 172.5kB +2kB

Performance

The following benchmarks were ran on the following specs:

  • OS: Fedora Linux 40 (Workstation Edition) x86_64
  • Kernel: 6.8.9-300.fc40.x86_64
  • CPU: AMD Ryzen 5 3600 (12) @ 3.6GHz
  • GPU: NVIDIA GeForce RTX 2070 SUPER
  • Memory: 31.24 GiB
  • BIOS: American Megatrends Inc. 5.17 (06/15/2023)

For clarity, here are the two benchmarks I had to add to System.Linq which can also be found in this PR:

[Benchmark]
[ArgumentsSource(nameof(SelectArguments))]
public void SelectTwoArgs(LinqTestData input) => input.Collection.Select((i, _) => i + 1).Consume(_consumer);

[Benchmark]
[ArgumentsSource(nameof(SelectArguments))]
public void Index(LinqTestData input) => input.Collection.Index().Consume(_consumer);

Before:

Method Job OutlierMode MemoryRandomization input1 input2 input Mean Error StdDev Median Min Max Gen0 Gen1 Allocated
SelectTwoArgs Job-OHSSWX Default Default ? ? Array 289.393 ns 0.7792 ns 0.6084 ns 289.638 ns 288.361 ns 290.241 ns 0.0105 - 88 B
Index Job-OHSSWX Default Default ? ? Array 442.137 ns 7.3575 ns 6.1439 ns 439.917 ns 435.546 ns 453.958 ns 0.0107 - 96 B
SelectTwoArgs Job-OHSSWX Default Default ? ? IEnumerable 297.529 ns 2.1482 ns 2.0095 ns 297.319 ns 293.311 ns 300.414 ns 0.0095 - 88 B
Index Job-OHSSWX Default Default ? ? IEnumerable 399.280 ns 1.6105 ns 1.4277 ns 398.921 ns 397.490 ns 402.281 ns 0.0111 - 96 B
SelectTwoArgs Job-OHSSWX Default Default ? ? IList 295.300 ns 1.0585 ns 0.8839 ns 295.175 ns 293.870 ns 296.760 ns 0.0095 - 88 B
Index Job-OHSSWX Default Default ? ? IList 448.469 ns 9.8079 ns 11.2948 ns 442.788 ns 437.454 ns 471.771 ns 0.0106 - 96 B
SelectTwoArgs Job-OHSSWX Default Default ? ? List 326.419 ns 6.2312 ns 5.8287 ns 324.128 ns 320.319 ns 341.352 ns 0.0106 - 96 B
Index Job-OHSSWX Default Default ? ? List 420.871 ns 3.7947 ns 3.5495 ns 419.996 ns 415.642 ns 427.337 ns 0.0120 - 104 B

After (Size):

Method Job OutlierMode MemoryRandomization input1 input2 input Mean Error StdDev Median Min Max Gen0 Gen1 Allocated
SelectTwoArgs Job-HHHSDK Default Default ? ? Array 305.939 ns 5.0186 ns 4.6944 ns 306.782 ns 297.635 ns 314.629 ns 0.0099 - 88 B
Index Job-HHHSDK Default Default ? ? Array 447.542 ns 5.2369 ns 4.8986 ns 447.845 ns 438.340 ns 456.185 ns 0.0107 - 96 B
SelectTwoArgs Job-HHHSDK Default Default ? ? IEnumerable 295.164 ns 5.1138 ns 4.2703 ns 293.172 ns 291.494 ns 306.610 ns 0.0096 - 88 B
Index Job-HHHSDK Default Default ? ? IEnumerable 447.696 ns 4.5719 ns 4.0529 ns 446.909 ns 443.248 ns 455.190 ns 0.0108 - 96 B
SelectTwoArgs Job-HHHSDK Default Default ? ? IList 299.786 ns 2.4750 ns 2.3151 ns 300.205 ns 296.367 ns 304.496 ns 0.0096 - 88 B
Index Job-HHHSDK Default Default ? ? IList 446.041 ns 1.8207 ns 1.6140 ns 445.544 ns 444.221 ns 449.126 ns 0.0106 - 96 B
SelectTwoArgs Job-HHHSDK Default Default ? ? List 331.369 ns 5.1637 ns 4.8301 ns 331.554 ns 325.195 ns 342.152 ns 0.0108 - 96 B
Index Job-HHHSDK Default Default ? ? List 428.238 ns 4.7347 ns 3.9537 ns 428.055 ns 421.260 ns 434.336 ns 0.0121 - 104 B

After (Speed):

Method Job OutlierMode MemoryRandomization input1 input2 input Mean Error StdDev Median Min Max Gen0 Gen1 Allocated
SelectTwoArgs Job-YLOCZA Default Default ? ? Array 309.608 ns 3.4150 ns 2.8517 ns 309.068 ns 304.499 ns 315.975 ns 0.0100 - 88 B
Index Job-YLOCZA Default Default ? ? Array 397.406 ns 5.8171 ns 5.1567 ns 396.914 ns 391.216 ns 409.981 ns 0.0095 - 88 B
SelectTwoArgs Job-YLOCZA Default Default ? ? IEnumerable 316.334 ns 5.6888 ns 5.0429 ns 314.942 ns 309.132 ns 325.836 ns 0.0099 - 88 B
Index Job-YLOCZA Default Default ? ? IEnumerable 399.424 ns 4.5314 ns 4.2387 ns 399.317 ns 392.139 ns 406.830 ns 0.0095 - 88 B
SelectTwoArgs Job-YLOCZA Default Default ? ? IList 294.247 ns 1.2717 ns 1.0619 ns 294.200 ns 292.499 ns 296.539 ns 0.0095 - 88 B
Index Job-YLOCZA Default Default ? ? IList 352.187 ns 0.9567 ns 0.7469 ns 352.286 ns 351.089 ns 353.619 ns 0.0099 - 88 B
SelectTwoArgs Job-YLOCZA Default Default ? ? List 325.543 ns 1.5635 ns 1.3056 ns 325.214 ns 324.163 ns 328.834 ns 0.0104 - 96 B
Index Job-YLOCZA Default Default ? ? List 392.757 ns 1.2555 ns 1.0484 ns 392.946 ns 390.955 ns 394.513 ns 0.0111 - 96 B

Before vs After (Size):

Slower diff/base Base Median (ns) Diff Median (ns) Modality
System.Linq.Tests.Perf_Enumerable.Index(input: IEnumerable) 1.13 398.92 451.83
System.Linq.Tests.Perf_Enumerable.SelectTwoArgs(input: IList) 1.02 295.17 302.00
Faster base/diff Base Median (ns) Diff Median (ns) Modality
System.Linq.Tests.Perf_Enumerable.Index(input: IList) 1.10 442.79 403.82

Before vs After (Speed):

Slower diff/base Base Median (ns) Diff Median (ns) Modality
System.Linq.Tests.Perf_Enumerable.SelectTwoArgs(input: Array) 1.07 289.64 309.07
System.Linq.Tests.Perf_Enumerable.SelectTwoArgs(input: IEnumerable) 1.06 297.32 314.94
Faster base/diff Base Median (ns) Diff Median (ns) Modality
System.Linq.Tests.Perf_Enumerable.Index(input: IList) 1.26 442.79 352.29
System.Linq.Tests.Perf_Enumerable.Index(input: Array) 1.11 439.92 396.91
System.Linq.Tests.Perf_Enumerable.Index(input: List) 1.07 420.00 392.95

I am admittedly very confused on why SelectTwoArgs performs much worse in Speed when both are compiled identically.

Personal Opinion

Enumerable.Index very clearly benefits from this, and I think it's a pretty open-and-shut case to include this change. The same cannot be said for Select, or perhaps we need more hardware to benchmark this. If the new implementation proves insufficient, we can scrap the Select implementation but keep the Index iterator class.

Hopefully I did everything correctly, and by all means I welcome any suggestions or changes to make this better or faster.

…2252)

* Allows both methods to have their lengths obtained from Enumerable.TryGetNonEnumeratedCount().

* Improves performance of Enumerable.Index with an Array/IList/List source.
@dotnet-policy-service dotnet-policy-service bot added the community-contribution Indicates that the PR has been added by a community member label May 16, 2024
Copy link
Contributor

Tagging subscribers to this area: @dotnet/area-system-linq
See info in area-owners.md if you want to be subscribed.

@Emik03
Copy link
Author

Emik03 commented May 16, 2024

Oh dear, the tables turned out far less readable than it looked in preview, I don't know if I can do anything to make them look better.

@stephentoub
Copy link
Member

Binary Size

What binary size is being measured here, the System.Linq.dll assembly? While that's interesting, the more impactful size is assembly, e.g. if you publish a NativeAOT app that's using LINQ, say a Select operator somewhere, what does the addition of these types do to that NativeAOT published binary size?

@Emik03
Copy link
Author

Emik03 commented May 17, 2024

Binary Size

What binary size is being measured here, the System.Linq.dll assembly? While that's interesting, the more impactful size is assembly, e.g. if you publish a NativeAOT app that's using LINQ, say a Select operator somewhere, what does the addition of these types do to that NativeAOT published binary size?

I originally thought file-size concerns were regarding System.Linq.dll, my mistake. I'll edit the post tomorrow to additionally include a basic NativeAOT test, with the source code I'm testing with.

@eiriktsarpalis
Copy link
Member

In light of the inconclusive results, I think it probably isn't worth applying iterator optimizations to Index. Primarily the method is intended to faciliate foreach expressions:

foreach ((int i, T item) in source.Index) { }

And I wouldn't consider it common enough for people to chain it with ToArray/ToList/TryGetFirst calls.

@Emik03
Copy link
Author

Emik03 commented May 17, 2024

I've been trying all day but had trouble compiling NativeAOT with the self-built System.Linq, so I can't tell you how this fares in size.

However, my idea is that if performance and binary size stays more-or-less the same, isn't that already good enough? Perhaps the average use-case isn't optimized, but if .Select or .Index ever gets passed directly or indirectly into .ToArray() or similar, then having the count be computable is a small improvement.

@eiriktsarpalis
Copy link
Member

However, my idea is that if performance and binary size stays more-or-less the same, isn't that already good enough?

It also replaces a simple implementation with a more complex one. Any change comes with added cost, including risk of potential regression so this would need to be outweighed with measurable benefits.

@eiriktsarpalis
Copy link
Member

That being said, I really appreciate your effort and the attention to detail that you put into this PR! I think we collectively learned something by it, so let this not dissuade you from similar experimentation in the future.

@Emik03
Copy link
Author

Emik03 commented May 17, 2024

However, my idea is that if performance and binary size stays more-or-less the same, isn't that already good enough?

It also replaces a simple implementation with a more complex one. Any change comes with added cost, including risk of potential regression so this would need to be outweighed with measurable benefits.

If I may ask, what kind of potential regressions could you be referring to? All tests pass just fine, and performance is the same, or slightly better for Index (speed variant) in lists/arrays. I hope I haven't repeated myself too much by saying that, I'm just having trouble understanding the potential problems here.

@eiriktsarpalis
Copy link
Member

If I may ask, what kind of potential regressions could you be referring to?

No specific concerns, I'm just pointing out that every PR that we merge introduces risk and maintenance cost. If it doesn't move the needle substantially, we err towards not making the change at all.

@stephentoub
Copy link
Member

Closing per #102314 (comment). But thanks!

@github-actions github-actions bot locked and limited conversation to collaborators Jul 24, 2024
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
area-System.Linq community-contribution Indicates that the PR has been added by a community member
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants