7

我正在研究代理,对于具有引用类型参数的泛型类,它非常慢。特别是对于泛型方法(大约 400 毫秒,而对于仅返回 null 的普通泛型方法则需要 3200 毫秒)。我决定尝试看看如果我用 C# 重写生成的类会如何执行,它的性能要好得多,与我的非泛型类代码的性能大致相同。

这是我写的 C# 类:: (注意我改变了命名方案但不是很多)::

namespace TestData
{
    public class TestClassProxy<pR> : TestClass<pR>
    {
        private InvocationHandler<Func<TestClass<pR>, object>> _0_Test;
        private InvocationHandler<Func<TestClass<pR>, pR, GenericToken, object>> _1_Test;
        private static readonly InvocationHandler[] _proxy_handlers = new InvocationHandler[] { 
            new InvocationHandler<Func<TestClass<pR>, object>>(new Func<TestClass<pR>, object>(TestClassProxy<pR>.s_0_Test)), 
        new GenericInvocationHandler<Func<TestClass<pR>, pR, GenericToken, object>>(typeof(TestClassProxy<pR>), "s_1_Test") };



        public TestClassProxy(InvocationHandler[] handlers)
        {
            if (handlers == null)
            {
                throw new ArgumentNullException("handlers");
            }
            if (handlers.Length != 2)
            {
                throw new ArgumentException("Handlers needs to be an array of 2 parameters.", "handlers");
            }
            this._0_Test = (InvocationHandler<Func<TestClass<pR>, object>>)(handlers[0] ?? _proxy_handlers[0]);
            this._1_Test = (InvocationHandler<Func<TestClass<pR>, pR, GenericToken, object>>)(handlers[1] ?? _proxy_handlers[1]);
        }


        private object __0__Test()
        {
            return base.Test();
        }

        private object __1__Test<T>(pR local1) where T:IConvertible
        {
            return base.Test<T>(local1);
        }

        public static object s_0_Test(TestClass<pR> class1)
        {
            return ((TestClassProxy<pR>)class1).__0__Test();
        }

        public static object s_1_Test<T>(TestClass<pR> class1, pR local1) where T:IConvertible
        {
            return ((TestClassProxy<pR>)class1).__1__Test<T>(local1);
        }

        public override object Test()
        {
            return this._0_Test.Target(this);
        }

        public override object Test<T>(pR local1)
        {
             return this._1_Test.Target(this, local1, GenericToken<T>.Token);
        }
    }
}

这是在发布模式下编译为与我生成的代理相同的 IL 这里是它的代理类::

namespace TestData
{
    public class TestClass<R>
    {
        public virtual object Test()
        {
            return default(object);
        }

        public virtual object Test<T>(R r) where T:IConvertible
        {
            return default(object);
        }
    }
}

有一个例外,我没有在生成的类型上设置 beforefieldinit 属性。我只是设置以下属性::public auto ansi

为什么使用 beforefieldinit 让性能提升如此之大?

(唯一的另一个区别是我没有命名我的参数,这在总体方案中并不重要。方法和字段的名称被打乱以避免与实际方法发生冲突。GenericToken 和 InvocationHandlers 是无关紧要的实现细节为了争论.
GenericToken 实际上只是用作类型化数据持有者,因为它允许我向处理程序发送“T”

InvocationHandler 只是委托字段目标的持有者,没有实际的实现细节。

GenericInvocationHandler 使用像 DLR 这样的调用站点技术来根据需要重写委托以处理传递的不同泛型参数)

编辑:: 这是测试工具::

private static void RunTests(int count = 1 << 24, bool displayResults = true)
{
    var tests = Array.FindAll(Tests, t => t != null);
    var maxLength = tests.Select(x => GetMethodName(x.Method).Length).Max();

    for (int j = 0; j < tests.Length; j++)
    {
        var action = tests[j];
        Stopwatch sw = Stopwatch.StartNew();
        for (int i = 0; i < count; i++)
        {
            action();
        }
        sw.Stop();
        if (displayResults)
        {
            Console.WriteLine("{2}  {0}: {1}ms", GetMethodName(action.Method).PadRight(maxLength),
                              ((int)sw.ElapsedMilliseconds).ToString(), j);
        }
        GC.Collect();
        GC.WaitForPendingFinalizers();
        GC.Collect();
    }
}

private static string GetMethodName(MethodInfo method)
{
    return method.IsGenericMethod
            ? string.Format(@"{0}<{1}>", method.Name, string.Join<Type>(",", method.GetGenericArguments()))
            : method.Name;
}

在测试中,我执行以下操作:

Tests[0] = () => proxiedTestClass.Test();
Tests[1] = () => proxiedTestClass.Test<string>("2");
Tests[2] = () => handClass.Test();
Tests[3] = () => handClass.Test<string>("2");
RunTests(100, false);
RunTests();

其中 Tests 是一个Func<object>[20],并且proxiedTestClass是我的程序集生成的类,并且handClass是我手动生成的类。RunTests 被调用了两次,一次是为了“热身”,另一次是运行它并打印到屏幕上。我主要从 Jon Skeet 的帖子中获取此代码。

4

2 回答 2

5

ECMA-335(CLI 规范)第一部分第 8.9.5 节所述:

何时以及什么触发了此类初始化方法的执行的语义如下:

  1. 一个类型可以有一个类型初始化方法,也可以没有。
  2. 一个类型可以被指定为它的类型初始化方法有一个宽松的语义(为了下面的方便,我们称之为宽松的语义BeforeFieldInit)。
  3. 如果标记为BeforeFieldInit,则该类型的初始化方法在第一次访问为该类型定义的任何静态字段时或之前的某个时间执行。
  4. 如果未标记BeforeFieldInit则该类型的初始化方法在以下位置执行(即,由以下方式触发):

    一种。首次访问该类型的任何静态字段,或

    湾。首次调用该类型的任何静态方法,或

    C。如果它是值类型,则首次调用该类型的任何实例或虚拟方法,或者

    d。首次调用该类型的任何构造函数。

此外,正如您从上面迈克尔的代码中看到的那样,TestClassProxy只有一个静态字段:_proxy_handlers. 请注意,它只使用了两次:

  1. 在实例构造函数中
  2. 而在静态字段初始化器本身

因此,当BeforeFieldInit指定时,类型初始化器将只被调用一次:在实例构造函数中,就在第一次访问_proxy_handlers.

但如果BeforeFieldInit省略,CLR 将在每次 TestClassProxy's静态方法调用、静态字段访问等之前调用类型初始化器。

s_0_Test特别是,每次调用s_1_Test<T>静态方法时都会调用类型初始化器。

当然,正如ECMA-334 (C# Language Specification)第 17.11 节所述:

非泛型类的静态构造函数在给定的应用程序域中最多执行一次。对于从类声明(第 25.1.5 节)构造的每个封闭构造类型,泛型类声明的静态构造函数最多执行一次。

但是为了保证这一点,CLR 必须检查(以线程安全的方式)该类是否已经初始化。

这些检查会降低性能。

PS:您可能会感到惊讶,一旦您更改s_0_Tests_1_Test<T>成为实例方法,性能问题就会消失。

于 2013-01-29T15:55:41.007 回答
4

首先,如果您想了解更多关于. 的信息beforefieldinit,请阅读 Jon Skeet 的文章C# 和beforefieldinit. 该答案的部分内容基于此,我将在此处重复相关部分。

其次,你的代码做的很少,所以开销会对你的测量产生重大影响。在实际代码中,影响可能要小得多。

第三,你不需要使用 Reflection.Emit 来设置一个类是否有beforefieldint. 您可以通过添加静态构造函数(例如static TestClassProxy() {})在 C# 中禁用该标志。

现在,beforefieldinit它控制何时调用类型初始化程序(称为方法.cctor)。在 C# 术语中,类型初始化程序包含所有静态字段初始化程序和来自静态构造函数的代码(如果有的话)。

如果您不设置该标志,则在创建类的实例或引用类的任何静态成员时将调用类型初始化程序。(取自 C# 规范,这里使用 CLI 规范会更准确,但最终结果是一样的。*

这意味着如果没有beforefieldinit,编译器在何时调用类型初始化程序方面非常受限,它不能决定更早地调用它,即使这样做会更方便(并导致更快的代码)。

知道了这一点,我们就可以查看您的代码中实际发生的情况。有问题的情况是静态方法,因为这是可能调用类型初始化程序的地方。(实例构造函数是另一个,但你没有测量它。)

我专注于方法s_1_Test()。而且因为我实际上不需要它来做任何事情,所以我将它简化(以使生成的本机代码更短)为:

public static object s_1_Test<T>(TestClass<pR> class1, pR local1) where T:IConvertible
{
    return null;
}

现在,让我们看看VS中的反汇编(在Release模式下),首先没有静态构造函数,即beforefieldinit

00000000  xor         eax,eax
00000002  ret

在这里,结果设置为(出于性能原因0,它以某种模糊的方式完成)并且方法返回,非常简单。

静态构造函数(即没有beforefieldinit)会发生什么?

00000000  sub         rsp,28h
00000004  mov         rdx,rcx
00000007  xor         ecx,ecx
00000009  call        000000005F8213A0
0000000e  xor         eax,eax
00000010  add         rsp,28h
00000014  ret

这要复杂得多,真正的问题是call指令,它可能会调用一个在必要时调用类型初始化程序的函数。

我相信这是两种情况之间性能差异的根源。

之所以需要添加检查,是因为您的类型是通用的,并且您将它与引用类型一起用作类型参数。在这种情况下,您的类的不同泛型版本的 JIT 代码是共享的,但必须为每个泛型版本调用类型初始化程序。将静态方法移至另一种非泛型类型将是解决问题的一种方法。


*除非你做一些疯狂的事情,比如在nullusing上调用实例方法call(而不是callvirt,它会抛出 for null)。

于 2013-01-29T16:03:57.333 回答