为什么开箱比装箱快100倍
本文关键字:100倍 为什么 | 更新日期: 2023-09-27 18:28:37
为什么装箱和拆箱操作之间的速度变化如此之大?相差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;
}
}
}
尽管人们已经为为什么开箱比装箱更快提供了极好的解释。我想对您用来测试性能差异的方法说更多的话。
你从你发布的代码中得到结果了吗(速度相差10倍)?如果我在发布模式下运行该程序,这里是输出:
Program started
Boxing time : 0.2741
UnBoxing time : 4.5847
Program ended
每当我进行微观性能基准测试时,我倾向于进一步验证我确实在比较我想要比较的操作。编译器可以对代码进行优化。在ILDASM中打开可执行文件:
以下是UnBoxing的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方法只执行一个空循环的迭代。因此,在取消装箱中测量的时间将成为装箱和取消装箱的总时间。
微基准测试非常容易受到编译器技巧的影响。我建议你也看看你的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
这是否证明开箱比装箱快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)
开箱程序集:
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方法再次执行空循环。在验证为这两种方法执行的代码是否可比较之前,很难简单地得出"开箱比装箱快10倍"的结论
将开箱视为从装箱对象到寄存器的单个内存加载指令。也许需要一些周围的地址计算和强制转换验证逻辑。带框的对象就像一个有一个带框类型字段的类。这些手术的费用有多高?不是很好,尤其是因为基准测试中的一级缓存命中率约为100%。
拳击包括分配一个新的对象,然后再进行GC’ing。在您的代码中,GC可能在99%的情况下触发分配。
也就是说,您的基准测试是无效的,因为循环没有副作用。幸运的是,当前的JIT无法对它们进行优化。不知何故,让循环计算一个结果,并将其输入到GC.KeepAlive
中,以使结果看起来已被使用。此外,您可能正在运行调试模式。
因为装箱涉及对象,而取消装箱涉及基元。OOP语言中原语的全部目的是提高性能;因此,它的成功似乎并不奇怪。
考虑一下:对于装箱,您必须分配内存。对于开箱,你不能。假设开箱是一个琐碎的操作(尤其是在结果什么都没有发生的情况下。
装箱和取消装箱是计算成本高昂的过程。当一个值类型被装箱时,必须创建一个全新的对象。这可能比简单的参考作业花费20倍的时间。取消装箱时,铸造过程可能需要一次分配的四倍时间。
Boxing在堆上创建一个新对象。类似阵列初始化:
int[] arr = {10, 20, 30};
boxing提供了一种方便的初始化语法,因此您不必显式地使用新的运算符。但事实上,正在进行实例化。
取消装箱要便宜得多:按照装箱值的引用,检索该值。
Boxing具有在堆上创建引用类型对象的所有开销。
取消装箱只会产生间接开销。
Why unboxing is 100 time faster than boxing
框选值类型时,必须创建一个新对象,并且必须将值复制到新对象中。取消装箱时,只需从装箱的实例中复制值。因此,拳击增加了一个对象的创建。然而,这在.NET中确实很快,所以差异可能不是很大。如果你需要最大速度,首先要尽量避免整个拳击过程。请记住,装箱会创建需要由垃圾收集器清理的对象
当您必须在内存中移动一些东西时,会使程序变慢。如果没有必要(如果你想要速度),应该避免访问内存。
如果我查看开箱和装箱的内容,您会发现不同之处在于装箱会在堆上分配内存,而开箱会将值类型变量移动到堆栈中。加速堆栈比堆更快,因此在您的情况下开箱更快。
堆栈更快,因为访问模式使得从中分配和释放内存变得微不足道(指针/整数只是递增或递减),而堆在分配或释放内存时有更复杂的记账。此外,堆栈中的每个字节往往会被频繁重用,这意味着它往往会被映射到处理器的缓存,从而使其非常快速。堆的另一个性能打击是,堆主要是一种全局资源,通常必须是多线程安全的,即每个分配和释放都需要与程序中的"所有"其他堆访问同步。
我从SwankyLegg那里得到了这些信息:堆栈和堆在哪里?
要了解取消装箱和装箱对内存(堆栈和堆)的影响,可以在此处查找:http://msdn.microsoft.com/en-us/library/yz2be5wk.aspx
为了保持简单,尽量使用基元类型,如果可以的话不要引用内存。如果你真的想要速度,你应该研究缓存、预取、阻塞。。