【问题标题】:Curiosity: Why does Expression<...> when compiled run faster than a minimal DynamicMethod?好奇心:为什么 Expression<...> 在编译时比最小的 DynamicMethod 运行得更快?
【发布时间】:2010-11-20 18:24:51
【问题描述】:

我目前正在做一些最后的优化,主要是为了好玩和学习,并发现了一些让我有几个问题的东西。

首先,问题:

  1. 当我通过使用DynamicMethod 构造内存中的方法并使用调试器时,在反汇编视图中查看代码时,有什么方法可以让我进入生成的汇编代码?调试器似乎只是为我跳过了整个方法
  2. 或者,如果这不可能,我是否可以以某种方式将生成的 IL 代码作为程序集保存到磁盘,以便我可以使用 Reflector 检查它?
  3. 为什么我的简单加法方法 (Int32+Int32 => Int32) 的 Expression&lt;...&gt; 版本比最小的 DynamicMethod 版本运行得更快?

这是一个简短而完整的演示程序。在我的系统上,输出是:

DynamicMethod: 887 ms
Lambda: 1878 ms
Method: 1969 ms
Expression: 681 ms

我预计 lambda 和方法调用具有更高的值,但 DynamicMethod 版本始终慢约 30-50%(可能因 Windows 和其他程序而异)。有人知道原因吗?

这是程序:

using System;
using System.Linq.Expressions;
using System.Reflection.Emit;
using System.Diagnostics;

namespace Sandbox
{
    public class Program
    {
        public static void Main(String[] args)
        {
            DynamicMethod method = new DynamicMethod("TestMethod",
                typeof(Int32), new Type[] { typeof(Int32), typeof(Int32) });
            var il = method.GetILGenerator();

            il.Emit(OpCodes.Ldarg_0);
            il.Emit(OpCodes.Ldarg_1);
            il.Emit(OpCodes.Add);
            il.Emit(OpCodes.Ret);

            Func<Int32, Int32, Int32> f1 =
                (Func<Int32, Int32, Int32>)method.CreateDelegate(
                    typeof(Func<Int32, Int32, Int32>));
            Func<Int32, Int32, Int32> f2 = (Int32 a, Int32 b) => a + b;
            Func<Int32, Int32, Int32> f3 = Sum;
            Expression<Func<Int32, Int32, Int32>> f4x = (a, b) => a + b;
            Func<Int32, Int32, Int32> f4 = f4x.Compile();
            for (Int32 pass = 1; pass <= 2; pass++)
            {
                // Pass 1 just runs all the code without writing out anything
                // to avoid JIT overhead influencing the results
                Time(f1, "DynamicMethod", pass);
                Time(f2, "Lambda", pass);
                Time(f3, "Method", pass);
                Time(f4, "Expression", pass);
            }
        }

        private static void Time(Func<Int32, Int32, Int32> fn,
            String name, Int32 pass)
        {
            Stopwatch sw = new Stopwatch();
            sw.Start();
            for (Int32 index = 0; index <= 100000000; index++)
            {
                Int32 result = fn(index, 1);
            }
            sw.Stop();
            if (pass == 2)
                Debug.WriteLine(name + ": " + sw.ElapsedMilliseconds + " ms");
        }

        private static Int32 Sum(Int32 a, Int32 b)
        {
            return a + b;
        }
    }
}

【问题讨论】:

  • 非常好的问题。首先,对于这种类型的分析,我会使用发布/控制台——所以Debug.WriteLine 看起来不合适;但即使使用Console.WriteLine,我的统计数据也相似:DynamicMethod:630 毫秒 Lambda:561 毫秒方法:553 毫秒表达式:360 毫秒我还在寻找...
  • 有趣的问题。这类事情可以使用 WinDebug 和 SOS 解决。我在我的博客blog.barrkel.com/2006/05/clr-tailcall-optimization-or-lack.html 中逐步发布了我很多个月前所做的类似分析
  • 我想我应该 ping 你 - 我发现了如何强制 JIT 而不必调用该方法一次。使用restrictedSkipVisibility DynamicMethod 构造函数参数。根据上下文(代码安全性),它可能不可用。
  • 好问题!关于#1,我不认为 DynamicMethods 是可调试的(与发出程序集相反)。但是,您可以哑巴并分析 DynamicMethod 的主体。我使用ILVisualizer - 够花哨和方便。问候,瓦迪姆

标签: c# profiling reflection.emit expression dynamicmethod


【解决方案1】:

DynamicMethod 创建的方法经过两次 thunk,而Expression&lt;&gt; 创建的方法不经过任何一次。

这是它的工作原理。下面是在Time 方法中调用fn(0, 1) 的调用顺序(为了便于调试,我将参数硬编码为0 和1):

00cc032c 6a01            push    1           // 1 argument
00cc032e 8bcf            mov     ecx,edi
00cc0330 33d2            xor     edx,edx     // 0 argument
00cc0332 8b410c          mov     eax,dword ptr [ecx+0Ch]
00cc0335 8b4904          mov     ecx,dword ptr [ecx+4]
00cc0338 ffd0            call    eax // 1 arg on stack, two in edx, ecx

对于我调查的第一个调用,DynamicMethodcall eax 行如下所示:

00cc0338 ffd0            call    eax {003c2084}
0:000> !u 003c2084
Unmanaged code
003c2084 51              push    ecx
003c2085 8bca            mov     ecx,edx
003c2087 8b542408        mov     edx,dword ptr [esp+8]
003c208b 8b442404        mov     eax,dword ptr [esp+4]
003c208f 89442408        mov     dword ptr [esp+8],eax
003c2093 58              pop     eax
003c2094 83c404          add     esp,4
003c2097 83c010          add     eax,10h
003c209a ff20            jmp     dword ptr [eax]

这似乎是在做一些堆栈调整来重新排列参数。我推测这是由于使用隐式“this”参数的委托与不使用的委托之间的差异。

最后的跳转是这样解决的:

003c209a ff20            jmp     dword ptr [eax]      ds:0023:012f7edc=0098c098
0098c098 e963403500      jmp     00ce0100

0098c098 处代码的其余部分看起来像一个 JIT thunk,它的开头在 JIT 之后被重写为 jmp。只有在这个跳转之后,我们才能得到真正的代码:

0:000> !u eip
Normal JIT generated code
DynamicClass.TestMethod(Int32, Int32)
Begin 00ce0100, size 5
>>> 00ce0100 03ca            add     ecx,edx
00ce0102 8bc1            mov     eax,ecx
00ce0104 c3              ret

通过Expression&lt;&gt; 创建的方法的调用顺序不同 - 它缺少堆栈调动代码。这是,从第一次跳转通过eax

00cc0338 ffd0            call    eax {00ce00a8}

0:000> !u eip
Normal JIT generated code
DynamicClass.lambda_method(System.Runtime.CompilerServices.ExecutionScope, Int32, Int32)
Begin 00ce00a8, size b
>>> 00ce00a8 8b442404        mov     eax,dword ptr [esp+4]
00ce00ac 03d0            add     edx,eax
00ce00ae 8bc2            mov     eax,edx
00ce00b0 c20400          ret     4

现在,事情是怎么变成这样的?

  1. 不需要堆栈调配(实际使用了委托的隐式第一个参数,即不像委托绑定到静态方法)
  2. JIT 一定是由 LINQ 编译逻辑强制执行的,因此委托持有真实的目标地址,而不是假的。

我不知道 LINQ 如何强制执行 JIT,但我知道如何自己强制执行 JIT - 至少调用一次函数。更新:我找到了另一种强制 JIT 的方法:使用 restrictedSkipVisibility 参数给构造函数并传递 true。所以,这里是修改后的代码,通过使用隐式的 'this' 参数来消除堆栈混乱,并使用备用构造函数进行预编译,以便绑定地址是真实地址,而不是 thunk:

using System;
using System.Linq.Expressions;
using System.Reflection.Emit;
using System.Diagnostics;

namespace Sandbox
{
    public class Program
    {
        public static void Main(String[] args)
        {
            DynamicMethod method = new DynamicMethod("TestMethod",
                typeof(Int32), new Type[] { typeof(object), typeof(Int32),
                typeof(Int32) }, true);
            var il = method.GetILGenerator();

            il.Emit(OpCodes.Ldarg_1);
            il.Emit(OpCodes.Ldarg_2);
            il.Emit(OpCodes.Add);
            il.Emit(OpCodes.Ret);

            Func<Int32, Int32, Int32> f1 =
                (Func<Int32, Int32, Int32>)method.CreateDelegate(
                    typeof(Func<Int32, Int32, Int32>), null);
            Func<Int32, Int32, Int32> f2 = (Int32 a, Int32 b) => a + b;
            Func<Int32, Int32, Int32> f3 = Sum;
            Expression<Func<Int32, Int32, Int32>> f4x = (a, b) => a + b;
            Func<Int32, Int32, Int32> f4 = f4x.Compile();
            for (Int32 pass = 1; pass <= 2; pass++)
            {
                // Pass 1 just runs all the code without writing out anything
                // to avoid JIT overhead influencing the results
                Time(f1, "DynamicMethod", pass);
                Time(f2, "Lambda", pass);
                Time(f3, "Method", pass);
                Time(f4, "Expression", pass);
            }
        }

        private static void Time(Func<Int32, Int32, Int32> fn,
            String name, Int32 pass)
        {
            Stopwatch sw = new Stopwatch();
            sw.Start();
            for (Int32 index = 0; index <= 100000000; index++)
            {
                Int32 result = fn(index, 1);
            }
            sw.Stop();
            if (pass == 2)
                Console.WriteLine(name + ": " + sw.ElapsedMilliseconds + " ms");
        }

        private static Int32 Sum(Int32 a, Int32 b)
        {
            return a + b;
        }
    }
}

这是我系统上的运行时:

DynamicMethod: 312 ms
Lambda: 417 ms
Method: 417 ms
Expression: 312 ms

更新添加

我尝试在我的新系统上运行此代码,该系统是运行 Windows 7 x64 并安装了 .NET 4 beta 2 的 Core i7 920(mscoree.dll 版本 4.0.30902),结果是可变的。

csc 3.5, /platform:x86, runtime v2.0.50727 (via .config)

Run #1
DynamicMethod: 214 ms
Lambda: 571 ms
Method: 570 ms
Expression: 249 ms

Run #2
DynamicMethod: 463 ms
Lambda: 392 ms
Method: 392 ms
Expression: 463 ms

Run #3
DynamicMethod: 463 ms
Lambda: 570 ms
Method: 570 ms
Expression: 463 ms

这可能是英特尔 SpeedStep 影响结果,或者可能是 Turbo Boost。无论如何,这很烦人。

csc 3.5, /platform:x64, runtime v2.0.50727 (via .config)
DynamicMethod: 428 ms
Lambda: 392 ms
Method: 392 ms
Expression: 428 ms

csc 3.5, /platform:x64, runtime v4
DynamicMethod: 428 ms
Lambda: 356 ms
Method: 356 ms
Expression: 428 ms

csc 4, /platform:x64, runtime v4
DynamicMethod: 428 ms
Lambda: 356 ms
Method: 356 ms
Expression: 428 ms

csc 4, /platform:x86, runtime v4
DynamicMethod: 463 ms
Lambda: 570 ms
Method: 570 ms
Expression: 463 ms

csc 3.5, /platform:x86, runtime v4
DynamicMethod: 214 ms
Lambda: 570 ms
Method: 571 ms
Expression: 249 ms

这些结果中的许多将是时间上的意外,无论是什么导致了 C# 3.5 / runtime v2.0 场景中的随机加速。我必须重新启动,看看 SpeedStep 或 Turbo Boost 是否对这些影响负责。

【讨论】:

  • 所以这意味着我需要添加一种方法来安全地调用我的方法,只是为了提高性能?我当然可以。
  • 我的意思是……我创建的方法实际上并不是将两个数字相加,而是负责在 IoC 实现中构建和解析服务。在这种情况下,我真的不想要完整的方法来执行和构建服务,只是为了获得微小的性能提升。鉴于某些服务会大量使用,而实际的服务又小又轻,我也在实际的解析代码中投入了一些精力。此外,对于反射.emit 来说,这是一个有趣的学习项目。非常感谢您在回答中所做的工作!
  • 好文章!使用“静态”调用 DynamicMethod 会产生这种额外的 thunking,这是一个“已知事实”。
  • 顺便说一句,我已经考虑过了,我相信 DynamicMethod 的额外 thunk 是这样的:thunk 的结果是减小激活记录的大小高于动态用 1 个字调用。 thunk 还会导致删除函数调用中的隐式 this 指针。您会注意到在 thunk 之后,没有 calls,只有 jmp 指令。因此,在动态方法中发生的任何堆栈遍历都会将当前激活记录视为静态方法,即使它是使用this 指针调用的。
  • 为什么 CLR 在 JIT 时总是将动态方法视为非静态方法,然后根据 CreateDelegate 调用创建的委托类型插入一个 thunk?我很确定这是用于内存管理的。存储委托对象本身的内存、动态方法和对象(及其委托类型)之间的绑定以及发出的 IL(我认为)都存储在垃圾收集堆上。这是托管内存。 JITed 代码存储在进程工作集中,但不受垃圾收集器管理。
猜你喜欢
  • 1970-01-01
  • 2012-11-06
  • 2013-11-21
  • 2012-07-13
  • 1970-01-01
  • 2018-07-08
  • 2019-01-31
  • 1970-01-01
  • 2011-02-24
相关资源
最近更新 更多