C# 中的泛型与非泛型性能

本文关键字:泛型 性能 | 更新日期: 2023-09-27 18:19:57

我写了两个等效的方法:

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 快了三倍......

PS:我的目标平台是x64。

C# 中的泛型与非泛型性能

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

  1. 使用秒表
  2. 执行发布模式
  3. 防止内联。
  4. 使用 GetHashCode(( 做一些实际的工作
  5. 查看生成的程序集代码

这是代码:

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 优化的发布模式下通过调试器获取了程序集代码。默认设置是在调试期间禁用 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 

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

您的测试方法有缺陷。你是如何做到的有几个大问题。

首先,您没有提供"热身"。在 .NET 中,第一次访问某些内容时,它将比后续调用慢,因此它可以加载任何所需的程序集。如果要执行这样的测试,则必须至少执行一次每个功能,否则要运行的第一个测试将受到很大的惩罚。继续交换订单,您可能会看到相反的结果。

DateTime仅精确到 16 毫秒,因此在比较两次时,您的 +/- 误差为 32 毫秒。两个结果之间的差异为21 毫秒,完全在实验误差范围内。必须使用更精确的计时器,如秒表类。

最后,不要做这样的人工测试。除了吹嘘一个或另一个班级的权利之外,他们不会向您显示任何有用的信息。而是学习使用代码探查器。这将向您显示导致代码变慢的原因,您可以就如何解决问题做出明智的决定,而不是"猜测"不使用模板化类会使您的代码更快。

下面是一个示例代码,显示了它"应该"如何完成:

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

交换最后两个的顺序,第一个总是更短,所以实际上它们是"同一时间",因为它在实验误差内。

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

这些方法并不等效。 其中一个使用class Aoperator==,另一个使用objectoperator==

两件事:

  1. 您正在与DateTime.Now进行基准测试。请改用Stopwatch
  2. 您正在运行的代码不在正常情况下。JIT 最有可能影响首次运行,使第一个方法变慢。

如果您切换测试的顺序(即首先测试非通用方法(,您的结果会逆转吗?我会怀疑是这样。当我将您的代码插入 LINQPad 中,然后复制它以便它运行两次两个测试时,第二次迭代的执行时间相差不到几百个刻度。

所以,在回答你的问题时:是的,有人知道为什么。这是因为您的基准不准确!

我重写了你的测试代码:

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

所以拳击操作是你时差的原因。问题是为什么要添加装箱操作。检查一下,堆栈溢出问题在 C# 中使用泛型时的装箱

在我的职业生涯中,我曾多次以专业身份进行绩效分析,并有一些观察。

  • 首先,测试太短而无效。我的经验法则是性能测试应该运行 30 分钟左右。
  • 其次,重要的是要多次
  • 运行测试,以获得一系列时间。
  • 第三,我很惊讶编译器没有优化循环,因为没有使用函数结果并且被调用的函数没有副作用。
  • 第四,微观基准往往具有误导性。

我曾经在一个编译器团队工作,他们有一个大胆的性能目标。一个版本引入了一种优化,消除了特定序列的几条指令。它本应提高性能,但一个基准的性能却急剧下降。 我们在具有直接映射缓存的硬件上运行。事实证明,循环的代码和内部循环中调用的函数在新的优化到位时占据了相同的缓存行,但没有使用先前生成的代码。换句话说,该基准实际上是一个内存基准,完全依赖于内存缓存命中和未命中,而作者认为他们已经编写了一个计算基准。

似乎更公平,不是吗?: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。

我想这是我问题的答案,但我可以用什么魔法来否认拳击?

如果您的目标只是比较,则可以执行以下操作:

    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;
    }

这将避免拳击。

至于主要的时间偏差,我想每个人都已经确定你如何设置秒表存在问题。我使用了一种不同的技术,如果我想从时间结果中删除循环本身,我会选择一个空的基线,然后从时间差异中减去它。它并不完美,但它会产生公平的结果,并且不会一遍又一遍地启动和停止计时器。