Измеряем производительность SearchValues<char> против IndexOfAny и ручного цикла. Узнайте, когда новый тип из .NET 8 действительно ускоряет код, а когда лучше оставить старый API.
Вы наверняка слышали, что SearchValues<T> — это чит-код для ускорения поиска подстрок. Мол, замените им IndexOfAny — и строка сканируется в пять раз быстрее. Я поддерживаю парсер логов, который активно использует IndexOfAny, и решил проверить это утверждение на практике. Спойлер: первый же бенчмарк показал ничью.
Тип SearchValues<T> появился в .NET 8, живёт в пространстве имён System.Buffers и хранит предварительно вычисленный неизменяемый набор значений для поиска. Вы создаёте его один раз, а среда выполнения выбирает самую быструю стратегию сканирования для этого набора.
static readonly SearchValues<char> Delimiters = SearchValues.Create(new[] { '=', '&', ';', '?', '#' });
int i = span.IndexOfAny(Delimiters);
Мой тест: буфер логов размером ~32 МБ, подсчёт пяти символов-разделителей, лучший из пяти прогонов после прогрева, .NET 10, Release, на небольшом Linux-контейнере. Не лаборатория — и не надо, меня интересуют соотношения, а не абсолютные числа.
IndexOfAny(char[]) : 10.2 ms совпадений: 265 398
ручной цикл по символам : 19.4 ms совпадений: 265 398
SearchValues<char> : 9.7 ms совпадений: 265 398
Новый тип обогнал старый string.IndexOfAny на полмиллисекунды на 32 МБ. Причина в том, что IndexOfAny с небольшим набором символов уже активно использует векторизацию в современном .NET — он получил те же оптимизации, что и SearchValues.
Обратите внимание на среднюю строку. Самописный foreach с проверкой c is '=' or '&' or ';' — то, что многие пишут, чтобы «избежать накладных расходов API», — работает вдвое медленнее обоих вариантов. Мой вывод: враг здесь не IndexOfAny, а наши собственные «хитрые» циклы.
В моём буфере логов разделитель встречался каждые ~60 символов. Это частая ситуация. Векторизованный быстрый путь едва успевает разогнаться, как приходится останавливаться и сообщать о совпадении.
Я пересобрал буфер с редкими разделителями — один на ~40 КБ, то есть случай «поиск чего-то необычного»:
IndexOfAny(char[]) : 5.6 ms совпадений: 723
SearchValues<char> : 3.3 ms совпадений: 723
Теперь разница примерно в 1.7 раза. Когда совпадения редки, SearchValues делает то, ради чего он создан: мчится через длинные участки пустоты широкими векторизованными шагами.
Один неожиданный момент: я предположил, что увеличение набора искомых символов (12 вместо 5) расширит разрыв. На моей машине этого не произошло — оба варианта показали около 10.6 мс. У IndexOfAny явно есть свои трюки и для такого размера. Я рассказываю это, потому что бенчмарк-посты, показывающие только лестные цифры, меня раздражают.
Вот число, которое должно изменить то, как вы пишете код. Весь смысл SearchValues — заплатить за настройку один раз. Что произойдёт, если создавать его внутри метода при каждом вызове? Проверим на миллионе коротких строк:
static SearchValues : 25.1 ms
SearchValues на каждый вызов : 70.2 ms
Почти в три раза медленнее — хуже, чем вообще не использовать этот тип. Если SearchValues.Create не лежит в статическом readonly поле, вы построили машину замедления с дополнительными шагами.
// Да
static readonly SearchValues<char> Delimiters = SearchValues.Create(new[] { ... });
// Нет. Пожалуйста, нет.
var delimiters = SearchValues.Create(new[] { ... }); // внутри горячего метода
Если ваш поиск — горячий путь над большими буферами в поисках редких символов: да, и вы почувствуете разницу. Если вы сканируете короткие строки с частыми совпадениями через небольшой набор, честно говоря, IndexOfAny с закешированным char[] уже достаточно хорош — не заводите задачу на рефакторинг. А если у вас в горячем коде есть ручные циклы по символам, замена их — вот где спрятано настоящее ускорение в 2 раза, независимо от выбранного API.
Существует также SearchValues<string> для поиска нескольких подстрок, появившийся в .NET 9, — он заслуживает отдельной статьи.
Не верьте слухам — измеряйте. Скачайте пример с GitHub и запустите на своём железе. Если вы используете SearchValues, убедитесь, что он объявлен как static readonly. И главное: замените свои ручные циклы в горячих путях — это даст наибольший прирост.
Полный рабочий пример: github.com/ssukhpinder/dev-to-code-samples/001-searchvalues-fast-scanning
Хочешь закрепить знания на практике?
Решай задачи на Algolit — интерактивная платформа для обучения
Начать бесплатно →