Article infructueux sur l'accélération de la réflexion

Je vais immédiatement expliquer le titre de l'article. Le plan initial était de donner de bons conseils fiables sur la façon d'accélérer l'utilisation de la réflexion à l'aide d'un exemple simple mais réaliste, mais lors de l'analyse comparative, il s'est avéré que la réflexion n'est pas aussi lente que je le pensais, LINQ est plus lent que dans mes cauchemars. Mais au final, il s'est avéré que j'avais aussi fait une erreur dans les mesures... Les détails de cette histoire de vie se trouvent sous la coupe et dans les commentaires. Étant donné que l'exemple est assez banal et mis en œuvre en principe comme cela se fait habituellement dans une entreprise, il s'est avéré être une démonstration de vie assez intéressante, me semble-t-il : l'impact sur la vitesse du sujet principal de l'article était non perceptible en raison de la logique externe : Moq, Autofac, EF Core et autres "straps".

J'ai commencé à travailler sous l'impression de cet article : Pourquoi la réflexion est-elle lente

Comme vous pouvez le voir, l'auteur suggère d'utiliser des délégués compilés au lieu d'appeler directement des méthodes de type réflexion, ce qui constitue un excellent moyen d'accélérer considérablement l'application. Il y a bien sûr des émissions d'IL, mais j'aimerais l'éviter, car c'est la manière la plus laborieuse d'accomplir la tâche, qui est semée d'erreurs.

Considérant que j’ai toujours eu une opinion similaire sur la vitesse de la réflexion, je n’avais pas particulièrement l’intention de remettre en question les conclusions de l’auteur.

Je suis souvent confronté à un usage naïf de la réflexion dans l’entreprise. Le type est pris. Les informations sur la propriété sont prises. La méthode SetValue est appelée et tout le monde se réjouit. La valeur est arrivée dans le champ cible, tout le monde est content. Des personnes très intelligentes - des seniors et des chefs d'équipe - écrivent leurs extensions pour objecter, en se basant sur une telle implémentation naïve de mappeurs « universels » d'un type à l'autre. L'essence est généralement la suivante : nous prenons tous les champs, prenons toutes les propriétés, les parcourons : si les noms des membres du type correspondent, nous exécutons SetValue. De temps en temps, nous détectons des exceptions dues à des erreurs où nous n'avons pas trouvé de propriété dans l'un des types, mais même ici, il existe une solution qui améliore les performances. Essayez/attrapez.

J'ai vu des gens réinventer les analyseurs et les mappeurs sans disposer d'informations complètes sur le fonctionnement des machines qui les ont précédés. J'ai vu des gens cacher leurs implémentations naïves derrière des stratégies, derrière des interfaces, derrière des injections, comme si cela pouvait excuser les bacchanales qui s'ensuivraient. J’ai levé le nez face à de telles réalisations. En fait, je n'ai pas mesuré la véritable fuite de performances et, si possible, j'ai simplement modifié l'implémentation pour une implémentation plus « optimale » si je pouvais mettre la main dessus. Par conséquent, les premières mesures discutées ci-dessous m’ont sérieusement dérouté.

Je pense que beaucoup d'entre vous, en lisant Richter ou d'autres idéologues, sont tombés sur une déclaration tout à fait juste selon laquelle la réflexion dans le code est un phénomène qui a un impact extrêmement négatif sur les performances de l'application.

L'appel de la réflexion oblige le CLR à parcourir les assemblys pour trouver celui dont ils ont besoin, extraire leurs métadonnées, les analyser, etc. De plus, la réflexion lors du parcours des séquences conduit à l’allocation d’une grande quantité de mémoire. Nous utilisons de la mémoire, CLR découvre le GC et les frises commencent. Cela devrait être sensiblement lent, croyez-moi. Les énormes quantités de mémoire présentes sur les serveurs de production modernes ou sur les machines cloud n'empêchent pas des retards de traitement élevés. En fait, plus il y a de mémoire, plus vous avez de chances de NOTER le fonctionnement du GC. La réflexion est, en théorie, un chiffon rouge supplémentaire pour lui.

Cependant, nous utilisons tous des conteneurs IoC et des mappeurs de dates, dont le principe de fonctionnement repose également sur la réflexion, mais leurs performances ne posent généralement aucune question. Non, pas parce que l’introduction de dépendances et l’abstraction de modèles externes à contexte limité sont si nécessaires que nous devons de toute façon sacrifier les performances. Tout est plus simple - cela n'affecte pas vraiment les performances.

Le fait est que les frameworks les plus courants basés sur la technologie de réflexion utilisent toutes sortes d'astuces pour travailler avec elle de manière plus optimale. Il s'agit généralement d'un cache. Il s'agit généralement d'expressions et de délégués compilés à partir de l'arborescence des expressions. Le même automapper gère un dictionnaire compétitif qui fait correspondre les types avec des fonctions capables de se convertir les uns en autres sans appeler la réflexion.

Comment y parvient-on ? Essentiellement, cela n’est pas différent de la logique que la plateforme elle-même utilise pour générer du code JIT. Lorsqu'une méthode est appelée pour la première fois, elle est compilée (et, oui, ce processus n'est pas rapide) ; lors des appels suivants, le contrôle est transféré à la méthode déjà compilée et il n'y aura pas de baisse significative des performances.

Dans notre cas, vous pouvez également utiliser la compilation JIT puis utiliser le comportement compilé avec les mêmes performances que ses homologues AOT. Les expressions nous viendront en aide dans ce cas.

Le principe en question peut être brièvement formulé ainsi :
Vous devez mettre en cache le résultat final de la réflexion en tant que délégué contenant la fonction compilée. Il est également judicieux de mettre en cache tous les objets nécessaires avec des informations de type dans les champs de votre type, le travailleur, qui sont stockés en dehors des objets.

Il y a de la logique là-dedans. Le bon sens nous dit que si quelque chose peut être compilé et mis en cache, alors cela doit être fait.

Pour l'avenir, il faut dire que le cache dans le travail avec réflexion a ses avantages, même si vous n'utilisez pas la méthode proposée pour compiler des expressions. En fait, je ne fais ici que reprendre les thèses de l’auteur de l’article auquel je fais référence plus haut.

Parlons maintenant du code. Regardons un exemple basé sur ma récente douleur à laquelle j'ai dû faire face dans une production sérieuse d'un établissement de crédit sérieux. Toutes les entités sont fictives afin que personne ne puisse les deviner.

Il y a une certaine essence. Qu'il y ait Contact. Il existe des lettres au corps standardisé, à partir desquelles l'analyseur et l'hydrateur créent ces mêmes contacts. Une lettre est arrivée, nous l'avons lue, l'avons analysée en paires clé-valeur, avons créé un contact et l'avons enregistrée dans la base de données.

C'est élémentaire. Supposons qu'un contact possède les propriétés Nom complet, Âge et Téléphone du contact. Ces données sont transmises dans la lettre. L'entreprise souhaite également que le support puisse ajouter rapidement de nouvelles clés pour mapper les propriétés d'entité en paires dans le corps de la lettre. Au cas où quelqu'un aurait fait une faute de frappe dans le modèle ou si, avant la sortie, il est nécessaire de lancer de toute urgence le mapping depuis un nouveau partenaire, en s'adaptant au nouveau format. Ensuite, nous pouvons ajouter une nouvelle corrélation de cartographie en tant que datafix bon marché. Autrement dit, un exemple de vie.

Nous implémentons, créons des tests. Travaux.

Je ne fournirai pas le code : il existe de nombreuses sources, et elles sont disponibles sur GitHub via le lien en fin d'article. Vous pouvez les charger, les torturer au-delà de toute reconnaissance et les mesurer, comme cela affecterait votre cas. Je ne donnerai que le code de deux méthodes modèles qui distinguent l'hydrateur, qui était censé être rapide, de l'hydrateur, qui était censé être lent.

La logique est la suivante : la méthode modèle reçoit les paires générées par la logique de base de l'analyseur. La couche LINQ est l'analyseur et la logique de base de l'hydrateur, qui fait une requête au contexte de la base de données et compare les clés avec les paires de l'analyseur (pour ces fonctions, il existe du code sans LINQ pour comparaison). Ensuite, les paires sont transmises à la méthode d'hydratation principale et les valeurs des paires sont définies sur les propriétés correspondantes de l'entité.

« Rapide » (Préfixe Rapide dans les 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;
        }

Comme nous pouvons le voir, une collection statique avec des propriétés setter est utilisée - des lambdas compilés qui appellent l'entité setter. Créé par le code suivant :

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

En général, c'est clair. Nous parcourons les propriétés, créons pour elles des délégués qui appellent les setters et les enregistrons. Ensuite, nous appelons lorsque cela est nécessaire.

« Slow » (Préfixe Slow dans les 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;
        }

Ici, nous contournons immédiatement les propriétés et appelons directement SetValue.

Pour plus de clarté et à titre de référence, j'ai implémenté une méthode naïve qui écrit les valeurs de leurs paires de corrélation directement dans les champs d'entité. Préfixe – Manuel.

Prenons maintenant BenchmarkDotNet et examinons les performances. Et soudain... (spoiler - ce n'est pas le bon résultat, les détails sont ci-dessous)

Article infructueux sur l'accélération de la réflexion

Que voit-on ici ? Les méthodes qui portent triomphalement le préfixe Fast s'avèrent plus lentes dans presque toutes les passes que les méthodes avec le préfixe Slow. Cela est vrai tant pour l'allocation que pour la rapidité du travail. D'un autre côté, une implémentation belle et élégante du mappage utilisant les méthodes LINQ prévues à cet effet dans la mesure du possible réduit au contraire considérablement la productivité. La différence est d’ordre. La tendance ne change pas avec un nombre différent de passes. La seule différence réside dans l'échelle. Avec LINQ, c'est 4 à 200 fois plus lent, il y a plus de déchets à peu près à la même échelle.

ACTUALISÉ

Je n'en croyais pas mes yeux, mais plus important encore, notre collègue n'en croyait ni mes yeux ni mon code - Dmitri Tikhonov 0x1000000. Après avoir revérifié ma solution, il a brillamment découvert et signalé une erreur que j'avais manquée en raison d'un certain nombre de changements dans l'implémentation, du début au final. Après avoir corrigé le bug trouvé dans la configuration de Moq, tous les résultats se sont mis en place. Selon les résultats du nouveau test, la tendance principale ne change pas : LINQ affecte toujours davantage les performances que la réflexion. Cependant, il est agréable que le travail de compilation d'expressions ne soit pas fait en vain et que le résultat soit visible à la fois en termes d'allocation et de temps d'exécution. Le premier lancement, lors de l'initialisation des champs statiques, est naturellement plus lent pour la méthode « rapide », mais ensuite la situation change.

Voici le résultat du nouveau test :

Article infructueux sur l'accélération de la réflexion

Conclusion : lors de l'utilisation de la réflexion dans une entreprise, il n'est pas particulièrement nécessaire de recourir à des astuces - LINQ consommera davantage de productivité. Cependant, dans les méthodes à forte charge nécessitant une optimisation, vous pouvez enregistrer la réflexion sous la forme d'initialiseurs et de compilateurs délégués, qui fourniront alors une logique « rapide ». De cette façon, vous pouvez conserver à la fois la flexibilité de la réflexion et la rapidité de l’application.

Le code de référence est disponible ici. Tout le monde peut revérifier mes propos :
HabraRéflexionTests

PS : le code dans les tests utilise IoC, et dans les benchmarks, il utilise une construction explicite. Le fait est que dans la mise en œuvre finale, j'ai supprimé tous les facteurs susceptibles d'affecter les performances et de rendre le résultat bruyant.

PPS : Merci à l'utilisateur Dmitri Tikhonov @0x1000000 pour avoir découvert mon erreur de configuration de Moq, qui a affecté les premières mesures. Si l'un des lecteurs a suffisamment de karma, merci de l'aimer. L’homme s’est arrêté, l’homme a lu, l’homme a revérifié et a signalé l’erreur. Je pense que cela mérite respect et sympathie.

PPPS : merci au lecteur méticuleux qui est allé au fond du style et du design. Je suis pour l'uniformité et la commodité. La diplomatie de la présentation laisse beaucoup à désirer, mais j'ai tenu compte des critiques. Je demande le projectile.

Source: habr.com

Ajouter un commentaire