为什么不缓存使用lambda表达式初始化的非捕获表达式树?

时间:2018-10-13 12:48:50

标签: c# lambda compilation delegates expression-trees

请考虑以下课程:

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) { /*...*/ }
}

这是编译器生成的(以一种 不太可读的方式):

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

这样,第一次调用时传递的委托将被初始化一次,并在多个Test调用中重新使用。

但是,第二次调用传递的表达式树不会被重用-每次Test调用都会初始化一个新的lambda表达式。

如果它不能捕获任何东西并且表达式树是不可变的,那么缓存表达式树又会有什么问题呢?

修改

我认为我需要澄清为什么我认为表达式树适合缓存。

  1. 在编译时就知道了结果表达式树(好吧,它是 由编译器创建的。)
  2. 它们是一成不变的。因此,与下面X39给出的数组示例不同,表达式树在初始化后无法修改,因此可以安全地缓存。
  3. 在一个代码库中只能有那么多的表达式树-再次,我说的是那些可以缓存的树,即使用lambda表达式初始化的树(而不是手动创建的树),而没有捕获任何外部状态/变量。字符串文字的自动interintering将是一个类似的示例。
  4. 它们应被遍历-可以对其进行编译以创建一个委托,但这不是它们的主要功能。如果某人想要一个编译的委托,他们可以只接受一个委托(Func<T>,而不是Expression<Func<T>>)。接受表达式树表示它将被用作数据结构。因此,“他们应该先编译”并不是反对缓存表达式树的明智论据。

我要问的是缓存这些表达式树的潜在缺点。 svick提到的内存需求是一个更可能的例子。

2 个答案:

答案 0 :(得分:9)

  

为什么不捕获使用lambda表达式初始化的非捕获表达式树?

我在原始C#3实现和Roslyn重写中都在编译器中编写了该代码。

就像我经常被问到一个“为什么不”的问题时一样:不需要编译器作者来提供为什么他们做某事的原因。做某事需要工作,需要努力,并且要花钱。因此,默认位置始终是在不需要工作时不执行任何操作。

相反,想要完成工作的人需要证明为什么这项工作值得 成本。实际上,需求要强于此。需要完成工作的人必须证明为什么不必要的工作是比其他任何可能的开发人员时间花费更好的时间,精力和金钱的方法。实际上,有无数种方法可以改善编译器的性能,功能集,健壮性,可用性等。是什么使它如此出色?

现在,每当我给出这样的解释时,我都会反驳说“微软很富有,等等等等”。拥有大量资源与拥有无限资源并不相同,并且编译器已经非常昂贵。我也遭到推斥,说“开放源代码可以节省劳动力”,但这绝对不是。

我注意到时间是一个因素。进一步扩展可能会有所帮助。

在开发C#3.0时,Visual Studio有一个特定的日期,即“发布到生产”的日期,这是一个古怪的字眼,指的是软件主要分布在CDROM上,一旦打印就无法更改。这个日期不是任意的。相反,随之而来的是整个依赖链。例如,如果SQL Server具有依赖LINQ的功能,则将VS版本推迟到该年的SQL Server版本之后才有意义,因此VS计划影响了SQL Server计划,进而影响了其他团队的计划。时间表等等。

因此,VS组织中的每个团队都提交了时间表,而按照该时间表工作最多的团队就是“长杆”。 C#团队是VS的长期支柱,而我是C#编译器团队的长期支柱,因此我迟迟交付编译器功能的每一天都是Visual Studio,而每个下游产品都会拖延进度并让客户失望

这对进行不必要的绩效工作有很大的抑制作用,尤其是可能会使情况变得更糟而不是更好的绩效工作。没有到期策略的缓存具有一个名称:这是内存泄漏

您注意到,匿名函数被缓存。当我实现lambda时,我使用了与匿名函数相同的基础结构代码,因此缓存是(1)“降低成本” —该工作已经完成,关闭它比打开它还要做更多的工作,并且(2)已经由我的前任进行过测试和审查。

我考虑使用相同的逻辑在表达式树上实现类似的缓存,但意识到这将(1)是可行的,这需要时间,而我已经很早了;(2)我不知道该怎么做。性能影响将是缓存这样的对象。 代表真的很小。代表是一个单一的对象;如果委托在逻辑上是静态的(C#主动缓存的委托是静态的),则它甚至不包含对接收者的引用。相反,表情树是潜在的大树。它们是小对象的图,但该图可能很大。对象图的生存时间越长,垃圾收集器的工作就越多!

因此,由于内存负担是完全不同的,因此无论使用什么性能测试和度量标准来证明决定缓存代表的理由都不适用于表达式树。我不想在我们最重要的新语言功能中创建新的内存泄漏源。风险太高了。

但是,如果收益很大,风险可能值得。那有什么好处呢?首先问自己“表达式树在哪里使用?”在LINQ中,将要查询到数据库的查询。 这在时间和内存上都是非常昂贵的操作。添加缓存并不能使您大获全胜,因为您要做的工作比获胜要贵上百万倍。胜利就是噪音。

将其与代表获胜的业绩进行比较。 “分配x => x + 1,然后调用它”一百万次与“检查缓存,如果未缓存则分配它,然后调用它”之间的区别是为检查分配了一笔交易,这可以节省整个纳秒。这似乎没什么大不了,但通话也将花费纳秒的时间,因此按百分比计算,这很重要。缓存代表是一个明显的胜利。缓存表达式树远不是一个明确的目标。我们需要的数据是证明风险的益处。

因此,很容易做出决定,不要花任何时间在C#3中这种不必要的,可能不明显的,不重要的优化上。

在C#4期间,我们要做的工作比重审此决定还重要得多。

在C#4之后,该团队分为两个子团队,一个是重写编译器“ Roslyn”,另一个是在原始编译器代码库中实现async-await。通过实现该复杂而困难的功能,异步等待团队被完全消耗掉了,当然,该团队比平时要小。他们知道所有的工作最终都将在罗斯林复制,然后扔掉。那个编译器已经寿终正寝了。因此,没有动力花费时间或精力来添加优化。

建议的优化是我在Roslyn中重写代码时要考虑的事情,但是我们的最高优先级是使编译器端到端工作,然后再优化其中的一小部分。我于2012年离开了Microsoft ,在这项工作完成之前。

关于我离开后为什么没有一个同事再访这个问题的原因,您必须要问他们,但是我敢肯定,他们非常忙于在真实客户要求的真实功能或性能方面进行真实的工作以较小的成本获得更大胜利的优化。这项工作包括将编译器开源,这并不便宜。

因此,如果您想完成这项工作,则可以选择。

  • 编译器是开源的;你可以自己做。如果这听起来像是很多工作,对您几乎没有什么好处,那么您现在可以更直观地了解自2005年实施该功能以来为何没人进行这项工作。

当然,这对于编译器团队来说仍然不是“免费”的。有人将需要花费时间,精力和金钱来审查您的工作。请记住,性能优化的大部分成本不是更改代码所需的五分钟时间。经过数周的测试,在所有可能的实际条件下进行的测试表明,优化有效且不会使情况更糟!表演工作是我最昂贵的工作。

  • 设计过程已打开。输入一个问题,并在该问题中给出一个令人信服的理由,为什么您认为此增强功能值得。有了数据。

到目前为止,您只说了为什么它是可能的。可能不会削减!许多事情都是可能的。给我们提供数字,证明为什么编译器开发人员应该花时间进行增强,而不是实现客户要求的新功能。

避免复杂表达式树重复分配的实际胜利是避免了收集压力,这是一个严重的问题。 C#中的许多功能都是为了避免收集压力而设计的,而表达式树并不是其中之一。 如果要进行此优化,我对您的建议是专注于压力的影响,因为这是您找到最大的胜利并能够提出最有说服力的论据的地方。

答案 1 :(得分:0)

编译器会做它一直在做的事情,而不是缓存您提供给它的任何东西。

要意识到这种情况一直存在,请研究将新数组传递给您的方法。

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

将进入

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[])

而不是一次缓存数组并每次都重复使用。

Same或多或少地适用于Expressions,只是在这里编译器正在为您生成树的工作很方便,这意味着最后您应该知道何时需要缓存并相应地应用它。

要获取缓存的版本,请使用以下内容:

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