Przyspieszamy Regexy – prekompilacja

By | December 13, 2016

Ostatnio musiałem trochę popracować z ukochanymi przez niektórych regexami. Nie mogę o sobie powiedzieć, że klepię z pamięci skomplikowane patterny, ale wyrażenia regularne są naprawdę świetną i użyteczną funkcjonalnością. Zupełnym przypadkiem zetknąłem się ze sposobem na przyspieszenie ich działania za pomocą metody Regex.CompileToAssembly().

Na potrzeby testów korzystam z czterech sposobów używania klasy Regex. W każdym z nich odpalam pętlę n powtórzeń i wykonuję Regex.Replace na tym samym tekście i z tym samym patternem, wynik wysyłam do metody konsumującej stringa (która absolutnie nic nie robi), a czas mierzę Stopwatchem od początku do końca wykonywania całej pętli. Oto pattern, na którym będę operował cały czas (stosunkowo proste wyrażenie do wyłuskiwania adresów e-mail z tekstu):

string Pattern = @"(([\w-]+\.)+[\w-]+|([a-zA-Z]{1}|[\w-]{2,}))@((([0-1]?[0-9]{1,2}|25[0-5]|2[0-4][0-9])\.([0-1]?[0-9]{1,2}|25[0-5]|2[0-4][0-9])\.([0-1]?[0-9]{1,2}|25[0-5]|2[0-4][0-9])\.([0-1]?[0-9]{1,2}|25[0-5]|2[0-4][0-9])){1}|([a-zA-Z]+[\w-]+\.)+[a-zA-Z]{2,4})";

Pierwsze dwa sposoby, które będę sprawdzał są dosyć często wykorzystywane w projektach. Jest to tworzenie nowego obiektu Regex z określonym patternem i przechowywanie go w polu statycznym, co również widziałem kilka razy i spotkałem się z opinią, że może pomóc jeśli z wyrażenia korzystamy dosyć często. Body obu pętli wygląda w ten sposób:

//Test: 1
sw.Start();
for (int i = 0; i < Repetitions; i++)
{
    var newRegex = new Regex(Pattern);
    StringConsumer(newRegex.Replace(TestString, "Email was here"));
}
sw.Stop();

//Test: 2
sw.Start();
for (int i = 0; i < Repetitions; i++)
{
    StringConsumer(StaticRegexes.StaticRegex.Replace(TestString, "Email was here"));
}
sw.Stop();

W trzecim przypadku przechowuję Regex w polu statycznym, a do jego utworzenia używam opcji RegexOptions.Compiled przekazanej w parametrze (new Regex(Pattern, RegexOptions.Copmpiled)). Co ona robi?

Tradycyjnie pattern, który przekazujemy jako parametr nie jest kompilowany, a interpretowany w locie. Dzięki temu inicjalizacja jest o wiele szybsza, ale samo działanie jest odrobinę spowolniona. Przekazanie enuma RegexOptions.Compiled sprawia, że całe wyrażenie jest prekompilowane do ILa i o wiele szybsze działanie, za które należy zapłacić nieco większym czasem inicjalizacji.

Teoretycznie wydajność powinna ulec zwiększeniu. Według MSDN, aby tak się stało musimy spełnić trzy warunki:

  • Musi nastąpić kilka wywołań za pomocą tego samego obiektu (stąd też trzymam go w polu statycznym)
  • Obiekt z Regexem nie powinien nam zniknąć ze Scope’a, by zapewnić jego reużywalność.
  • Obiekt z naszym Regexem powinien być statyczny

Czyli kurczowo trzymamy referencję i patrząc na sprawę logicznie, to wszystko trzyma się kupy, bo faktycznie, skoro czas inicjalizacji takiego obiektu jest dosyć długi, to powinniśmy unikać jego tworzenia od początku.

Został nam jeszcze czwarty sposób, który polega na prekompilowaniu Regexa używając wspomnianej wcześniej metody Regex.CompileToAssembly(), a którą najlepiej jest wykorzystać w oddzielnym projekcie (w moim wypadku aplikacja konsolowa). Pozwala ona na zebranie kilku wyrażeń regularnych, a po wykonaniu kodu zawartego aplikacji (stąd też jest to oddzielny projekt) wygenerowanie odrębnego pliku .dll zawierającego nasze Regexy, który następnie dołączamy do naszego głównego projektu.

Poniżej przedstawiam kod, który służy do generowania naszego regexowego assembly, a dokładniej omówię go w najbliższym poście za kilka dni.

var pattern = @"(([\w-]+\.)+[\w-]+|([a-zA-Z]{1}|[\w-]{2,}))@((([0-1]?[0-9]{1,2}|25[0-5]|2[0-4][0-9])\.([0-1]?[0-9]{1,2}|25[0-5]|2[0-4][0-9])\.([0-1]?[0-9]{1,2}|25[0-5]|2[0-4][0-9])\.([0-1]?[0-9]{1,2}|25[0-5]|2[0-4][0-9])){1}|([a-zA-Z]+[\w-]+\.)+[a-zA-Z]{2,4})";

var info = new RegexCompilationInfo(pattern,
    RegexOptions.IgnoreCase | RegexOptions.CultureInvariant,
     "ExtractEmail", //Regex name
     "My.Regex.Namespace", //Regex namespace
     true);
var infoArray = new RegexCompilationInfo[] { info };
Regex.CompileToAssembly(infoArray, new System.Reflection.AssemblyName("RegexLib, Version = 1.0.0.1001, Culture = neutral, PublicKeyToken = null"));

Powyższy kod generuje nam assembly zawierające jedną klasę (może ich być więcej, bo metoda CompileToAssembly() przyjmuje tablicę). Klasa ta nosi nazwę ExtractEmail i dziedziczy po System.Text.RegularExpressions.Regex.

Tej klasy możemy wykorzystać po utworzeniu jej instancji, a w czwartym teście nie mam nawet zamiaru przechowywać utworzonego obiektu w żadnym polu. Oczywiście powinienem to zrobić, ale byłem ciekaw czy mimo tego taka opcja okaże się szybsza.

Snippet

sw.Start();
for (int i = 0; i < Repetitions; i++)
{
    StringConsumer(new My.Regex.Namespace.ExtractEmail().Replace(TestString, "Email was here"));
}
sw.Stop();

Wykonałem kilka testów na różnych długościach tekstu, z różną ilością dopasowań i różną ilością powtórzeń a wyniki (w milisekundach dla całej pętli) możecie obejrzeć poniżej.

1043 znaki / 3 dopasowania

Ilość powtórzeń Typ Regexa
 Nowy Regex  Statyczny Regex  RegexOptions.Compiled  Regex.CompileToAssembly()
1 2 ms 2 ms 15 ms 15 ms
10 17 ms 16 ms 114 ms 24 ms
100 153 ms 144 ms 1192 ms  138 ms
1000  1536 ms 1451 ms 9830 ms 1348 ms

 

10430 znaków / 30 dopasowań

Ilość powtórzeń Typ Regexa
 Nowy Regex  Statyczny Regex  RegexOptions.Compiled  Regex.CompileToAssembly()
1 16 ms 18 ms 20 ms 25 ms
10 153 ms 146 ms 191 ms 141 ms
100 1601 ms 1495 ms 2078 ms  1370 ms
1000  15084 ms 14759 ms 18148 ms 13682 ms

 

26075 znaków / 75 dopasowań

Ilość powtórzeń Typ Regexa
 Nowy Regex  Statyczny Regex  RegexOptions.Compiled  Regex.CompileToAssembly()
1 42 ms 41 ms 41 ms 48 ms
10 376 ms 389 ms 364 ms 332 ms
100 3765 ms 3673 ms 3823 ms  3341 ms
1000  38406 ms 37015 ms 38849 ms 33150 ms

 

104300 znaków / 300 dopasowania

Ilość powtórzeń Typ Regexa
 Nowy Regex  Statyczny Regex  RegexOptions.Compiled  Regex.CompileToAssembly()
1 148 ms 151 ms 156 ms 157 ms
10 1493 ms 1498 ms 1362 ms 1225 ms
100 15042 ms 14964 ms 13511 ms  12531 ms
1000  147852 ms 147381 ms 132212 ms 12181 ms

 

Wnioski? Zaskakuje mnie raczej słaby wynik Regexa utworzonego z opcją RegexOptions.Compiled. Z kolei w miejscach gdzie wykorzystujemy to samo wyrażenie więcej niż raz świetnie sprawdza się wyrażenie prekompilowane do oddzielnego .dll.

Mimo, że nie było przechowywane w żadnym polu to wyraźnie widać opóźnienie w przypadku pojedynczego wywołania. Nie jest ono trwałe i wiąże się wyłącznie z tym, że assembly, w którym znajduje się nasz prekompilowany Regex musi zostać załadowane do pamięci. Ten pliczek nie był w moim przypadku duży, więc i różnica w czasie nie jest zbyt wielka, ale jeśli umieścimy tam setkę różnych wyrażeń, to podejrzewam, że czas ten nieco wzrośnie.

Różnice w czasach nie są jakieś szczególnie wielkie, ale są zauważalne. Zwłaszcza przy dużej liczbie powtórzeń i dłuższych stringach. Ocenę czy warto komplikować sprawę dla tych kilku procent wzrostu prędkości pozostawiam Tobie. Jeśli temat zainteresował Cię podobnie jak mnie, a nie chce Ci się wnikać w temat na własną rękę, to za kilka dni powinien pojawić tu post opisujący metodkę Regex.CompileToAssembly() i jej zastosowanie, aby go nie przegapić pozwolę sobie na zachęcenie Cię do śledzenia mnie na Facebooku.