Od razu wyjaśnię tytuł artykułu. Początkowo planowałem dać dobry, niezawodny poradnik dotyczący przyspieszenia użycia refleksji na prostym, ale realistycznym przykładzie, jednak podczas benchmarkingu okazało się, że refleksja nie działa tak wolno, jak myślałem, a LINQ działa wolniej niż to się wydawało w najgorszych koszmarach. Na koniec zauważyłem, że popełniłem błąd w pomiarach… Szczegóły tej życiowej historii poniżej oraz w komentarzach. Ponieważ przykład jest dość codzienny i zrealizowany w zasadzie tak, jak zwykle robi się to w przedsiębiorstwie, powstała, jak sądzę, naprawdę interesująca demonstracja życia: wpływ na prędkość działania głównego tematu artykułu nie był zauważalny z powodu zewnętrznej logiki: Moq, Autofac, EF Core i innej „owijki”.
Rozpocząłem pracę pod wrażeniem tego artykułu:
Jak widać, autor proponuje użycie skompilowanych delegatów zamiast bezpośredniego dostępu do metod typów refleksyjnych jako doskonałego sposobu na znaczne przyspieszenie działania aplikacji. Oczywiście jest tam jeszcze emisja IL, ale chciałbym jej uniknąć, ponieważ jest to najbardziej pracochłonny sposób wykonania zadania, który wiąże się z błędami.
Mając na uwadze, że zawsze byłem zdania, że refleksja jest wolna, nie miałem zamiaru kwestionować wniosków autora.
Nierzadko spotykam się z naiwnym użyciem refleksji w przedsiębiorstwie. Bierze się typ. Bierze się informacje o właściwości. Wywołuje się metodę SetValue i wszyscy się radują. Wartość trafiła do docelowego pola, wszyscy zadowoleni. Ludzie są dość mądrzy — seniorzy i liderzy zespołów — piszą swoje rozszerzenia na obiekt, opierając się na takiej naiwnej implementacji „uniwersalnych” mapperów jednego typu do drugiego. Zazwyczaj polega to na tym: bierzemy wszystkie pola, bierzemy wszystkie właściwości, iterujemy po nich: przy dopasowaniu nazw członków typu wykonujemy SetValue. Czasami łapiemy wyjątki na pomyłkach, gdzie nie znaleźliśmy jakiejś właściwości w jednym z typów, ale i tu jest wyjście, które poprawia wydajność. Try/catch.
Widziałem, jak ludzie na nowo wymyślają parsery i mapery, nie mając pełnej informacji na temat tego, jak działają wynalezione przed nimi rowery. Widziałem, jak ludzie ukrywają swoje naiwną implementacje za strategiami, interfejsami, wstrzyknięciami, jakby to miało usprawiedliwić późniejszą wakhanalię. Z takich implementacji kręciłem nosem. Faktycznie nie mierzyłem rzeczywistej utraty wydajności, a gdy miałem okazję, po prostu zmieniałem implementację na bardziej „optymalną”, jeśli miałem na to czas. Dlatego pierwsze pomiary, o których mowa poniżej, poważnie mnie zaniepokoiły.
Myślę, że wielu z was, czytając Richtera lub innych ideologów, spotkało się z całkowicie uzasadnionym stwierdzeniem, że refleksyjność w kodzie to zjawisko, które ma ekstremalnie negatywny wpływ na wydajność aplikacji.
Wywołanie refleksji zmusza CLR do przeszukiwania zestawów w poszukiwaniu odpowiednich informacji, ściągania ich metadanych, parsowania itp. Ponadto refleksyjność podczas przeszukiwania sekwencji prowadzi do alokacji dużych wolumenów pamięci. Kiedy konsumujemy pamięć, CLR wyciąga GС i zaczynają się lagi. To powinno być widocznie wolne, uwierzcie mi. Ogromne ilości pamięci nowoczesnych serwerów produkcyjnych lub maszyn chmurowych nie ratują przed wysokimi opóźnieniami w przetwarzaniu. Faktycznie, im więcej pamięci, tym większe prawdopodobieństwo, że ZAUWAŻYSZ, jak działa GС. Refleksja to w zasadzie zbędna czerwona flaga dla niego.
Niemniej jednak wszyscy używamy zarówno kontenerów IoC, jak i mapów danych, zasady działania których również opierają się na refleksji, jednak nie ma zazwyczaj zastrzeżeń co do ich wydajności. Nie dlatego, że wstrzykiwanie zależności i abstrahowanie od zewnętrznych modeli ograniczonego kontekstu są tak niezbędnymi rzeczami, że musimy poświęcać wydajność w każdym przypadku. Wszystko jest prostsze – naprawdę nie wpływają one znacząco na wydajność.
Faktem jest, że najpopularniejsze frameworki oparte na technologii refleksji wykorzystują różnorodne sztuczki do bardziej optymalnej pracy z nią. Zwykle jest to pamięć podręczna. Zwykle – to Expressions i skompilowane z drzewa wyrażenia delegaty. Ten sam automapper posiada współdzieloną słownik, który mapuje typy na funkcje, które mogą zamieniać jeden na drugi już bez wywoływania refleksji.
Jak to osiągnięto? Zasadniczo nie różni się to od logiki, której używa sama platforma do generowania kodu JIT. Przy pierwszym wywołaniu metody jest ona kompilowana (tak, ten proces nie jest szybki), a przy kolejnych wywołaniach kontrola jest przekazywana już skompilowanej metodzie, i tutaj nie ma większych spadków wydajności.
W naszym przypadku można również skorzystać z kompilacji JIT, a następnie użyć skompilowanego zachowania z taką samą wydajnością, jak jego odpowiedniki AOT. W tym przypadku pomogą nam wyrażenia.
Ogólnie rzecz biorąc, można sformułować zasady, o których mowa, w następujący sposób:
Należy zapisać ostateczny wynik działania refleksji w postaci delegata zawierającego skompilowaną funkcję. Wszystkie niezbędne obiekty z informacjami o typach również warto zapisać w polach typu – roboczego.
Logiczne to. Zwykły rozsądek mówi nam, że jeśli coś można skompilować i zapisać w pamięci podręcznej, to należy to zrobić.
Wyprzedzając fakty, należy powiedzieć, że pamięć podręczna w pracy z refleksją ma swoje zalety, nawet jeśli nie korzysta się z zaproponowanej metody kompilacji wyrażeń. Właściwie powtórzę tutaj tezy autora artykułu, do którego się odwołuję.
Teraz o kodzie. Przyjrzyjmy się przykładom, które opierają się na moim niedawnym bólu, z którym musiałem się zmierzyć w poważnym produkcie poważnej instytucji kredytowej. Wszystkie encje są fikcyjne, aby nikt się nie domyślił.
Istnieje pewien byt. Niech będzie to Kontakt. Istnieją wiadomości o ustandaryzowanej treści, z których parser i hydrator tworzą te wszystkie kontakty. Przychodzi wiadomość, czytamy ją, rozdzielamy na pary klucz-wartość, tworzymy kontakt, zapisujemy w bazie danych.
To proste. Załóżmy, że kontakt ma właściwości Imię i Nazwisko, Wiek oraz numer telefonu. Te dane są przekazywane w wiadomości. Biznes chce również, aby wsparcie mogło szybko dodawać nowe klucze do mapowania właściwości encji na pary w treści wiadomości. Na wypadek, gdyby ktoś popełnił literówkę w szablonie lub aby w razie potrzeby szybko uruchomić mapowanie dla nowego partnera, dostosowując się do nowego formatu. Wtedy nową korelację mapowania będziemy mogli dodać jako tani data fix. Czyli, to jest przykład z życia.
Realizujemy, tworzymy testy. Działa.
Nie będę podawać kodu: źródeł jest dużo i są dostępne na GitHubie pod linkiem na końcu artykułu. Możecie je pobrać, zmęczyć do niepoznania i sprawdzić, jak to wpłynie w waszym przypadku. Przedstawię tylko kod dwóch metod szablonowych, którymi różni się hydrator, który miał być szybki, od hydratora, który miał być wolny.
Logika jest następująca: metoda szablonowa otrzymuje pary utworzone przez podstawową logikę parsera. Poziom LINQ to parser i podstawowa logika hydratora, która wykonuje zapytanie do kontekstu bazy danych i mapuje klucze z parami od parsera (dla tych funkcji istnieje kod bez LINQ do porównania). Następnie pary są przekazywane do głównej metody hydratacji, a wartości par są przypisywane do odpowiednich właściwości encji.
„Szybki” (Prefiks Fast w benchmarkach):
protected override Contact GetContact(PropertyToValueCorrelation[] correlations)
{
var contact = new Contact();
foreach (var setterMapItem in _proprtySettersMap)
{
var correlation = correlations.FirstOrDefault(x => x.PropertyName == setterMapItem.Key);
setterMapItem.Value(contact, correlation?.Value);
}
return contact;
}
Jak widzimy, używana jest statyczna kolekcja z setterami właściwości – skompilowanymi lambdami, które wywołują setter encji. Tworzone są następującym kodem:
static FastContactHydrator()
{
var type = typeof(Contact);
foreach (var property in type.GetProperties())
{
_proprtySettersMap[property.Name] = GetSetterAction(property);
}
}
private static Action GetSetterAction(PropertyInfo property)
{
var setterInfo = property.GetSetMethod();
var paramValueOriginal = Expression.Parameter(property.PropertyType, "value");
var paramEntity = Expression.Parameter(typeof(Contact), "entity");
var setterExp = Expression.Call(paramEntity, setterInfo, paramValueOriginal).Reduce();
var lambda = (Expression<Action>)Expression.Lambda(setterExp, paramEntity, paramValueOriginal);
return lambda.Compile();
}
Wszystko jasne. Przechodzimy przez właściwości, tworzymy dla nich delegaty, które wywołują settery i je zapisujemy. Potem wywołujemy, kiedy trzeba.
„Wolny” (Prefiks Slow w benchmarkach):
protected override Contact GetContact(PropertyToValueCorrelation[] correlations)
{
var contact = new Contact();
foreach (var property in _properties)
{
var correlation = correlations.FirstOrDefault(x => x.PropertyName == property.Name);
if (correlation?.Value == null)
continue;
property.SetValue(contact, correlation.Value);
}
return contact;
}
Tutaj od razu przechodzimy przez właściwości i bezpośrednio wywołujemy SetValue.
Aby zobrazć wyniki, zaimplementowałem naiwną metodę, która zapisuje wartości ich par korelacji bezpośrednio w polach encji. Prefiks – Manual.
Teraz bierzemy BenchmarkDotNet i badamy wydajność. I nagle… (spoiler – to nie jest poprawny wynik, szczegóły poniżej)

Co tu widzimy? Metody z prefiksem Fast, pomimo różnych prób, niemal zawsze okazują się wolniejsze niż metody z prefiksem Slow. Dotyczy to zarówno alokacji, jak i szybkości działania. Z drugiej strony, piękna i elegancka implementacja mapowania z wykorzystaniem wszędzie, gdzie to możliwe, metod LINQ, znacząco obniża wydajność. Różnica jest ogromna. Tendencja nie zmienia się przy różnej liczbie prób. Różnica jest tylko w skali. Z LINQ jest 4 — 200 razy wolniej, śmieci więcej w mniej więcej podobnych proporcjach.
ZAAKTUALIZOWANO
Nie mogłem uwierzyć własnym oczom, ale co ważniejsze, ani moje oczy, ani mój kod nie uwierzył naszemu koledze — . Po sprawdzeniu mojego rozwiązania doskonale wykrył i wskazał błąd, który przeoczyłem z powodu szeregu zmian w implementacji od początkowej do końcowej. Po naprawieniu wykrytej usterki w konfiguracji Moq, wszystkie wyniki wróciły na swoje miejsce. Po retestach główny trend się nie zmienia — LINQ nadal wpływa na wydajność bardziej niż refleksja. Niemniej jednak, miło jest, że praca nad kompilacją wyrażeń nie idzie na marne, a wyniki są widoczne zarówno w alokacji, jak i w czasie wykonywania. Pierwsze uruchomienie, podczas którego inicjalizowane są pola statyczne, jest słusznie wolniejsze w przypadku „szybkiej” metody, ale potem sytuacja się zmienia.
Oto wyniki retestów:

Wnioski: przy użyciu refleksji w przedsiębiorstwie nie ma potrzeby stosowania specjalnych sztuczek — LINQ znacznie bardziej obniży wydajność. Niemniej jednak, w metodach o dużym obciążeniu, które wymagają optymalizacji, można zachować refleksję w postaci inicjalizatorów i kompilatorów delegatów, które później zapewnią „szybką” logikę. Dzięki temu można zachować zarówno elastyczność refleksji, jak i szybkość działania aplikacji.
Kod z benchmarkiem dostępny jest tutaj. Wszyscy chętni mogą zweryfikować moje słowa:
PS: Kod w testach używa IoC, a w benchmarkach – wyraźnej konstrukcji. Chodzi o to, że w ostatecznej implementacji odrzuciłem wszystkie czynniki, które mogą wpłynąć na wydajność i zanużyć wyniki.
PPS: Dziękuję użytkownikowi za odkrycie mojego błędu w konfiguracji Moq, który wpłynął na pierwsze pomiary. Jeśli ktoś z czytelników ma wystarczającą karmę, proszę, polubcie go. Człowiek się zatrzymał, człowiek wczytał, człowiek sprawdził i wskazał na błąd. Uważam, że to zasługuje na szacunek i sympatię.
PPPS: Dzięki temu dociekliwemu czytelnikowi, który zwrócił uwagę na styl i formatowanie. Jestem za jednolitością i wygodą. Dyplomacja formy pozostawia wiele do życzenia, ale uwagi wziąłem pod uwagę. Proszę o granat.
Źródło: habr.com
