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

Проверить строку регулярным выражением в .NET можно по-разному, но совет всегда один: включите RegexOptions.Compiled или возьмите генератор. Замеры это подтверждают. А вот подготовка самого выражения оказалась не там, где её меряют: конструктор занимает 10 микросекунд, а первая проверка на этом объекте — ещё 1307.

Сравниваются шесть способов:

  • new Regex — разбирает паттерн при создании и дальше работает интерпретатором;

  • RegexOptions.Compiled — собирает под паттерн IL-метод во время работы приложения;

  • [GeneratedRegex] — генератор пишет обычный C# код при сборке проекта;

  • статический Regex.IsMatch — паттерн передаётся строкой в каждый вызов;

  • RegexOptions.NonBacktracking — движок, который не возвращается назад по строке;

  • string.Contains — обычный поиск подстроки, без регулярного выражения.

Будет 3 истории:

  • что не попадает в замер создания;

  • генератор против Compiled на проверке строки — почему два промаха на строках одной длины отличаются в тысячу раз;

  • что написал генератор — и что стало с классическим примером про опасность регулярных выражений.

Весь код проверки — в репозитории RegexProof. Замеры сделаны на четырёх машинах:

CPU

Ядра/потоки

Частота

ОС

№1

AMD Ryzen 9 5950X

16 / 32

3,4 ГГц, до 4,9 в турбо

Windows 10 1809

№2

Intel Core i9-10900KF

10 / 20

3,7 ГГц, до 5,3 в турбо

Windows 10 22H2

№3

2 × Intel Xeon Silver 4314

2×16 / 64

2,4 ГГц, до 3,4 в турбо

Windows Server 2022

№4

Intel Xeon W-2255

10 / 20

3,7 ГГц, до 4,5 в турбо

Windows Server 2022

BenchmarkDotNet 0.15.8, сборка Release, рантаймы .NET 8, 9 и 10. Патч-версии на машинах разные, поэтому сравнивать имеет смысл столбцы внутри одной машины, а не числа с разных машин.

Все таблицы ниже — по .NET 10. У Compiled и генератора на восьмёрке и девятке те же числа в пределах нескольких процентов. У интерпретатора и RegexOptions.NonBacktracking разброс больше и зависит от машины: на №1 разницы между рантаймами нет, а на №4 они на .NET 8 медленнее почти вдвое. Полные отчёты по всем трём рантаймам лежат в репозитории.

Паттернов три:

// RegexProof, Subjects.cs
 
// точная подстрока
public const string LiteralPattern = "orders/export";
 
// разбор адреса электронной почты
public const string EmailPattern = @"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}";
 
// повторы и варианты
public const string HeavyPattern = @"(?:[a-z]+\d+){2,}(?:foo|bar|baz)+end";

История 1. Конструктор показал 10 мкс, первый вызов — 1307

Сначала посмотрим, сколько занимает получение готового объекта. Паттерн для адреса электронной почты, .NET 10.

Способ

№1 Ryzen, нс

№2 i9, нс

№3 Xeon Silver, нс

№4 Xeon W, нс

Выделено

new Regex

1 402

1 330

2 185

1 953

2 736 Б

new Regex + Compiled

9 503

10 055

14 822

13 501

14 024 Б

new Regex + NonBacktracking

53 798

80 079

99 201

121 907

198 176 Б

[GeneratedRegex]

1

1

2

1

0 Б

Получение готового объекта, .NET 10: в прогретом коде обращение к методу генератора занимает наносекунду и не выделяет памяти, потому что объект уже создан

Читается таблица так:

  • new Regex разбирает паттерн и строит внутреннее представление — от полутора до двух с небольшим микросекунд и пара килобайт;

  • с RegexOptions.Compiled к этому добавляется сборка IL-метода под конкретный паттерн, отсюда и 14 килобайт;

  • RegexOptions.NonBacktracking строит из паттерна автомат, и на него уходит 198 килобайт и от 54 до 122 микросекунд;

  • [GeneratedRegex] не строит ничего: код написан при сборке проекта, объект создаётся один раз при первом обращении, а дальше метод отдаёт его из статического поля.

Последнее видно в исходнике, который написал генератор, — он лежит в папке Generated:

// Generated/System.Text.RegularExpressions.Generator/.../RegexGenerator.g.cs
 
public static partial global::System.Text.RegularExpressions.Regex EmailGenerated()
    => global::System.Text.RegularExpressions.Generated.EmailGenerated_1.Instance;

Теперь про кэш. У статических методов Regex есть общий кэш разобранных паттернов, его размер задаёт свойство Regex.CacheSize. В документации сказано прямым текстом: по умолчанию кэш хранит пятнадцать паттернов. Пока их не больше, каждый вызов берёт из кэша готовый объект. Шестнадцатый вытесняет первый.

Замер перебирает границу: 1, 8 и 15 паттернов помещаются в кэш, 16 и 32 — уже нет.

Машина №1, .NET 10: до пятнадцати паттернов вызов не превышает 125 наносекунд, на шестнадцатом обычная перегрузка замедляется в 22 раза, а перегрузка с Compiled — в 6 700
Машина №1, .NET 10: до пятнадцати паттернов вызов не превышает 125 наносекунд, на шестнадцатом обычная перегрузка замедляется в 22 раза, а перегрузка с Compiled — в 6 700

Зелёные столбики — контроль. Те же паттерны, но объекты созданы заранее и лежат в поле: 29–35 наносекунд, и число паттернов на результат не влияет. Значит дело именно в кэше.

Группу с одним паттерном сравнивать с остальными не надо: там каждый вызов заканчивается совпадением, а дальше совпадает только один вызов из N. Сопоставимы группы от восьми и правее.

Граница воспроизводится на всех четырёх машинах.

Машина

IsMatch, 15

IsMatch, 16

IsMatch + Compiled, 15

IsMatch + Compiled, 16

№1 Ryzen

67 нс

1 490 нс

68 нс

456 566 нс

№2 i9

76 нс

1 416 нс

81 нс

471 368 нс

№3 Xeon Silver

118 нс

2 563 нс

119 нс

654 996 нс

№4 Xeon W

100 нс

2 192 нс

105 нс

600 756 нс

Переход от пятнадцати паттернов к шестнадцати, .NET 10: обычная перегрузка замедляется в 19–22 раза, перегрузка с Compiled — в 5 483–6 723 раза

С обычной перегрузкой всё сходится: промах кэша даёт 1 490 наносекунд и 3 528 байт, а создание объекта из первой таблицы — 1 402 наносекунды и 2 736 байт. Разница в пределах того, что дают разные паттерны, то есть при промахе просто строится новый объект.

А с Compiled числа не сходятся. Промах кэша выделяет 15 014 байт против 14 024 у одного создания объекта, то есть объект тоже построен один раз. А времени уходит 456 микросекунд вместо 9,5.

Что меряется

Время

Выделено

new Regex(pattern, Compiled)

9 503 нс

14 024 Б

Промах кэша: создание и одна проверка

456 566 нс

15 014 Б

Машина №1, .NET 10: памяти выделяется почти одинаково, времени — в 48 раз больше

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

Что меряется

№1 Ryzen, нс

№2 i9, нс

№3 Xeon Silver, нс

№4 Xeon W, нс

new Regex

1 402

1 330

2 185

1 953

new Regex и одна проверка

1 773

1 695

2 905

2 491

new Regex + Compiled

9 503

10 055

14 822

13 501

new Regex + Compiled и одна проверка

1 336 834

1 307 369

1 879 379

1 593 919

.NET 10: у интерпретатора первая проверка добавляет 365–720 наносекунд, у Compiled — от 1,3 до 1,9 миллисекунды

У интерпретатора первая проверка добавляет 365–720 наносекунд — примерно столько же, сколько такая проверка занимает на прогретом объекте во второй истории. У Compiled та же проверка добавляет от 1,3 до 1,9 миллисекунды, то есть в 118–141 раз больше самого создания. На точной подстроке разрыв в 117–130 раз, на тяжёлом паттерне — в 217–231. Так на всех четырёх машинах.

Причина в том, как устроен RegexOptions.Compiled, и это описано в документации: конструктор превращает выражение в IL и складывает его в объекты DynamicMethod, а дальше этот IL компилирует JIT — уже при первом вызове. Тем же занимается RegexCompiler.cs в исходниках рантайма. Замер создания видит только первую часть работы, а вторая, которая в сто с лишним раз больше, приходит с первой проверкой.

Собранный метод попадает в дизассемблированный листинг наравне с обычными методами сборки:

; Disasm/Listings_Comp_2/disasm_net10.txt
; System.Text.RegularExpressions.CompiledRegexRunner:
;   Regex1_TryFindNextPossibleStartingPosition(RegexRunner, ReadOnlySpan<char>):bool (FullOpts)
 
    lea      rcx, bword ptr [r8+2*rcx]
    mov      edx, edi
    sub      edx, esi
    call     [System.Buffers.IndexOfAnyAsciiSearcher:IndexOfAnyCore[...]]
    test     eax, eax
    jl       SHORT G_M000_IG06
 
; Total bytes of code 120

Пометка FullOpts в заголовке листинга означает, что JIT сразу собрал полностью оптимизированный код, без промежуточного быстрого уровня. Для сравнения, у генератора в том же листинге стоит Tier0: его код проходит обычные уровни компиляции, как любой другой метод сборки. Отсюда и разница в первом вызове.

Заодно видно, чем занят поиск стартовой позиции: в этом листинге за него отвечает IndexOfAnyAsciiSearcher из System.Buffers — векторный поиск сразу по набору символов. Набор здесь произвольный, [A-Za-z0-9._%+-]. Для непрерывного диапазона вроде [a-z] берётся вариант попроще, IndexOfAnyInRange, — он встретится в третьей истории.

Отсюда практический вывод. Документация описывает кэш и его размер, но про промах с RegexOptions.Compiled там ничего нет. А происходит вот что: больше пятнадцати разных паттернов через статический Regex.IsMatch с этим флагом — и каждый вызов собирает IL и компилирует его заново.

Тот же эффект виден и на старте приложения. BenchmarkDotNet для такого замера не подходит: он прогревает код, а вопрос как раз в первом вызове. Поэтому замер сделан отдельно: Stopwatch запускается внутри Main, то есть меряется не старт процесса, а первое обращение к регулярному выражению после него. Каждый способ идёт в своём процессе, по десять запусков.

Первое обращение к регулярному выражению после запуска процесса, медиана из десяти запусков: интерпретатор быстрее всех, Compiled — в 3,7–5,1 раза медленнее него. Разброс: на №1 и №2 в пределах 10%, на №3 и №4 до 30%
Первое обращение к регулярному выражению после запуска процесса, медиана из десяти запусков: интерпретатор быстрее всех, Compiled — в 3,7–5,1 раза медленнее него. Разброс: на №1 и №2 в пределах 10%, на №3 и №4 до 30%

На всех четырёх машинах порядок один и тот же. Приложению, которое работает сутками, эти миллисекунды безразличны. А для консольной утилиты или функции, которая стартует на каждый запрос, они заметны.

История 2. Проверка строки: генератор против Compiled и два разных промаха

Дальше — прогретый код. Замеры идут по трём паттернам, двум длинам строки (100 и 10 000 символов) и четырём вариантам самой строки:

  • совпадение в начале строки;

  • совпадение в конце;

  • совпадения нет, и символов, с которых паттерн может начаться, в строке тоже нет;

  • совпадения нет, но такие символы стоят на каждом шагу.

Начнём с адреса электронной почты в конце строки на 10 000 символов.

Способ

№1 Ryzen, нс

№2 i9, нс

№3 Xeon Silver, нс

№4 Xeon W, нс

new Regex

8 926

10 053

16 194

12 869

new Regex + Compiled

246

302

497

398

[GeneratedRegex]

227

295

482

389

статический Regex.IsMatch

8 916

10 051

14 645

13 418

new Regex + NonBacktracking

8 859

10 006

15 304

12 890

string.Contains

291

455

466

593

Совпадение в конце строки на 10 000 символов, .NET 10: Compiled и генератор дают одинаковый результат и обгоняют интерпретатор в 32–39 раз

Отсюда три наблюдения:

  • Генератор и Compiled неразличимы. По всем 48-ми сочетаниям паттерна, варианта строки и машины отношение генератора к Compiled укладывается в 0,78–1,15 при медиане 0,96. Код у них разный, но оба уходят от интерпретатора к машинному коду, просто разными путями;

  • Статический Regex.IsMatch идёт наравне с обычным new Regex. Кэш экономит создание объекта, но в перегрузке без RegexOptions этот объект интерпретируемый — там просто негде указать Compiled. Поэтому сама проверка быстрее не становится. С таблицей из первой истории эти числа не сравниваются: там строка была короткая и мерился кэш, здесь строка на десять тысяч символов и мерится сама проверка;

  • RegexOptions.NonBacktracking держится на уровне интерпретатора и отстаёт от Compiled в 31–36 раз. Это на адресе: на двух других паттернах разницы с Compiled нет. В третьей истории будет видно, где он выходит вперёд.

Теперь уберём совпадение из строки. Сделаем это двумя разными способами:

  • в первой строке нет ни одного символа, с которого мог бы начаться адрес;

  • во второй такие символы стоят на каждом шагу, но адреса в ней всё равно нет.

Паттерн и строка

№1 Ryzen, нс

№2 i9, нс

№3 Xeon Silver, нс

№4 Xeon W, нс

Адрес, символов из набора нет

210

273

440

367

Адрес, символы из набора на каждом шагу

11 060

12 512

23 246

18 141

Тяжёлый паттерн, символов нет

185

220

223

329

Тяжёлый паттерн, символы на каждом шагу

120 316

135 745

244 016

189 733

RegexOptions.Compiled, 10 000 символов, совпадения нет, .NET 10: у адреса разница в 46–53 раза, у тяжёлого паттерна — в 577–1 094 раза

Строки одинаковой длины, совпадения нет ни в одной, а разница доходит до трёх порядков. Дело в том, что перед запуском движка выполняется поиск возможной стартовой позиции.

Если подходящих символов в строке нет, поиск уходит в IndexOfAnyAsciiSearcher — тот самый вызов из листинга в первой истории — и проверяет по несколько символов за раз. Если такие символы есть, движок запускается на каждом из них и каждый раз упирается в несовпадение.

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

На длинах до миллиона символов обе зависимости остаются линейными, отличается только множитель.

Машина №1, .NET 10: начиная с тысячи символов обе зависимости линейные, но вторая растёт в 45–54 раза быстрее
Машина №1, .NET 10: начиная с тысячи символов обе зависимости линейные, но вторая растёт в 45–54 раза быстрее

И последнее в этой истории. Всё выше замерялось методом IsMatch. Он останавливается на первом совпадении и не собирает ни границы групп, ни сами подстроки, поэтому работы у него меньше, чем у остальных методов.

Метод

№1 Ryzen, нс

№2 i9, нс

№3 Xeon Silver, нс

№4 Xeon W, нс

Выделено

IsMatch

43

51

84

63

0 Б

Match

58

75

119

102

208 Б

EnumerateMatches

3 452

5 048

8 668

6 041

0 Б

EnumerateMatches, только счёт

3 697

5 000

7 903

6 018

0 Б

Count(string)

4 864

4 367

7 226

5 952

0 Б

Count(ReadOnlySpan<char>)

3 608

4 071

7 021

4 993

0 Б

Replace

4 256

5 071

8 451

6 195

824 Б

Matches

7 456

8 233

13 843

11 914

23 080 Б

Сто совпадений в строке, .NET 10: IsMatch и Match останавливаются на первом совпадении, поэтому их числа не сравнимы с остальными строками

IsMatch и Match до конца строки не доходят, так что сравнивать между собой имеет смысл шесть нижних строк. Главное там — Matches: он единственный выделяет память, 23 килобайта на сотню совпадений, и работает в 1,6–2,2 раза дольше EnumerateMatches.

Остальные четыре способа обходят строку без единого выделенного байта и укладываются в один диапазон. Единственная устойчивая разница между ними — перегрузка Count: со строкой она на 3–35% медленнее, чем со span, на всех четырёх машинах. А обход с обращением к длине совпадения и без него не отличается.

И ещё одно, что видно по таблице: EnumerateMatches отдаёт только границы совпадений. Групп и захваченных подстрок от него не получить — если они нужны, придётся брать Matches со всеми его аллокациями.

История 3. Что написал генератор

У генератора есть свойство, которого нет у Compiled: его результат можно открыть и прочитать. По умолчанию этот файл на диск не попадает, но включается он двумя строками в файле проекта:

<!-- RegexProof.csproj -->
 
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
<CompilerGeneratedFilesOutputPath>Generated</CompilerGeneratedFilesOutputPath>

После сборки в папке Generated лежит обычный C# код. Вот что получилось для точной подстроки:

// Generated/.../RegexGenerator.g.cs, паттерн "orders/export"
 
protected override void Scan(ReadOnlySpan<char> inputSpan)
{
    if (TryFindNextPossibleStartingPosition(inputSpan))
    {
        // The search in TryFindNextPossibleStartingPosition performed the entire match.
        int start = base.runtextpos;
        int end = base.runtextpos = start + 13;
        base.Capture(0, start, end);
    }
}

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

А вот поиск стартовой позиции для тяжёлого паттерна:

// Generated/.../RegexGenerator.g.cs, паттерн (?:[a-z]+\d+){2,}(?:foo|bar|baz)+end
 
private bool TryFindNextPossibleStartingPosition(ReadOnlySpan<char> inputSpan)
{
    int pos = base.runtextpos;
 
    // Any possible match is at least 10 characters.
    if (pos <= inputSpan.Length - 10)
    {
        // The pattern begins with a character in the set [a-z].
        int i = inputSpan.Slice(pos).IndexOfAnyInRange('a', 'z');
        if (i >= 0)
        {
            base.runtextpos = pos + i;
            return true;
        }
    }
 
    base.runtextpos = inputSpan.Length;
    return false;
}

Вот и объяснение тем 185–329 наносекундам на десяти тысячах символов из второй истории. Разбор выражения тут вообще не выполняется: всё делает один вызов IndexOfAnyInRange, который проверяет по несколько символов за раз. Но стоит в строке появиться строчным буквам, и эта проверка начинает возвращать true на каждом шаге. Дальше берётся за работу сам разбор — отсюда и разница в тысячу раз.

Теперь про возвраты. Есть известный пример, которым принято показывать, чем опасны регулярные выражения: паттерн с вложенным повтором одного и того же символа и строка, которая ему почти подходит. Каждый лишний символ должен удваивать число вариантов разбора, и на двадцати с небольшим символах проверка должна занимать минуты.

// RegexProof, Subjects.cs
 
public const string NestedPattern = "^(?:a+)+$";
 
// строка: Length символов "a" и один "X" в конце - совпадения нет

Замер на строках от 16 до 22 символов, машина №1, .NET 10.

Машина №1, .NET 10: удвоения нет — на этих длинах время упирается в накладные расходы на вызов
Машина №1, .NET 10: удвоения нет — на этих длинах время упирается в накладные расходы на вызов

Удвоения не происходит, и ответ снова в сгенерированном исходнике:

// Generated/.../RegexGenerator.g.cs, паттерн ^(?:a+)+$
 
/// Explanation:
///   ○ Match if at the beginning of the string.
///   ○ Match 'a' atomically at least once.
///   ○ Match if at the end of the string or if before an ending newline.
 
// и сам разбор - один вызов вместо перебора вариантов:
int iteration = slice.IndexOfAnyExcept('a');

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

Вместо него остался обычный поиск первого символа, отличного от «a»: время у него тоже растёт с длиной строки, но линейно, а не вдвое на каждый символ. Свернул повтор не генератор, а разбор паттерна, общий для всех вариантов, — на графике выше интерпретатор ведёт себя так же. Появилось это в .NET 7, Stephen Toub разбирает такие упрощения паттернов в статье Regular Expression Improvements in .NET 7.

Но возвраты никуда не делись. Вернёмся к тяжёлому паттерну и строке, где символы из его набора встречаются на каждом шагу, — и посмотрим на RegexOptions.NonBacktracking, который во второй истории держался на уровне интерпретатора.

Способ

№1 Ryzen, нс

№2 i9, нс

№3 Xeon Silver, нс

№4 Xeon W, нс

new Regex

595 096

580 309

910 933

958 761

new Regex + Compiled

120 316

135 745

244 016

189 733

[GeneratedRegex]

101 020

123 831

223 948

168 051

new Regex + NonBacktracking

33 123

35 762

56 672

48 395

Тяжёлый паттерн, 10 000 символов, совпадения нет, но символы из набора стоят на каждом шагу, .NET 10: RegexOptions.NonBacktracking быстрее скомпилированного в 3,6–4,3 раза

Порядок мест поменялся. Интерпретатор тратит почти миллисекунду на десять тысяч символов, Compiled — в 3,7–5,1 раза меньше, а RegexOptions.NonBacktracking обгоняет Compiled в 3,6–4,3 раза, хотя на обычных строках отставал в тридцать с лишним. Он проходит строку один раз и не перебирает варианты — там, где вариантов много, это и решает.

Выводы

По цифрам:

  • обращение к методу с [GeneratedRegex] в прогретом коде занимает 1–2 наносекунды и не выделяет памяти: код написан при сборке проекта, объект создаётся один раз при первом обращении;

  • new Regex с RegexOptions.Compiled для адреса электронной почты занимает 9,5–14,8 мкс и 14 КБ, но это не вся работа: первая проверка на только что созданном объекте добавляет ещё 1,3–1,9 мс. По трём паттернам и четырём машинам это в 117–231 раз больше самого создания;

  • статический Regex.IsMatch держит кэш на 15 паттернов. Шестнадцатый паттерн замедляет вызов в 19–22 раза, а с RegexOptions.Compiled — в 5 483–6 723 раза на всех четырёх машинах;

  • на прогретом коде [GeneratedRegex] и RegexOptions.Compiled неразличимы: 0,78–1,15 при медиане 0,96 по 48-ми замерам;

  • первое обращение после запуска процесса: интерпретатор 4,6–6,7 мс, генератор 9,0–15,0 мс, Compiled 18,0–29,3 мс;

  • два промаха на строках одной длины отличаются в разы: если символов из набора в строке нет, проверка занимает 185–440 нс на 10 000 символов, если есть — в 46–53 раза дольше у адреса и в 577–1 094 раза у тяжёлого паттерна;

  • RegexOptions.NonBacktracking сильно зависит от паттерна: на адресе электронной почты он отстаёт от Compiled в 31–36 раз, на точной подстроке в 1,1–1,5 раза, а на тяжёлом паттерне разницы между ними нет. Зато на строке, где паттерн начинает совпадать на каждом шагу и каждый раз обрывается, обгоняет в 3,6–4,3 раза;

  • вложенный повтор ^(?:a+)+$ разбирается за наносекунды: разбор паттерна сворачивает его в атомарный, и это работает во всех вариантах, включая интерпретатор. Роста вдвое на каждый символ нет, остаётся линейный проход;

  • Matches — единственный способ обойти все совпадения, который выделяет память: 23 080 байт на сотню совпадений, и он в 1,6–2,2 раза дольше EnumerateMatches. Count, Replace и EnumerateMatches обходятся без аллокаций.

Что делать на практике:

  • если паттерн известен на этапе компиляции, лучший вариант — [GeneratedRegex]. Проверяет он с той же скоростью, что Compiled, но ничего не собирает после запуска приложения, отдаёт готовый объект и работает под NativeAOT, где сборка IL во время работы недоступна: в Regex.cs компиляция включается только при RuntimeFeature.IsDynamicCodeCompiled, иначе создаётся интерпретатор. То же самое советует и документация. Плюс сгенерированный код можно открыть и посмотреть, что получилось;

  • если паттерн становится известен только после запуска, подойдёт new Regex с RegexOptions.Compiled — но объект имеет смысл создать один раз и держать в статическом поле, иначе за него придётся расплачиваться на каждом первом вызове;

  • статический Regex.IsMatch удобен, пока разных паттернов в приложении не больше пятнадцати — это размер кэша по умолчанию, он задаётся через Regex.CacheSize. Дальше кэш начинает вытеснять паттерны, а потому Regex лучше создать заранее и держать в поле;

  • RegexOptions.Compiled в статическом Regex.IsMatch — сочетание, которого лучше избегать: при промахе кэша каждый вызов собирает IL и компилирует его заново, а это сотни микросекунд;

  • если искать фиксированную подстроку, регулярное выражение не даёт выигрыша: у string.Contains и скомпилированной регулярки результаты почти одинаковые, на двух машинах из четырёх Contains быстрее, на двух медленнее;

  • если во входных строках часто встречаются символы, с которых паттерн может начаться, есть смысл померить RegexOptions.NonBacktracking: на обычных данных он отстаёт, а на таких обгоняет остальных. Везде его поставить не получится — обратных ссылок и просмотра назад он не поддерживает;

  • если нужно обойти все совпадения, а сами объекты Match не нужны, подойдёт любой из способов без аллокаций — EnumerateMatches, Count или Replace. Matches стоит брать, только если нужны сами объекты: он вдвое медленнее и выделяет 23 килобайта на сотню совпадений;

  • для короткоживущих процессов первое обращение важнее прогретой скорости, а там RegexOptions.Compiled медленнее интерпретатора в 3,7–5,1 раза.

Код из статьи

  • RegexProof — бенчмарки, прогоны на четырёх машинах, сгенерированный исходник и листинги машинного кода

Ссылки

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

Комментарии (0)