Уважаемые читатели, в этой статье я хочу разобраться, в какой момент .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 — уже нет.

Зелёные столбики — контроль. Те же паттерны, но объекты созданы заранее и лежат в поле: 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, то есть меряется не старт процесса, а первое обращение к регулярному выражению после него. Каждый способ идёт в своём процессе, по десять запусков.

На всех четырёх машинах порядок один и тот же. Приложению, которое работает сутками, эти миллисекунды безразличны. А для консольной утилиты или функции, которая стартует на каждый запрос, они заметны.
История 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 — тот самый вызов из листинга в первой истории — и проверяет по несколько символов за раз. Если такие символы есть, движок запускается на каждом из них и каждый раз упирается в несовпадение.
Поэтому «промах отрабатывает за наносекунды» верно только для первого случая. В кэше, где промахи выглядят так же, как попадания, работает второй.
На длинах до миллиона символов обе зависимости остаются линейными, отличается только множитель.

И последнее в этой истории. Всё выше замерялось методом 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.

Удвоения не происходит, и ответ снова в сгенерированном исходнике:
// 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 — бенчмарки, прогоны на четырёх машинах, сгенерированный исходник и листинги машинного кода
Ссылки
Всем удачи и до новых встреч!
