Бенчмаркая строки и циклы: Replace, Split и Substring

в 8:53, , рубрики: .net, C#, бенчмаркинг, оптимизация, Программирование

Уважаемые читатели, в этой статье я хочу рассказать о небольших тестах со строками и представить свои выводы. Тесты сделаны на .net 7.

Все коды представлены для повторения но отмечу, что больше всего удивили циклы.

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

Все тесты сделаны с использованием BenchmarkDotNet, так что каждый может проверить результаты и сделать свои выводы.

Хочется начать с string.Replace, который проверяется разными вариантами, начиная с базового:

    [Benchmark]
    public string Replace()
    {
        return Content.Replace(' ', ',');
    }

Следующий и часто используемый при работе «на скорую руку» вариант тоже прост и однострочен:

    [Benchmark]
    public string Join()
    {
        return string.Join(',', Content.Split(' '));
    }

Под капотов string.Join и string.Split используют ReadOnlySpan и в целом вроде должны работать не очень медленно, так как работают со стеком. Кстати, если написать что-то вроде string.Split('  ', '.'), то перегрузка позволит убрать точки.

Далее следует обычный Regex:

    [Benchmark]
    public string Base_Regex()
    {
        return Regex.Replace(Content, " ", ", ", RegexOptions.Compiled);
    }

Вариант с конструктором:

    private readonly Regex constructorRegexCompiled;

    public ReplaceString()
    {
        constructorRegexCompiled = new Regex(" ", RegexOptions.Compiled);
    }

    [Benchmark]
    public string Сonstructor_Regex()
    {
        return constructorRegexCompiled.Replace(Content, ",");
    }

Новая версия:

    [GeneratedRegex(" ")]
    private static partial Regex GeneratedRegexReplace();

    [Benchmark]
    public string Generated_Regex()
    {
        return GeneratedRegexReplace().Replace(Content, ",");
    }

Отмечу тот факт, что в GeneratedRegex по дефолту RegexOptions.Compiled игнорируется, а потому нет смысла его указывать. 

Пришло время простого перебора, но в двух вариациях. Решение будет следующим:

    [Benchmark]
    public string Char_NewString()
    {
        char[] chars = Content.ToCharArray();
        for (int i = 0; i < chars.Length; i++)
        {
            if (chars[i] == ' ')
            {
                chars[i] = ',';
            }
        }

        return new string(chars);
    }

И второй вариант, в котором используется более оптимизированный for (раскладывается в while но с приятным дополнением):

    [Benchmark]
    public string Char_NewString_FastFor()
    {
        char[] chars = Content.ToCharArray();
        for (int i = 0, length = chars.Length; i < length; i++)
        {
            if (chars[i] == ' ')
            {
                chars[i] = ',';
            }
        }

        return new string(chars);
    }

Добавим работу с обычным Span в версии new string(chars) и string.Concat:

    [Benchmark]
    public string Span_NewString()
    {
        Span<char> chars = new Span<char>(Content.ToCharArray());
        for (int i = 0; i < chars.Length; i++)
        {
            if (chars[i] == ' ')
            {
                chars[i] = ',';
            }
        }

        return new string(chars);
    }

Кроме того в полном листинге вы найдете работу с unsafe ReadOnlySpan, прямую модификацию строки (иммутабельность под вопросом:)) и ранее мною не используемые но вполне рабочий вариант с string.Create().

Hidden text
using BenchmarkDotNet.Attributes;
using System.Runtime.InteropServices;
using System.Text.RegularExpressions;

namespace Benchmarks.Benchmarks;

[MemoryDiagnoser(false)]
public partial class ReplaceString
{
    private const string Content =
        "Hello World! Add new line. Ok. Substring allocates a new string object on the heap and performs a full copy of the extracted text." +
        "String manipulation is a performance bottleneck for many programs. Many APIs that accept strings also have overloads that accept a ReadOnlySpan<System.Char> argument." +
        "When such overloads are available, you can improve performance by calling AsSpan instead of Substring.";

    private readonly Regex constructorRegexCompiled;

    public ReplaceString()
    {
        constructorRegexCompiled = new Regex(" ", RegexOptions.Compiled);
    }

    [Benchmark]
    public string Replace()
    {
        return Content.Replace(' ', ',');
    }

    [Benchmark]
    public string Join()
    {
        return string.Join(',', Content.Split(' '));
    }

    [Benchmark]
    public string Base_Regex()
    {
        return Regex.Replace(Content, " ", ", ", RegexOptions.Compiled);
    }

    [GeneratedRegex(" ")]
    private static partial Regex GeneratedRegexReplace();

    [Benchmark]
    public string Generated_Regex()
    {
        return GeneratedRegexReplace().Replace(Content, ",");
    }

    [Benchmark]
    public string Сonstructor_Regex()
    {
        return constructorRegexCompiled.Replace(Content, ",");
    }

    [Benchmark]
    public string Char_NewString()
    {
        char[] chars = Content.ToCharArray();
        for (int i = 0; i < chars.Length; i++)
        {
            if (chars[i] == ' ')
            {
                chars[i] = ',';
            }
        }

        return new string(chars);
    }

    [Benchmark]
    public string Char_NewString_FastFor()
    {
        char[] chars = Content.ToCharArray();
        for (int i = 0, length = chars.Length; i < length; i++)
        {
            if (chars[i] == ' ')
            {
                chars[i] = ',';
            }
        }

        return new string(chars);
    }

    [Benchmark]
    public string Span_NewString()
    {
        Span<char> chars = new Span<char>(Content.ToCharArray());
        for (int i = 0; i < chars.Length; i++)
        {
            if (chars[i] == ' ')
            {
                chars[i] = ',';
            }
        }

        return new string(chars);
    }

    [Benchmark]
    public string Span_Concat()
    {
        Span<char> chars = new Span<char>(Content.ToCharArray());
        for (int i = 0; i < chars.Length; i++)
        {
            if (chars[i] == ' ')
            {
                chars[i] = ',';
            }
        }

        return string.Concat(chars.ToArray());
    }

    [Benchmark]
    public string Char_Concat()
    {
        char[] chars = Content.ToCharArray();
        for (int i = 0; i < chars.Length; i++)
        {
            if (chars[i] == ' ')
            {
                chars[i] = ',';
            }
        }

        return string.Concat(chars);
    }

    [Benchmark]
    public string Unsafe_ReadOnlySpan_NewString()
    {
        ReadOnlySpan<char> chars = Content.AsSpan();
        for (int i = 0; i < chars.Length; i++)
        {
            if (chars[i] != ' ')
            {
                continue;
            }

            unsafe
            {
                fixed (char* baseChar = chars)
                {
                    char* newChar = baseChar + i;
                    *newChar = ',';
                }
            }
        }

        return new string(chars);
    }

    [Benchmark]
    public string Unsafe_ReadOnlySpan_NewString_Foreach()
    {
        int i = 0;

        ReadOnlySpan<char> chars = Content.AsSpan();
        foreach (char @char in chars)
        {
            ++i;
            if (@char != ' ')
            {
                continue;
            }
            
            unsafe
            {
                fixed (char* baseChar = chars)
                {
                    char* newChar = baseChar + i;
                    *newChar = ',';
                }
            }
        }

        return new string(chars);
    }

    [Benchmark]
    public string Unsafe_String()
    {
        for (int i = 0; i < Content.Length; i++)
        {
            if (Content[i] != ' ')
            {
                continue;
            }

            unsafe
            {
                fixed (char* baseChar = Content)
                {
                    char* newChar = baseChar + i;
                    *newChar = ',';
                }
            }
        }

        return Content;
    }

    [Benchmark]
    public string Unsafe_String_Foreach()
    {
        int i = 0;
        foreach (char @char in Content)
        {
            ++i;
            if (@char!= ' ')
            {
                continue;
            }

            unsafe
            {
                fixed (char* baseChar = Content)
                {
                    char* newChar = baseChar + i;
                    *newChar = ',';
                }
            }
        }

        return Content;
    }

    [Benchmark]
    public string Marshal_Span_ToString()
    {
        Span<char> chars = MemoryMarshal.CreateSpan(ref MemoryMarshal.GetReference(Content.AsSpan()), Content.Length);
        for (int i = 0; i < chars.Length; i++)
        {
            if (chars[i] == ' ')
            {
                chars[i] = ',';
            }
        }

        return chars.ToString();
    }

    [Benchmark]
    public string Marshal_Span_NewString()
    {
        Span<char> chars = MemoryMarshal.CreateSpan(ref MemoryMarshal.GetReference(Content.AsSpan()), Content.Length);
        for (int i = 0; i < chars.Length; i++)
        {
            if (chars[i] == ' ')
            {
                chars[i] = ',';
            }
        }

        return new string(chars);
    }

    [Benchmark]
    public string String_Create()
    {
        string result = string.Create(Content.Length, Content, (chars, buffer) =>
        {
            for (int i = 0; i < chars.Length; i++)
            {
                chars[i] = buffer[i];
                if (chars[i] == ' ')
                {
                    chars[i] = ',';
                }
            }
        });
        
        return result;
    }
}

Теперь самое интересное, а именно результаты:

Бенчмаркая строки и циклы: Replace, Split и Substring - 1

Как оказалось, стандартный метод вполне удачен, но не во всех отношениях, так как память все же расходует, а вот unsafe нет, что в целом ожидаемо, но Скорость при этом крайне хромает.

Стоит обратить внимание на обычный for и более «новую» версию, что по факту обычный сахар, но все же разница в скорости есть.

Как и было во все времена, Regex не очень удачен для работы со строками в подобном варианте, хоть вполне красив на вид. Лично для меня интрига была в foreach и все же обычный for до сих пор удачный выбор, что бы так ни говорили про «это уже оптимизировано».

В остальном цифры вполне интересные.

Пришло время проверить что там со Split?

В целом идеи будут подобные для тестов, но с небольшими дополнениями или изменениями. В полном листинге можно глянуть что как тестировалось, только отмечу, что различные перегрузки стандартного string.Split() крайне удивили разнице в результатах теста, а работа с массивами была частично найдена на просторах Интернета, частично дописана своими силами.

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

namespace Benchmarks.Benchmarks;

[MemoryDiagnoser(false)]
public partial class SplitString
{
    private const string Content =
        "Hello World! Add new line. Ok. Substring allocates a new string object on the heap and performs a full copy of the extracted text." +
        "String manipulation is a performance bottleneck for many programs. Many APIs that accept strings also have overloads that accept a ReadOnlySpan<System.Char> argument." +
        "When such overloads are available, you can improve performance by calling AsSpan instead of Substring.";
    
    private readonly Regex constructorRegexCompiled;
    
    public SplitString()
    {
        constructorRegexCompiled = new Regex(" ", RegexOptions.Compiled);
    }

    [Benchmark]
    public string[] Split_Default()
    {
        return Content.Split();
    }

    [Benchmark]
    public string[] Split_Whitespace()
    {
        return Content.Split(' ');
    }

    [Benchmark]
    public string[] Split_Null()
    {
        return Content.Split(null);
    }

    [Benchmark]
    public string[] Split_NewChar()
    {
        return Content.Split(new char[0]);
    }

    [Benchmark]
    public string[] Base_Regex()
    {
        return Regex.Split(Content, " ", RegexOptions.Compiled);
    }
    
    [GeneratedRegex(" ")]
    private static partial Regex GeneratedRegexDefault();

    [Benchmark]
    public string[] Generated_Regex()
    {
        return GeneratedRegexDefault().Split(Content);
    }

    [Benchmark]
    public string[] Сonstructor_Regex()
    {
        return constructorRegexCompiled.Split(Content);
    }

    [Benchmark]
    public string[] Array_Indexator()
    {
        int index = -1;
        StringBuilder sb = new(Content.Length);

        int elementCount = 0;

        string[] strings = new string[Content.Length];
        for (int i = 0; i < Content.Length; i++)
        {
            if (Content[i] == ' ')
            {
                ++elementCount;

                strings[++index] = sb.ToString();

                sb.Clear();
                continue;
            }

            sb.Append(Content[i]);
        }

        ++elementCount;
        strings[++index] = sb.ToString();

        Array.Resize(ref strings, elementCount);
        
        return strings;
    }

    [Benchmark]
    public string[] Array_SetValue()
    {
        int index = -1;
        StringBuilder sb = new(Content.Length);

        int elementCount = 0;

        string[] strings = new string[Content.Length];
        for (int i = 0; i < Content.Length; i++)
        {
            if (Content[i] == ' ')
            {
                ++elementCount;

                strings.SetValue(sb.ToString(), ++index);

                sb.Clear();
                continue;
            }

            sb.Append(Content[i]);
        }

        ++elementCount;
        strings[++index] = sb.ToString();

        Array.Resize(ref strings, elementCount);

        return strings;
    }
}

Результаты тестов:

Бенчмаркая строки и циклы: Replace, Split и Substring - 2

Тут я удивился, что вроде однотипные перегрузки, а все же дают разные эффект по скорости. Понятное дело, что лучше всего обычный char передавать, так как не создаются массивы за счет использования params, но все же тесты есть тесты, надо пощупать, чтобы точно все понять.

Остается разобраться с Substring. Метод вполне популярный, чтобы дополнить общеизвестные факты, но отмечу, что варианты тестов были собраны ради посмотреть «А как еще можно и что это даст?».

Hidden text
using BenchmarkDotNet.Attributes;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.RegularExpressions;

namespace Benchmarks.Benchmarks;

[MemoryDiagnoser(false)]
public partial class SubString
{
    private const string Content =
        "Hello World! Add new line. Ok. Substring allocates a new string object on the heap and performs a full copy of the extracted text." +
        "String manipulation is a performance bottleneck for many programs. Many APIs that accept strings also have overloads that accept a ReadOnlySpan<System.Char> argument." +
        "When such overloads are available, you can improve performance by calling AsSpan instead of Substring.";

    [Benchmark]
    public string Indexator()
    {
        return Content[6..11];
    }

    [Benchmark]
    public string Substring()
    {
        return Content.Substring(6, 5);
    }

    [Benchmark]
    public string ReadOnlySpan_ToString()
    {
        return Content.AsSpan(6, 5).ToString();
    }

    [Benchmark]
    public string ReadOnlySpan_NewString()
    {
        return new string(Content.AsSpan(6, 5));
    }

    [Benchmark]
    public unsafe string Unsafe()
    {
        fixed (char* chars = Content)
        {
            return new string(chars, 6, 5);
        }
    }

    [Benchmark]
    public string StringBuilder()
    {
        StringBuilder sb = new(Content);
        return sb.ToString(6, 5);
    }

    [GeneratedRegex("(\w+)")]
    private static partial Regex GeneratedRegexFirst();

    [Benchmark]
    public string GeneratedRegex_First()
    {
        Regex match = GeneratedRegexFirst();
        return match.Match(Content, 6, 5).Value;
    }
    
    [GeneratedRegex("^.{6}(.{5}).*$")]
    private static partial Regex GeneratedRegexSecond();

    [Benchmark]
    public string GeneratedRegex_Second()
    {
        return GeneratedRegexSecond().Match(Content).Groups[1].Value;
    }

    [Benchmark]
    public string MemoryMarshal_Span_NewString()
    {
        Span<char> span = MemoryMarshal.CreateSpan(ref MemoryMarshal.GetReference(Content.AsSpan()), Content.Length);
        return new string(span.Slice(6, 5));
    }

    [Benchmark]
    public string MemoryMarshal_ReadOnlySpan_NewString()
    {
        ReadOnlySpan<char> span = MemoryMarshal.CreateReadOnlySpan(ref MemoryMarshal.GetReference(Content.AsSpan()), Content.Length);
        return new string(span.Slice(6, 5));
    }

    [Benchmark]
    public string MemoryMarshal_Span_ToString()
    {
        Span<char> span = MemoryMarshal.CreateSpan(ref MemoryMarshal.GetReference(Content.AsSpan()), Content.Length);
        return span.Slice(6, 5).ToString();
    }

    [Benchmark]
    public string MemoryMarshal_ReadOnlySpan_ToString()
    {
        ReadOnlySpan<char> span = MemoryMarshal.CreateReadOnlySpan(ref MemoryMarshal.GetReference(Content.AsSpan()), Content.Length);
        return span.Slice(6, 5).ToString();
    }
}

И сразу к результатам:

Бенчмаркая строки и циклы: Replace, Split и Substring - 3

Интересный факт в том, что, делая тест многократно можно заметить, что индексаторы чаще лидируют, а потому они вполне пригодны для замены стандартному Substring, что удивило.

Все коды представлены для повторения но отмечу, что больше всего удивили циклы.

Для старта обычный for:

    [Benchmark]
    public int For()
    {
        int sum = 0;
        for (int i = 0; i < Items.Length; i++)
        {
            sum += Items[i];
        }

        return sum;
    }

Далее не обычный for:

    [Benchmark]
    public int New_For()
    {
        int sum = 0;
        for (int i = 0, length = Items.Length; i < length; i++)
        {
            sum += Items[i];
        }

        return sum;
    }

Тут мы заранее говорим, сколько элементов и минус проверка. Да, увы, c# пока не так разумен, как хочется:(

Не забываем про обычный foreach:

    [Benchmark]
    public int Foreach()
    {
        int sum = 0;
        foreach (int number in Items)
        {
            sum += number;
        }

        return sum;
    }

И, бесспорно, while:

    [Benchmark]
    public int While()
    {
        int sum = 0;

        int i = 0;
        while (i < Items.Length)
        {
            sum += Items[i++];
        }

        return sum;
    }

И вторая версия с оптимизацией:

    [Benchmark]
    public int New_While()
    {
        int sum = 0;
        int length = Items.Length;

        int i = 0;
        while (i < length)
        {
            sum += Items[i++];
        }

        return sum;
    }

Не будет лишним проверить Span:

    [Benchmark]
    public int For_Span()
    {
        Span<int> items = Items.AsSpan();

        int sum = 0;
        for (int i = 0, length = items.Length; i < length; i++)
        {
            sum += items[i];
        }

        return sum;
    }

И ReadOnlySpan:

    [Benchmark]
    public int For_ReadOnlySpan()
    {
        ReadOnlySpan<int> items = new(Items);

        int sum = 0;
        for (int i = 0, length = items.Length; i < length; i++)
        {
            sum += items[i];
        }

        return sum;
    }

Все на выходе дает одно и то же значение, что вы сможете легко проверить своими силами.

Hidden text
using BenchmarkDotNet.Attributes;

namespace Benchmarks.Benchmarks;

[MemoryDiagnoser(false)]
public class Cycles
{
    private readonly int[] Items;

    public Cycles()
    {
        Items = Enumerable.Range(1, 10_000).ToArray();
    }

    [Benchmark]
    public int For_Span()
    {
        Span<int> items = Items.AsSpan();

        int sum = 0;
        for (int i = 0, length = items.Length; i < length; i++)
        {
            sum += items[i];
        }

        return sum;
    }

    [Benchmark]
    public int For_ReadOnlySpan()
    {
        ReadOnlySpan<int> items = new(Items);

        int sum = 0;
        for (int i = 0, length = items.Length; i < length; i++)
        {
            sum += items[i];
        }

        return sum;
    }

    [Benchmark]
    public int For()
    {
        int sum = 0;
        for (int i = 0; i < Items.Length; i++)
        {
            sum += Items[i];
        }

        return sum;
    }

    [Benchmark]
    public int New_For()
    {
        int sum = 0;
        for (int i = 0, length = Items.Length; i < length; i++)
        {
            sum += Items[i];
        }

        return sum;
    }

    [Benchmark]
    public int Foreach()
    {
        int sum = 0;
        foreach (int number in Items)
        {
            sum += number;
        }

        return sum;
    }

    [Benchmark]
    public int While()
    {
        int sum = 0;

        int i = 0;
        while (i < Items.Length)
        {
            sum += Items[i++];
        }

        return sum;
    }

    [Benchmark]
    public int New_While()
    {
        int sum = 0;
        int length = Items.Length;

        int i = 0;
        while (i < length)
        {
            sum += Items[i++];
        }

        return sum;
    }
}

Итог говорит сам за себя:

Бенчмаркая строки и циклы: Replace, Split и Substring - 4

Остальные результаты каждый проанализирует на свое усмотрение и ради комментов :-)

Всем удачи и до новых встреч!

PS: статья немного поправлена по данным комментариев. Всем спасибо за участие!

Автор:
Geronom

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js