【问题标题】:Why unboxing is 100 time faster than boxing为什么拆箱比装箱快 100 倍
【发布时间】:2014-11-01 08:41:05
【问题描述】:

为什么装箱和拆箱操作之间的速度变化如此之大?相差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;
    }
}
}

【问题讨论】:

  • 你的代码是不是有错误?在 Unboxing() 中,您启动了第二个秒表,但不是使用该秒表的经过时间,而是首先从经过时间中减去装箱时间。为什么?
  • @DirkTrilsbeek 他正在计算差异
  • 现在是 10 还是 100?
  • @Shaharyar:但他将其作为“拆箱时间”写入控制台,这是错误的。我只是在想他可能会将已写入控制台的值用作拆箱时间,但事实并非如此。

标签: c# performance memory


【解决方案1】:

尽管人们已经对为什么拆箱比装箱更快提供了奇妙的解释。我想多说一点你用来测试性能差异的方法。

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

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

每当我进行微观性能基准测试时,我倾向于进一步验证我确实在比较我打算比较的操作。编译器可以对您的代码进行优化。在 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 方法除了迭代一个空循环之外什么都不做。因此,在拆箱中测量的时间成为装箱和拆箱的总时间。

微基准测试很容易受到编译器技巧的影响。我建议你也看看你的 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 倍”的结论

【讨论】:

    【解决方案2】:

    将拆箱视为从装箱对象到寄存器的单个内存加载指令。也许有一些周围的地址计算和强制转换验证逻辑。一个装箱的对象就像一个具有一个装箱类型字段的类。这些操作的成本有多高?不是很好,特别是因为您的基准测试中的 L1 缓存命中率约为 100%。

    装箱涉及分配一个新对象并稍后对其进行 GC。在您的代码中,GC 可能会在 99% 的情况下触发分配。

    这表示您的基准测试无效,因为循环没有副作用。当前的 JIT 无法优化它们可能是幸运的。不知何故让循环计算一个结果并将其汇集到GC.KeepAlive 以使结果显示为已使用。此外,您可能正在运行调试模式。

    【讨论】:

      【解决方案3】:

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

      【讨论】:

        【解决方案4】:

        考虑一下:对于装箱,您必须分配内存。对于拆箱,您一定不要。鉴于拆箱是一项微不足道的操作(尤其是在您的情况下,结果甚至没有发生任何事情。

        【讨论】:

          【解决方案5】:

          装箱和拆箱是计算成本高昂的过程。当一个值类型被装箱时,必须创建一个全新的对象。与简单的参考分配相比,这可能需要长达 20 倍的时间。拆箱时,铸造过程可能需要四倍于作业的时间。

          【讨论】:

            【解决方案6】:

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

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

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

            拆箱便宜得多:按照对装箱值的引用,并检索该值。

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

            拆箱只有间接开销。

            【讨论】:

              【解决方案7】:
              Why unboxing is 100 time faster than boxing
              

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

              【讨论】:

                【解决方案8】:

                使程序变慢的原因之一是您必须将某些内容移入和移出内存。如果没有必要(如果你想要速度的话),应该避免访问内存。

                如果我查看什么是拆箱和装箱,您会发现不同之处在于装箱在堆上分配内存,而拆箱将值类型变量移动到堆栈。访问堆栈比堆更快,因此在您的情况下拆箱更快。

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

                我从 SwankyLegg 那里得到了这个信息:What and where are the stack and heap?

                要了解拆箱和装箱对内存(堆栈和堆)的影响,您可以在此处查看:http://msdn.microsoft.com/en-us/library/yz2be5wk.aspx

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

                【讨论】:

                  猜你喜欢
                  • 1970-01-01
                  • 2011-10-11
                  • 1970-01-01
                  • 1970-01-01
                  • 2011-01-07
                  • 1970-01-01
                  • 1970-01-01
                  • 2015-02-23
                  相关资源
                  最近更新 更多