Nieudany artykuł o przyspieszaniu refleksji

Zaraz wyjaśnię tytuł artykułu. Pierwotny plan był taki, aby dać dobrą, rzetelną radę dotyczącą przyspieszenia użycia refleksji na prostym, ale realistycznym przykładzie, jednak podczas benchmarkingu okazało się, że odbicie nie jest tak powolne, jak myślałem, LINQ jest wolniejszy niż w moich koszmarach. Ale ostatecznie okazało się, że też się pomyliłam w pomiarach... Szczegóły tej historii życia znajdziecie pod wycinkiem i w komentarzach. Ponieważ przykład jest dość powszechny i ​​w zasadzie realizowany jak zwykle w przedsiębiorstwie, okazał się całkiem ciekawym, jak mi się wydaje, pokazem życia: wpływ na prędkość głównego tematu artykułu był niezauważalne ze względu na logikę zewnętrzną: Moq, Autofac, EF Core i inne „pasmowania”.

Zacząłem pracować pod wrażeniem tego artykułu: Dlaczego odbicie jest powolne

Jak widać, autor sugeruje używanie skompilowanych delegatów zamiast bezpośredniego wywoływania metod typu refleksyjnego, co jest świetnym sposobem na znaczne przyspieszenie aplikacji. Istnieje oczywiście emisja IL, ale chciałbym jej uniknąć, ponieważ jest to najbardziej pracochłonny sposób wykonania zadania i obarczony błędami.

Biorąc pod uwagę, że zawsze miałem podobne zdanie na temat szybkości odbicia, nie miałem specjalnego zamiaru kwestionować wniosków autora.

Często spotykam się w przedsiębiorstwie z naiwnym stosowaniem refleksji. Typ jest zajęty. Informacje o nieruchomości są pobierane. Wywołuje się metodę SetValue i wszyscy się cieszą. Wartość dotarła do pola docelowego, wszyscy są zadowoleni. Bardzo inteligentni ludzie – seniorzy i liderzy zespołów – piszą swoje rozszerzenia do obiektu, opierając się na tak naiwnej implementacji „uniwersalnych” maperów jednego typu na drugi. Istota jest zwykle taka: bierzemy wszystkie pola, bierzemy wszystkie właściwości, iterujemy po nich: jeśli nazwy elementów typu są zgodne, wykonujemy SetValue. Od czasu do czasu łapiemy wyjątki z powodu błędów, w których nie znaleźliśmy jakiejś właściwości w jednym z typów, ale nawet tutaj istnieje wyjście, które poprawia wydajność. Próbuj złapać.

Widziałem, jak ludzie wymyślali na nowo parsery i programy mapujące, nie będąc w pełni uzbrojeni w informacje o tym, jak działają maszyny, które były przed nimi. Widziałem, jak ludzie ukrywali swoje naiwne wdrożenia za strategiami, za interfejsami, za zastrzykami, jakby to miało usprawiedliwić późniejsze bachanalia. Zakręciłem nosem na takie spostrzeżenia. Tak naprawdę nie mierzyłem rzeczywistego wycieku wydajności i, jeśli to możliwe, po prostu zmieniłem implementację na bardziej „optymalną”, jeśli tylko wpadło mi w ręce. Dlatego pierwsze pomiary omówione poniżej poważnie mnie zmyliły.

Myślę, że wielu z Was, czytając Richtera czy innych ideologów, spotkało się z całkowicie słusznym stwierdzeniem, że odbicie w kodzie to zjawisko, które ma niezwykle negatywny wpływ na wydajność aplikacji.

Wywołanie refleksji zmusza środowisko CLR do przeglądania zespołów w celu znalezienia tego, czego potrzebuje, pobrania metadanych, przeanalizowania ich itp. Dodatkowo refleksja podczas przechodzenia przez sekwencje prowadzi do alokacji dużej ilości pamięci. Zużywamy pamięć, CLR odkrywa GC i zaczynają się fryzy. Uwierz mi, powinno to być zauważalnie powolne. Ogromne ilości pamięci na nowoczesnych serwerach produkcyjnych czy maszynach chmurowych nie zapobiegają dużym opóźnieniom przetwarzania. Tak naprawdę, im więcej pamięci, tym większe prawdopodobieństwo, że ZAUWAŻYSZ, jak działa GC. Odbicie to teoretycznie dla niego dodatkowa czerwona szmata.

Wszyscy jednak używamy kontenerów IoC i maperów dat, których zasada działania również opiera się na refleksji, ale zwykle nie ma żadnych pytań co do ich działania. Nie, nie dlatego, że wprowadzenie zależności i abstrakcja z modeli o ograniczonym kontekście zewnętrznym są na tyle konieczne, że i tak musimy poświęcić wydajność. Wszystko jest prostsze - tak naprawdę nie wpływa to zbytnio na wydajność.

Faktem jest, że najpopularniejsze frameworki oparte na technologii refleksji wykorzystują różnego rodzaju sztuczki, aby pracować z nią bardziej optymalnie. Zwykle jest to pamięć podręczna. Zazwyczaj są to wyrażenia i delegaty skompilowane z drzewa wyrażeń. Ten sam automapper utrzymuje konkurencyjny słownik, który dopasowuje typy do funkcji, które mogą konwertować je na inne bez wywoływania refleksji.

Jak to osiągnąć? Zasadniczo nie różni się to od logiki, której sama platforma używa do generowania kodu JIT. Kiedy metoda jest wywoływana po raz pierwszy, jest ona kompilowana (i tak, proces ten nie jest szybki); przy kolejnych wywołaniach kontrola jest przekazywana do już skompilowanej metody i nie nastąpi znaczący spadek wydajności.

W naszym przypadku możesz także użyć kompilacji JIT, a następnie użyć skompilowanego zachowania z taką samą wydajnością, jak jego odpowiedniki AOT. W tym przypadku z pomocą przyjdą nam wyrażenia.

Zasadę, o której mowa, można w skrócie sformułować w następujący sposób:
Powinieneś buforować końcowy wynik odbicia jako delegat zawierający skompilowaną funkcję. Sensowne jest również buforowanie wszystkich niezbędnych obiektów z informacjami o typie w polach Twojego typu, pracownika, które są przechowywane poza obiektami.

Jest w tym logika. Zdrowy rozsądek podpowiada nam, że jeśli coś można skompilować i zapisać w pamięci podręcznej, to należy to zrobić.

Patrząc w przyszłość, należy powiedzieć, że pamięć podręczna w pracy z refleksją ma swoje zalety, nawet jeśli nie zastosujesz proponowanej metody kompilacji wyrażeń. Właściwie powtarzam tutaj po prostu tezy autora artykułu, do którego odsyłam powyżej.

Teraz o kodzie. Spójrzmy na przykład oparty na moim niedawnym bólu, z którym musiałem się zmierzyć przy poważnej produkcji poważnej instytucji kredytowej. Wszystkie byty są fikcyjne, więc nikt się nie domyśli.

Jest pewna esencja. Niech będzie Kontakt. Istnieją litery o ustandaryzowanym korpusie, z których parser i hydrator tworzą te same kontakty. Otrzymaliśmy list, przeczytaliśmy go, podzieliliśmy na pary klucz-wartość, utworzyliśmy kontakt i zapisaliśmy go w bazie danych.

To elementarne. Załóżmy, że kontakt ma właściwości Imię i nazwisko, Wiek i Telefon kontaktowy. Dane te przekazywane są w piśmie. Firma potrzebuje także pomocy umożliwiającej szybkie dodawanie nowych kluczy do mapowania właściwości encji w pary w treści listu. Na wypadek, gdyby ktoś zrobił literówkę w szablonie lub jeśli przed wydaniem konieczne jest pilne uruchomienie mapowania od nowego partnera, dostosowującego się do nowego formatu. Następnie możemy dodać nową korelację mapowania jako tanią poprawkę danych. Czyli przykład z życia.

Wdrażamy, tworzymy testy. Pracuje.

Nie będę podawać kodu: źródeł jest mnóstwo i są one dostępne na GitHubie poprzez link na końcu artykułu. Możesz je ładować, torturować nie do poznania i mierzyć, tak jak miałoby to wpływ w Twoim przypadku. Podam jedynie kod dwóch szablonowych metod odróżniających hydrator, który miał być szybki, od hydratora, który miał być wolny.

Logika jest następująca: metoda szablonowa otrzymuje pary wygenerowane przez podstawową logikę parsera. Warstwa LINQ to parser i podstawowa logika hydratora, który wysyła żądanie do kontekstu bazy danych i porównuje klucze z parami z parsera (dla tych funkcji istnieje kod bez LINQ dla porównania). Następnie pary przekazywane są do głównej metody hydratacji i wartości par ustawiane są na odpowiadające im właściwości podmiotu.

„Szybki” (przedrostek Szybki w testach porównawczych):

 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 właściwościami ustawiającymi - skompilowanymi lambdami, które wywołują jednostkę ustawiającą. Utworzony za pomocą następującego kodu:

        static FastContactHydrator()
        {
            var type = typeof(Contact);
            foreach (var property in type.GetProperties())
            {
                _proprtySettersMap[property.Name] = GetSetterAction(property);
            }
        }

        private static Action<Contact, string> 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<Contact, string>>)Expression.Lambda(setterExp, paramEntity, paramValueOriginal);

            return lambda.Compile();
        }

Ogólnie rzecz biorąc, wszystko jest jasne. Przechodzimy przez właściwości, tworzymy dla nich delegatów wywołujących metody ustawiające i zapisujemy je. Potem dzwonimy, gdy zajdzie taka potrzeba.

„Slow” (przedrostek Slow w testach porównawczych):

        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 natychmiast pomijamy właściwości i bezpośrednio wywołujemy SetValue.

Dla przejrzystości i jako odniesienie zaimplementowałem naiwną metodę, która zapisuje wartości ich par korelacji bezpośrednio w polach encji. Przedrostek – ręczny.

Teraz weźmy BenchmarkDotNet i sprawdźmy wydajność. I nagle... (spoiler - to nie jest prawidłowy wynik, szczegóły poniżej)

Nieudany artykuł o przyspieszaniu refleksji

Co tu widzimy? Metody, które triumfalnie noszą przedrostek Fast, okazują się wolniejsze w prawie wszystkich przebiegach niż metody z przedrostkiem Slow. Dotyczy to zarówno przydziału, jak i szybkości pracy. Z drugiej strony piękna i elegancka implementacja mapowania z wykorzystaniem przeznaczonych do tego metod LINQ tam, gdzie to możliwe, wręcz przeciwnie, znacznie zmniejsza produktywność. Różnica polega na porządku. Trend nie zmienia się przy różnej liczbie przejść. Jedyna różnica polega na skali. Z LINQ jest to 4 - 200 razy wolniejsze, jest więcej śmieci w mniej więcej tej samej skali.

AKTUALIZACJA

Nie wierzyłem własnym oczom, ale co ważniejsze, nasz kolega nie wierzył ani moim oczom, ani mojemu kodowi - Dmitrij Tichonow 0x1000000. Po ponownym sprawdzeniu mojego rozwiązania genialnie odkrył i wskazał błąd, który przeoczyłem ze względu na szereg zmian w implementacji, od początku do końca. Po naprawieniu znalezionego błędu w konfiguracji Moq, wszystkie wyniki wróciły na swoje miejsce. Według wyników retestu główny trend się nie zmienia – LINQ nadal bardziej wpływa na wydajność niż odbicie. Miło jednak, że praca przy kompilacji wyrażeń nie idzie na marne, a wynik jest widoczny zarówno w czasie alokacji, jak i wykonania. Pierwsze uruchomienie, gdy inicjowane są pola statyczne, w przypadku metody „szybkiej” jest oczywiście wolniejsze, ale potem sytuacja się zmienia.

Oto wynik ponownego testu:

Nieudany artykuł o przyspieszaniu refleksji

Wniosek: stosując refleksję w przedsiębiorstwie, nie ma szczególnej potrzeby uciekania się do sztuczek - LINQ bardziej pochłonie produktywność. Jednak w metodach o dużym obciążeniu, które wymagają optymalizacji, można zapisać odbicie w postaci inicjatorów i delegować kompilatory, które następnie zapewnią „szybką” logikę. W ten sposób można zachować zarówno elastyczność odbicia, jak i szybkość aplikacji.

Kod testu porównawczego jest dostępny tutaj. Każdy może sprawdzić moje słowa:
Testy HabraReflection

PS: kod w testach wykorzystuje IoC, a w testach porównawczych używa jawnej konstrukcji. Faktem jest, że w ostatecznej implementacji odciąłem wszystkie czynniki, które mogłyby wpłynąć na wydajność i spowodować, że wynik będzie zaszumiony.

PPS: Dziękuję użytkownikowi Dmitrij Tichonow @0x1000000 za wykrycie mojego błędu w ustawieniu Moq, który miał wpływ na pierwsze pomiary. Jeśli któryś z czytelników ma wystarczającą karmę, proszę o polubienie. Mężczyzna zatrzymał się, mężczyzna przeczytał, mężczyzna ponownie sprawdził i wskazał błąd. Uważam, że jest to godne szacunku i współczucia.

PPPS: dzięki skrupulatnemu czytelnikowi, który dotarł do sedna stylu i designu. Jestem za jednolitością i wygodą. Dyplomacja prezentacji pozostawia wiele do życzenia, ale uwzględniłem krytykę. Poproszę o pocisk.

Źródło: www.habr.com

Dodaj komentarz