为什么拆箱比拳击快100倍

为什么装箱和拆箱操作之间的速度变化如此之大? 有10倍的差异。 我们什么时候应该关心这个? 上周,Azure支持人员告诉我们,我们的应用程序的堆内存存在问题。 我很想知道它是否与装箱拆箱问题有关。

using System; using System.Diagnostics; namespace ConsoleBoxing { class Program { static void Main(string[] args) { Console.WriteLine("Program started"); var elapsed = Boxing(); Unboxing(elapsed); Console.WriteLine("Program ended"); Console.Read(); } private static void Unboxing(double boxingtime) { Stopwatch s = new Stopwatch(); s.Start(); for (int i = 0; i < 1000000; i++) { int a = 33;//DATA GOES TO STACK object b = a;//HEAP IS REFERENCED int c = (int)b;//unboxing only hEre ....HEAP GOES TO STACK } s.Stop(); var UnBoxing = s.Elapsed.TotalMilliseconds- boxingtime; Console.WriteLine("UnBoxing time : " + UnBoxing); } private static double Boxing() { Stopwatch s = new Stopwatch(); s.Start(); for (int i = 0; i < 1000000; i++) { int a = 33; object b = a; } s.Stop(); var elapsed = s.Elapsed.TotalMilliseconds; Console.WriteLine("Boxing time : " + elapsed); return elapsed; } } } 

将拆箱视为从盒装​​对象到寄存器的单个内存加载指令。 可能还有一些周围的地址计算和转换validation逻辑。 盒装对象就像一个带有一个盒装类型字段的类。 这些操作有多贵? 不是很特别,因为基准测试中的L1缓存命中率约为100%。

拳击涉及分配一个新对象和GC以后。 在您的代码中,GC可能会在99%的情况下触发分配。

这表示你的基准测试无效,因为循环没有副作用。 目前的JIT可能无法优化它们。 以某种方式让循环计算结果并将其GC.KeepAliveGC.KeepAlive以使结果显示为使用。 此外,您可能正在运行调试模式。

虽然人们已经提供了很好的解释,为什么拆箱比拳击更快。 我想更多地谈谈用于测试性能差异的方法。

您从您发布的代码中获得了结果(速度差异的10倍)吗? 如果我在发布模式下运行该程序,这是输出:

 Program started Boxing time : 0.2741 UnBoxing time : 4.5847 Program ended 

每当我进行微观性能基准测试时,我倾向于进一步validation我确实在比较我打算比较的操作。 编译器可以对您的代码进行优化。 在ILDASM中打开可执行文件:

这是拆箱的IL :(我只包括最重要的部分)

 IL_0000: newobj instance void [System]System.Diagnostics.Stopwatch::.ctor() IL_0005: stloc.0 IL_0006: ldloc.0 IL_0007: callvirt instance void [System]System.Diagnostics.Stopwatch::Start() IL_000c: ldc.i4.0 IL_000d: stloc.1 IL_000e: br.s IL_0025 IL_0010: ldc.i4.s 33 IL_0012: stloc.2 IL_0013: ldloc.2 IL_0014: box [mscorlib]System.Int32 //Here is the boxing IL_0019: stloc.3 IL_001a: ldloc.3 IL_001b: unbox.any [mscorlib]System.Int32 //Here is the unboxing IL_0020: pop IL_0021: ldloc.1 IL_0022: ldc.i4.1 IL_0023: add IL_0024: stloc.1 IL_0025: ldloc.1 IL_0026: ldc.i4 0xf4240 IL_002b: blt.s IL_0010 IL_002d: ldloc.0 IL_002e: callvirt instance void [System]System.Diagnostics.Stopwatch::Stop() 

这是拳击的代码:

 IL_0000: newobj instance void [System]System.Diagnostics.Stopwatch::.ctor() IL_0005: stloc.0 IL_0006: ldloc.0 IL_0007: callvirt instance void [System]System.Diagnostics.Stopwatch::Start() IL_000c: ldc.i4.0 IL_000d: stloc.1 IL_000e: br.s IL_0017 IL_0010: ldc.i4.s 33 IL_0012: stloc.2 IL_0013: ldloc.1 IL_0014: ldc.i4.1 IL_0015: add IL_0016: stloc.1 IL_0017: ldloc.1 IL_0018: ldc.i4 0xf4240 IL_001d: blt.s IL_0010 IL_001f: ldloc.0 IL_0020: callvirt instance void [System]System.Diagnostics.Stopwatch::Stop() 

在拳击方法中根本没有拳击指令 。 它已被编译器完全删除。 Boxing方法除了迭代空循环外什么都不做。 因此,在UnBoxing中测量的时间将成为装箱和拆箱的总时间。

微基准测试非常容易受到编译器技巧的影响。 我建议你也看看你的IL。 如果您使用不同的编译器,可能会有所不同。

我稍微修改了你的测试代码:

拳击方法:

 private static object Boxing() { Stopwatch s = new Stopwatch(); int unboxed = 33; object boxed = null; s.Start(); for (int i = 0; i < 1000000; i++) { boxed = unboxed; } s.Stop(); var elapsed = s.Elapsed.TotalMilliseconds; Console.WriteLine("Boxing time : " + elapsed); return boxed; } 

和拆箱方法:

 private static int Unboxing() { Stopwatch s = new Stopwatch(); object boxed = 33; int unboxed = 0; s.Start(); for (int i = 0; i < 1000000; i++) { unboxed = (int)boxed; } s.Stop(); var time = s.Elapsed.TotalMilliseconds; Console.WriteLine("UnBoxing time : " + time); return unboxed; } 

这样他们就可以翻译成类似的IL:

对于拳击方法:

 IL_000c: callvirt instance void [System]System.Diagnostics.Stopwatch::Start() IL_0011: ldc.i4.0 IL_0012: stloc.3 IL_0013: br.s IL_0020 IL_0015: ldloc.1 IL_0016: box [mscorlib]System.Int32 //Here is the boxing IL_001b: stloc.2 IL_001c: ldloc.3 IL_001d: ldc.i4.1 IL_001e: add IL_001f: stloc.3 IL_0020: ldloc.3 IL_0021: ldc.i4 0xf4240 IL_0026: blt.s IL_0015 IL_0028: ldloc.0 IL_0029: callvirt instance void [System]System.Diagnostics.Stopwatch::Stop() 

对于取消装箱:

 IL_0011: callvirt instance void [System]System.Diagnostics.Stopwatch::Start() IL_0016: ldc.i4.0 IL_0017: stloc.3 IL_0018: br.s IL_0025 IL_001a: ldloc.1 IL_001b: unbox.any [mscorlib]System.Int32 //Here is the UnBoxng IL_0020: stloc.2 IL_0021: ldloc.3 IL_0022: ldc.i4.1 IL_0023: add IL_0024: stloc.3 IL_0025: ldloc.3 IL_0026: ldc.i4 0xf4240 IL_002b: blt.s IL_001a IL_002d: ldloc.0 IL_002e: callvirt instance void [System]System.Diagnostics.Stopwatch::Stop() 

运行几个循环以删除冷启动效果:

 static void Main(string[] args) { Console.WriteLine("Program started"); for (int i = 0; i < 10; i++) { Boxing(); Unboxing(); } Console.WriteLine("Program ended"); Console.Read(); } 

这是输出:

 Program started Boxing time : 3.4814 UnBoxing time : 0.1712 Boxing time : 2.6294 ... Boxing time : 2.4842 UnBoxing time : 0.1712 Program ended 

这是否certificate拆箱比拳击快10倍 ? 让我们用windbg检查汇编代码:

 0:004> !u 000007fe93b83940 Normal JIT generated code MicroBenchmarks.Program.Boxing() ... 000007fe`93ca01b3 call System_ni+0x2905e0 (000007fe`f07a05e0) (System.Diagnostics.Stopwatch.GetTimestamp(), mdToken: 00000000060040d2) ... //This is the for loop 000007fe`93ca01c2 mov eax,21h 000007fe`93ca01c7 mov dword ptr [rsp+20h],eax 000007fe`93ca01cb lea rdx,[rsp+20h] 000007fe`93ca01d0 lea rcx,[mscorlib_ni+0x6e92b0 (000007fe`f18b92b0)] //here is the boxing 000007fe`93ca01d7 call clr!JIT_BoxFastMP_InlineGetThread (000007fe`f33126d0) 000007fe`93ca01dc mov rsi,rax //loop unrolling. instead of increment i by 1, we are actually incrementing i by 4 000007fe`93ca01df add edi,4 000007fe`93ca01e2 cmp edi,0F4240h // 0F4240h = 1000000 000007fe`93ca01e8 jl 000007fe`93ca01c2 // jumps to the line "mov eax,21h" //end of the for loop 000007fe`93ca01ea mov rcx,rbx 000007fe`93ca01ed call System_ni+0x2acb70 (000007fe`f07bcb70) (System.Diagnostics.Stopwatch.Stop(), mdToken: 00000000060040cb) 

UnBoxing程序集:

 0:004> !u 000007fe93b83930 Normal JIT generated code MicroBenchmarks.Program.Unboxing() Begin 000007fe93ca02c0, size 117 000007fe`93ca02c0 push rbx ... 000007fe`93ca030a call System_ni+0x2905e0 (000007fe`f07a05e0) (System.Diagnostics.Stopwatch.GetTimestamp(), mdToken: 00000000060040d2) 000007fe`93ca030f mov qword ptr [rbx+10h],rax 000007fe`93ca0313 mov byte ptr [rbx+18h],1 000007fe`93ca0317 xor eax,eax 000007fe`93ca0319 mov edi,dword ptr [rdi+8] 000007fe`93ca031c nop dword ptr [rax] //This is the for loop //again, loop unrolling 000007fe`93ca0320 add eax,4 000007fe`93ca0323 cmp eax,0F4240h // 0F4240h = 1000000 000007fe`93ca0328 jl 000007fe`93ca0320 //jumps to "add eax,4" //end of the for loop 000007fe`93ca032a mov rcx,rbx 000007fe`93ca032d call System_ni+0x2acb70 (000007fe`f07bcb70) (System.Diagnostics.Stopwatch.Stop(), mdToken: 00000000060040cb) 

您可以看到即使在IL级别上比较似乎是合理的,JIT仍然可以在运行时执行另一个优化。 UnBoxing方法再次进行空循环。 直到你validation为两种方法执行的代码是可比较的,很难简单地总结“拆箱比拳击快10倍”

因为装箱涉及对象,而拆箱涉及基元。 OOP语言中原语的全部目的是提高性能; 所以它成功了就不足为奇了。

Boxing在堆上创建一个新对象。 像数组初始化一样:

 int[] arr = {10, 20, 30}; 

boxing提供了方便的初始化语法,因此您不必显式使用new运算符。 但事实上正在进行实例化。

拆箱要便宜得多:遵循对盒装值的引用,并检索值。

Boxing具有在堆上创建引用类型对象的所有开销。

拆箱只有间接开销。

考虑一下:对于拳击你必须分配内存。 对于拆箱,你不能。 鉴于拆箱是一项微不足道的操作(特别是在你的情况下,即使没有任何事情发生在结果上)。

拳击和拆箱是计算上昂贵的过程。 装箱值类型时,必须创建一个全新的对象。 这可能比简单的参考分配长20倍。 拆箱时,铸造过程可能需要四倍的分配。

 Why unboxing is 100 time faster than boxing 

当您键入值类型时,必须创建一个新对象,并且必须将值复制到新对象中。 取消装箱时,只需从装箱实例中复制值。 所以拳击添加了一个对象的创建。 然而,这在.NET中确实很快,因此差异可能不是很大。 如果您需要最大速度,请尽量避免整个拳击程序。 请记住,装箱会创建需要由垃圾收集器清理的对象

使程序变慢的一个原因是当你必须移入和移出内存时。 如果没有必要(如果你想要速度),应该避免访问内存。

如果我查看拆箱和装箱你看到的区别在于装箱在堆上分配内存并且拆箱将值类型变量移动到堆栈。 访问堆栈比堆快,因此在您的情况下拆箱更快。

堆栈更快,因为访问模式使得从中分配和释放内存变得微不足道(指针/整数简单地递增或递减),而堆在分配或免费中涉及更复杂的簿记。 此外,堆栈中的每个字节都经常被频繁地重用,这意味着它往往被映射到处理器的缓存,使其非常快。 堆的另一个性能影响是堆(主要是全局资源)通常必须是multithreading安全的,即每个分配和释放需要 – 通常 – 与程序中的“所有”其他堆访问同步。

我从SwankyLegg这里得到了这些信息: 堆栈和堆的内容和位置是什么?

要查看拆箱和装箱对内存(堆栈和堆)的区别,可以在这里查看: http : //msdn.microsoft.com/en-us/library/yz2be5wk.aspx

为了简单起见,尽可能使用原始类型,如果可以的话,不要引用内存。 如果你真的想要速度,你应该考虑缓存,预取,阻止..