Уважаемые читатели, в этой статье я хочу рассказать о небольших тестах со строками и представить свои выводы. Тесты сделаны на .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 Span, 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 unsafe string Unsafe_ReadOnlySpan()
{
ReadOnlySpan<char> chars = Content.AsSpan();
fixed (char* baseChar = chars)
{
for (int i = 0; i < chars.Length; i++)
{
if (baseChar[i] == ' ')
{
baseChar[i] = ',';
}
}
}
return new string(chars);
}
[Benchmark]
public unsafe string Unsafe_Span()
{
Span<char> chars = new(Content.ToCharArray());
fixed (char* baseChar = chars)
{
for (int i = 0; i < chars.Length; i++)
{
if (baseChar[i] == ' ')
{
baseChar[i] = ',';
}
}
}
return new string(chars);
}
[Benchmark]
public string Marshal_Span()
{
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 Content;
}
[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;
}
}
Теперь самое интересное, а именно результаты:
Пришло время проверить что там со 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;
}
}
Результаты тестов:
Тут я удивился, что вроде однотипные перегрузки, а все же дают разные эффект по скорости. Понятное дело, что лучше всего обычный 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();
}
}
И сразу к результатам:
Интересный факт в том, что, делая тест многократно можно заметить, что индексаторы чаще лидируют, а потому они вполне пригодны для замены стандартному 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;
}
}
Итог говорит сам за себя:
Всем удачи и до новых встреч!
PS: статья немного поправлена по данным комментариев. Всем спасибо за участие!
Комментарии (19)
Gradiens
20.05.2023 10:40+1А вас не смущают результаты последнего бенчмарка? Разница составляет 4(!) порядка.
Я бы предположил, что компилятор неплохо оптимизирует большинство циклов. Не бежит по всему циклу, а "разворачивает" его в код, сразу выдающий нужный результат.
Было бы интересно взглянуть на IL код этих методов, чтобы понять причины таких радикальных отличий.
JC_Fruit
20.05.2023 10:40+3Там не только разница на 4 порядка, там еще и 300Мб (!) аллокаций в методе, который все что делает - считает сумму элементов массива.
В бенчмарке явно есть какая-то ошибка
UPD:
Пришлось запустить код самому, что бы увидеть. В последнем бенчмарке, данные в классе с бенчмарком задаются в виде конструкцииprivate static int[] Items => Enumerable.Range(1, 10_000).ToArray();
Это значит:
При каждом обращении к Items аллоцируется новый массив на 10k элементов
Аллокация массива тестовых данных учитывается в бенчмарке
В итоге, в тестах которые обращаются к Items.Length в цикле, на каждый проход цикла аллоцируется новый массив. Если поправить бенчмарк:
public class NewCyclesBenchmark { private readonly int[] _items; public NewCyclesBenchmark() { _items = Enumerable.Range(1, 10_000).ToArray(); } }
то результаты будут куда менее провокационными
teoadal
20.05.2023 10:40+1Там точно ошибка.
В бенчмарке For есть надпись i < Items.Length. Items в данном бенчмарке это статическое свойство, которое вычисляется при обращении к нему. То есть при каждой итерации по циклу мы каждый раз дергаем свойство Items где каждый раз заново создается массив у которого берется Length.
Отсюда такая бешенная аллокация.
Отмечу ещё, что автор статьи не бежит по массиву, а просто инкрементирует i и приплюсовывает результат к sum. То есть он не обращается к содержимому массива вообще. В деле поиска подстроки это поможет вряд ли, так как строку для разделения всё-таки надо читать.
Hixon10
20.05.2023 10:40А зачем мы в этом способе выделяем новую строку (2), если мы уже поменяли исходную (1)?
[Benchmark] public string Marshal_Span() { 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 Content; (1) return chars.ToString(); (2) }
| Method | Mean | Error | StdDev | Allocated | |-------------------------------------- |------------:|----------:|----------:|----------:| | Replace | 55.15 ns | 0.110 ns | 0.102 ns | 824 B | | Join | 877.49 ns | 2.387 ns | 2.233 ns | 3480 B | | Base_Regex | 2,107.93 ns | 2.620 ns | 2.323 ns | 936 B | | Generated_Regex | 2,167.31 ns | 23.824 ns | 19.894 ns | 824 B | | Сonstructor_Regex | 2,175.78 ns | 17.405 ns | 14.534 ns | 824 B | | Char_NewString | 347.75 ns | 0.762 ns | 0.713 ns | 1648 B | | Char_NewString_FastFor | 345.49 ns | 0.764 ns | 0.677 ns | 1648 B | | Span_NewString | 346.33 ns | 1.008 ns | 0.943 ns | 1648 B | | Span_Concat | 1,503.21 ns | 1.903 ns | 1.687 ns | 2504 B | | Char_Concat | 1,453.93 ns | 3.316 ns | 2.940 ns | 1680 B | | Unsafe_ReadOnlySpan_NewString | 297.68 ns | 0.470 ns | 0.416 ns | 824 B | | Unsafe_ReadOnlySpan_NewString_Foreach | 308.40 ns | 1.223 ns | 1.144 ns | 824 B | | Unsafe_String | 257.23 ns | 0.308 ns | 0.288 ns | - | | Unsafe_String_Foreach | 292.52 ns | 0.715 ns | 0.634 ns | - | | Marshal_Span | 257.43 ns | 0.517 ns | 0.484 ns | - | | Marshal_Span_ToString | 302.45 ns | 0.891 ns | 0.790 ns | 824 B | | Marshal_Span_NewString | 297.55 ns | 0.448 ns | 0.419 ns | 824 B | | String_Create | 300.35 ns | 0.430 ns | 0.359 ns | 824 B |
Geronom Автор
20.05.2023 10:40Исходная не меняется, поэтому нужно работать с результатом в виде chars, что можно проверить вызвав метод просто так.
Hixon10
20.05.2023 10:40Geronom Автор
20.05.2023 10:40В данных тестах у меня private const string Content, поэтому не меняет. Если сделать не константой, то да, меняет. Заменил и переснял результаты.
Hixon10
20.05.2023 10:40Как вы проверяете? Я вижу что строка изменяется и в случае
private const string Content
.
https://dotnetfiddle.net/mHRNKR
viruseg
20.05.2023 10:40+1Результаты тестирования unsafe методов абсолютно неверны, потому что сами методы написаны неправильно. Закрепление объекта внутри цикла так себе идея, fixed нужно вынести на уровень выше.
public unsafe string Unsafe_ReadOnlySpan_NewString() { ReadOnlySpan<char> chars = Content.AsSpan(); fixed (char* baseChar = chars) for (int i = 0; i < chars.Length; i++) { if (baseChar[i] == ' ') baseChar[i] = ','; } return new string(chars); }
ARad
Тесты с регулярным выражением надо переделать, у вас "\s+", а должно быть "\s", в остальных тестах я вижу что каждый пробел заменяется на запятую, а не группа пробелов.
SozTr
Можно ещё вместо generated попробовать старый способ, чтобы посмотреть на сколько разница будет
Geronom Автор
Спасибо за идею, дополнил и переделал тесты
ARad
Подобный способ тестируется, но без статической переменной, что несколько хуже из-за времени на поиск в кэше...
FFoxDiArt
Необходимо переделать даже не на "\s", а на регулярку с единственным пробельным символом (space character). Регулярка \s ищет еще кучу всего помимо пробелов (всякие табуляции, переносы строк и вовзраты кареток при включенном многострочном режиме).