Erfolgloser Artikel über die Beschleunigung der Reflexion

Ich erkläre gleich den Titel des Artikels. Der ursprüngliche Plan bestand darin, anhand eines einfachen, aber realistischen Beispiels gute und verlässliche Ratschläge zu geben, wie man den Einsatz von Reflection beschleunigen kann. Beim Benchmarking stellte sich jedoch heraus, dass Reflection nicht so langsam ist, wie ich dachte, LINQ ist langsamer als in meinen Albträumen. Aber am Ende stellte sich heraus, dass ich auch einen Fehler bei den Messungen gemacht habe... Details zu dieser Lebensgeschichte gibt es unter dem Schnitt und in den Kommentaren. Da das Beispiel recht alltäglich ist und im Prinzip so umgesetzt wird, wie es in einem Unternehmen üblich ist, erwies es sich, wie mir scheint, als recht interessante Demonstration des Lebens: Die Auswirkung auf die Geschwindigkeit des Hauptthemas des Artikels war Aufgrund externer Logik nicht erkennbar: Moq, Autofac, EF Core und andere „Bandings“.

Ich begann unter dem Eindruck dieses Artikels zu arbeiten: Warum ist Reflection langsam?

Wie Sie sehen, empfiehlt der Autor die Verwendung kompilierter Delegaten anstelle des direkten Aufrufs von Reflection-Typ-Methoden als hervorragende Möglichkeit, die Anwendung erheblich zu beschleunigen. Natürlich gibt es eine IL-Emission, aber ich möchte sie vermeiden, da dies die arbeitsintensivste und fehlerbehaftete Art ist, die Aufgabe auszuführen.

Da ich schon immer eine ähnliche Meinung über die Geschwindigkeit des Nachdenkens vertrat, hatte ich nicht die Absicht, die Schlussfolgerungen des Autors besonders in Frage zu stellen.

Im Unternehmen stoße ich oft auf den naiven Einsatz von Reflexion. Der Typ ist vergeben. Es werden Informationen über die Immobilie eingeholt. Die SetValue-Methode wird aufgerufen und alle freuen sich. Der Wert ist im Zielfeld angekommen, alle sind zufrieden. Sehr kluge Leute – Vorgesetzte und Teamleiter – schreiben ihre Objekterweiterungen auf der Grundlage einer so naiven Implementierung „universeller“ Mapper von einem Typ zum anderen. Das Wesentliche ist normalerweise Folgendes: Wir nehmen alle Felder, nehmen alle Eigenschaften, durchlaufen sie: Wenn die Namen der Typmitglieder übereinstimmen, führen wir SetValue aus. Von Zeit zu Zeit kommt es zu Ausnahmen aufgrund von Fehlern, bei denen wir in einem der Typen keine Eigenschaft gefunden haben, aber selbst hier gibt es einen Ausweg, der die Leistung verbessert. Versuchen/fangen.

Ich habe Leute gesehen, die Parser und Mapper neu erfunden haben, ohne vollständig mit Informationen darüber ausgestattet zu sein, wie die Maschinen, die vor ihnen kamen, funktionieren. Ich habe Leute gesehen, die ihre naiven Umsetzungen hinter Strategien, hinter Schnittstellen, hinter Injektionen versteckten, als ob dies die anschließende Bacchanie entschuldigen würde. Bei solchen Erkenntnissen rümpfte ich die Nase. Tatsächlich habe ich den tatsächlichen Leistungsverlust nicht gemessen und, wenn möglich, einfach die Implementierung auf eine „optimalere“ umgestellt, wenn ich sie in die Finger bekommen konnte. Daher haben mich die ersten unten besprochenen Messungen ernsthaft verwirrt.

Ich denke, viele von Ihnen sind bei der Lektüre von Richter und anderen Ideologen auf die völlig berechtigte Aussage gestoßen, dass die Reflexion im Code ein Phänomen ist, das sich äußerst negativ auf die Leistung der Anwendung auswirkt.

Der Aufruf von Reflection zwingt die CLR dazu, Assemblys zu durchsuchen, um die benötigte zu finden, ihre Metadaten abzurufen, sie zu analysieren usw. Darüber hinaus führt die Reflexion beim Durchlaufen von Sequenzen dazu, dass viel Speicher zugewiesen wird. Wir verbrauchen Speicher, CLR deckt den GC auf und Friese beginnen. Es sollte spürbar langsam sein, glauben Sie mir. Die riesigen Speichermengen moderner Produktionsserver oder Cloud-Maschinen verhindern keine hohen Verarbeitungsverzögerungen. Tatsächlich gilt: Je mehr Speicher vorhanden ist, desto wahrscheinlicher ist es, dass Sie bemerken, wie der GC funktioniert. Reflexion ist für ihn theoretisch ein zusätzliches rotes Tuch.

Allerdings verwenden wir alle IoC-Container und Date-Mapper, deren Funktionsprinzip ebenfalls auf Reflexion basiert, an deren Leistung es jedoch in der Regel keine Fragen gibt. Nein, nicht, weil die Einführung von Abhängigkeiten und die Abstraktion von externen begrenzten Kontextmodellen so notwendig sind, dass wir in jedem Fall auf Leistung verzichten müssen. Alles ist einfacher – es hat keinen großen Einfluss auf die Leistung.

Tatsache ist, dass die gängigsten Frameworks, die auf der Reflexionstechnologie basieren, allerlei Tricks anwenden, um optimaler damit zu arbeiten. Normalerweise ist dies ein Cache. In der Regel handelt es sich dabei um Ausdrücke und Delegaten, die aus der Ausdrucksbaumstruktur kompiliert werden. Derselbe Automapper verwaltet ein konkurrierendes Wörterbuch, das Typen mit Funktionen abgleicht, die einen in einen anderen konvertieren können, ohne Reflektion aufzurufen.

Wie wird dies erreicht? Im Wesentlichen unterscheidet sich dies nicht von der Logik, die die Plattform selbst zum Generieren von JIT-Code verwendet. Wenn eine Methode zum ersten Mal aufgerufen wird, wird sie kompiliert (und ja, dieser Prozess ist nicht schnell); bei nachfolgenden Aufrufen wird die Kontrolle an die bereits kompilierte Methode übertragen, und es kommt zu keinen nennenswerten Leistungseinbußen.

In unserem Fall können Sie auch die JIT-Kompilierung verwenden und dann das kompilierte Verhalten mit der gleichen Leistung wie seine AOT-Gegenstücke verwenden. Ausdrücke werden uns in diesem Fall helfen.

Das in Rede stehende Prinzip lässt sich kurz wie folgt formulieren:
Sie sollten das Endergebnis der Reflexion als Delegaten zwischenspeichern, der die kompilierte Funktion enthält. Es ist auch sinnvoll, alle notwendigen Objekte mit Typinformationen in den Feldern Ihres Typs, dem Worker, zwischenzuspeichern, die außerhalb der Objekte gespeichert sind.

Darin liegt Logik. Der gesunde Menschenverstand sagt uns, dass etwas getan werden sollte, wenn es kompiliert und zwischengespeichert werden kann.

Mit Blick auf die Zukunft sollte gesagt werden, dass der Cache bei der Arbeit mit Reflektion seine Vorteile hat, auch wenn Sie die vorgeschlagene Methode zum Kompilieren von Ausdrücken nicht verwenden. Eigentlich wiederhole ich hier lediglich die Thesen des Autors des Artikels, auf den ich mich oben beziehe.

Nun zum Code. Schauen wir uns ein Beispiel an, das auf meinen jüngsten Schmerzen basiert, denen ich bei einer seriösen Produktion eines seriösen Kreditinstituts ausgesetzt war. Alle Entitäten sind fiktiv, sodass niemand es erraten kann.

Es gibt etwas Wesentliches. Lass es Kontakt geben. Es gibt Buchstaben mit einem standardisierten Körper, aus dem Parser und Hydrator dieselben Kontakte erstellen. Ein Brief ist angekommen, wir haben ihn gelesen, ihn in Schlüssel-Wert-Paare analysiert, einen Kontakt erstellt und ihn in der Datenbank gespeichert.

Es ist elementar. Nehmen wir an, ein Kontakt hat die Eigenschaften Vollständiger Name, Alter und Kontakttelefon. Diese Daten werden im Brief übermittelt. Das Unternehmen möchte außerdem Unterstützung dabei haben, schnell neue Schlüssel für die paarweise Zuordnung von Entitätseigenschaften in den Briefkörper einfügen zu können. Für den Fall, dass sich jemand in der Vorlage vertippt hat oder es vor der Veröffentlichung dringend erforderlich ist, das Mapping von einem neuen Partner aus zu starten und sich an das neue Format anzupassen. Dann können wir eine neue Zuordnungskorrelation als kostengünstige Datenkorrektur hinzufügen. Das heißt, ein Lebensbeispiel.

Wir implementieren, erstellen Tests. Funktioniert.

Ich werde den Code nicht bereitstellen: Es gibt viele Quellen und sie sind auf GitHub über den Link am Ende des Artikels verfügbar. Sie können sie laden, bis zur Unkenntlichkeit quälen und messen, wie es sich in Ihrem Fall auswirken würde. Ich werde nur den Code von zwei Vorlagenmethoden angeben, die den Hydrator, der eigentlich schnell sein sollte, von dem Hydrator unterscheiden, der eigentlich langsam sein sollte.

Die Logik ist wie folgt: Die Vorlagenmethode empfängt Paare, die von der grundlegenden Parserlogik generiert werden. Die LINQ-Schicht ist der Parser und die Grundlogik des Hydrators, der eine Anfrage an den Datenbankkontext stellt und Schlüssel mit Paaren vom Parser vergleicht (für diese Funktionen gibt es Code ohne LINQ zum Vergleich). Als nächstes werden die Paare an die Haupthydratationsmethode übergeben und die Werte der Paare werden auf die entsprechenden Eigenschaften der Entität gesetzt.

„Schnell“ (Präfix Fast in Benchmarks):

 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;
        }

Wie wir sehen können, wird eine statische Sammlung mit Setter-Eigenschaften verwendet – kompilierte Lambdas, die die Setter-Entität aufrufen. Erstellt durch den folgenden Code:

        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();
        }

Im Großen und Ganzen ist es klar. Wir durchlaufen die Eigenschaften, erstellen für sie Delegaten, die Setter aufrufen, und speichern sie. Dann rufen wir bei Bedarf an.

„Langsam“ (Präfix Langsam in Benchmarks):

        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;
        }

Hier umgehen wir sofort die Eigenschaften und rufen SetValue direkt auf.

Aus Gründen der Übersichtlichkeit und als Referenz habe ich eine naive Methode implementiert, die die Werte ihrer Korrelationspaare direkt in die Entitätsfelder schreibt. Präfix – Manuell.

Nehmen wir nun BenchmarkDotNet und untersuchen die Leistung. Und plötzlich... (Spoiler – das ist nicht das korrekte Ergebnis, Details siehe unten)

Erfolgloser Artikel über die Beschleunigung der Reflexion

Was sehen wir hier? Methoden, die das Fast-Präfix erfolgreich tragen, erweisen sich in fast allen Durchgängen als langsamer als Methoden mit dem Slow-Präfix. Dies gilt sowohl für die Zuordnung als auch für die Arbeitsgeschwindigkeit. Andererseits verringert eine schöne und elegante Implementierung des Mappings mit dafür vorgesehenen LINQ-Methoden, wo immer möglich, die Produktivität erheblich. Der Unterschied liegt in der Reihenfolge. Der Trend ändert sich bei unterschiedlicher Durchlaufzahl nicht. Der einzige Unterschied besteht im Maßstab. Mit LINQ ist es 4- bis 200-mal langsamer, es gibt mehr Müll in etwa der gleichen Größenordnung.

AKTUALISIERT

Ich habe meinen Augen nicht getraut, aber was noch wichtiger ist: Unser Kollege hat weder meinen Augen noch meinem Code geglaubt – Dmitri Tichonow 0x1000000. Nachdem er meine Lösung noch einmal überprüft hatte, entdeckte und wies er hervorragend auf einen Fehler hin, den ich aufgrund einer Reihe von Änderungen in der Implementierung von Anfang bis Ende übersehen hatte. Nachdem der gefundene Fehler im Moq-Setup behoben wurde, stimmten alle Ergebnisse überein. Den Ergebnissen des erneuten Tests zufolge ändert sich der Haupttrend nicht – LINQ wirkt sich immer noch stärker auf die Leistung aus als auf die Reflexion. Es ist jedoch schön, dass die Arbeit mit der Ausdruckskompilierung nicht umsonst war und das Ergebnis sowohl in der Zuweisung als auch in der Ausführungszeit sichtbar ist. Der erste Start, wenn statische Felder initialisiert werden, ist bei der „schnellen“ Methode natürlich langsamer, aber dann ändert sich die Situation.

Hier ist das Ergebnis des erneuten Tests:

Erfolgloser Artikel über die Beschleunigung der Reflexion

Fazit: Beim Einsatz von Reflection in einem Unternehmen besteht kein besonderer Grund, auf Tricks zurückzugreifen – LINQ wird die Produktivität stärker verschlingen. Bei Hochlastmethoden, die optimiert werden müssen, können Sie die Reflexion jedoch in Form von Initialisierern und Delegaten-Compilern speichern, die dann „schnelle“ Logik bereitstellen. Auf diese Weise können Sie sowohl die Flexibilität der Reflexion als auch die Geschwindigkeit der Anwendung beibehalten.

Der Benchmark-Code ist hier verfügbar. Jeder kann meine Worte noch einmal überprüfen:
HabraReflectionTests

PS: Der Code in den Tests verwendet IoC und in den Benchmarks ein explizites Konstrukt. Tatsache ist, dass ich in der endgültigen Implementierung alle Faktoren herausgeschnitten habe, die die Leistung beeinträchtigen und das Ergebnis verrauschen könnten.

PPS: Danke an den Benutzer Dmitri Tichonow @0x1000000 für die Entdeckung meines Fehlers beim Einrichten von Moq, der sich auf die ersten Messungen ausgewirkt hat. Wenn einer der Leser über genügend Karma verfügt, gefällt es ihm bitte. Der Mann blieb stehen, der Mann las, der Mann überprüfte noch einmal und wies auf den Fehler hin. Ich denke, das verdient Respekt und Mitgefühl.

PPPS: Vielen Dank an den sorgfältigen Leser, der dem Stil und Design auf den Grund gegangen ist. Ich bin für Einheitlichkeit und Bequemlichkeit. Die Diplomatie der Präsentation lässt zu wünschen übrig, aber ich habe die Kritik berücksichtigt. Ich bitte um das Projektil.

Source: habr.com

Kommentar hinzufügen