【问题标题】:Why don't non-capturing expression trees that are initialized using lambda expressions get cached?为什么不缓存使用 lambda 表达式初始化的非捕获表达式树?
【发布时间】:2019-03-18 11:40:48
【问题描述】:

考虑以下类:

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 表达式初始化的那些(不是手动创建的)捕获任何外部状态/变量。字符串文字的自动实习就是一个类似的例子。
  4. 它们是用来遍历的——它们可以被编译来创建一个委托,但这不是它们的主要功能。如果有人想要一个编译的委托,他们可以只接受一个(Func&lt;T&gt;,而不是Expression&lt;Func&lt;T&gt;&gt;)。接受表达式树表示它将被用作数据结构。因此,“应该先编译它们”并不是反对缓存表达式树的明智论据。

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

【问题讨论】:

  • 缓存也有它的成本(增加内存使用)。而且由于处理表达式树的代码可能会比这里的编译器生成的代码更昂贵(在时间和分配方面),我不清楚这种优化实际上是否是一种改进。
  • 表达式不仅用于方法执行。表达式应先编译,然后才能作为函数运行。假设表达式只是某个类的另一个实例,那么它取决于如何处理它来兑现或不兑现
  • 只有 C# 语言设计者才能回答 why 部分。我同意你的观点,也希望它们被缓存。但我想答案更加琐碎且非技术性 - 就像Eric Lippertthis answer 的开头和相关的博客文章(太糟糕了,他不再为MS 工作了)。简而言之,实现功能的时间、资源、成本、优先级和收益之间的典型平衡:)

标签: c# lambda compilation delegates expression-trees


【解决方案1】:

为什么不缓存使用 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 和每个下游产品的一天耽误时间表并让客户失望

这是对做不必要的绩效工作的强大抑制因素,尤其是可能会使事情变得更糟而不是更好的绩效工作。没有过期策略的缓存有一个名称:它是内存泄漏

如您所见,匿名函数是被缓存的。当我实现 lambdas 时,我使用了与匿名函数相同的基础架构代码,因此缓存是 (1)“沉没成本”——工作已经完成,关闭它比让它打开更费力,并且(2) 已经经过我的前辈的测试和审查。

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

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

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

将其与代表的表现进行比较。 “分配x =&gt; x + 1,然后调用它”一百万次和“检查缓存,如果没有缓存分配它,调用它”之间的区别在于用分配换检查,这可以节省整个纳秒。这似乎没什么大不了的,但调用也将花费纳秒,所以按百分比计算,这很重要。缓存代表是一个明显的胜利。缓存表达式树并不是一个明显的胜利。我们需要数据证明这是一种好处,可以证明风险是合理的。

因此,不花任何时间在 C# 3 中这种不必要的、可能不引人注意的、不重要的优化上是一个简单的决定。

在 C# 4 期间,我们有许多比重新审视这个决定更重要的事情要做。

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

当我在 Roslyn 中重写代码时,建议的优化在我要考虑的事项列表中,但我们的首要任务是在我们优化它的一小部分之前让编译器端到端工作,我在 2012 年离开了微软,在这项工作完成之前。

至于为什么我离开后我的同事都没有重新讨论这个问题,你得问他们,但我很确定他们正忙于在实际客户要求的实际功能或性能方面进行实际工作以更低的成本获得更大胜利的优化。这项工作包括开源编译器,这并不便宜。

因此,如果您希望完成这项工作,您有一些选择。

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

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

  • 设计过程是开放的。输入一个问题,并在该问题中给出一个令人信服的理由,说明您认为这种增强是值得的。有数据。

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

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

【讨论】:

  • 这也忽略了非常重要的一点,即程序员在给定的实际程序中自己执行优化是非常微不足道的(通过简单地将表达式存储在字段中而不是让它在行中)如果开发人员处于他们确信缓存表达式会带来性能优势的情况下。最有价值的编译器优化要么难以实现,要么无法通过用户代码实现。
  • @Servy:这是我考虑提出的一个很好的观点;对这一点的反对是表达式树通常用于查询理解表达式,而在这些表达式中,开发人员编写缓存确实不方便。您必须将查询重写为流畅的形式,然后将 lambdas 拉入单独的声明中。如果这些 lambdas 涉及匿名类型,那么就会存在类型推断问题。所以在这种情况下,编译器可以比开发人员做得更好。
  • 什么是收集压力,如果我不那么沉闷?
  • @BurakKarakuş:垃圾收集既昂贵又具有破坏性,因此我们只想在您可能收集大量垃圾时才这样做。 GC 跟踪“压力”,这是对“GC 感觉应该很快收集到多强?”这一概念的抽象。分配大量小对象会增加压力,因为这些对象很可能很快就会死掉。增加压力会增加收集的可能性,这会降低性能,因此您需要注意高性能应用程序中的压力。
  • @BurakKarakuş:建议的优化在压力方面特别有趣,因为它减少小对象的分配数量,从而减少压力,但它增加 小而长的对象的数量,这增加了每个集合的成本。这就是为什么完全不清楚这种优化对性能的影响,因为我们现在被拉向两个不同的方向:更少的集合,但每个都更昂贵。这一点都不明显这是一场胜利!
【解决方案2】:

编译器做它一直在做的事情,而不是缓存你输入的任何东西。

要意识到这种情况总是会发生,请考虑将新数组传递给您的方法。

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

而不是将数组缓存一次然后每次都重用它。

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

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

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

【讨论】:

  • 数组不能被缓存,因为它们可以被变异,然后你会传递一个变异的数组而不是你想要的值。表达式树可以被缓存,因为它们是不可变的。
  • 此外,如果数组由常量整数组成,C#确实缓存数组。尝试改用int[] x = {10, 20, 30}; 并查看生成的代码。 C# 在编译时生成数组的内存映像,然后创建一个复制该映像的新数组,这样它就不必花时间将每个单独的元素复制到数组中,就像您的字符串示例一样。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 2020-12-22
  • 2021-01-07
  • 2012-01-15
  • 1970-01-01
  • 1970-01-01
  • 2021-02-06
相关资源
最近更新 更多