Wprowadzenie
Pod wpisem o lokalizowaniu enumów pojawił się komentarz, w którym Janko zwrócił mi uwagę, że rozbijanie napisów na małe pliki resource nie jest najlepszym pomysłem pod względem wydajności. Jak zaznaczyłem w innym wpisie (Używanie napisów w aplikacji), stosuję wiele plików resource do organizacji napisów wyświetlanych użytkownikowi. Dlatego chciałem sprawdzić, czy to, co pisze Janko, to prawda.
Przeglądając Internet, można trafić na pytania na stackoverflow: https://stackoverflow.com/questions/8792180/1-resource-file-vs-many czy https://stackoverflow.com/questions/19910926/multiple-resource-files-versus-single-resource-file. Wynika z nich, że stosowanie wielu plików resource będzie mniej wydajne niż stosowanie jednego, ale narzut nie będzie miał dużego wpływu na działanie aplikacji. Jedynie pierwsze pobranie wartości z pliku wiąże się z załadowaniem danych z dysku, przez co ów proces będzie mniej wydajny. A o ile wolniejszy? Zaraz sprawdzimy to za pomocą BenchmarkDotNet.
Przykład do testów
Przygotowałem bardzo prostą aplikację konsolową, w której znajduje się jeden plik z zasobami: Resources. Plik zawiera 10 napisów. Podczas testów będziemy pobierać jeden z napisów.
Gdy przeprowadzałem test, wyszło mi, że nie ma znaczenia, czy pobieramy jeden i ten sam napis, czy za każdym razem inny. Czas pobrania napisu jest taki sam (oczywiście poza pierwszym pobraniem, jak zobaczymy później). Dlatego właśnie w testach zawsze pobieram jeden i ten sam napis.
Przygotowany plik ma cztery wersje: domyślną oraz wersje dla kultur: pl-PL, de-De, en-GB:
Ile trwa pobranie wartości z pliku resource?
W pierwszej kolejności sprawdzimy pobranie wartości z pliku resource. Wynik ten posłuży jako punkt odniesienia do kolejnych testów.
Etap rozgrzewania z BenchmarkDotNet spowoduje, że pomiar zmierzy tylko odczyt wartości z cache, bez ładowania ich z dysku. Test wygląda tak:
public class Benchmarks | |
{ | |
[Benchmark] | |
public void UsingResources() | |
{ | |
var test = Resources.One; | |
} | |
} |
Wyniki wykonanego testu:
BenchmarkDotNet=v0.10.14, OS=Windows 10.0.17134 | |
Intel Core i7-4790K CPU 4.00GHz (Haswell), 1 CPU, 8 logical and 4 physical cores | |
Frequency=3897226 Hz, Resolution=256.5928 ns, Timer=TSC | |
[Host] : .NET Framework 4.6 (CLR 4.0.30319.42000), 32bit LegacyJIT-v4.7.3131.0 | |
DefaultJob : .NET Framework 4.6 (CLR 4.0.30319.42000), 32bit LegacyJIT-v4.7.3131.0 | |
Method | Mean | Error | StdDev | Median | | |
-------------------------------------------- |--------------:|------------:|------------:|--------------:| | |
UsingResources | 97.55 ns | 1.9698 ns | 4.3237 ns | 95.63 ns | |
Test odczytania jednego napisu z resource trwał około 97 nanosekund.
Niestety dość trudno napisać test dla klasy wygenerowanej na podstawie pliku resource, który sprawdzi, ile czasu trwa pierwszy odczyt wartości. Dlatego po analizie kodu klasy postanowiłem, że w kolejnych testach będę wykorzystywał bezpośrednio klasę ResourceManager. Klasa wygenerowana przez Visual Studio tylko w ładny sposób opakowuje wywołania metod z klasy ResourceManager.
Dla pewności przygotowałem drugi test, który zmierzy czas wykonywania metody GetString, aby upewnić się, że czas będzie podobny do czasu z pierwszego testu.
[Benchmark] | |
public void UsingResourceManager() | |
{ | |
var resourceManager = Resources.ResourceManager; | |
var test = resourceManager.GetString("One"); | |
} |
BenchmarkDotNet=v0.10.14, OS=Windows 10.0.17134 | |
Intel Core i7-4790K CPU 4.00GHz (Haswell), 1 CPU, 8 logical and 4 physical cores | |
Frequency=3897226 Hz, Resolution=256.5928 ns, Timer=TSC | |
[Host] : .NET Framework 4.6 (CLR 4.0.30319.42000), 32bit LegacyJIT-v4.7.3131.0 | |
DefaultJob : .NET Framework 4.6 (CLR 4.0.30319.42000), 32bit LegacyJIT-v4.7.3131.0 | |
Method | Mean | Error | StdDev | Median | | |
-------------------------------------------- |--------------:|------------:|------------:|--------------:| | |
UsingResources | 97.55 ns | 1.9698 ns | 4.3237 ns | 95.63 ns | | |
UsingResourceManager | 85.96 ns | 0.1613 ns | 0.1509 ns | 85.90 ns | |
Korzystanie bezpośrednio z klasy ResourceManager daje podobny wynik, dlatego w kolejnych testach będą ją wykorzystywał.
Ile trwa utworzenie ResourceManagera?
Dwa kolejne testy sprawdzą to, co jest najistotniejsze w dzisiejszym wpisie. Pierwszy sprawdzi, ile trwa samo utworzenie instancji klasy ResourceManager. Drugi natomiast dodatkowo wykona pobranie jednego zapisu. Testy oraz wyniki prezentują się tak:
[Benchmark] | |
public void CreatingResourceManager() | |
{ | |
var resourceManager = | |
new ResourceManager("OneVsManyResourceFiles.Resources", typeof(Resources).Assembly); | |
} | |
[Benchmark] | |
public void CreatingResourceManagerAndGetOneString() | |
{ | |
var resourceManager = | |
new ResourceManager("OneVsManyResourceFiles.Resources", typeof(Resources).Assembly); | |
var test = resourceManager.GetString("One"); | |
} |
BenchmarkDotNet=v0.10.14, OS=Windows 10.0.17134 | |
Intel Core i7-4790K CPU 4.00GHz (Haswell), 1 CPU, 8 logical and 4 physical cores | |
Frequency=3897226 Hz, Resolution=256.5928 ns, Timer=TSC | |
[Host] : .NET Framework 4.6 (CLR 4.0.30319.42000), 32bit LegacyJIT-v4.7.3131.0 | |
DefaultJob : .NET Framework 4.6 (CLR 4.0.30319.42000), 32bit LegacyJIT-v4.7.3131.0 | |
Method | Mean | Error | StdDev | Median | | |
-------------------------------------------- |--------------:|------------:|------------:|--------------:| | |
UsingResources | 97.55 ns | 1.9698 ns | 4.3237 ns | 95.63 ns | | |
UsingResourceManager | 85.96 ns | 0.1613 ns | 0.1509 ns | 85.90 ns | | |
CreatingResourceManager | 2,244.81 ns | 8.3171 ns | 6.9452 ns | 2,242.87 ns | | |
CreatingResourceManagerAndGetOneString | 27,095.96 ns | 94.5670 ns | 88.4580 ns | 27,093.58 ns | |
Jak widać, utworzenie instancji klasy jest około 26 razy wolniejsze niż późniejsze pobranie danych z cache. Utworzenie instancji oraz pierwsze pobranie jest już natomiast około 315 razy wolniejsze. Na wnioski przyjdzie jeszcze czas pod koniec wpisu.
Poniżej jeszcze jeden test, w którym po utworzeniu instancji klasy ResourceManager dodatkowo wykonywanych jest 1000 odczytów napisu – aby sprawdzić, czy nic dodatkowo nie wpływa jeszcze na czas pobrania wartości.
[Benchmark] | |
public void CreatingResourceManagerAndGetThousandString() | |
{ | |
var resourceManager = | |
new ResourceManager("OneVsManyResourceFiles.Resources", typeof(Resources).Assembly); | |
for (int i = 0; i < 1000; i++) | |
{ | |
var test = resourceManager.GetString("One"); | |
} | |
} |
BenchmarkDotNet=v0.10.14, OS=Windows 10.0.17134 | |
Intel Core i7-4790K CPU 4.00GHz (Haswell), 1 CPU, 8 logical and 4 physical cores | |
Frequency=3897226 Hz, Resolution=256.5928 ns, Timer=TSC | |
[Host] : .NET Framework 4.6 (CLR 4.0.30319.42000), 32bit LegacyJIT-v4.7.3131.0 | |
DefaultJob : .NET Framework 4.6 (CLR 4.0.30319.42000), 32bit LegacyJIT-v4.7.3131.0 | |
Method | Mean | Error | StdDev | Median | | |
-------------------------------------------- |--------------:|------------:|------------:|--------------:| | |
UsingResources | 97.55 ns | 1.9698 ns | 4.3237 ns | 95.63 ns | | |
UsingResourceManager | 85.96 ns | 0.1613 ns | 0.1509 ns | 85.90 ns | | |
CreatingResourceManager | 2,244.81 ns | 8.3171 ns | 6.9452 ns | 2,242.87 ns | | |
CreatingResourceManagerAndGetOneString | 27,095.96 ns | 94.5670 ns | 88.4580 ns | 27,093.58 ns | | |
CreatingResourceManagerAndGetThousandString | 111,263.32 ns | 413.9859 ns | 366.9877 ns | 111,085.54 ns | |
Jak widać, kolejne odczytania wartości są już podobnie szybkie jak pierwsze testy, więc dodatkowy narzut pojawia się tylko na początku korzystania z klasy ResourceManager.
A co z innymi językami?
Wcześniejsze testy pobierały dane z domyślnego pliku resource. Przygotowałem również trzy inne pliki dla poszczególnych kultur. Należałoby sprawdzić, jak wygląda pobranie tego samego napisu dla różnych kultur. Jakie tutaj będą czasy?
Test jest bardzo podobny do testu trzeciego, czyli: tworzymy nową instancję klasy ResourceManager i następnie dla każdej dodatkowej kultury odczytujemy ten sam napis:
BenchmarkDotNet=v0.10.14, OS=Windows 10.0.17134 | |
Intel Core i7-4790K CPU 4.00GHz (Haswell), 1 CPU, 8 logical and 4 physical cores | |
Frequency=3897226 Hz, Resolution=256.5928 ns, Timer=TSC | |
[Host] : .NET Framework 4.6 (CLR 4.0.30319.42000), 32bit LegacyJIT-v4.7.3131.0 | |
DefaultJob : .NET Framework 4.6 (CLR 4.0.30319.42000), 32bit LegacyJIT-v4.7.3131.0 | |
Method | Mean | Error | StdDev | Median | | |
-------------------------------------------- |--------------:|------------:|------------:|--------------:| | |
UsingResources | 97.55 ns | 1.9698 ns | 4.3237 ns | 95.63 ns | | |
UsingResourceManager | 85.96 ns | 0.1613 ns | 0.1509 ns | 85.90 ns | | |
CreatingResourceManager | 2,244.81 ns | 8.3171 ns | 6.9452 ns | 2,242.87 ns | | |
CreatingResourceManagerAndGetOneString | 27,095.96 ns | 94.5670 ns | 88.4580 ns | 27,093.58 ns | | |
CreatingResourceManagerAndGetThousandString | 111,263.32 ns | 413.9859 ns | 366.9877 ns | 111,085.54 ns | | |
ReadManyLanguage | 89,069.69 ns | 402.8163 ns | 357.0861 ns | 88,993.29 ns | |
Wynik jest zbliżony do trzykrotności wyniku trzeciego testu, więc widać, że narzut mamy również dla każdej poszczególnej kultury, którą wspieramy w aplikacji.
Przykład do uruchomienia
Na githubie (https://github.com/danielplawgo/OneVsManyResourceFiles) znajduje się przykład, który wykorzystałem podczas pracy nad tym wpisem. Zachęcam do jego pobrania i własnych testów.
Jest to prosta aplikacja konsolowa, która uruchamia przygotowane testy BenchmarkDotNet. Po pobraniu projektu należy go skompilować w trybie release i uruchomić aplikację z katalogu bin/release.
Jeden czy wiele plików resource – podsumowanie
Analizując wyniki testów, można zauważyć, że odczyt pierwszej wartości z pliku resource jest zauważalnie wolniejszy niż każdy kolejny. Prawdopodobnie nikogo to nie zdziwiło.
A jaka jest odpowiedź na pytanie pojawiające się w tytule? Jeden czy wiele plików resource?
Odpowiedzieć musisz sobie sam, drogi Czytelniku. Wszystko zależy od tego, jakie aplikacje tworzysz.
Ja w swoich nadal zostaję przy wielu plikach resource i sposobie organizacji napisów omówionym w poprzedni wpisie. W aplikacjach webowych, które najczęściej tworzę, łączny narzut kilku milisekund (na ogół mam od kilkudziesięciu do kilkuset plików resource) nie jest czymś problematycznym. Szczególnie gdy jest on jednorazowy i rozłożony w czasie (na ogół 0,1 ms raz dla jednego widoku, gdy mamy plik resource per plik widoku).
Z drugiej strony w moich projektach używanie jednego pliku (lub ich bardzo małej liczby) nie sprawdzało się. Bardzo często przy mergach różnych branchy pojawiało się dużo konfliktów właśnie na takich wspólnych plikach. Konflikty nie były trudne do rozwiązania, na ogół wystarczyło dodać zmiany z obu branchy.
Przy korzystaniu z pull requestów takie konflikty wydłużają cały proces pracy nad zmianami. Wciągnięcie jednego brancha do mastera powodowało w innych pull requestach konflikty, przez co nie można było ich automatycznie zmergować i trzeba było zrobić to ręcznie. A to było dużo kosztowniejsze niż dodatkowe kilka milisekund raz na kilka dni działania aplikacji webowej.
A jaka jest Twoja odpowiedź? Jeden czy wiele plików?
1 thought on “Jeden czy wiele plików resource a wydajność”