Skip to content

2.0.0

Compare
Choose a tag to compare
@github-actions github-actions released this 23 Dec 04:50
· 43 commits to main since this release
234da64

Changes

✨ New Features

Select and Where

This release expands the list of Range extensions with several convenience methods that bring it much closer to counterparts in Rust, Python and others.

var floats = (0..100).Select(i => (float)i);
var odd = (0..100).Where(i => i % 2 != 0);

var randomNumbers = (0..1000)
    .Select(_ => Random.Shared.Next())
    .ToArray();

Iterating through these directly is significantly faster when compared to Enumerable.Range.
In fact, with DynamicPGO enabled, on osx-arm64 the loop foreach (var i in (0..1000).Select(i => i * 2)) gets within 1.3-1.5x ratio of a regular plain for loop. Delegate devirtualization has gotten so good!

IList on RangeEnumerable and SelectRange

Now both these types also implement IList.
While RangeEnumerable is effectively a materialized sequence of numbers, SelectRange serves a purpose of "Select View" over that sequence, with exact T values materialized on access similar to plain IEnumerable<T>.

While this does not conform 100% to existing semantics, it was a necessary change to take the advantage of IEnumerable internals for methods that lack bespoke implementations in this library or when RangeExtensions enumerators are boxed.

Bespoke Aggregate, First, Last and more

The library now exposes additional bespoke LINQ method implementations for shorthand usage together with Range that further improve its usability in functional scenarios.

var digits = (0..10)
    .Aggregate(new StringBuilder(), (sb, i) => sb.Append(i))
    .ToString();

Assert.Equal("0123456789", digits);

// None of these allocate, and all of them are O(1)
var fifteenthDecimal = (0..1337)
    .Select(i => (decimal)i)
    .Take(10)
    .ElementAt(4); // or even just [4]

// Efficient scan from the end of the range
var lastEven = (0..1337)
    .Where(i => i % 2 is 0)
    .Last();

Further optimizations on RangeEnumerable

Previously,Range was nested inside of RangeEnumerable only being unwrapped when creating an enumerator.
This was complicating the job for the JIT code generation and preventing it from seeing that we have already verified a few conditions like whether the range is valid and doesn't start from end for either of the indexes, whether its start value is greater or equal to zero, etc (depends on particular loop used with range).

In addition, this approach was causing further issues when RangeEnumerable was boxed or nested in either RangeSelect or RangeWhere which made the initial prototype have performance barely better or sometimes worse than private implementations of Select and Where iterators from BCL.

Therefore, the best solution turned out to be the most straightforward and likely intellectually disappointing to people who read release notes till the end.

First and foremost, reinterpret-casting Indexes nested inside of Range into ints allowed for better constant propagation and dead branch elimination reducing the size and improving the quality of codegen for precondition checks on an enumerable/enumerator creation.

Next, by flattening all nested structs into their parent holders i.e. storing int _start; int _end; instead of Range and copying RangeEnumerable implementation bits to RangeSelect and RangeWhere instead of nesting RangeEnumerable, it was possible to achieve pretty good enregistering of struct fields, effectively removing the abstraction and turning them into locals.

This in combination with delegate inlining (requires .NET 7 and DynamicPGO) allowed to bring the performance of direct foreach (var i in (0..Length).Select(i => i * 2)) loops to within 30% of plain for loop without any delegates. This also applies to (0..100).Aggregate(...) methods and any other that uses LINQ methods to produce a value functional-style.

However, I decided to postpone (or maybe it was small brain cope?) correctly abstracting away duplicate code in .SpeedOpt.cs via either generics or templating/code generation to deliver the update as is. If you are interested, I will be happy to review and accept a PR that closes #14

📦 Dependencies

  • Bump Microsoft.NET.Test.Sdk from 17.3.1 to 17.3.2 (PR #9) by @dependabot (bot)
  • Bump coverlet.collector from 3.1.2 to 3.2.0 (PR #11) by @dependabot (bot)
  • Bump Microsoft.NET.Test.Sdk from 17.3.2 to 17.4.0 (PR #12) by @dependabot (bot)
  • Bump Microsoft.NET.Test.Sdk from 17.4.0 to 17.4.1 (PR #13) by @dependabot (bot)

Full Changelog: 1.2.2...2.0.0

Published with dotnet-releaser