Start Debugging

SearchValues<T> in .NET 11 richtig verwenden

SearchValues<T> schlägt IndexOfAny um Faktor 5 bis 250, aber nur wenn Sie es so verwenden, wie es die Laufzeit erwartet. Die Cache-als-static-Regel, die StringComparison-Falle, wann sich der Aufwand nicht lohnt und der IndexOfAnyExcept-Inversionstrick, den niemand dokumentiert.

SearchValues<T> liegt in System.Buffers. Es ist eine vorab berechnete, unveränderliche Wertemenge, die zusammen mit den Erweiterungsmethoden IndexOfAny, IndexOfAnyExcept, ContainsAny, LastIndexOfAny und LastIndexOfAnyExcept auf ReadOnlySpan<T> verwendet wird. Die Regel, die 90% der Verwendungen verfehlen, ist einfach: Bauen Sie die SearchValues<T>-Instanz einmal, legen Sie sie in einem static readonly-Feld ab und verwenden Sie sie wieder. Wer sie innerhalb der heißen Methode aufbaut, behält die gesamten Kosten (die SIMD-Strategieauswahl, die Bitmap-Allokation, den Aho-Corasick-Automaten für die String-Überladung) und verliert den gesamten Nutzen. Die zweite Regel: Greifen Sie nicht zu SearchValues<T> für Mengen aus einem oder zwei Werten. IndexOf ist für die trivialen Fälle bereits vektorisiert und schneller.

Dieser Beitrag richtet sich an .NET 11 (Preview 4) auf x64 und ARM64. Die Byte- und Char-Überladungen von SearchValues.Create sind seit .NET 8 stabil. Die String-Überladung (SearchValues<string>) ist seit .NET 9 stabil und in .NET 10 und .NET 11 unverändert. Das unten beschriebene Verhalten ist auf Windows, Linux und macOS identisch, weil die SIMD-Codepfade plattformübergreifend geteilt werden und nur dort auf Skalarcode zurückfallen, wo AVX2 / AVX-512 / NEON nicht verfügbar sind.

Warum SearchValues existiert

ReadOnlySpan<char>.IndexOfAny('a', 'b', 'c') ist ein einmaliger Aufruf. Die Laufzeit kann nicht wissen, ob der nächste Aufruf dieselbe Menge oder eine andere verwendet, also muss sie jedes Mal vor Ort eine Suchstrategie auswählen. Für drei Zeichen inlinet der JIT einen handoptimierten vektorisierten Pfad, der Overhead ist also gering, aber sobald die Menge über vier oder fünf Elemente hinauswächst, fällt IndexOfAny auf eine generische Schleife mit Hash-Set-Mitgliedschaftsprüfung pro Zeichen zurück. Diese Schleife ist für kurze Eingaben in Ordnung und ein Desaster für lange.

SearchValues<T> entkoppelt den Planungsschritt vom Suchschritt. Wenn Sie SearchValues.Create(needles) aufrufen, inspiziert die Laufzeit die Suchwerte einmal: Sind sie ein zusammenhängender Bereich? Eine spärliche Menge? Teilen sie Präfixe (für die String-Überladung)? Sie wählt eine von mehreren Strategien (Bitmap mit Vector256-Shuffle, IndexOfAnyAsciiSearcher, ProbabilisticMap, Aho-Corasick, Teddy) und backt die Metadaten in die Instanz. Jeder folgende Aufruf gegen diese Instanz überspringt die Planung und springt direkt in den gewählten Kernel. Bei einer Menge mit 12 Elementen sehen Sie typischerweise einen Speedup von Faktor 5 bis 50 gegenüber der entsprechenden IndexOfAny-Überladung. Bei String-Mengen mit 5 oder mehr Suchwerten sehen Sie Faktor 50 bis 250 gegenüber einer manuellen Contains-Schleife.

Die Asymmetrie ist der Punkt: Planen ist teuer, Suchen ist billig. Wer pro Aufruf ein frisches SearchValues<T> baut, bezahlt den Planer ohne ihn zu amortisieren.

Die Cache-als-static-Regel

Das ist das kanonische Muster. Achten Sie auf das static readonly:

// .NET 11, C# 14
using System.Buffers;

internal static class CsvScanner
{
    private static readonly SearchValues<char> Delimiters =
        SearchValues.Create(",;\t\r\n\"");

    public static int FindNextDelimiter(ReadOnlySpan<char> input)
    {
        return input.IndexOfAny(Delimiters);
    }
}

Die falsche Variante, die ich jede Woche in PRs sehe:

// .NET 11 -- BROKEN, do not ship
public static int FindNextDelimiter(ReadOnlySpan<char> input)
{
    var delims = SearchValues.Create(",;\t\r\n\"");
    return input.IndexOfAny(delims);
}

Sieht harmlos aus. Allokiert bei jedem Aufruf, und der Planer läuft bei jedem Aufruf. Benchmarks, die ich auf .NET 11 Preview 4 mit BenchmarkDotNet ausgeführt habe:

| Method                     | Mean       | Allocated |
|--------------------------- |-----------:|----------:|
| StaticSearchValues_1KB     |    71.4 ns |       0 B |
| RebuiltSearchValues_1KB    |   312.0 ns |     208 B |
| LoopWithIfChain_1KB        |   846.0 ns |       0 B |

Die Allokation ist die gefährlichere Hälfte. Ein falsch platziertes Create in einem heißen Pfad wird zu einem stetigen Strom LOH-naher Müllobjekte. Bei einem Service mit 100k Anfragen pro Sekunde sind das Gigabytes pro Minute, mit denen der GC für einen Wert unter Druck gesetzt wird, den Sie wiederverwenden sollten.

Wenn Sie static readonly nicht verwenden können, weil die Suchwerte beim Start vom Benutzer kommen, bauen Sie die Instanz einmal während der Initialisierung und legen Sie sie in einem Singleton-Service ab:

// .NET 11, C# 14
public sealed class TokenScanner
{
    private readonly SearchValues<string> _tokens;

    public TokenScanner(IEnumerable<string> tokens)
    {
        _tokens = SearchValues.Create(tokens.ToArray(), StringComparison.Ordinal);
    }

    public bool ContainsAny(ReadOnlySpan<char> input) => input.ContainsAny(_tokens);
}

Registrieren Sie ihn als Singleton in der Dependency Injection. Nicht als Transient. Transient gibt Ihnen dieselbe Pro-Aufruf-Neuaufbau-Falle mit zusätzlichen Schritten.

Die StringComparison-Falle

SearchValues<string> (die in .NET 9 hinzugefügte Multi-String-Überladung) nimmt ein StringComparison-Argument:

private static readonly SearchValues<string> Forbidden =
    SearchValues.Create(["drop", "delete", "truncate"], StringComparison.OrdinalIgnoreCase);

Nur vier Werte sind erlaubt: Ordinal, OrdinalIgnoreCase, InvariantCulture und InvariantCultureIgnoreCase. Wer CurrentCulture oder CurrentCultureIgnoreCase übergibt, bei dem wirft der Konstruktor beim Start ArgumentException. Das ist korrekt: Eine kulturabhängige Multi-String-Suche müsste pro Aufruf allokieren, um die aktuelle Thread-Kultur zu respektieren, was die Vorabberechnung zunichtemachen würde.

Zwei Folgen:

Die char- und byte-Überladungen haben keinen StringComparison-Parameter. Sie vergleichen exakt. Wenn Sie ASCII-Suche ohne Beachtung der Groß-/Kleinschreibung mit SearchValues<char> wollen, nehmen Sie beide Schreibweisen in die Menge auf:

// case-insensitive ASCII vowels in .NET 11, C# 14
private static readonly SearchValues<char> Vowels =
    SearchValues.Create("aeiouAEIOU");

Billiger, als zuerst ToLowerInvariant auf der Eingabe aufzurufen.

Mengenmitgliedschaft: SearchValues.Contains ist nicht das, was Sie denken

SearchValues<T> exponiert eine Contains(T)-Methode:

SearchValues<char> set = SearchValues.Create("abc");
bool isInSet = set.Contains('b'); // true

Bitte genau lesen: das prüft, ob ein einzelner Wert in der Menge liegt. Das Pendant zu HashSet<T>.Contains, keine Substring-Suche. Leute greifen danach, erwarten string.Contains-Semantik und liefern Code aus, der fragt “ist das Zeichen ‘h’ in meiner Menge verbotener Tokens” statt “enthält meine Eingabe irgendein verbotenes Token”. Dieser Bug-Typ besteht den Typcheck und läuft.

Die richtigen Aufrufe für “enthält die Eingabe einen davon”:

Verwenden Sie SearchValues<T>.Contains(value) nur, wenn Sie tatsächlich einen einzelnen Wert haben und ein Mengen-Lookup wollen, etwa innerhalb eines eigenen Tokenizers, der entscheidet, ob das aktuelle Zeichen ein Trennzeichen ist.

Der IndexOfAnyExcept-Inversionstrick

IndexOfAnyExcept(SearchValues<T>) liefert den Index des ersten Elements, das nicht in der Menge liegt. Das ist der Weg, den Anfang des inhaltsreichen Bereichs in einer Zeichenkette nach führendem Whitespace, Padding oder Rauschen in einem einzigen SIMD-Durchgang zu finden:

// .NET 11, C# 14
private static readonly SearchValues<char> WhitespaceAndQuotes =
    SearchValues.Create(" \t\r\n\"'");

public static ReadOnlySpan<char> TrimStart(ReadOnlySpan<char> input)
{
    int firstReal = input.IndexOfAnyExcept(WhitespaceAndQuotes);
    return firstReal < 0 ? ReadOnlySpan<char>.Empty : input[firstReal..];
}

Das schlägt string.TrimStart(' ', '\t', '\r', '\n', '"', '\'') bei Eingaben mit langen führenden Sequenzen, weil TrimStart für Mengen über vier Zeichen auf eine Schleife pro Zeichen zurückfällt. Für den typischen Fall “64 Zeichen Einrückung entfernen” ist mit Faktor 4 bis 8 Speedup zu rechnen.

LastIndexOfAnyExcept ist das rechtsseitige Pendant. Zusammen ergeben sie ein vektorisiertes Trim:

public static ReadOnlySpan<char> TrimBoth(ReadOnlySpan<char> input)
{
    int start = input.IndexOfAnyExcept(WhitespaceAndQuotes);
    if (start < 0) return ReadOnlySpan<char>.Empty;

    int end = input.LastIndexOfAnyExcept(WhitespaceAndQuotes);
    return input[start..(end + 1)];
}

Zwei Slices, zwei SIMD-Scans, null Allokationen. Die naive string.Trim(charsToTrim)-Überladung allokiert in .NET 11 intern ein temporäres Array, selbst wenn die Eingabe gar nicht getrimmt werden müsste.

Wann byte statt char

Beim Protokollparsen (HTTP, JSON, ASCII-CSV, Log-Zeilen) liegt die Eingabe oft als ReadOnlySpan<byte> vor, nicht als ReadOnlySpan<char>. SearchValues<byte> aus den ASCII-Bytewerten zu bauen, ist deutlich schneller, als zuerst nach UTF-16 zu dekodieren:

// .NET 11, C# 14 -- HTTP header value sanitiser
private static readonly SearchValues<byte> InvalidHeaderBytes =
    SearchValues.Create([(byte)'\0', (byte)'\r', (byte)'\n', (byte)'\t']);

public static bool IsValidHeaderValue(ReadOnlySpan<byte> value)
{
    return value.IndexOfAny(InvalidHeaderBytes) < 0;
}

Der Byte-Pfad zieht 32 Bytes pro AVX2-Zyklus gegenüber 16 Chars; auf AVX-512-fähiger Hardware zieht er 64 Bytes gegenüber 32 Chars. Bei ASCII-Daten verdoppeln Sie den Durchsatz, indem Sie den UTF-16-Umweg sparen.

Der Compiler warnt nicht, wenn Sie versehentlich char-Codepoints über 127 auf eine Weise einsetzen, die bricht. Aber der SearchValues-Planer schaltet bewusst auf einen langsamen Pfad, wenn die Char-Menge über den BMP-ASCII-Bereich hinausgeht und gemischte bidi-Eigenschaften enthält. Wenn Ihr Benchmark sagt “das wurde langsamer als erwartet”, prüfen Sie, ob Sie ein Nicht-ASCII-Zeichen in eine Menge gepackt haben, die nur ASCII enthalten sollte.

Wann SearchValues NICHT zu verwenden ist

Eine kurze Liste der Fälle, in denen die richtige Antwort “lassen Sie es” lautet:

Allokationsfreie statische Initialisierung

Die Create-Überladungen akzeptieren ReadOnlySpan<T>. Sie können ein String-Literal übergeben (der C#-Compiler wandelt String-Literale seit .NET 7 über RuntimeHelpers.CreateSpan in ReadOnlySpan<char> um), ein Array oder einen Collection Expression. Alle drei erzeugen dieselbe SearchValues<T>-Instanz; der Compiler erzeugt für die String-Literal-Form keine Zwischenarrays.

// .NET 11, C# 14 -- all three are equivalent in cost at runtime
private static readonly SearchValues<char> A = SearchValues.Create("abc");
private static readonly SearchValues<char> B = SearchValues.Create(['a', 'b', 'c']);
private static readonly SearchValues<char> C = SearchValues.Create(new[] { 'a', 'b', 'c' });

Für die String-Überladung muss die Eingabe ein Array (string[]) oder ein Collection Expression sein, der auf eines abzielt:

private static readonly SearchValues<string> Tokens =
    SearchValues.Create(["select", "insert", "update"], StringComparison.OrdinalIgnoreCase);

Der Konstruktor kopiert die Suchwerte in den internen Zustand, das Quellarray wird also nicht gehalten. Das Quellarray nach der Konstruktion zu verändern, hat keinen Effekt auf die SearchValues<string>-Instanz. Das ist das Gegenteil von Regex mit gecachten Mustern, wo die Quellzeichenkette gehalten wird.

Source-Generator-freundliches Muster

Wenn Sie eine partial-Klasse und einen Codegenerator haben (eigenen oder System.Text.RegularExpressions.GeneratedRegex), ist es ein sauberes Muster, ein static readonly SearchValues<char>-Feld als Teil der generierten Ausgabe zu erzeugen. Trim-sicher, AOT-sicher, ohne Reflection, ohne Heap-Allokation pro Aufruf.

// .NET 11, C# 14 -- hand-rolled equivalent of what a generator would emit
internal static partial class IdentifierScanner
{
    private static readonly SearchValues<char> NonIdentifierChars =
        SearchValues.Create(GetNonIdentifierAscii());

    private static ReadOnlySpan<char> GetNonIdentifierAscii()
    {
        // Build a 96-element set of non-[A-Za-z0-9_] ASCII chars at type init.
        Span<char> buffer = stackalloc char[96];
        int i = 0;
        for (int c = ' '; c <= '~'; c++)
        {
            if (!(char.IsAsciiLetterOrDigit((char)c) || c == '_'))
                buffer[i++] = (char)c;
        }
        return buffer[..i].ToArray();
    }
}

Das stackalloc läuft genau einmal, weil static readonly vom Typinitialisierer der Laufzeit genau einmal initialisiert wird. Das .ToArray() ist die einzige Allokation in der Lebenszeit des Typs. Danach ist jede Suche allokationsfrei.

Native AOT und Trim-Warnungen

SearchValues<T> ist vollständig kompatibel mit Native AOT. Innen gibt es keine Reflection und keine dynamische Codegenerierung zur Laufzeit. Ihr per AOT veröffentlichtes Binary enthält dieselben SIMD-Kernels wie die JIT-Version, ausgewählt zur AOT-Compile-Zeit anhand der angegebenen Ziel-ISA (-r linux-x64 schließt standardmäßig x64-Baseline mit SSE2- und AVX2-Pfaden ein; -p:TargetIsa=AVX-512 erweitert auf AVX-512). Keine Trim-Warnungen, keine [DynamicallyAccessedMembers]-Annotationen erforderlich.

Wenn Sie für linux-arm64 veröffentlichen, werden die NEON-Kernels automatisch gewählt. Derselbe Quellcode kompiliert für beide Ziele ohne Codeverzweigung.

Verwandte Lektüre

Quellen

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Zurück