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

Şaf*_*Gür 16 c# lambda delegates compilation 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) { /*...*/ }
}
Run Code Online (Sandbox Code Playgroud)

这是编译器生成的内容(以稍微不易读的方式):

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;
    }
}
Run Code Online (Sandbox Code Playgroud)

这样,第一次调用传递的委托被初始化一次并在多次Test调用中重用.

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

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

编辑

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

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

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

Eri*_*ert 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的一天,而且每一个下游产品都会放弃它的时间表并使其客户失望.

这对于执行不必要的性能工作具有强大的抑制作用,特别是性能工作可能会使事情变得更糟,而不是更好.没有过期策略的缓存有一个名称:它是内存泄漏.

如您所知,匿名函数被缓存.当我实现lambdas时,我使用相同的基础设施代码作为匿名函数,因此缓存是(1)"沉没成本" - 工作已经完成,将其关闭比留下它更多的工作,并且(2)我的前任已经过测试和审查.

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

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

但如果收益很大,风险可能是值得的.那么有什么好处呢?首先问自己"表达树在哪里使用?" 在将要远程数据库的LINQ查询中.这在时间和记忆方面都是一项非常昂贵的操作.添加缓存并不会让你获得巨大的胜利,因为你要做的工作比胜利要贵几百万倍; 胜利是噪音.

将其与代表的表现胜利进行比较."分配x => x + 1,然后调用它"一百万次和"检查缓存,如果它没有缓存分配它,调用它" 之间的区别是交换分配进行检查,这可以节省你整整纳秒.这似乎没什么大不了的,但是这个电话也需要几纳秒,所以在百分比的基础上,它是重要的.缓存代表是一个明显的胜利.缓存表达式树并不是一个接近明显胜利的地方; 我们需要数据证明风险是合理的.

因此,在C#3中不花时间处理这种不必要的,可能不明显的,不重要的优化是一个简单的决定.

在C#4期间,我们有许多重要的事情要做,而不是重新审视这个决定.

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

当我在Roslyn中重写代码时,我提出的优化是我要考虑的事项列表,但我们最优先考虑的是让我们在优化其中的一小部分之前让编译器端到端地工作,并在2012年离开Microsoft之前工作完成了.

至于为什么我离开之后没有一个同事重新审视这个问题,你必须问他们,但我很确定他们非常忙于真正的客户要求的真实功能或者有性能优化的工作.较小的成本获得更大的胜利 这项工作包括开源编译器,这并不便宜.

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

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

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

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

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

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

  • 这也忽略了一个非常重要的一点,即如果开发人员处于某种情况,那么程序员在给定的实际程序中自己执行优化(通过简单地将表达式存储在一个字段而不是让它排成一行)是非常重要的.他们相信这是一个缓存表达的性能胜利.最有价值的编译器优化是难以实现或无法从用户代码实现的优化. (2认同)