web-dev-qa-db-fra.com

Pourquoi les arbres d'expression non capturés initialisés à l'aide d'expressions lambda ne sont-ils pas mis en cache?

Considérez la classe suivante:

class Program
{
    static void Test()
    {
        TestDelegate<string, int>(s => s.Length);

        TestExpressionTree<string, int>(s => s.Length);
    }

    static void TestDelegate<T1, T2>(Func<T1, T2> del) { /*...*/ }

    static void TestExpressionTree<T1, T2>(Expression<Func<T1, T2>> exp) { /*...*/ }
}

Voici ce que le compilateur génère (de manière légèrement moins lisible):

class Program
{
    static void Test()
    {
        // The delegate call:
        TestDelegate(Cache.Func ?? (Cache.Func = Cache.Instance.FuncImpl));

        // The expression call:
        var paramExp = Expression.Parameter(typeof(string), "s");
        var propExp = Expression.Property(paramExp, "Length");
        var lambdaExp = Expression.Lambda<Func<string, int>>(propExp, paramExp);
        TestExpressionTree(lambdaExp);
    }

    static void TestDelegate<T1, T2>(Func<T1, T2> del) { /*...*/ }

    static void TestExpressionTree<T1, T2>(Expression<Func<T1, T2>> exp) { /*...*/ }

    sealed class Cache
    {
        public static readonly Cache Instance = new Cache();

        public static Func<string, int> Func;

        internal int FuncImpl(string s) => s.Length;
    }
}

De cette façon, le délégué passé avec le premier appel est initialisé une fois et réutilisé pour plusieurs appels Test.

Toutefois, l'arborescence d'expression transmise lors du deuxième appel n'est pas réutilisée - une nouvelle expression lambda est initialisée à chaque appel Test.

À condition qu'il ne capture rien et que les arbres d'expression soient immuables, quel est le problème avec la mise en cache de l'arbre d'expression?

Modifier

Je pense avoir besoin de clarifier pourquoi je pense que les arbres d'expression sont aptes à être mis en cache.

  1. L'arbre d'expression résultant est connu au moment de la compilation (et bien, il est créé par le compilateur).
  2. Ils sont immuables. Ainsi, contrairement à l'exemple de tableau donné par X39 ci-dessous, un arbre d'expression ne peut pas être modifié après son initialisation et peut donc être mis en cache en toute sécurité.
  3. Une base de code ne peut contenir qu'un nombre limité d'arbres d'expression - Encore une fois, je parle de ceux qui peuvent être mis en cache, c'est-à-dire ceux qui sont initialisés à l'aide d'expressions lambda (et non celles créées manuellement) sans capturer l'extérieur. état variable. L'auto-interning des littéraux de chaîne serait un exemple similaire.
  4. Ils sont censés être parcourus - Ils peuvent être compilés pour créer un délégué, mais ce n'est pas leur fonction principale. Si quelqu'un veut un délégué compilé, il ne peut en accepter qu'un (un Func<T> au lieu d'un Expression<Func<T>>). L'acceptation d'un arbre d'expression indique qu'il va être utilisé comme structure de données. Ainsi, "ils devraient être compilés en premier" n'est pas un argument judicieux contre la mise en cache des arbres d'expression.

Ce que je demande, ce sont les inconvénients potentiels de la mise en cache de ces arbres d'expression. Les besoins en mémoire mentionnés par svick en sont un exemple plus probable.

16
Şafak Gür

Pourquoi les arbres d'expression non capturés initialisés à l'aide d'expressions lambda ne sont-ils pas mis en cache?

J'ai écrit ce code dans le compilateur, à la fois dans l'implémentation C # 3 d'origine et dans la réécriture de Roslyn.

Comme je le dis toujours quand on me pose une question "pourquoi pas": les écrivains du compilateur ne sont pas tenus de fournir une raison pour laquelle ils ont pas fait quelque chose . Faire quelque chose demande du travail, demande des efforts et coûte de l'argent. La position par défaut est donc toujours de pas faire quelque chose lorsque le travail est inutile. 

Au contraire, la personne qui veut que le travail soit effectué doit justifier pourquoi ce travail en vaut la peine. Et en réalité, l'exigence est plus forte que cela. La personne qui veut que le travail soit effectué est tenue de justifier en quoi un travail inutile est un moyen plus efficace de dépenser du temps, des efforts et de l’argent que toute autre utilisation possible du temps du développeur []. Il y a littéralement un nombre infini de façons d'améliorer les performances, le jeu de fonctionnalités, la robustesse, la facilité d'utilisation, etc. du compilateur. Qu'est-ce qui rend celui-ci si formidable?

Maintenant, chaque fois que je donne cette explication, je reçois des critiques disant "Microsoft est riche, bla bla bla". Avoir beaucoup de ressources n'est pas la même chose que d'avoir des ressources infinies et le compilateur est déjà extrêmement coûteux. Je reçois aussi des critiques disant "l'open source rend le travail gratuit", ce qui n'est absolument pas le cas. 

J'ai noté que le temps était un facteur. Il serait peut-être utile d’élaborer davantage à ce sujet. 

Lors du développement de C # 3.0, Visual Studio avait une date précise à partir de laquelle il serait "publié pour la fabrication", terme pittoresque à partir du moment où le logiciel était principalement distribué sur des CD-ROM qui ne pouvaient pas être modifiés une fois imprimés. Cette date n'était pas arbitraire; il y a plutôt toute une chaîne de dépendances qui l'a suivie. Si, par exemple, SQL Server possédait une fonctionnalité dépendant de LINQ, cela n'aurait aucun sens de différer la publication du système virtuel jusqu'au lendemain de la publication de SQL Server de cette année. Par conséquent, la planification de VS affectait la planification de SQL Server, qui affectait à son tour horaires, et ainsi de suite.

Par conséquent, chaque équipe de l'organisation VS a soumis un calendrier, et l'équipe avec le plus grand nombre de jours de travail correspondant à ce calendrier correspondait au "pôle long". L’équipe C # était la longue perche de VS, et j’étais la longue perche de l’équipe du compilateur C #. Ainsi, chaque jour où j’étais en retard dans la présentation de mes fonctionnalités de compilateur était un jour où Visual Studio et chaque produit aval glissaient son calendrier et décevoir ses clients - /.

Cela décourage fortement de faire un travail de performance inutile, en particulier un travail de performance qui pourrait aggraver les choses, pas mieux . Un cache sans politique d'expiration a un nom: c'est une fuite de mémoire .

Comme vous le constatez, les fonctions anonymes sont mises en cache. Lorsque j'ai implémenté lambdas, j'ai utilisé le même code d'infrastructure que les fonctions anonymes, de sorte que le cache était (1) "coût irrécupérable" - le travail était déjà terminé et il aurait fallu plus de travail pour l'éteindre que pour le laisser en marche, et (2) avait déjà été testé et approuvé par mes prédécesseurs. 

J'ai envisagé de mettre en place un cache similaire sur des arbres d'expression, en utilisant la même logique, mais je me suis rendu compte qu'il s'agirait (1) d'un travail qui nécessite du temps, ce qui me faisait déjà défaut, et (2) je n'avais aucune idée des impacts sur les performances. être de la mise en cache d'un tel objet. Les délégués sont vraiment petits . Les délégués sont un seul objet. si le délégué est logiquement statique, comme le sont ceux que C # met en cache de manière agressive, il ne contient même pas de référence au destinataire. Les arbres d’expression, en revanche, sont des arbres potentiellement énormes. Ils sont un graphe de petits objets, mais ce graphe est potentiellement grand. Les graphiques d'objets font plus de travail pour le ramasse-miettes, plus il vit longtemps!

Par conséquent, quels que soient les tests de performance et les métriques utilisés pour justifier la décision de mettre en cache les délégués, ils ne seraient pas applicables aux arbres d’expression, car la charge de mémoire était complètement différente. Je ne voulais pas créer une nouvelle source de fuites de mémoire dans notre plus importante nouvelle fonctionnalité linguistique. Le risque était trop élevé.Mais un risque pourrait valoir la peine si le bénéfice est important. Alors, quel est l'avantage? Commencez par vous demander "où sont utilisés les arbres d'expression?" Dans LINQ, les requêtes vont être remises aux bases de données. C’est une opération extrêmement coûteuse en temps et en mémoire . Ajouter une cache ne vous rapporte pas beaucoup, car le travail que vous êtes sur le point de faire est des millions de fois plus coûteux que le gain; la victoire est le bruit.

Comparez cela à la performance gagnée pour les délégués. La différence entre "allocate x => x + 1, puis appelez-le" un million de fois et "contrôlez le cache, si l'appel n'est pas mis en cache, appelez-le" échange une allocation pour un contrôle, ce qui peut vous faire économiser des nanosecondes entières. Cela ne semble pas grave, mais l'appel prendra également une nanoseconde, donc, en pourcentage, c'est important. Caching délégués est une victoire claire. La mise en cache des arbres d’expression n’est nulle part proche d’une victoire claire; nous aurions besoin de données indiquant que c'est un avantage qui justifie le risque. 

Par conséquent, il était facile de ne pas perdre de temps à optimiser en C # 3 cette optimisation inutile, probablement imperceptible.

Au cours de la quatrième étape, nous avions beaucoup plus de choses importantes à faire que de revenir sur cette décision.

Après C # 4, l'équipe s'est divisée en deux sous-équipes, une pour réécrire le compilateur, "Roslyn", et l'autre pour implémenter async-wait dans la base de code du compilateur d'origine. L’attente asynchrone était entièrement consommée par la mise en œuvre de cette fonctionnalité complexe et difficile, et bien sûr, elle était plus petite que d’habitude. Et ils savaient que tout leur travail allait éventuellement être reproduit à Roslyn puis jeté. ce compilateur était en fin de vie. Il n'y avait donc aucune incitation à dépenser du temps ou des efforts pour ajouter des optimisations.

L'optimisation proposée figurait sur ma liste d'éléments à prendre en compte lorsque j'ai réécrit le code dans Roslyn, mais notre priorité absolue consistait à faire fonctionner le compilateur de bout en bout avant d'en optimiser de petites parties. J'ai quitté Microsoft en 2012, avant cela. le travail était fini.

En ce qui concerne la raison pour laquelle aucun de mes collègues n’a réexaminé cette question après mon départ, vous devez le leur demander, mais je suis presque certain qu’ils étaient très occupés à travailler sur de vraies fonctionnalités demandées par de vrais clients, ou sur des optimisations de performances des gains plus importants à moindre coût. Ce travail comprenait l’open-sourcing du compilateur, qui n’est pas bon marché.

Donc, si vous voulez que ce travail soit effectué, vous avez le choix.

 

  • Bien sûr, cela n’est toujours pas "gratuit" pour l’équipe du compilateur. Quelqu'un devrait consacrer du temps, des efforts et de l'argent à la révision de votre travail. N'oubliez pas que l'optimisation des performances ne représente en grande partie le coût que de cinq minutes pour changer le code. Ce sont les semaines de tests sur un échantillon de toutes les conditions réelles possibles qui démontrent que l'optimisation fonctionne et n'aggrave pas les choses! Le travail de performance est le travail le plus coûteux que je fasse. 

.

  • Jusqu'ici tout ce que vous avez dit c'est pourquoi c'est possible. Possible ne le coupe pas! Beaucoup de choses sont possibles. Donnez-nous des chiffres qui justifient pourquoi les développeurs du compilateur devraient consacrer leur temps à cette amélioration plutôt qu'à la mise en œuvre de nouvelles fonctionnalités demandées par les clients.

En évitant les affectations répétées d’arbres d’expression complexes, on gagne à éviter pression de collecte, ce qui est un grave problème. De nombreuses fonctionnalités de C # sont conçues pour éviter les pressions de la collection, et les arbres d'expression n'en font PAS partie. Mon conseil pour vous si vous voulez cette optimisation est de vous concentrer sur son impact sur la pression, car c'est là que vous trouverez le plus gros gain et serez en mesure de présenter l'argument le plus convaincant. .

The actual win in avoiding repeated allocations of complex expression trees is avoiding collection pressure, and this is a serious concern. Many features in C# are designed to avoid collection pressure, and expression trees are NOT one of them. My advice to you if you want this optimization is to concentrate on its impact on pressure, because that's where you'll find the biggest win and be able to make the most compelling argument.

9
Eric Lippert

Le compilateur fait ce qu'il fait toujours, pas la mise en cache de ce que vous lui donnez.

Pour vous rendre compte que cela se produit toujours, envisagez de passer un nouveau tableau à votre méthode.

this.DoSomethingWithArray(new string[] {"foo","bar" });

va arriver à 

IL_0001: ldarg.0
IL_0002: ldc.i4.2
IL_0003: newarr    [mscorlib]System.String
IL_0008: dup
IL_0009: ldc.i4.0
IL_000A: ldstr     "foo"
IL_000F: stelem.ref
IL_0010: dup
IL_0011: ldc.i4.1
IL_0012: ldstr     "bar"
IL_0017: stelem.ref
IL_0018: call      instance void Test::DoSomethingWithArray(string[])

au lieu de mettre en cache le tableau une fois et de le réutiliser à chaque fois.

La même chose s’applique plus ou moins aux expressions, mais le compilateur fait ici le travail pratique de générer votre arbre, ce qui signifie qu’il est attendu que vous sachiez quand la mise en cache est nécessaire et que vous l’appliquez en conséquence.

Pour obtenir une version en cache, utilisez quelque chose comme ceci:

private static System.Linq.Expressions.Expression<Func<object, string>> Exp = (obj) => obj.ToString();
0
X39