В мире высоконагруженных .NET-приложений каждая наносекунда на счету. Когда ваш код обрабатывает миллионы запросов, даже микрооптимизации могут дать ощутимый прирост производительности. Две ключевые фичи, появившиеся в .NET 8 — SearchValues<T> и FrozenSet<T>/FrozenDictionary<TKey, TValue> — позволяют выжать максимум из «горячих путей» (hot paths) благодаря умной предварительной оптимизации.

Проблема: неоптимальный поиск

Представьте, что вам нужно проверить строку на наличие любого символа из набора (например, разделителей или спецсимволов). Стандартный string.IndexOfAny при каждом вызове анализирует набор символов и выбирает стратегию поиска. Для коротких строк это быстро, но в цикле или на больших объемах данных накладные расходы суммируются.

Решение:

SearchValues<T> — это immutable коллекция, создаваемая один раз для конкретного набора значений поиска. При создании экземпляра .NET один раз выполняет «тяжелую» работу:

  • Анализирует набор значений (один символ, диапазон, множество)

  • Выбирает оптимальный алгоритм под конкретное CPU (с учетом SIMD-инструкций)

  • Генерирует оптимизированные структуры данных для поиска

// Создаем оптимизированный набор один раз
SearchValues<char> separators = SearchValues.Create(".,;!?");

// Используем многократно в горячем пути
ReadOnlySpan<char> input = "Hello, world!";
int index = input.IndexOfAny(separators); // Мгновенный поиск

SearchValues<char> использует несколько стратегий в зависимости от входных данных:

  1. Одиночный символ — максимально простая векторная проверка

  2. Диапазон символов (например, 0-9A-Z) — битовая маска (bitmap) для O(1) проверки

  3. Небольшой набор (≤4 символов) — специализированный SIMD-код

  4. Большой набор — хеш-таблица с оптимизациями

Кроме того, SearchValues учитывает аппаратные возможности процессора (AVX2, SSE2), используя самые широкие доступные векторные регистры

Бенчмарки показывают впечатляющие результаты:

Сценарий

IndexOfAny

SearchValues

Ускорение

Короткая строка (совпадение в начале)

~5 нс

~5 нс

1x

Длинная строка (2000 символов)

~52 нс

~4 нс

~13x

Для длинных строк (2062 символа) SearchValues работает в 13 раз быстрее стандартного подхода. Основной выигрыш достигается за счет того, что предварительный анализ выполняется только при создании коллекции, а не при каждом поиске.

Когда использовать SearchValues

Идеальные сценарии:

  • Поиск по одному и тому же набору значений миллионы раз

  • Обработка длинных строк (>100 символов)

  • Парсинг логов, токенизация, валидация входных данных

Неэффективно:

  • Единичный поиск

  • Очень короткие строки с совпадением в первой позиции

  • Набор значений постоянно меняется

FrozenSet и FrozenDictionary: константная скорость поиска

Проблема: HashSet/Dictionary

HashSet<T> и Dictionary<TKey, TValue> — отличные структуры, но они проектировались с учетом возможности изменений. Это накладывает ограничения:

  • Хеш-таблица должна поддерживать перехеширование

  • Структуры данных не могут быть полностью «выровнены» под конкретный набор ключей

  • При каждом вызове Contains приходится выполнять дополнительные проверки

Решение:

FrozenSet<T> и FrozenDictionary<TKey, TValue> — неизменяемые коллекции из пространства имен System.Collections.Frozen, появившиеся в .NET 8. Они создаются один раз и больше никогда не меняются, что позволяет:

  1. При создании — проанализировать все ключи и построить идеальную хеш-таблицу без коллизий (perfect hash function)

  2. При поиске — выполнять минимальное количество операций, часто сводя проверку к одному обращению к массиву

// Создаем замороженный словарь из существующих данных
var dict = Enumerable.Range(0, 100000)
    .ToFrozenDictionary(x => x, x => $"Value {x}");

// Поиск без аллокаций и с максимальной скоростью
if (dict.TryGetValue(42, out string? value))
{
    Console.WriteLine(value);
}

При создании FrozenSet/FrozenDictionary .NET выполняет следующие оптимизации:

  1. Анализ распределения ключей — подбор хеш-функции, минимизирующей коллизии

  2. Оптимальное выравнивание — данные размещаются в памяти для максимальной локальности (cache-friendly)

  3. Удаление лишних проверок — поскольку коллекция неизменяема, многие рантайм-проверки становятся ненужными

В некоторых случаях для маленьких коллекций FrozenSet может использовать вообще битовую маску вместо хеш-таблицы.

Бенчмарки с 100 000 элементов показывают впечатляющие результаты:

Операция

Dictionary

HashSet

FrozenDictionary

FrozenSet

TryGetValue / Contains

3.78 нс

4.47 нс

1.87 нс

3.37 нс

Относительная скорость

2.02x

2.39x

1x (базовый)

1.8x

FrozenDictionary.TryGetValue работает в 2 раза быстрее обычного Dictionary!

Когда использовать Frozen-коллекции

Идеальные сценарии:

  • Конфигурации, загруженные при старте (и больше не меняющиеся)

  • Статические lookup-таблицы (например, коды ошибок, статусы)

  • Данные, загруженные из базы при инициализации приложения

  • Read-heavy сценарии с тысячами операций чтения

Неэффективно:

  • Данные часто изменяются

  • Коллекция создается, используется пару раз и удаляется

  • Нужно добавлять/удалять элементы после создания

Практические рекомендации

Концепция «заплати сейчас, получи выгоду потом» (pay now, gain later) критически важна. Одноразовая инициализация на старте приложения окупается многократно в горячих путях.

public static class AppCache
{
    // Создаем при старте приложения
    public static readonly SearchValues<char> UrlSafeChars = 
        SearchValues.Create("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~");
    
    public static readonly FrozenSet<string> ValidCurrencies = 
        new[] { "USD", "EUR", "GBP", "JPY", "CNY" }.ToFrozenSet();
}

Для максимальной производительности используйте оба инструмента там, где это уместно:

// Валидация email: быстрая проверка недопустимых символов
SearchValues<char> invalidEmailChars = SearchValues.Create("()<>[]:;@\\,?\"");

// Проверка доменов верхнего уровня (неизменяемый набор)
FrozenSet<string> validTlds = LoadTldsFromConfig().ToFrozenSet();

Заключение

Всегда профилируйте конкретный сценарий с помощью BenchmarkDotNet. Иногда накладные расходы на создание коллекции могут превысить выгоду, если данных мало или поиск выполняется редко .

SearchValues<T> и FrozenCollections — примеры того, как умная предварительная оптимизация на уровне runtime может дать 2-10-кратный прирост производительности. Они особенно эффективны в read-heavy сценариях с повторяющимися операциями поиска.

Используйте SearchValues<T> для поиска символов и байт в строках и спанах, а FrozenSet<T>/FrozenDictionary<TKey, TValue> — для неизменяемых наборов объектов. Ваши горячие пути скажут вам спасибо.