Porównywanie tekstu w .NET – badanie szybkości z użyciem BenchmarkDotNet

Czas czytania ~ 150 sekund.

Jest to wpis zainspirowany prawdziwymi wydarzeniami. Wszelkie podobieństwo do osób, Spotkanie z klientem. Omawiamy nową funkcjonalność wyszukiwarki. Pada pytanie, dlaczego system nie szuka w środku wyrazu,.przecież Excel tak potrafi. Odpowiadamy, że porównywanie tekstów po przedrostkach jest znacznie szybsze, a przecież tutaj będzie sporo danych. No właśnie, ale znacznie to znaczy jak szybsze ? Dwukrotnie ? Pięciokrotnie ? Spróbujmy to zmierzyć.

Załóżmy, że mamy sporo instancji takiej klasy:

public class UserProfile
{
    public int Id { get; set; }
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public string Email { get; set; }
    public string Login { get; set; }
    public DateTime CreationDate { get; set; }
}

Teksty będą podobnej długości (analogicznie jak w projekcie), a my chcemy sprawdzić, jak szybko .NET jest w stanie porównywać teksty z frazą wyszukiwania poprzez różne metody klasy String.

BenchmarkDotNet

Jest prostą biblioteką autorstwa ludzi skupionych wokół .NET Foundation i dostarcza właściwie wszystko, co potrzebne do pisania profesjonalnych benchmarków. Możemy porównywać różne platformy (pełny .NET, .NET Core, Mono),  czy różne mechanizmy JIT (Legacy vs RyuJIT).

Instalujemy NuGeta

bdn

Najlepiej jest dodać go do aplikacji konsolowej, kompilowanej od razu pod Release. W głównej metodzie określamy, który zestaw benchmarków ma się wykonać.

static void Main(string[] args)
{
    var summary = BenchmarkRunner.Run<StringComparisons>();
    Console.ReadKey();
}

W klasie UserProfileTest wygenerujemy sobie 100 000 instancji wspomnianej klasy z losowymi tekstami i spróbujemy w różny sposób je przeszukiwać.

[MinColumn, MaxColumn, RankColumn]
public class StringComparisons
{
    private List<UserProfile> _profiles;
    private readonly string phrase = RandomString(5);

    private static Random random = new Random();

    public static string RandomString(int length)
    {
        const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
        return new string(Enumerable.Repeat(chars, length)
          .Select(s => s[random.Next(s.Length)]).ToArray());
    }

    [Setup]
    public void SetupData()
    {
        var profiles = from id in Enumerable.Range(0, 100 * 1000)
                       let name = RandomString(7)
                       let surname = RandomString(10)
                       let login = (name.First() + surname).ToLower()
                       select new UserProfile()
                       {
                           Id = id,
                           Email = RandomString(15),
                           FirstName = name,
                           LastName = surname,
                           CreationDate = DateTime.Now.AddDays(-random.Next(60)),
                           Login = login
                       };

        _profiles = new List<UserProfile>(profiles);

    }

    [Benchmark]
    public int Exact()
    {
        return _profiles.Count(p => p.Login == phrase);
    }

    [Benchmark]
    public int StartsWith()
    {
        return _profiles.Count(p => p.Login.StartsWith(phrase));
    }

    [Benchmark]
    public int Contains()
    {
        return _profiles.Count(p => p.Login.Contains(phrase));
    }

    [Benchmark]
    public int EndsWith()
    {
        return _profiles.Count(p => p.Login.EndsWith(phrase));
    }

    [Benchmark]
    public int OrdinalStartsWith()
    {
        return _profiles.Count(p => p.Login.StartsWith(phrase, StringComparison.Ordinal));
    }
}

Atrybuty nad klasą definiują to, w jaki sposób będą wyświetlane wyniki (o tym poniżej). Metoda z atrybutem Setup, podobnie jak w NUnit wywoła się przed samym testem i służy przygotowaniu losowych danych. Każda publiczna metoda z atrybutem Benchmark zostanie przez runner zinterpretowana jako osobny test.

I tutaj pojawia się  mała niespodzianka. Jeśli chodzi o porównywanie stringów w pamięci to Contains jest szybsze od StartsWith. Sytuacja w bazie danych (EF przetłumaczy do LIKE), czy Elasticsearchu będzie diametralnie inna, ale na zbiorach w pamięci efekt może być zaskakujący. Prawidłowość taka będzie występowała tylko dla stringów do pewnej skończonej liczby znaków.

Różnica wynika z faktu brania pod uwagę kultury wątku przy porównywaniu stringów. StartsWith domyślnie to robi, Contains nie (potwierdzone przez niezawodny StackOverflow). Dlatego w ostatnim benchmarku wymuszamy OrdinalComparison.

Benchmark .NET bardzo ładnie wypisuje wyniki na konsolę (z uwzględnieniem opcji min,max i rank).

summary

A dlaczego nie Stopwatch ?

No właśnie. Wielokrotnie System.Diagnostics.Stopwatch wystarcza do zmierzenia czasu, ale:

  • Benchmark.NET jest wygodny,  mamy deklaratywny sposób definiowania benchmarków przez atrybuty
  • BenchmarkRunner izoluje od siebie każdą metodę z atrybutem Benchmark
  • Możemy jednym uruchomieniem zbadać, jak ten sam kod zachowa się np. na .NET Core i Mono
  • Testy uruchamiane są wielokrotnie, a w końcowym zestawieniu mamy dostępne wielkości statystyczne, takie jak średnia, odch. standardowe, mediana itd
  • Po uruchomieniu biblioteka generuje szczegółowy raport do pliku csv
  • Podczas testów runner przeprowadza fazy IdleWarmup i IdleTarget, by wyznaczyć, jaki narzut na zmierzony czas wprowadza sama biblioteka
  • Przed głównymi pomiarami przeprowadzana jest faza “rozgrzewania” głównej metody, końcowy wynik to różnica między właściwymi pomiarami, a zmierzonym narzutem biblioteki

Jeżeli interesuje nas rzetelny benchmark, taki, który wyeliminuje wszystkie czynniki mogące zakłócić pomiar, to wybór wydaje się prosty: benchmarkdotnet.org

 

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s