C#中的通用性能与非通用性能

28

我写了两个等价的方法:

static bool F<T>(T a, T b) where T : class
{
    return a == b;
}

static bool F2(A a, A b)
{
    return a == b;
}

时间差:
00:00:00.0380022
00:00:00.0170009

测试代码:

var a = new A();
for (int i = 0; i < 100000000; i++)
    F<A>(a, a);
Console.WriteLine(DateTime.Now - dt);

dt = DateTime.Now;
for (int i = 0; i < 100000000; i++)
    F2(a, a);
Console.WriteLine(DateTime.Now - dt);

有人知道为什么吗?

在下面的评论中,dtb* 展示了 CIL

IL for F2: ldarg.0, ldarg.1, ceq, ret. IL for F<T>: ldarg.0, box !!T, ldarg.1, box !!T, ceq, ret.

我认为这是我的问题的答案,但是我可以使用什么魔法来拒绝装箱?

接下来我使用来自Psilon的代码:

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;

namespace ConsoleApplication58
{
    internal class Program
    {
        private class A
        {

        }

        private static bool F<T>(T a, T b) where T : class
        {
            return a == b;
        }

        private static bool F2(A a, A b)
        {
            return a == b;
        }

        private static void Main()
        {
            const int rounds = 100, n = 10000000;
            var a = new A();
            var fList = new List<TimeSpan>();
            var f2List = new List<TimeSpan>();
            for (int i = 0; i < rounds; i++)
            {
                // Test generic
                GCClear();
                bool res;
                var sw = new Stopwatch();
                sw.Start();
                for (int j = 0; j < n; j++)
                {
                    res = F(a, a);
                }
                sw.Stop();
                fList.Add(sw.Elapsed);

                // Test not-generic
                GCClear();
                bool res2;
                var sw2 = new Stopwatch();
                sw2.Start();
                for (int j = 0; j < n; j++)
                {
                    res2 = F2(a, a);
                }
                sw2.Stop();
                f2List.Add(sw2.Elapsed);
            }
            double f1AverageTicks = fList.Average(ts => ts.Ticks);
            Console.WriteLine("Elapsed for F = {0} \t ticks = {1}", fList.Average(ts => ts.TotalMilliseconds),
                              f1AverageTicks);
            double f2AverageTicks = f2List.Average(ts => ts.Ticks);
            Console.WriteLine("Elapsed for F2 = {0} \t ticks = {1}", f2List.Average(ts => ts.TotalMilliseconds),
                  f2AverageTicks);
            Console.WriteLine("Not-generic method is {0} times faster, or on {1}%", f1AverageTicks/f2AverageTicks,
                              (f1AverageTicks/f2AverageTicks - 1)*100);
            Console.ReadKey();
        }

        private static void GCClear()
        {
            GC.Collect();
            GC.WaitForPendingFinalizers();
            GC.Collect();
        }
    }
}

不附加发布优化版,使用 Windows 7、.NET 4.5 和 Visual Studio 2012。

x64

Elapsed for F = 23.68157         ticks = 236815.7
Elapsed for F2 = 1.701638        ticks = 17016.38
Not-generic method is 13.916925926666 times faster, or on 1291.6925926666%

x86

Elapsed for F = 6.713223         ticks = 67132.23
Elapsed for F2 = 6.729897        ticks = 67298.97
Not-generic method is 0.997522398931217 times faster, or on -0.247760106878314%

我有了新的魔法:x64比之前快三倍...

附注:我的目标平台是x64。


2
在没有调试器的发布版本中,我使用计时器获得了00:00:00.2080244和00:00:00.0071957。 - dtb
3
你检查过中间语言(IL)了吗?第二个例子可能会被编译器内联。 - Matthew
1
F2的指令码为:ldarg.0, ldarg.1, ceq, ret。F<T>的指令码为:ldarg.0, box !!T, ldarg.1, box !!T, ceq, ret。那么答案就是因为参数被装箱了。但是为什么会被装箱呢? - dtb
1
@Matthew,你无法在IL级别上看到内联。那是JIT的技巧。那是汇编语言。 - Steven
4
你正在查看 IL,这并不重要。你需要查看 JIT 生成的本地代码。 - Ben Voigt
显示剩余11条评论
8个回答

28

我对您的代码进行了一些更改,以正确测量性能。

  1. 使用Stopwatch
  2. 执行Release模式
  3. 防止内联。
  4. 使用GetHashCode()来做一些实际的工作
  5. 查看生成的Assembly代码

以下是代码:

class A
{
}

[MethodImpl(MethodImplOptions.NoInlining)]
static bool F<T>(T a, T b) where T : class
{
    return a.GetHashCode() == b.GetHashCode();
}

[MethodImpl(MethodImplOptions.NoInlining)]
static bool F2(A a, A b)
{
    return a.GetHashCode() == b.GetHashCode();
}

static int Main(string[] args)
{
    const int Runs = 100 * 1000 * 1000;
    var a = new A();
    bool lret = F<A>(a, a);
    var sw = Stopwatch.StartNew();
    for (int i = 0; i < Runs; i++)
    {
        F<A>(a, a);
    }
    sw.Stop();
    Console.WriteLine("Generic: {0:F2}s", sw.Elapsed.TotalSeconds);

    lret = F2(a, a);
    sw = Stopwatch.StartNew();
    for (int i = 0; i < Runs; i++)
    {
        F2(a, a);
    }
    sw.Stop();
    Console.WriteLine("Non Generic: {0:F2}s", sw.Elapsed.TotalSeconds);

    return lret ? 1 : 0;
}

在我的测试中,非泛型版本略微更快一些(.NET 4.5 x32 Windows 7)。 但速度上几乎没有可测量的差异。我会说它们两者是相等的。 为了完整起见,这里是泛型版本的汇编代码: 我通过启用JIT优化的Release模式下的调试器获取了汇编代码。默认情况下,在调试期间禁用JIT优化,以使断点设置和变量检查更加容易。

泛型

static bool F<T>(T a, T b) where T : class
{
        return a.GetHashCode() == b.GetHashCode();
}

push        ebp 
mov         ebp,esp 
push        ebx 
sub         esp,8 // reserve stack for two locals 
mov         dword ptr [ebp-8],ecx // store first arg on stack
mov         dword ptr [ebp-0Ch],edx // store second arg on stack
mov         ecx,dword ptr [ebp-8] // get first arg from stack --> stupid!
mov         eax,dword ptr [ecx]   // load MT pointer from a instance
mov         eax,dword ptr [eax+28h] // Locate method table start
call        dword ptr [eax+8] //GetHashCode // call GetHashCode function pointer which is the second method starting from the method table
mov         ebx,eax           // store result in ebx
mov         ecx,dword ptr [ebp-0Ch] // get second arg
mov         eax,dword ptr [ecx]     // call method as usual ...
mov         eax,dword ptr [eax+28h] 
call        dword ptr [eax+8] //GetHashCode
cmp         ebx,eax 
sete        al 
movzx       eax,al 
lea         esp,[ebp-4] 
pop         ebx 
pop         ebp 
ret         4 

非泛型

static bool F2(A a, A b)
{
  return a.GetHashCode() == b.GetHashCode();
}

push        ebp 
mov         ebp,esp 
push        esi 
push        ebx 
mov         esi,edx 
mov         eax,dword ptr [ecx] 
mov         eax,dword ptr [eax+28h] 
call        dword ptr [eax+8] //GetHashCode
mov         ebx,eax 
mov         ecx,esi 
mov         eax,dword ptr [ecx] 
mov         eax,dword ptr [eax+28h] 
call        dword ptr [eax+8] //GetHashCode
cmp         ebx,eax 
sete        al 
movzx       eax,al 
pop         ebx 
pop         esi 
pop         ebp 
ret 

正如您所看到的,由于存在更多的堆栈内存操作,通用版本看起来略微效率低下。但实际上,由于所有内容都适合处理器的L1缓存中,因此内存操作相对于非通用版本的纯寄存器操作来说成本更低。如果您需要支付非CPU缓存中获取的真实内存访问,则我认为非通用版本在现实世界中应该表现得更好。
就实际目的而言,这两种方法是相同的。您应该在其他地方寻找真实世界的性能提升。我首先会关注数据访问模式和使用的数据结构。算法更改往往比这样的低级别操作带来更多的性能增益。
编辑1:如果您想使用==,则会发现
00000000  push        ebp 
00000001  mov         ebp,esp 
00000003  cmp         ecx,edx // Check for reference equality 
00000005  sete        al 
00000008  movzx       eax,al 
0000000b  pop         ebp 
0000000c  ret         4 

这两种方法生成的机器码完全相同。任何你测量到的差异都是你的测量误差。


谢谢,但我对如何加速我的应用程序感兴趣,我不想使用GetHashCode,在这种情况下它很慢。 - Dmitry
1
请看我的编辑。两种方法生成的汇编代码是相同的。之前的帖子中猜测IL代码和其他影响因素的差异从未看过实际执行的代码。在汇编级别上没有区别!任何性能差异都只是测量误差。 - Alois Kraus
有趣的是,我有IL差异,在主帖中可以看到UPD。 - Dmitry

7
你的测试方法存在缺陷。你的做法有几个大问题。
首先,你没有提供“warm-up”。在 .NET 中,第一次访问某个东西时,它会比后续调用慢,因此它可以加载所需的程序集。如果你要执行这样的测试,你必须至少对每个函数执行一次,否则第一个运行的测试将受到巨大的惩罚。请交换顺序,你可能会看到相反的结果。
其次,DateTime只精确到16毫秒,因此当比较两个时间时,你有一个32毫秒的+/—误差。两个结果之间的差异为21毫秒,远远在实验误差范围内。你必须使用更准确的计时器,如Stopwatch类。
最后,不要进行这样的人为测试。它们除了为某个班级或另一个班级炫耀权利之外,不会向您显示任何有用的信息。相反,学习使用代码分析器。这将显示您的代码正在减速的原因,并且您可以做出明智的决策来解决问题,而不是“猜测”不使用模板类会使您的代码更快。以下是一个示例代码,展示了应该如何完成:
using System;
using System.Diagnostics;

namespace Sandbox_Console
{
    class A
    {
    }

    internal static class Program
    {
        static bool F<T>(T a, T b) where T : class
        {
            return a == b;
        }

        static bool F2(A a, A b)
        {
            return a == b;
        }

        private static void Main()
        {
            var a = new A();
            Stopwatch st = new Stopwatch();

            Console.WriteLine("warmup");
            st.Start();
            for (int i = 0; i < 100000000; i++)
                F<A>(a, a);
            Console.WriteLine(st.Elapsed);

            st.Restart();
            for (int i = 0; i < 100000000; i++)
                F2(a, a);
            Console.WriteLine(st.Elapsed);

            Console.WriteLine("real");
            st.Restart();
            for (int i = 0; i < 100000000; i++)
                F<A>(a, a);
            Console.WriteLine(st.Elapsed);

            st.Restart();
            for (int i = 0; i < 100000000; i++)
                F2(a, a);
            Console.WriteLine(st.Elapsed);

            Console.WriteLine("Done");
            Console.ReadLine();
        }

    }
}

这里是结果:

warmup
00:00:00.0297904
00:00:00.0298949
real
00:00:00.0296838
00:00:00.0297823
Done

交换最后两个的顺序始终更短,因此在实验误差范围内它们实际上是“同一时间”。


1
@dtb,我必须不同意你的结果,因为我刚刚发布了我的结果,我得到了0.1毫秒的差异,并且第一个始终获胜。你做错了什么,导致第一个运行超级慢。 - Scott Chamberlain
热身<br/> 00:00:00.2611852<br/> 00:00:00.0265555<br/> 真实的<br/> 00:00:00.2700937<br/> 00:00:00.0181213<br/> win7,vs 12,.net 4.5,x64 - Dmitry
你在使用什么作为 A?是类(像我的)还是像 int 一样的结构体?另外,你是在没有附加调试器的发布模式下运行吗?附加调试器会极大地影响结果,因为它禁用了许多内置的垃圾回收优化器(因为如果你暂停程序并想要在监视窗口中查看变量的值,当调试器附加时,不那么容易让变量超出范围)。 - Scott Chamberlain
@Scott Chamberlain,你的代码,你的A级,发布时不要附加。 - Dmitry
1
有趣...以x86重新运行您的代码,看看会发生什么;)。我不知道为什么构建为x64会使其中一个变得如此缓慢。 - Scott Chamberlain
显示剩余2条评论

6

不要担心时间,而要担心正确性。

这两种方法并不等价。其中一种使用class Aoperator==,另一种使用objectoperator==


F<T> 使用对象的 operator==,而 F2 使用类 A 的 operator==,对吗? - dtb
@dtb:是的,泛型必须使用System.Object的运算符进行调用。 - Ben Voigt
6
除非A类重载了operator==,否则两者都将使用对象的operator==,因此它们是等价的。 - dtb
@dtb 如果 A 或其任何超类型重载了 operator ==。如果 object 是继承链中最派生的类型,那么它将使用该类型的重载函数。 - Servy
1
@dtb:这个问题中没有告诉我class A是否有一个operator==...而且对于一些可能的泛型参数,结果可能是不同的。 - Ben Voigt
我认为这个问题并不是关于性能方面的担忧,而是对内部运行机制的好奇。两种测试引用相等性的方法表现如此不同,这真的很令人惊讶;在 A 覆盖 == 的情况下,在这里并不重要。我已经查看了生成的两种方法的 x64 汇编代码,但没有发现任何大的区别,但也许我做错了。 - dtb

3
两件事情:
1. 你正在使用`DateTime.Now`进行基准测试。使用`Stopwatch`代替。 2. 你正在运行非正常情况下的代码。JIT很可能会影响第一次运行,使你的第一个方法变慢。
如果你交换测试顺序(即首先测试非泛型方法),你的结果是否会颠倒?我会怀疑是这样的。当我将你的代码插入到LINQPad中,并复制它以使其运行两次,第二次迭代的执行时间相差不到几百个滴答声。
所以,回答你的问题:是的,有人知道原因。这是因为你的基准测试不准确!

顺序不会改变任何事情。问题在于通用方法中的装箱操作。 - nirmus
7
这里没有拳击。对于引用类型来说,box指令根本无效。JIT 将会移除它。 - Ben Voigt

3

我重写了你的测试代码:

var stopwatch = new Stopwatch();
var a = new A();

stopwatch.Reset();
stopwatch.Start();
for (int i = 0; i < 100000000; i++)
    F<A>(a, a);
stopwatch.Stop();
Console.WriteLine(stopwatch.ElapsedMilliseconds);

stopwatch.Reset();
stopwatch.Start();
for (int i = 0; i < 100000000; i++)
    F2(a, a);
stopwatch.Stop();
Console.WriteLine(stopwatch.ElapsedMilliseconds);

交换顺序不会改变任何内容。
泛型方法可使用 CIL
L_0000: nop
L_0001: ldarg.0
L_0002: box !!T
L_0007: ldarg.1
L_0008: box !!T
L_000d: ceq
L_000f: stloc.0
L_0010: br.s L_0012
L_0012: ldloc.0
L_0013: ret

而对于非通用的:

L_0000: nop
L_0001: ldarg.0
L_0002: ldarg.1
L_0003: ceq
L_0005: stloc.0
L_0006: br.s L_0008
L_0008: ldloc.0
L_0009: ret

所以拆箱操作是导致时间差异的原因。问题是为什么需要拆箱操作。请查看Stack Overflow问题C#中使用泛型时的拆箱问题

3
box”指令会如何影响JIT生成的代码(也就是处理器实际执行的代码)? - Ben Voigt
是的,我该如何停止在模板中进行装箱? - Dmitry
1
@Dmitry:C#没有模板。.NET泛型与C++模板完全不同(至少在这个问题上)。 - Ben Voigt

2
我在职业生涯中多次进行过性能分析,并得出了一些观察结果:
  • 首先,测试时间太短,无法有效。我的经验法则是:性能测试应该运行30分钟左右。
  • 其次,重要的是运行多次测试,以获得一系列计时。
  • 第三,我很惊讶编译器没有优化掉循环,因为函数结果未被使用,且被调用的函数没有副作用。
  • 第四,微基准测试通常具有误导性。
我曾经在编译器团队工作,团队设定了一个宏伟的性能目标。一次构建引入了一项优化,消除了特定序列的多个指令。这应该提高了性能,但实际上一个基准测试的性能急剧下降了。我们在直接映射缓存的硬件上运行。结果发现,在内部循环和被调用的函数的代码与新的优化放置在同一缓存线中(prior generated code没有达到这个效果)。换句话说,那个基准测试实际上是内存基准测试,并完全依赖于内存缓存命中和未命中,而作者认为他们编写了一个计算基准测试。

1

我认为这是我的问题的答案,但我可以使用什么方法来避免装箱?

如果你的目标只是比较,你可以这样做:

    public class A : IEquatable<A> {
        public bool Equals( A other ) { return this == other; }
    }
    static bool F<T>( IEquatable<T> a, IEquatable<T> b ) where T : IEquatable<T> {
        return a==b;
    }

这将避免出现盒子效应。

至于主要的时间偏差,我认为每个人都已经确定了你设置秒表的问题。我的技术不同,如果我想从时间结果中移除循环本身,我会使用一个空的基准线,并从时间差中减去它。它并不完美,但可以产生公正的结果,而且不会因为一遍又一遍地启动和停止计时器而变慢。


1

似乎更公平,不是吗?:D

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;

namespace ConsoleApplication58
{
    internal class Program
    {
        private class A
        {

        }

        private static bool F<T>(T a, T b) where T : class
        {
            return a == b;
        }

        private static bool F2(A a, A b)
        {
            return a == b;
        }

        private static void Main()
        {
            const int rounds = 100, n = 10000000;
            var a = new A();
            var fList = new List<TimeSpan>();
            var f2List = new List<TimeSpan>();
            for (int i = 0; i < rounds; i++)
            {
                //test generic
                GCClear();
                bool res;
                var sw = new Stopwatch();
                sw.Start();
                for (int j = 0; j < n; j++)
                {
                    res = F(a, a);
                }
                sw.Stop();
                fList.Add(sw.Elapsed);

                //test not-generic
                GCClear();
                bool res2;
                var sw2 = new Stopwatch();
                sw2.Start();
                for (int j = 0; j < n; j++)
                {
                    res2 = F2(a, a);
                }
                sw2.Stop();
                f2List.Add(sw2.Elapsed);
            }
            double f1AverageTicks = fList.Average(ts => ts.Ticks);
            Console.WriteLine("Elapsed for F = {0} \t ticks = {1}", fList.Average(ts => ts.TotalMilliseconds),
                              f1AverageTicks);
            double f2AverageTicks = f2List.Average(ts => ts.Ticks);
            Console.WriteLine("Elapsed for F2 = {0} \t ticks = {1}", f2List.Average(ts => ts.TotalMilliseconds),
                  f2AverageTicks);
            Console.WriteLine("Not-generic method is {0} times faster, or on {1}%", f1AverageTicks/f2AverageTicks,
                              (f1AverageTicks/f2AverageTicks - 1)*100);
            Console.ReadKey();
        }

        private static void GCClear()
        {
            GC.Collect();
            GC.WaitForPendingFinalizers();
            GC.Collect();
        }
    }
}

在我的笔记本电脑i7-3615qm上,通用比非通用速度更快。请参见http://ideone.com/Y1GIJK

你使用了什么编译选项?文件名表明它是一个没有优化的调试版本,这并不有趣。 - Ben Voigt
为了公平测试,我已经在ideone上发布了它,你可以自己测试。至于优化-我没有使用它,因为编译器无法编译一个循环中没有被任何地方使用的变量。而且,你也可以将这段代码复制粘贴到你的环境中,并按照你的心愿进行测试。 - Psilon
在我的笔记本电脑上,结果是:非通用方法快了13.9610133964644倍,或者说快了1296.10133964644%。 - Dmitry
这很奇怪...你没有改变N吗?增加N可以提高泛型的性能... - Psilon
没有改变N,但我使用了x64。在x86上的结果遗传/非遗传相同,但是大小增加了6倍。 - Dmitry
谢谢,我已将您的代码添加到帖子中,看起来似乎由于 x64/x86 平台的不同而有所不同。 - Dmitry

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