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

Why don't non-capturing expression trees that are initialized using lambda expressions get cached?

考虑以下 class:

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 表达式初始化的表达式树(不是手动创建的表达式树) ) 没有捕获任何外部 state/variable。字符串文字的自动驻留是一个类似的例子。
  4. 它们旨在被遍历 - 它们 可以 被编译以创建委托,但这不是它们的主要功能。如果有人想要编译委托,他们可以只接受一个(Func<T>,而不是 Expression<Func<T>>)。接受表达式树表示它将被用作数据结构。因此,"they should be compiled first" 不是反对缓存表达式树的明智论据。

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

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

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

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

Why don't non-capturing expression trees that are initialized using lambda expressions get cached?

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

正如我在被问到 "why not" 问题时总是说的那样:编译器作者 不需要 提供他们这样做的原因 做某事。做某事需要付出努力,需要努力,而且要花钱。因此,默认位置始终是 在不需要工作时做某事。

相反,想要完成工作的人需要证明为什么这项工作值得 成本。实际上,要求比这更强烈。希望完成工作的人需要证明为什么不必要的工作 比任何其他可能使用开发人员时间的方式 更能花费时间、精力和金钱。确实有无数种方法可以提高编译器的性能、功能集、健壮性、可用性等。是什么让这个如此伟大?

现在,每当我给出这个解释时,我都会遭到拒绝说 "Microsoft is rich, blah blah blah"。拥有大量资源与拥有无限资源不同,编译器已经非常昂贵。我也收到了反对意见,说 "open source makes labour free",但事实并非如此。

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

在开发 C# 3.0 时,Visual Studio 有一个特定的日期 "released to manufacturing",这是一个古怪的术语,当时软件主要通过无法更改的 CDROM 分发一旦它们被打印出来。这个日期不是任意的;相反,它后面有一整条依赖链。如果,比方说,SQL 服务器有一个依赖于 LINQ 的特性,那么将 VS 的发布推迟到那年的 SQL 服务器发布之后就没有任何意义,因此 VS 的时间表影响了 SQL 服务器排程,进而影响其他队伍的排程,等等。

因此,VS 组织中的每个团队都提交了一份时间表,并且在该时间表上工作天数最多的团队是 "long pole"。 C# 团队是 VS 的长杆,而我是 C# 编译器团队的长杆,所以 我延迟交付编译器功能的每一天都是 Visual Studio 的一天,并且每个下游产品都会延期并让客户失望

这会极大地抑制不必要的性能工作,特别是 可能使事情变得更糟而不是更好的性能工作。没有过期策略的缓存有一个名字:它是一个内存泄漏

正如您所注意到的,匿名函数被缓存。当我实现 lambdas 时,我使用了与匿名函数相同的基础结构代码,因此缓存是 (1) "sunk cost" —— 工作已经完成,关闭它比保持打开状态需要更多的工作, (2) 已经过我的前任的测试和审查。

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

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

但如果收益很大,那么冒风险可能是值得的。那么有什么好处呢?首先问自己 "where are expression trees used?" In LINQ queries that are going to be remoteed to databases。 这是一个非常耗费时间和内存的操作。添加缓存不会让您大获全胜,因为您将要做的工作比获胜要昂贵数百万倍;胜利是噪音。

将其与代表的绩效胜利进行比较。 "allocate x => x + 1, then call it" 一百万次和 "check the cache, if it is not cached allocate it, call it" 之间的区别在于用分配换取支票,这可以为您节省整整纳秒。这似乎没什么大不了的,但是 调用也将花费纳秒 ,因此在百分比基础上,它很重要。缓存委托是一个明显的胜利。缓存表达式树并不是一个明显的胜利;我们需要数据证明它是一种证明风险合理的收益。

因此,很容易做出决定,不花任何时间在 C# 3 中这个不必要的、可能不引人注意的、不重要的优化上。

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

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

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

至于为什么 none 我的同事在我离开后重新讨论这个问题,你得问问他们,但我敢肯定他们正忙于处理由真正的客户,或者以更小的成本获得更大胜利的性能优化。这项工作包括开源编译器,这并不便宜。

所以,如果你想完成这项工作,你有一些选择。

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

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

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

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

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