Neue Api GetAlternateLookup()

In .net 9 gibt es ein paar coole neue Api Methoden. Eine die ich sehr geil finde ist GetAlternateLookup und steht bei den Typen Dictionary, ConcurrentDictionary, FrozenDictionary, Hashset etc. zur Verfügung. Diese neue Methode zog einige Änderungen in der Runtime, in Bibliotheken und in der Sprache nach sich. Da ein ref struct jetzt als generic Methoden Parameter verwendet werden kann.
Diese neue Methode sorgt in meinem Beispiel dafür, dass kaum mehr Speicher bei der Verarbeitung eines fast 1MB großen Textdokuments verwendet wird.

Beispiel:

Ich habe ein ca. 1 MB großes Textdokument und möchte die darin enthaltenen Wörter zählen.
Als Performance Analyse Tool benutze ich BenchmarkDotNet.
Hier die zu untersuchende Klasse in der ersten Version:

using System.Text.RegularExpressions;
using BenchmarkDotNet.Attributes;

namespace ConsoleApp1;

[MemoryDiagnoser]
public partial class WordCounter
{
private string text;
Dictionary<string, int=““> wordCount = new();</string,>

[Params(10)]
public int N { get; set; }

[GlobalSetup]
public void Setup()
{
text = File.ReadAllText(„input.txt“);
}

[Benchmark]
public void CountWords()
{
foreach (Match match in Helpers.Words().Matches(text))
{
var word = match.Value;
if (wordCount.TryGetValue(word, out var count))
{
wordCount[word] = count + 1;
}
else
{
wordCount[word] = 1;
}
}
}

internal static partial class Helpers
{
[GeneratedRegex(@“\b\w+\b“)]
public static partial Regex Words();
}
}

Der Aufruf erfolgt so:
using BenchmarkDotNet.Running;
using ConsoleApp1;

var summary = BenchmarkRunner.Run();

Hier das Ergebnis:

CountWords wird 10 mal aufgerufen und benötigt durchschnittlich 146 ms und verbraucht einen Speicher von ca. 35 MB.
Mit folgenden Veränderungen erzielen wir schon eine gut Verbesserung:

[Benchmark]
public void CountWords()
{
foreach (var match in Helpers.Words().EnumerateMatches(text))
{
var word = text.Substring(match.Index, match.Length);
if (wordCount.TryGetValue(word, out var count))
{
wordCount[word] = count + 1;
}
else
{
wordCount[word] = 1;
}
}
}

Die Methode EnumerateMatches liefert uns jetzt nur noch den Index und die Length. Die ganze Infrastruktur der Matches Funktion entfällt.

Das Ergebnis sieht wie folgt aus:

Die Zeit beträgt jetzt nur noch 21 ms und der Speicherverbrauch liegt nur noch bei 4,3 MB.

Wir haben aber immer noch mit Strings gearbeitet. Ich möchte aber nur noch mit Bereichen aus dem Speicher arbeiten und da kommt die AsSpan Methode zum Einsatz. Zurück wird dann ein ReadOnlySpan geliefert. Damit können wir dann nicht mehr ein LookUp auf das Dictionary machen, da der Datentyp nicht mehr passt. Jetzt kommt die GetAlternateLookup Methode zum Einsatz. Ich kann auf dem Dictionary einfach diese Methode aufrufen und darauf arbeiten. Mal sehen, was das mit der Performance Analyse macht.

Dann sieht der Code wie folgt aus:

using System.Text.RegularExpressions;
using BenchmarkDotNet.Attributes;

namespace ConsoleApp1;

[MemoryDiagnoser]
public partial class WordCounter
{
private string text;
Dictionary<string, int=““> wordCount = new();</string,>

[Params(10)]
public int N { get; set; }

[GlobalSetup]
public void Setup()
{
text = File.ReadAllText(„input.txt“);
}

[Benchmark]
public void CountWords()
{
var wordCountAsLookup = wordCount.GetAlternateLookup<readonlyspan>();
foreach (var match in Helpers.Words().EnumerateMatches(text))
{
var word = text.AsSpan(match.Index, match.Length);
if (wordCountAsLookup.TryGetValue(word, out var count))
{
wordCountAsLookup[word] = count + 1;
}
else
{
wordCountAsLookup[word] = 1;
}
}
}</readonlyspan

internal static partial class Helpers
{
[GeneratedRegex(@“\b\w+\b“)]
public static partial Regex Words();
}
}

Das Ergebnis sieht dann wie folgt aus:

Die Zeit, die benötigt wird, ist in etwas gleichgeblieben, aber der Speicherverbrauch ist nahezu bei null.

Ich bin sehr beeindruckt.