Введение

Изначально я не ставил своей целью исследовать работу строковых методов в .NET. Мне просто хотелось лучше разобраться с BenchmarkDotNet.

Исследование, я решил провести сравнивая работу наивного метода поиска нескольких подстрок в строке, с аналогичным, реализующим алгоритм Ахо-Корасик.

Процесс

Был написан следующий бенчмарк:

using System;
using System.Collections.Generic;
using System.Text;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Engines;
using project.library;

namespace bench
{
    [SimpleJob(
        RunStrategy.ColdStart,
        launchCount: 3,
        warmupCount: 2,
        targetCount: 5,
        invocationCount: 30000,
        "FindAll")]
    [RPlotExporter]
    public class KeywordsSearchBenchmark
    {
        private const string TestString =
            "Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2228.0 Safari/537.36";

        private string[] keywords;
        private AhoCorasickTree tree;

        [GlobalSetup]
        public void GlobalSetup()
        {
            this.keywords = new[] {"Safari", "Gecko", "Chrome"};
            this.tree = new AhoCorasickTree(this.keywords, StringComparer.CurrentCulture);
        }

        [Benchmark]
        [ArgumentsSource(nameof(Inputs))]
        public void AhoCorasick(string input)
        {
            this.tree.FindAll(input).Consume(new Consumer());
        }

        [Benchmark(Baseline = true)]
        [ArgumentsSource(nameof(Inputs))]
        public void Naive(string input)
        {
            FindAll(input, this.keywords).Consume(new Consumer());
        }
        
        public IEnumerable<object[]> Inputs()
        {
            yield return new object[] { CreateString(20) };
        }

        private static IEnumerable<string> FindAll(string str, string[] keywords)
        {
            for (var i = 0; i < keywords.Length; i++)
            {
                for (int index = 0;; index += keywords[i].Length)
                {
                    index = str.IndexOf(keywords[i], index, StringComparison.CurrentCulture);
                    if (index == -1)
                    {
                        break;
                    }

                    yield return keywords[i];
                }
            }
        }

        private static string CreateString(int repeatsCount)
        {
            var sb = new StringBuilder(repeatsCount * TestString.Length);
            for (var i = 0; i < repeatsCount; i++)
            {
                sb.Append(TestString);
            }

            return sb.ToString();
        }
    }
}

Код реализации алгоритма Ахо-Корасик я тут не привожу ибо это не особенно важно. Особо интересующиеся смотрите у меня на гитхабе.

Запустив этот бенчмарк на маке я увидел ожидаемый результат:

BenchmarkDotNet=v0.12.1, OS=macOS 11.2.3 (20D91) [Darwin 20.3.0]
Intel Core i5-1030NG7 CPU 1.10GHz, 1 CPU, 8 logical and 4 physical cores
.NET Core SDK=5.0.101
  [Host]  : .NET Core 5.0.1 (CoreCLR 5.0.120.57516, CoreFX 5.0.120.57516), X64 RyuJIT
  FindAll : .NET Core 5.0.1 (CoreCLR 5.0.120.57516, CoreFX 5.0.120.57516), X64 RyuJIT

Job=FindAll  InvocationCount=30000  IterationCount=5  
LaunchCount=3  RunStrategy=ColdStart  WarmupCount=2  

MethodinputMeanErrorStdDevRatioRatioSD
AhoCorasickMozi(…)7.36 [2000]25.39 μs1.823 μs1.705 μs1.670.09
NaiveMozi(…)7.36 [2000]15.20 μs0.610 μs0.571 μs1.000.00

Строка маленькая, ключевых слов немного, поэтому ожидаемо наивный алгоритм заметно быстрее.

Далее, просто для сравнения, запускаю ровно этот же код на компьютере под Windows:

BenchmarkDotNet=v0.12.1, OS=Windows 10.0.19042
Intel Core i9-9900K CPU 3.60GHz (Coffee Lake), 1 CPU, 16 logical and 8 physical cores
.NET Core SDK=5.0.202
  [Host]  : .NET Core 5.0.5 (CoreCLR 5.0.521.16609, CoreFX 5.0.521.16609), X64 RyuJIT
  FindAll : .NET Core 5.0.5 (CoreCLR 5.0.521.16609, CoreFX 5.0.521.16609), X64 RyuJIT

Job=FindAll  InvocationCount=30000  IterationCount=5  
LaunchCount=3  RunStrategy=ColdStart  WarmupCount=2  

MethodinputMeanErrorStdDevRatio
AhoCorasickMozi(…)7.36 [2000]18.19 μs0.691 μs0.647 μs0.04
NaiveMozi(…)7.36 [2000]470.17 μs6.156 μs5.758 μs1.00

Хмм … версия в лоб медленнее в 20 раз Карл!

Начинаем разбираться

И разбираться мы будем с наивным методом, а точнее со строковым методом IndexOf.

Благо теперь исходники .NET открыты, поэтому идем в репозиторий dotnet/runtime и ищем реализацию нужного нам метода. Интересующая нас реализация находится в файле String.Searching.cs

Интересующая нас реализация (на момент написания этого текста) начинается со строчки 333 и выглядит так:

        public int IndexOf(string value, int startIndex, int count, StringComparison comparisonType)
        {
            // Parameter checking will be done by CompareInfo.IndexOf.

            switch (comparisonType)
            {
                case StringComparison.CurrentCulture:
                case StringComparison.CurrentCultureIgnoreCase:
                    return CultureInfo.CurrentCulture.CompareInfo.IndexOf(this, value, startIndex, count, GetCaseCompareOfComparisonCulture(comparisonType));

                case StringComparison.InvariantCulture:
                case StringComparison.InvariantCultureIgnoreCase:
                    return CompareInfo.Invariant.IndexOf(this, value, startIndex, count, GetCaseCompareOfComparisonCulture(comparisonType));

                case StringComparison.Ordinal:
                case StringComparison.OrdinalIgnoreCase:
                    return Ordinal.IndexOf(this, value, startIndex, count, comparisonType == StringComparison.OrdinalIgnoreCase);

                default:
                    throw (value is null)
                        ? new ArgumentNullException(nameof(value))
                        : new ArgumentException(SR.NotSupported_StringComparison, nameof(comparisonType));
            }
        }

Поскольку у нас сравнение учитывает текущую культуру, нам надо искать как реализована CultureInfo.CurrentCulture.CompareInfo.IndexOf. Для начала, нам надо найти класс CultureInfo.CurrentCulture.CompareInfo — и тут начинается самое интересное.

Класс это немаленький и находится в runtime/src/libraries/System.Private.CoreLib/src/System/Globalization/. Раз он немаленький, — он partial, и разбит на несколько файлов.

Для начала пойдем в CompareInfo.cs. В конечном итоге, все перегрузки IndexOf приводят сюда (строчка 949):

        private unsafe int IndexOf(ReadOnlySpan<char> source, ReadOnlySpan<char> value, int* matchLengthPtr, CompareOptions options, bool fromBeginning)
        {
            Debug.Assert(matchLengthPtr != null);
            *matchLengthPtr = 0;

            int retVal = 0;

            if ((options & ValidIndexMaskOffFlags) == 0)
            {
                // Common case: caller is attempting to perform a linguistic search.
                // Pass the flags down to NLS or ICU unless we're running in invariant
                // mode, at which point we normalize the flags to Ordinal[IgnoreCase].

                if (!GlobalizationMode.Invariant)
                {
                    if (value.IsEmpty)
                    {
                        // empty target substring trivially occurs at beginning / end of search space
                        return (fromBeginning) ? 0 : source.Length;
                    }
                    else
                    {
                        return IndexOfCore(source, value, options, matchLengthPtr, fromBeginning);
                    }
                }

                if ((options & CompareOptions.IgnoreCase) == 0)
                {
                    retVal = (fromBeginning) ? source.IndexOf(value) : source.LastIndexOf(value);
                }
                else
                {
                    retVal = fromBeginning ? Ordinal.IndexOfOrdinalIgnoreCase(source, value) : Ordinal.LastIndexOfOrdinalIgnoreCase(source, value);
                }
            }
            else
            {
                // Less common case: caller is attempting to perform non-linguistic comparison,
                // or an invalid combination of flags was supplied.

                if (options == CompareOptions.Ordinal)
                {
                    retVal = (fromBeginning) ? source.IndexOf(value) : source.LastIndexOf(value);
                }
                else if (options == CompareOptions.OrdinalIgnoreCase)
                {
                    retVal = fromBeginning ? Ordinal.IndexOfOrdinalIgnoreCase(source, value) : Ordinal.LastIndexOfOrdinalIgnoreCase(source, value);
                }
                else
                {
                    ThrowHelper.ThrowArgumentException(ExceptionResource.Argument_InvalidFlag, ExceptionArgument.options);
                }
            }

            // Both Ordinal and OrdinalIgnoreCase match by individual code points in a non-linguistic manner.
            // Non-BMP code points will never match BMP code points, so given UTF-16 inputs the match length
            // will always be equivalent to the target string length.

            if (retVal >= 0)
            {
                *matchLengthPtr = value.Length;
            }
            return retVal;
        }

Поскольку мы не задаем опций, мы попадаем в первую ветку if. Далее, поскольку у нас сравнение с учетом культуры (т.е. не Invariant) и строка не пустая, вызывается IndexOfCore.

Смотрим как он сделан:

        private unsafe int IndexOfCore(ReadOnlySpan<char> source, ReadOnlySpan<char> target, CompareOptions options, int* matchLengthPtr, bool fromBeginning) =>
            GlobalizationMode.UseNls ?
                NlsIndexOfCore(source, target, options, matchLengthPtr, fromBeginning) :
                IcuIndexOfCore(source, target, options, matchLengthPtr, fromBeginning);

Ничего особенного — проверяем флаг и вызываем нужный метод. Флаг этот находится в классе GlobalizationMode.cs (лежит в той же папке). Он тоже partial и разбит на несколько файлов. И они уже имеют суффиксы соответствующие той или иной платформе.

Смотрим что у нас под Windows

        internal static bool UseNls { get; } = !Invariant &&
            (AppContextConfigHelper.GetBooleanConfig("System.Globalization.UseNls", "DOTNET_SYSTEM_GLOBALIZATION_USENLS") ||
                !LoadIcu());

И под Unix:

internal static bool UseNls => false;

Под Unix у нас без вариантов — только ICU (International Components for Unicode), под Windows возможно и то и другое, — и это весьма интересный факт. Поиск по ключевому слову DOTNET_SYSTEM_GLOBALIZATION_USENLS приводит нас к документации Microsoft о глобализации и ICU. Я не буду пересказывать все что написано в этой статье, скажу лишь что изначально, до .NET 5.0, на Windows, для глобализации, исторически использовался только NLS (National Language Support). Теперь же может использоваться и ICU.

В статье описаны и условия при которых в Windows будет использоваться ICU — это Windows 10 May 2019 Update и новее, включающая в себя icu.dll ну и разумеется использование .NET 5.0. В Случае если эта библиотека не будет найдена, будет использован старый механизм NLS.

В статье приведен и способ проверки того, используются ли у вас ICU. Для этого надо выполнить следующий код:

string s = "Hello\r\nworld!";
int idx = s.IndexOf("\n");
Console.WriteLine(idx);

Если задействованы ICU вернется -1 если NLS вернется 6. Ожидаемо под MacOSX вернулось -1. Под Windows, если не крутить никакие настройки, у меня (поскольку стоит Windows 10 2004 H2 и запуск под .NET 5) возвращается также -1. Если принудительно включить использование NLS (я делал через добавление RuntimeHostConfigurationOption в ItemGroup в файле проекта), возвращается 6, как и написано в документации. Теперь сделаем замеры с включенной опцией NLS под Windows:


BenchmarkDotNet=v0.12.1, OS=Windows 10.0.19042
Intel Core i9-9900K CPU 3.60GHz (Coffee Lake), 1 CPU, 16 logical and 8 physical cores
.NET Core SDK=5.0.202
  [Host]  : .NET Core 5.0.5 (CoreCLR 5.0.521.16609, CoreFX 5.0.521.16609), X64 RyuJIT
  FindAll : .NET Core 5.0.5 (CoreCLR 5.0.521.16609, CoreFX 5.0.521.16609), X64 RyuJIT

Job=FindAll  InvocationCount=30000  IterationCount=5  
LaunchCount=3  RunStrategy=ColdStart  WarmupCount=2  

MethodinputMeanErrorStdDevRatio
AhoCorasickMozi(…)7.36 [2000]18.16 μs0.629 μs0.589 μs0.04
NaiveMozi(…)7.36 [2000]464.42 μs7.434 μs6.953 μs1.00

Ничего вообще не поменялось в плане скорости. Возвращаемся к CompareInfo.cs. Под Windows, поскольку он поддерживает NLS, используется CompareInfo.Nls.cs

Под Unix используется CompareInfo.Icu.cs

Сначала Windows:

private unsafe int NlsIndexOfCore(ReadOnlySpan<char> source, ReadOnlySpan<char> target, CompareOptions options, int* matchLengthPtr, bool fromBeginning)
{
    Debug.Assert(!GlobalizationMode.Invariant);
    Debug.Assert(GlobalizationMode.UseNls);

    Debug.Assert(target.Length != 0);

    uint positionFlag = fromBeginning ? (uint)FIND_FROMSTART : FIND_FROMEND;
    return FindString(positionFlag | (uint)GetNativeCompareFlags(options), source, target, matchLengthPtr);
}

Все сводится к вызову FindString который имеет следующую реализацию:

        private unsafe int FindString(
                    uint dwFindNLSStringFlags,
                    ReadOnlySpan<char> lpStringSource,
                    ReadOnlySpan<char> lpStringValue,
                    int* pcchFound)
        {
            Debug.Assert(!GlobalizationMode.Invariant);
            Debug.Assert(!lpStringValue.IsEmpty);

            string? localeName = _sortHandle != IntPtr.Zero ? null : _sortName;

            // FindNLSStringEx disallows passing an explicit 0 for cchSource or cchValue.
            // The caller should've already checked that 'lpStringValue' isn't empty,
            // but it's possible for 'lpStringSource' to be empty. In this case we'll
            // substitute an empty null-terminated string and pass -1 so that the NLS
            // function uses the implicit string length.

            int lpStringSourceLength = lpStringSource.Length;
            if (lpStringSourceLength == 0)
            {
                lpStringSource = string.Empty;
                lpStringSourceLength = -1;
            }

            fixed (char* pLocaleName = localeName)
            fixed (char* pSource = &MemoryMarshal.GetReference(lpStringSource))
            fixed (char* pValue = &MemoryMarshal.GetReference(lpStringValue))
            {
                Debug.Assert(pSource != null && pValue != null);

                int result = Interop.Kernel32.FindNLSStringEx(
                                    pLocaleName,
                                    dwFindNLSStringFlags,
                                    pSource,
                                    lpStringSourceLength,
                                    pValue,
                                    lpStringValue.Length,
                                    pcchFound,
                                    null,
                                    null,
                                    _sortHandle);

                Debug.Assert(result >= -1 && result <= lpStringSource.Length);

                // SetLastError is only performed under debug builds.
                Debug.Assert(result >= 0 || Marshal.GetLastWin32Error() == Interop.Errors.ERROR_SUCCESS);

                return result;
            }
        }

В конечном итоге — все сводится к вызову функции FindNLSStringEx из kernel32.dll

Далее смотрим что у нас под Unix:

        private unsafe int IcuIndexOfCore(ReadOnlySpan<char> source, ReadOnlySpan<char> target, CompareOptions options, int* matchLengthPtr, bool fromBeginning)
        {
            Debug.Assert(!GlobalizationMode.Invariant);
            Debug.Assert(!GlobalizationMode.UseNls);
            Debug.Assert(target.Length != 0);

            if (_isAsciiEqualityOrdinal && CanUseAsciiOrdinalForOptions(options))
            {
                if ((options & CompareOptions.IgnoreCase) != 0)
                    return IndexOfOrdinalIgnoreCaseHelper(source, target, options, matchLengthPtr, fromBeginning);
                else
                    return IndexOfOrdinalHelper(source, target, options, matchLengthPtr, fromBeginning);
            }
            else
            {
                // GetReference may return nullptr if the input span is defaulted. The native layer handles
                // this appropriately; no workaround is needed on the managed side.

                fixed (char* pSource = &MemoryMarshal.GetReference(source))
                fixed (char* pTarget = &MemoryMarshal.GetReference(target))
                {
                    if (fromBeginning)
                        return Interop.Globalization.IndexOf(_sortHandle, pTarget, target.Length, pSource, source.Length, options, matchLengthPtr);
                    else
                        return Interop.Globalization.LastIndexOf(_sortHandle, pTarget, target.Length, pSource, source.Length, options, matchLengthPtr);
                }
            }
        }

Пути ведут в Interop.Globalization.IndexOf(_sortHandle, pTarget, target.Length, pSource, source.Length, options, matchLengthPtr); Это уже код из runtime/src/libraries/Common/src/Interop/

В итоге, все будет зависеть от конкретной системы, так как данная функция определена как:


        [DllImport(Libraries.GlobalizationNative, CharSet = CharSet.Unicode, EntryPoint = "GlobalizationNative_IndexOf")]
        internal static extern unsafe int IndexOf(IntPtr sortHandle, char* target, int cwTargetLength, char* pSource, int cwSourceLength, CompareOptions options, int* matchLengthPtr);

Очевидно под MacOSX (в моем случае) реализация эта сильно эффективнее нежели чем функция Windows.

Ну и сделаем сравнение без учета культуры (т.е. Invariant). Сначала MacOSX

BenchmarkDotNet=v0.12.1, OS=macOS 11.2.3 (20D91) [Darwin 20.3.0]
Intel Core i5-1030NG7 CPU 1.10GHz, 1 CPU, 8 logical and 4 physical cores
.NET Core SDK=5.0.101
  [Host]  : .NET Core 5.0.1 (CoreCLR 5.0.120.57516, CoreFX 5.0.120.57516), X64 RyuJIT
  FindAll : .NET Core 5.0.1 (CoreCLR 5.0.120.57516, CoreFX 5.0.120.57516), X64 RyuJIT

Job=FindAll  InvocationCount=30000  IterationCount=5  
LaunchCount=3  RunStrategy=ColdStart  WarmupCount=2  

MethodinputMeanErrorStdDevRatioRatioSD
AhoCorasickMozi(…)7.36 [2000]27.10 μs1.122 μs1.050 μs1.710.05
NaiveMozi(…)7.36 [2000]15.86 μs0.808 μs0.756 μs1.000.00

Ничего принципиально не меняется. Теперь Windows:

BenchmarkDotNet=v0.12.1, OS=Windows 10.0.19042
Intel Core i9-9900K CPU 3.60GHz (Coffee Lake), 1 CPU, 16 logical and 8 physical cores
.NET Core SDK=5.0.202
  [Host]  : .NET Core 5.0.5 (CoreCLR 5.0.521.16609, CoreFX 5.0.521.16609), X64 RyuJIT
  FindAll : .NET Core 5.0.5 (CoreCLR 5.0.521.16609, CoreFX 5.0.521.16609), X64 RyuJIT

Job=FindAll  InvocationCount=30000  IterationCount=5  
LaunchCount=3  RunStrategy=ColdStart  WarmupCount=2  

MethodinputMeanErrorStdDevRatioRatioSD
AhoCorasickMozi(…)7.36 [2000]18.16 μs0.637 μs0.596 μs1.500.04
NaiveMozi(…)7.36 [2000]12.13 μs0.773 μs0.723 μs1.000.00

Ага! Ускорение более чем в 30 раз! В относительных величинах ускорение огромное, но не такое впечатляющее, учитывая разницу в мощи используемых машин — мак это MacBook Air, а Windows — это мощная рабочая станция, которая раз пять мощнее чем легкий ультрабук.

Выводы

Я конечно не могу 100% утверждать, но все выглядит так, как будто под MacOS делается некая оптимизация и сравнение идет без учета культуры, даже если указано иное, возможно определятся что строка не требует сравнения с учетом культуры и идет простое сравнение байтов. Иначе, мне сложно объяснить одинаковую производительность, как при использовании NLS, так и при использовании ICU под Windows и при этом Unix вариант (где всегда ICU) быстрее почти в 30 раз. Хотя, я сначала подумал, что Windows всегда работает с использованием NLS, и вышеупомянутая FindNLSStringEx из kernel32.dll просто сильно медленнее. Но нет, она по скорости работы ничем не отличается от варианта по умолчанию в .NET 5 (ICU).

Кроме отличий в работе функции IndexOf — есть и другие различия в работе функций касающихся культурных особенностей (глобализации), и обо всем об этом читайте в документации Microsoft.