Каков самый быстрый способ перебора отдельных символов в строке на С#?
Название - вопрос. Ниже приведена моя попытка ответить на него посредством исследований. Но я не доверяю своим неосведомленным исследованиям, поэтому я все еще задаю вопрос (что является самым быстрым способом для итерации отдельных символов в строке на С#?).
Иногда я хочу циклически перемещать символы строки один за другим, например, при разборе для вложенных токенов - что-то, что не может быть выполнено с помощью регулярных выражений. Мне интересно, какой самый быстрый способ - перебирать отдельные символы в строке, особенно очень большие строки.
Я проверил себя, и мои результаты ниже. Однако есть много читателей с гораздо более глубокими знаниями о компиляторе .NET CLR и С#, поэтому я не знаю, не хватает ли я чего-то очевидного или если я допустил ошибку в своем тестовом коде. Поэтому я требую вашего коллективного ответа. Если у кого-то есть представление о том, как работает индексатор строк, это было бы очень полезно. (Является ли это языком языка С#, скомпилированным во что-то еще за сценой? Или что-то встроенное в CLR?).
Первый метод, использующий поток, был взят непосредственно из принятого ответа из потока: как создать поток из строки?
Испытания
longString
- это строка в 99,1 миллиона символов, состоящая из 89 экземпляров текстовой версии спецификации языка С#. Результаты показаны для 20 итераций. Там, где есть время запуска (например, для первой итерации неявно созданного массива в методе № 3), я тестировал это отдельно, например, разбивая его из цикла после первой итерации.
Результаты
Из моих тестов кеширование строки в массиве char с использованием метода ToCharArray() является самым быстрым для итерации по всей строке. Метод ToCharArray() является авансом, а последующий доступ к отдельным символам немного быстрее, чем встроенный индексный аксессуар.
milliseconds
---------------------------------
Method Startup Iteration Total StdDev
------------------------------ ------- --------- ----- ------
1 index accessor 0 602 602 3
2 explicit convert ToCharArray 165 410 582 3
3 foreach (c in string.ToCharArray)168 455 623 3
4 StringReader 0 1150 1150 25
5 StreamWriter => Stream 405 1940 2345 20
6 GetBytes() => StreamReader 385 2065 2450 35
7 GetBytes() => BinaryReader 385 5465 5850 80
8 foreach (c in string) 0 960 960 4
Обновление: В комментарии @Eric приведены результаты для 100 итераций более обычной строки 1.1 M char (одна копия спецификации С#). Indexer и char все еще быстрее, за ними следует foreach (char в строке), а затем методы потока.
milliseconds
---------------------------------
Method Startup Iteration Total StdDev
------------------------------ ------- --------- ----- ------
1 index accessor 0 6.6 6.6 0.11
2 explicit convert ToCharArray 2.4 5.0 7.4 0.30
3 for(c in string.ToCharArray) 2.4 4.7 7.1 0.33
4 StringReader 0 14.0 14.0 1.21
5 StreamWriter => Stream 5.3 21.8 27.1 0.46
6 GetBytes() => StreamReader 4.4 23.6 28.0 0.65
7 GetBytes() => BinaryReader 5.0 61.8 66.8 0.79
8 foreach (c in string) 0 10.3 10.3 0.11
Используемый код (проверяется отдельно, показывается вместе для краткости)
//1 index accessor
int strLength = longString.Length;
for (int i = 0; i < strLength; i++) { c = longString[i]; }
//2 explicit convert ToCharArray
int strLength = longString.Length;
char[] charArray = longString.ToCharArray();
for (int i = 0; i < strLength; i++) { c = charArray[i]; }
//3 for(c in string.ToCharArray)
foreach (char c in longString.ToCharArray()) { }
//4 use StringReader
int strLength = longString.Length;
StringReader sr = new StringReader(longString);
for (int i = 0; i < strLength; i++) { c = Convert.ToChar(sr.Read()); }
//5 StreamWriter => StreamReader
int strLength = longString.Length;
MemoryStream stream = new MemoryStream();
StreamWriter writer = new StreamWriter(stream);
writer.Write(longString);
writer.Flush();
stream.Position = 0;
StreamReader str = new StreamReader(stream);
while (stream.Position < strLength) { c = Convert.ToChar(str.Read()); }
//6 GetBytes() => StreamReader
int strLength = longString.Length;
MemoryStream stream = new MemoryStream(Encoding.Unicode.GetBytes(longString));
StreamReader str = new StreamReader(stream);
while (stream.Position < strLength) { c = Convert.ToChar(str.Read()); }
//7 GetBytes() => BinaryReader
int strLength = longString.Length;
MemoryStream stream = new MemoryStream(Encoding.Unicode.GetBytes(longString));
BinaryReader br = new BinaryReader(stream, Encoding.Unicode);
while (stream.Position < strLength) { c = br.ReadChar(); }
//8 foreach (c in string)
foreach (char c in longString) { }
Принятый ответ:
Я интерпретировал @CodeInChaos и Ben следующие:
fixed (char* pString = longString) {
char* pChar = pString;
for (int i = 0; i < strLength; i++) {
c = *pChar ;
pChar++;
}
}
Выполнение для 100 итераций по короткой строке составляло 4,4 мс, с < 0.1 ms st dev.
Ответы
Ответ 1
Самый быстрый ответ - использовать С++/CLI: Как получить доступ к символам в системе:: String
Этот подход выполняет итерацию через символы на месте в строке, используя арифметику указателя. Нет копий, неявных проверок диапазона и никаких вызовов функций для каждого элемента.
Вероятно, можно получить (почти, С++/CLI не требует закрепления) той же производительности с С#, написав небезопасную версию С# PtrToStringChars
.
Что-то вроде:
unsafe char* PtrToStringContent(string s, out GCHandle pin)
{
pin = GCHandle.Alloc(s, GCHandleType.Pinned);
return (char*)pin.AddrOfPinnedObject().Add(System.Runtime.CompilerServices.RuntimeHelpers.OffsetToStringData).ToPointer();
}
Не забудьте позвонить GCHandle.Free
после этого.
Забастовкa >
Комментарий CodeInChaos указывает на то, что С# предоставляет синтаксический сахар для этого:
fixed(char* pch = s) { ... }
Ответ 2
Любая причина не включать foreach
?
foreach (char c in text)
{
...
}
Неужели это будет ваше узкое место в производительности, кстати? Какая доля вашего общего времени выполнения занимает сама итерация?
Ответ 3
Эти искусственные тесты довольно опасны. Примечательно, что ваши // 2 и//3 версии кода никогда не индексируют строку. Оптимизатор джиттера просто отбрасывает код, поскольку переменная c не используется вообще. Вы просто измеряете, сколько времени занимает цикл for(). Вы не можете это увидеть, если не посмотрите на сгенерированный машинный код.
Измените его на c += longString[i];
, чтобы принудительно использовать индексатор массива.
Это, конечно, нонсенс. Профиль только реального кода.
Ответ 4
TL; DR: простой foreach
- самый быстрый способ итерации строки.
Для людей, возвращающихся к этому: времена меняются!
С новейшим 64-разрядным JIT.NET, небезопасная версия на самом деле самая медленная.
Ниже приводится эталонная реализация для BenchmarkDotNet. Из этого я получил следующие результаты:
Method | Mean | Error | StdDev |
---------------- |----------:|----------:|----------:|
Indexing | 5.9712 us | 0.8738 us | 0.3116 us |
IndexingOnArray | 8.2907 us | 0.8208 us | 0.2927 us |
ForEachOnArray | 8.1919 us | 0.6505 us | 0.1690 us |
ForEach | 5.6946 us | 0.0648 us | 0.0231 us |
Unsafe | 7.2952 us | 1.1050 us | 0.3941 us |
Интересными являются те, которые не работают с копией массива. Это показывает, что индексирование и foreach
очень схожи по производительности, разница 5%, foreach
быстрее. Использование unsafe
на самом деле на 28% медленнее, чем при использовании foreach
.
В прошлом unsafe
, возможно, был самым быстрым вариантом, но JIT все быстрее и умнее.
В качестве эталона используется эталонный код:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Configs;
using BenchmarkDotNet.Horology;
using BenchmarkDotNet.Jobs;
using BenchmarkDotNet.Running;
namespace StringIterationBenchmark
{
public class StringIteration
{
public static void Main(string[] args)
{
var config = new ManualConfig();
config.Add(DefaultConfig.Instance);
config.Add(Job.Default
.WithLaunchCount(1)
.WithIterationTime(TimeInterval.FromMilliseconds(500))
.WithWarmupCount(3)
.WithTargetCount(6)
);
BenchmarkRunner.Run<StringIteration>(config);
}
private readonly string _longString = BuildLongString();
private static string BuildLongString()
{
var sb = new StringBuilder();
var random = new Random();
while (sb.Length < 10000)
{
char c = (char)random.Next(char.MaxValue);
if (!Char.IsControl(c))
sb.Append(c);
}
return sb.ToString();
}
[Benchmark]
public char Indexing()
{
char c = '\0';
var longString = _longString;
int strLength = longString.Length;
for (int i = 0; i < strLength; i++)
{
c |= longString[i];
}
return c;
}
[Benchmark]
public char IndexingOnArray()
{
char c = '\0';
var longString = _longString;
int strLength = longString.Length;
char[] charArray = longString.ToCharArray();
for (int i = 0; i < strLength; i++)
{
c |= charArray[i];
}
return c;
}
[Benchmark]
public char ForEachOnArray()
{
char c = '\0';
var longString = _longString;
foreach (char item in longString.ToCharArray())
{
c |= item;
}
return c;
}
[Benchmark]
public char ForEach()
{
char c = '\0';
var longString = _longString;
foreach (char item in longString)
{
c |= item;
}
return c;
}
[Benchmark]
public unsafe char Unsafe()
{
char c = '\0';
var longString = _longString;
int strLength = longString.Length;
fixed (char* p = longString)
{
var p1 = p;
for (int i = 0; i < strLength; i++)
{
c |= *p1;
p1++;
}
}
return c;
}
}
}
В коде есть несколько незначительных изменений от предлагаемого кода. Шрифты, которые извлекаются из исходной строки, |
-ed с возвращаемой переменной, и мы возвращаем значение. Причина этого в том, что нам действительно нужно что-то сделать с результатом. В противном случае, если бы мы просто повторяли строку, например:
//8 foreach (c in string)
foreach (char c in longString) { }
JIT может удалить это, потому что он может сделать вывод о том, что вы фактически не наблюдаете результаты итерации. По |
-показав символы в массиве и вернув это, BenchmarkDotNet удостоверится, что JIT не может выполнить эту оптимизацию.
Ответ 5
Если для вас очень важна микро-оптимизация, попробуйте это. (Я предположил, что длина входной строки кратна 8 для простоты)
unsafe void LoopString()
{
fixed (char* p = longString)
{
char c1,c2,c3,c4;
Int64 len = longString.Length;
Int64* lptr = (Int64*)p;
Int64 l;
for (int i = 0; i < len; i+=8)
{
l = *lptr;
c1 = (char)(l & 0xffff);
c2 = (char)(l >> 16);
c3 = (char)(l >> 32);
c4 = (char)(l >> 48);
lptr++;
}
}
}
Просто шутите, никогда не используйте этот код:)
Ответ 6
Если скорость действительно имеет значение for
быстрее, чем foreach
for (int i = 0; i < text.Length; i++) {
char ch = text[i];
...
}