这个虚方法如何比密封方法调用更快地调用?

时间:2010-12-10 06:21:39

标签: .net compiler-construction virtual intermediate-language sealed

我正在修补虚拟与密封成员的表现。

以下是我的测试代码。

输出

virtual total 3166ms
per call virtual 3.166ns
sealed total 3931ms
per call sealed 3.931ns

我必须做错事,因为根据这个,虚拟呼叫比密封呼叫更快。

我正在发布模式下运行“优化代码”。

编辑:当在VS外部(作为控制台应用程序)运行时,时间接近于死热。但虚拟几乎总是出现在前面。

[TestFixture]
public class VirtTests
{

    public class ClassWithNonEmptyMethods
    {
        private double x;
        private double y;

        public virtual void VirtualMethod()
        {
            x++;
        }
        public void SealedMethod()
        {
            y++;
        }
    }

    const int iterations = 1000000000;


    [Test]
    public void NonEmptyMethodTest()
    {

        var foo = new ClassWithNonEmptyMethods();
        //Pre-call
        foo.VirtualMethod();
        foo.SealedMethod();

        var virtualWatch = new Stopwatch();
        virtualWatch.Start();
        for (var i = 0; i < iterations; i++)
        {
            foo.VirtualMethod();
        }
        virtualWatch.Stop();
        Console.WriteLine("virtual total {0}ms", virtualWatch.ElapsedMilliseconds);
        Console.WriteLine("per call virtual {0}ns", ((float)virtualWatch.ElapsedMilliseconds * 1000000) / iterations);


        var sealedWatch = new Stopwatch();
        sealedWatch.Start();
        for (var i = 0; i < iterations; i++)
        {
            foo.SealedMethod();
        }
        sealedWatch.Stop();
        Console.WriteLine("sealed total {0}ms", sealedWatch.ElapsedMilliseconds);
        Console.WriteLine("per call sealed {0}ns", ((float)sealedWatch.ElapsedMilliseconds * 1000000) / iterations);

    }

}

4 个答案:

答案 0 :(得分:4)

您正在测试内存对齐对代码效率的影响。 32位JIT编译器无法为大小超过32位,C#代码长和双的值类型生成高效代码。问题的根源是32位GC堆分配器,它只承诺在4的倍数的地址上对齐已分配的内存。这是一个问题,你正在增加双倍。双精度只有在地址为8的倍数时才有效。堆栈的问题,在局部变量的情况下,它也只在32位机器上与4对齐。

L1 CPU缓存在内部组织成称为“缓存行”的块。当程序读取错误对齐的双精度时会有一个惩罚。特别是跨越高速缓存行末尾的那个,必须读取两个高速缓存行的字节并将其粘合在一起。错误对齐在32位抖动中并不罕见,“x”字段恰好分配在8的倍数的地址上仅为50-50。如果不是那么'x'和'y'将会错位,其中一个可能会跨越缓存行。你编写测试的方式,将使VirtualMethod或SealedMethod更慢。确保让他们使用相同的字段来获得可比较的结果。

代码也是如此。交换虚拟和密封测试的代码以任意改变结果。我毫不费力地将密封测试的速度提高了很多。鉴于速度的适度差异,您可能正在考虑代码对齐问题。 x64抖动会努力插入NOP以使分支目标对齐,而x86抖动则不会。

您还应该在循环中运行定时测试多次,至少20次。您可能还会观察垃圾收集器移动类对象的效果。之后双倍可能会有不同的对齐方式,从而大大改变时间。访问64位值类型值(如long或double)具有3个不同的时序,在8上对齐,在高速缓存行中对齐4,并在4个跨两个高速缓存行对齐。在快速到慢速的顺序。

惩罚是陡峭的,读取一个跨越高速缓存行的双倍比读取对齐的高出大约三倍。也是double [](双精度数组)在大对象堆中分配的核心原因,即使它只有1000个元素,在80KB的正常阈值以南,LOH的对齐保证为8.这些对齐问题在x64抖动生成的代码中完全消失,堆栈和GC堆的对齐为8。

答案 1 :(得分:1)

首先,您必须标记方法sealed

其次,为虚拟方法提供override。创建派生类的实例。

作为第三个测试,创建一个sealed override方法。

现在你可以开始比较了。

编辑:你应该在VS外面运行它。

更新:

我的意思。

abstract class Foo
{
  virtual void Bar() {}
}

class Baz : Foo
{
  sealed override void Bar() {}
}

class Woz : Foo
{
  override void Bar() {}
}

现在测试BarBaz实例的Woz的通话速度。 我还怀疑程序集外的成员和类可见性会影响JIT分析。

答案 2 :(得分:1)

您可能会看到一些启动成本。尝试将Test-A / Test-B代码包装在一个循环中并运行几次。您可能也会看到某种排序效果。为了避免这种情况(以及循环效果的顶部/底部),请将其展开2-3次。

答案 3 :(得分:0)

使用以下代码作为测试参考,让我们分析编译器使用Ildasm.exe(IL反汇编程序)工具生成的 Microsoft中间语言(MSIL)信息。

public sealed class Sealed
{
    public string Message { get; set; }
    public void DoStuff() { }
}
public class Derived : Base
{
    public sealed override void DoStuff() { }
}
public class Base
{
    public string Message { get; set; }
    public virtual void DoStuff() { }
}
static void Main()
{
    Sealed sealedClass = new Sealed();
    sealedClass.DoStuff();
    Derived derivedClass = new Derived();
    derivedClass.DoStuff();
    Base BaseClass = new Base();
    BaseClass.DoStuff();
}

要运行此工具,请打开Visual Studio的“开发人员命令提示符”,然后执行命令 ildasm

**********************************************************************
** Visual Studio 2017 Developer Command Prompt v15.9.13
** Copyright (c) 2017 Microsoft Corporation
**********************************************************************


C:\Program Files (x86)\Microsoft Visual Studio\2017\Community>ildasm

应用程序启动后,加载上一个应用程序的可执行文件(或程序集)

此图片未提供替代文字 双击Main方法以查看Microsoft中间语言(MSIL)信息。

.method private hidebysig static void  Main() cil managed
{
  .entrypoint
  // Code size       41 (0x29)
  .maxstack  8
  IL_0000:  newobj     instance void ConsoleApp1.Program/Sealed::.ctor()
  IL_0005:  callvirt   instance void ConsoleApp1.Program/Sealed::DoStuff()
  IL_000a:  newobj     instance void ConsoleApp1.Program/Derived::.ctor()
  IL_000f:  callvirt   instance void ConsoleApp1.Program/Base::DoStuff()
  IL_0014:  newobj     instance void ConsoleApp1.Program/Base::.ctor()
  IL_0019:  callvirt   instance void ConsoleApp1.Program/Base::DoStuff()
  IL_0028:  ret
} // end of method Program::Main

您可以看到每个类都使用 newobj 通过将对象引用推入堆栈并使用 callvirt 来调用DoStuff的后期绑定来创建新实例( )各自对象的方法。

根据此信息判断,密封,派生和基类均由编译器以相同方式进行管理。可以肯定的是,让我们通过使用Visual Studio中的“反汇编”窗口分析 JIT编译的代码来做更深入的了解。

通过在工具>选项>调试>常规下选择启用地址级调试来启用反汇编。

此图片未提供替代文字 在应用程序的开头设置一个制动点,然后开始调试。一旦应用程序遇到故障,请选择 Debug> Windows> Disassembly 。打开Disassembly窗口。

--- C:\Users\Ivan Porta\source\repos\ConsoleApp1\Program.cs --------------------
        {
0066084A  in          al,dx  
0066084B  push        edi  
0066084C  push        esi  
0066084D  push        ebx  
0066084E  sub         esp,4Ch  
00660851  lea         edi,[ebp-58h]  
00660854  mov         ecx,13h  
00660859  xor         eax,eax  
0066085B  rep stos    dword ptr es:[edi]  
0066085D  cmp         dword ptr ds:[5842F0h],0  
00660864  je          0066086B  
00660866  call        744CFAD0  
0066086B  xor         edx,edx  
0066086D  mov         dword ptr [ebp-3Ch],edx  
00660870  xor         edx,edx  
00660872  mov         dword ptr [ebp-48h],edx  
00660875  xor         edx,edx  
00660877  mov         dword ptr [ebp-44h],edx  
0066087A  xor         edx,edx  
0066087C  mov         dword ptr [ebp-40h],edx  
0066087F  nop  
            Sealed sealedClass = new Sealed();
00660880  mov         ecx,584E1Ch  
00660885  call        005730F4  
0066088A  mov         dword ptr [ebp-4Ch],eax  
0066088D  mov         ecx,dword ptr [ebp-4Ch]  
00660890  call        00660468  
00660895  mov         eax,dword ptr [ebp-4Ch]  
00660898  mov         dword ptr [ebp-3Ch],eax  
            sealedClass.DoStuff();
0066089B  mov         ecx,dword ptr [ebp-3Ch]  
0066089E  cmp         dword ptr [ecx],ecx  
006608A0  call        00660460  
006608A5  nop  
            Derived derivedClass = new Derived();
006608A6  mov         ecx,584F3Ch  
006608AB  call        005730F4  
006608B0  mov         dword ptr [ebp-50h],eax  
006608B3  mov         ecx,dword ptr [ebp-50h]  
006608B6  call        006604A8  
006608BB  mov         eax,dword ptr [ebp-50h]  
006608BE  mov         dword ptr [ebp-40h],eax  
            derivedClass.DoStuff();
006608C1  mov         ecx,dword ptr [ebp-40h]  
006608C4  mov         eax,dword ptr [ecx]  
006608C6  mov         eax,dword ptr [eax+28h]  
006608C9  call        dword ptr [eax+10h]  
006608CC  nop  
            Base BaseClass = new Base();
006608CD  mov         ecx,584EC0h  
006608D2  call        005730F4  
006608D7  mov         dword ptr [ebp-54h],eax  
006608DA  mov         ecx,dword ptr [ebp-54h]  
006608DD  call        00660490  
006608E2  mov         eax,dword ptr [ebp-54h]  
006608E5  mov         dword ptr [ebp-44h],eax  
            BaseClass.DoStuff();
006608E8  mov         ecx,dword ptr [ebp-44h]  
006608EB  mov         eax,dword ptr [ecx]  
006608ED  mov         eax,dword ptr [eax+28h]  
006608F0  call        dword ptr [eax+10h]  
006608F3  nop  
        }
0066091A  nop  
0066091B  lea         esp,[ebp-0Ch]  
0066091E  pop         ebx  
0066091F  pop         esi  
00660920  pop         edi  
00660921  pop         ebp  

00660922  ret  

正如我们在前面的代码中看到的那样,虽然对象的创建是相同的,但是执行调用密封和派生/基类的方法的指令略有不同。将数据移入RAM的寄存器(移动指令)后,调用密封方法,在实际调用该方法之前,先对dword ptr [ecx]和ecx(cmp指令)进行比较。

根据Torbjorn Granlund撰写的报告,AMD和Intel x86处理器的指令等待时间和吞吐量,Intel Pentium 4中以下指令的速度为:

  • mov:具有1个周期的延迟,处理器可以承受2.5个周期 这种类型的每个周期的指令
  • cmp:有1个周期作为延迟,处理器可以承受2个周期 这种类型的每个周期的指令

总而言之,当今编译器和处理器的优化已使密封和未密封分类之间的性能变得如此之小,以至于与大多数应用程序无关。

参考