Уважаемые читатели, в этой статье я хочу разобраться, в какой момент .NET компилирует регулярное выражение — в конструкторе или при первом вызове, — и представить свои выводы.Проверить строку регулярным выражением в .NET можно по-разному, но совет всегда один: включите RegexOptions.Compiled или возьмите генератор. Замеры это подтверждают. А вот подготовка самого выражения оказалась не там, где её меряют: конструктор занимает 10 микросекунд, а первая проверка на этом объекте — ещё 1307.Сравниваются шесть способов:new Regex — разбирает паттерн при создании и дальше работает интерпретатором;RegexOptions.Compiled — собирает под паттерн IL-метод во время работы приложения;[GeneratedRegex] — генератор пишет обычный C# код при сборке проекта;статический Regex.IsMatch — паттерн передаётся строкой в каждый вызов;RegexOptions.NonBacktracking — движок, который не возвращается назад по строке;string.Contains — обычный поиск подстроки, без регулярного выражения.Будет 3 истории:что не попадает в замер создания;генератор против Compiled на проверке строки — почему два промаха на строках одной длины отличаются в тысячу раз;что написал генератор — и что стало с классическим примером про опасность регулярных выражений.Весь код проверки — в репозитории RegexProof. Замеры сделаны на четырёх машинах:№CPUЯдра/потокиЧастотаОС№1AMD Ryzen 9 5950X16 / 323,4 ГГц, до 4,9 в турбоWindows 10 1809№2Intel Core i9-10900KF10 / 203,7 ГГц, до 5,3 в турбоWindows 10 22H2№32 × Intel Xeon Silver 43142×16 / 642,4 ГГц, до 3,4 в турбоWindows Server 2022№4Intel Xeon W-225510 / 203,7 ГГц, до 4,5 в турбоWindows Server 2022BenchmarkDotNet 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 Regex1 4021 3302 1851 9532 736 Бnew Regex + Compiled9 50310 05514 82213 50114 024 Бnew Regex + NonBacktracking53 79880 07999 201121 907198 176 Б[GeneratedRegex]11210 БПолучение готового объекта, .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Зелёные столбики — контроль. Те же паттерны, но объекты созданы заранее и лежат в поле: 29–35 наносекунд, и число паттернов на результат не влияет. Значит дело именно в кэше.Группу с одним паттерном сравнивать с остальными не надо: там каждый вызов заканчивается совпадением, а дальше совпадает только один вызов из N. Сопоставимы группы от восьми и правее.Граница воспроизводится на всех четырёх машинах.МашинаIsMatch, 15IsMatch, 16IsMatch + Compiled, 15IsMatch + Compiled, 16№1 Ryzen67 нс1 490 нс68 нс456 566 нс№2 i976 нс1 416 нс81 нс471 368 нс№3 Xeon Silver118 нс2 563 нс119 нс654 996 нс№4 Xeon W100 нс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 Regex1 4021 3302 1851 953new Regex и одна проверка1 7731 6952 9052 491new Regex + Compiled9 50310 05514 82213 501new Regex + Compiled и одна проверка1 336 8341 307 3691 879 3791 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):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%На всех четырёх машинах порядок один и тот же. Приложению, которое работает сутками, эти миллисекунды безразличны. А для консольной утилиты или функции, которая стартует на каждый запрос, они заметны.История 2. Проверка строки: генератор против Compiled и два разных промахаДальше — прогретый код. Замеры идут по трём паттернам, двум длинам строки (100 и 10 000 символов) и четырём вариантам самой строки:совпадение в начале строки;совпадение в конце;совпадения нет, и символов, с которых паттерн может начаться, в строке тоже нет;совпадения нет, но такие символы стоят на каждом шагу.Начнём с адреса электронной почты в конце строки на 10 000 символов.Способ№1 Ryzen, нс№2 i9, нс№3 Xeon...
| # | Наименование новости | Тональность | Информативность | Дата публикации |
|---|---|---|---|---|
| 1 | Бенчмаркая Regex: конструктор показал 10 мкс, первый вызов — 1307 | 0 | 15.47 | 26-07-2026 |
| 2 | Бенчмаркая Regex: конструктор показал 10 мкс, первый вызов — 1307 | 0 | 19.31 | 26-07-2026 |
| 3 | Бенчмаркая Regex: конструктор показал 10 мкс, первый вызов — 1307 | 0 | 19.31 | 26-07-2026 |
| 4 | Бенчмаркая Regex: конструктор показал 10 мкс, первый вызов — 1307 | 0 | 19.31 | 26-07-2026 |
| 5 | Бенчмаркая Regex: конструктор показал 10 мкс, первый вызов — 1307 | 0 | 19.31 | 26-07-2026 |
| 6 | Бенчмаркая Regex: конструктор показал 10 мкс, первый вызов — 1307 | 0 | 19.31 | 26-07-2026 |
| 7 | Бенчмаркая Regex: конструктор показал 10 мкс, первый вызов — 1307 | 0 | 19.31 | 26-07-2026 |
| 8 | Бенчмаркая Regex: конструктор показал 10 мкс, первый вызов — 1307 | 0 | 19.31 | 26-07-2026 |
| 9 | Бенчмаркая Regex: конструктор показал 10 мкс, первый вызов — 1307 | 0 | 19.31 | 26-07-2026 |