为什么Func<>委托如此缓慢

10

我正在将重复的算术代码移入可重用的函数块中,但当我运行一个简单的测试来检测是否会更慢时,我惊讶地发现它比原来慢了两倍。

为什么评估表达式会变慢两倍?

using System;
using System.Runtime.CompilerServices;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Exporters;
using BenchmarkDotNet.Loggers;
using BenchmarkDotNet.Running;

namespace ConsoleApp1
{
    class Program
    {
        static void Main(string[] args)
        {
            var summary = BenchmarkRunner.Run<Calculations>();

            var logger = ConsoleLogger.Default;
            MarkdownExporter.Console.ExportToLog(summary, logger);

            Console.WriteLine(summary);
        }
    }

    public class Calculations
    {
        public Random RandomGeneration = new Random();

        [Benchmark]
        public void CalculateNormal()
        {
           var s =  RandomGeneration.Next() * RandomGeneration.Next();
        }

        [Benchmark]
        public void CalculateUsingFunc()
        {
            Calculate(() => RandomGeneration.Next() * RandomGeneration.Next());
        }

        [MethodImpl(MethodImplOptions.AggressiveInlining)]
        public int Calculate(Func<int> expr)
        {
            return expr();
        }
    }
}

以下是基准测试结果: 在此输入图片描述


有趣的观点,我会重新运行并使用它。 - Ricky Gummadi
如果 CalculateNormal 更快,您能否向我们解释为什么不只使用 CalculateNormal(或等效函数)? - mjwills
7
未来请在您的示例代码中包含 using 指令 - 我现在已经添加了它们,但如果您一开始就包含它们,那会节省我几分钟的时间。 - Jon Skeet
3
创建和调用委托有固定的开销,这个开销与被调用方法的操作无关,因此不会比直接调用该方法慢两倍。标准的优化措施并不适用于委托,没有AggressiveInlining,JIT编译时还不知道将要调用哪个方法,因此无法对其进行内联处理。它甚至不知道方法是实例方法还是静态方法,这对x64架构来说是一个大问题(实例方法更好)。只是因为被调用的方法很少做操作,所以看起来会慢两倍,但这几乎是不需要优化的代码。 - Hans Passant
1
显然对于C# 9,他们正在添加“静态”lambda表达式,这将有助于解决这种情况。 - Daniel A. White
显示剩余8条评论
1个回答

16

每次调用都会创建一个新的委托对象。这样做会带来相当大的开销,这并不奇怪。

如果你使用一个没有捕获this或任何局部变量的lambda表达式(在这种情况下编译器可以将其缓存到静态字段中),或者如果你明确地创建一个单独的实例并将其存储在自己的字段中,则大部分开销都会消失。

这是你测试的修改版:

public class Calculations
{
    public Random RandomGeneration = new Random();
    private Func<int> exprField;
    
    public Calculations()
    {
        exprField = () => RandomGeneration.Next() * RandomGeneration.Next();
    }
    
    [Benchmark]
    public void CalculateNormal()
    {
       var s =  RandomGeneration.Next() * RandomGeneration.Next();
    }

    [Benchmark]
    public void CalculateUsingFunc()
    {
        Calculate(() => RandomGeneration.Next() * RandomGeneration.Next());
    }
    
    [Benchmark]
    public void CalculateUsingFuncField()
    {
        Calculate(exprField);
    }

    
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public int Calculate(Func<int> expr)
    {
        return expr();
    }
}

在我的电脑上运行的结果:

|                  Method |     Mean |    Error |   StdDev |
|------------------------ |---------:|---------:|---------:|
|         CalculateNormal | 27.61 ns | 0.438 ns | 0.388 ns |
|      CalculateUsingFunc | 48.74 ns | 1.009 ns | 0.894 ns |
| CalculateUsingFuncField | 32.53 ns | 0.698 ns | 0.717 ns |

所以仍然有一点额外的开销,但比以前少得多。


4
我责怪@EricLippert导致我们必须处理这个问题,而编译器在没有额外开销的情况下不能进行优化,特别是当唯一被捕获的变量是“神奇”的this时。 - Rand Random
3
我不这么认为。并且“Random”不是线程安全的,因此应该避免使用“static Random”。 - Daniel A. White
3
@RickyG:重点是要避免捕获this - 但基本上,如果您正在访问使用实例中的某些内容(RandomGeneration),则必须在每个实例上缓存委托,或每次创建新的委托。 - Jon Skeet
1
@DanielA.White - 看起来你是正确的,我有点想到无论你在做什么,你都会始终捕获this,因为这就是lambda的工作方式,除非你将它们存储在变量中并重复使用该变量...看起来我有错误的信息,尽管我从未真正理解过,为什么一个lamba不能简单地编译成一个方法并在没有开销的情况下使用该方法,尽管我完全缺乏为什么/为什么不的背景知识,但在任何linq操作中都鼓励使用lamba,但性能/分配很差,感觉很“奇怪”。 - Rand Random
5
@RandRandom: 性能不会“差”- 你只需要了解发生了什么并做好相应的处理。在很多情况下-例如通常在LINQ中-你创建一个委托的单个实例并执行多次。这不是在你的基准测试中发生的情况,每个委托只执行一次...这是有开销的。如果你不想要封装行为的额外灵活性,那就直接调用方法... - Jon Skeet
显示剩余3条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接