代码改变世界

从汇编入手,探究泛型的性能问题

2009-05-30 05:21 Jeffrey Zhao 阅读(...) 评论(...) 编辑 收藏

经过了《泛型真的会降低性能吗?》一文中的性能测试,已经从实际入手,从测试数据上证明了泛型不会降低程序效率。只是还是有几位朋友谈到,“普遍认为”泛型的代码性能会略差一些,也有朋友正在进一步寻找泛型性能略差的证据。老赵认为这种探究问题的方式非常值得提倡。不过,老赵忽然想到,如果从能从汇编入手,证明非泛型和泛型的代码之间没有性能差距——好吧,或者说,存在性能差距,那么事情不就到此为止了吗?任何理论说明,都抵不过观察计算机是如何处理这个问题来的“直接”。因此,老赵最终决定通过这种极端的方式来一探究竟,把这个问题彻底解决。

需要一提的是,老赵并不希望这篇文章会引起一些不必要的争论,因此一些话就先说在前面。老赵并不喜欢用这种方式来解决问题。事实上,如果可以通过数据比较,理论分析,或者高级代码来说明问题,我连IL都不愿意接触,更别说深入汇编。如果是平时的工作,就算使用WinDbg也最多是查看查看内存中有哪些数据,系统到底出了哪些问题。如果您要老赵表态的话,我会说:我强烈反对接触汇编。我们有太多太多的东西需要学习,如果您并没有明确您的目标,老赵建议您就放过IL和汇编这种东西吧。我们知道这些是什么就行了,不必对它们有什么“深入”的了解。

下面就要开始真正的探索之旅了。这不是一个顺利的旅程,其中有些步骤是连蒙带猜,最后加以验证才得到的结果。原本老赵打算按照自己的思路一步一步进行下去,但是发现这样太过冗余,反而会让大家的思路难以集中。因此老赵最后决定重新设计一个流程,和大家一起步步为营,朝着目标前进。此外,为了方便某些朋友按照这文章亲手进行操作,老赵也制作了一个dump文件,如果您是安装了.NET 3.5 SP1的32位x86系统,可以直接下载进行试验。试验过程中出现的地址也会和文章中完全一致。

废话就说到这里,我们开始吧。

测试代码

测试代码便是我们的目标。和上一篇文章一样,我们准备了一份最简单的代码进行测试,这样可以尽可能摆脱其他因素的影响,得到最正确的结果:

namespace TestConsole
{
    public class MyArrayList
    {
        public MyArrayList(int length)
        {
            this.m_items = new object[length];
        }

        private object[] m_items;

        public object this[int index]
        {
            [MethodImpl(MethodImplOptions.NoInlining)]
            get
            {
                return this.m_items[index];
            }
            [MethodImpl(MethodImplOptions.NoInlining)]
            set
            {
                this.m_items[index] = value;
            }
        }
    }

    public class MyList<T>
    {
        public MyList(int length)
        {
            this.m_items = new T[length];
        }

        private T[] m_items;

        public T this[int index]
        {
            [MethodImpl(MethodImplOptions.NoInlining)]
            get
            {
                return this.m_items[index];
            }
            [MethodImpl(MethodImplOptions.NoInlining)]
            set
            {
                this.m_items[index] = value;
            }
        }
    }

    class Program
    {
        static void Main(string[] args)
        {
            MyArrayList arrayList = new MyArrayList(1);
            arrayList[0] = arrayList[0] ?? new object();

            MyList<object> list = new MyList<object>(1);
            list[0] = list[0] ?? new object();

            Console.WriteLine("Here comes the testing code.");

            var a = arrayList[0];
            var b = list[0];

            Console.ReadLine();
        }
    }
}

我们在这里构建了两个“容器”,一个是MyArrayList,另一个是MyList<T>,前者直接使用Object类型,而后者则是一个泛型类。我们对两个类的索引属性的get和set方法都加上了NoInlining标记,这样便可以避免这种简单的方法被JIT内联。而在Main方法中,前几行代码的作用都是构造两个类的对象,并确保索引的get和set方法都已经得到JIT。在打印出“Here comes the testing code.”之后,我们便对两个类的实例进行“下标访问”,并使控制台暂停。

当Release编译并运行之后,控制台会打印出“Here comes the testing code.”字样并停止。这时候我们便可以使用WinDbg来Attach to Process进行调试。老赵也是在这个时候制作了一个dump文件,您也可以Open Crash Dump命令打开这个文件。更多操作您可以参考互联网上的各篇文章,亦或是老赵之前写过的一篇《使用WinDbg获得托管方法的汇编代码》。

分析MyArrayList对象结构

假设您现在已经打开了WinDbg,并Attach to Process(或Open Crash Dump),而且加载了正确的sos.dll(可参考老赵之前给出的文章)。那么第一件事情,我们就要来分析一个MyArrayList对象的结构。

首先,我们还是在项目中查找MyArrayList类型的MT(Method Table,方法表)地址:

0:000> !name2ee *!TestConsole.MyArrayList
Module: 5bf71000 (mscorlib.dll)
--------------------------------------
Module: 00362354 (sortkey.nlp)
--------------------------------------
Module: 00362010 (sorttbls.nlp)
--------------------------------------
Module: 00362698 (prcp.nlp)
--------------------------------------
Module: 003629dc (mscorlib.resources.dll)
--------------------------------------
Module: 00342ff8 (TestConsole.exe)
Token: 0x02000002
MethodTable: 00343440
EEClass: 0034141c
Name: TestConsole.MyArrayList

我们得到了MyArrayList类型的MT地址之后,便可以在系统中寻找MyArrayList对象了:

0:000> !dumpheap -mt 00343440
 Address       MT     Size
0205be3c 00343440       12
total 1 objects
Statistics:
      MT    Count    TotalSize Class Name
00343440        1           12 TestConsole.MyArrayList
Total 1 objects

不出所料,当前程序中只有一个MyArrayList对象。我们继续追踪它的地址:

0:000> !do 0205be3c
Name: TestConsole.MyArrayList
MethodTable: 00343440
EEClass: 0034141c
Size: 12(0xc) bytes
 (E:\Users\Jeffrey Zhao\...\bin\Release\TestConsole.exe)
Fields:
      MT    Field   Offset                 Type VT     Attr    Value Name
5c1b41d0  4000001        4      System.Object[]  0 instance 0205be48 m_items

OK,到这里为止,我们得到一个结论。如果我们获得了一个MyArrayList对象的地址,那么偏移4个字节,便可以得到m_items字段,也就是存放元素的Object数组的地址。这点很关键,否则可能对于理解后面的汇编代码形成障碍。

如果您使用同样的方法来观察MyList<object>类型的话,您会发现其结果也完全相同:从对象地址开始偏移4个字节便是m_items字段,类型为Object数组。

分析数组对象的结构

接着我们来观察一下,一个数组对象在内存中的存放方式是什么样的。首先,我们打印出托管堆上的各种类型:

0:000> !dumpheap -stat
total 6922 objects
Statistics:
      MT    Count    TotalSize Class Name
5c1e3ed4        1           12 System.Text.DecoderExceptionFallback
5c1e3e90        1           12 System.Text.EncoderExceptionFallback
5c1e1ea4        1           12 System.RuntimeTypeHandle
5c1dfb28        1           12 System.__Filters
5c1dfad8        1           12 System.Reflection.Missing
5c1df9e0        1           12 System.RuntimeType+TypeCacheQueue
...
5c1e3150       48         8640 System.Collections.Hashtable+bucket[]
5c1e2d28      347         9716 System.Collections.ArrayList+ArrayListEnumeratorSimple
5c1b5ca4       46        11024 System.Reflection.CustomAttributeNamedParameter[]
5c1cc590      404        11312 System.Security.SecurityElement
5c1e2a30      578        13872 System.Collections.ArrayList
5c1b50e4      335        14740 System.Int16[]
5c1b41d0     1735        87172 System.Object[]
5c1e0a00      718       167212 System.String
5c1e3470       70       174272 System.Byte[]
Total 6922 objects

既然我们的代码中使用了Object数组,那么我们就把目标放在托管堆上的Object数组中。从上面的信息中我们已经获得了Object数组的MT地址,于是我们继续列举出托管堆上的此类对象:

0:000> !dumpheap -mt 5c1b41d0
 Address       MT     Size
01fd141c 5c1b41d0       80     
01fd1c84 5c1b41d0       16     
01fd1cc0 5c1b41d0       32     
...
0205baa4 5c1b41d0       20     
0205bc4c 5c1b41d0       20     
0205bc60 5c1b41d0       32     
0205bdc4 5c1b41d0       16     
0205be48 5c1b41d0       20     
0205be74 5c1b41d0       20     
0205c058 5c1b41d0       36     
02fd1010 5c1b41d0     4096     
02fd2020 5c1b41d0      528     
02fd2240 5c1b41d0     4096     
total 1735 objects
Statistics:
      MT    Count    TotalSize Class Name
5c1b41d0     1735        87172 System.Object[]
Total 1735 objects

我们随意抽取一个Object数组对象,查看它的内容:

0:000> !do 02fd2020
Name: System.Object[]
MethodTable: 5c1b41d0
EEClass: 5bf9da54
Size: 528(0x210) bytes
Array: Rank 1, Number of elements 128, Type CLASS
Element Type: System.Object
Fields:
None

WinDbg清楚明白地告诉我们,这个数组是1维的,共有128个元素。那么这个数组的长度信息是如何保存下来的呢(这个信息肯定是对象自带的,这个很容易理解吧)?我们直接查看这个数组对象地址上的数据吧:

0:000> dd 02fd2020
02fd2020  5c1b41d0 00000080 5c1e061c 01fd1198
02fd2030  0205bdf0 00000000 00000000 00000000
02fd2040  00000000 00000000 00000000 00000000
02fd2050  00000000 00000000 00000000 00000000
02fd2060  00000000 00000000 00000000 00000000
02fd2070  00000000 00000000 00000000 00000000
02fd2080  00000000 00000000 00000000 00000000
02fd2090  00000000 00000000 00000000 00000000

十六进制数00000080不就是十进制的128吗?没错,老赵对多个数组对象进行分析之后,发现数组对象存放的结构是从对象的地址开始:

  • 偏移0字节:存放了这个数组对象的MT地址,例如上面的5c1b41d0便是Object[]类型的MT地址。
  • 偏移4字节:存放了数组长度。
  • 偏移8字节:存放了数组元素类型的MT地址,例如上面的5c1e061c便是Object类型的MT地址,您可以使用!dumpmt -md 5c1e061c指令进行观察。
  • 偏移12字节:从这里开始,便存放了数组的每个元素了。也就是说,如果这是一个引用类型的数组,那么偏移12字节则存放了第1个(下标为0)元素的地址,偏移16字节则存放第2个元素的地址,以此类推。

实际上,这些是老赵在自己的试验过程中,从接下去会讲解的汇编代码出发猜测出来的结果,经过验证发现恰好符合。为了避免您走这些弯路,老赵就先将这一结果告诉大家了。

分析Main函数的汇编代码

接下去便要观察Main函数的汇编代码了。获取汇编代码的方法很简单,如果您对此还不太了解,老赵的文章《使用WinDbg获得托管方法的汇编代码》会给您一定帮助。Main函数的汇编代码如下:

0:000> !u 01d40070
Normal JIT generated code
TestConsole.Program.Main(System.String[])
Begin 01d40070, size e2
>>> 01d40070  push    ebp
01d40071  mov     ebp,esp
01d40073  push    edi
01d40074  push    esi
01d40075  push    ebx
...
01d4011d  mov     ecx,eax
// 打印字样“Here comes the testing code.”
01d4011f  mov     edx,dword ptr ds:[2FD2030h] ("Here comes the testing code.")
01d40125  mov     eax,dword ptr [ecx]
01d40127  call    dword ptr [eax+0D8h]
// 将MyArrayList对象的地址保存在ecx寄存器中
01d4012d  mov     ecx,esi
// 将edx寄存器清零,作为访问下面get_Item方法的参数
01d4012f  xor     edx,edx
// 获取地址0x343424中的数据(它是get_Item方法的访问入口),并调用
01d40131  call    dword ptr ds:[343424h] (...MyArrayList.get_Item(Int32), ...)
// 将MyList<object>对象的地址保存在ecx寄存器中
01d40137  mov     ecx,edi
// 将edx寄存器清零,作为访问下面get_Item方法的参数
01d40139  xor     edx,edx
// 获取地址0x343594中的数据(它是get_Item方法的访问入口),并调用
01d4013b  call    dword ptr ds:[343594h] (...MyList`1[...].get_Item(Int32), ...)
// 调用Console.ReadLine方法,请注意静态方法不需要把对象地址放到ecx寄存器中
01d40141  call    mscorlib_ni+0x6d1af4 (5c641af4) (System.Console.get_In(), ...)
01d40146  mov     ecx,eax
01d40148  mov     eax,dword ptr [ecx]
01d4014a  call    dword ptr [eax+64h]
01d4014d  pop     ebx
01d4014e  pop     esi
01d4014f  pop     edi
01d40150  pop     ebp
01d40151  ret

老赵为上面这段汇编代码添加了注释,我们主要从打印出“Here comes the testing code.”字样的代码开始进行分析。值得注意的是,在调用MyArrayList或MyList<object>的get_Item方法之前,都会把这个对象的地址放置到ecx寄存器中,然后把edx寄存器清零作为get_Item方法的参数。这样做的好处是加快访问对象及参数的速度,如果每次都需要从线程栈上读取这些(就像我们学习汇编时的那些经典案例),其性能肯定比不上读取寄存器。显然,调用Console.ReadLine静态方法是不需要对象地址的,因此无须对ecx寄存器有所操作。

分析get_Item方法的汇编代码

从Main函数的汇编代码中我们可以获得get_Item方法的入口。那么我们现在就来分析MyArrayList类型的get_Item方法,请注意,此时ecx寄存器保存的是MyArrayList对象的地址,edx保存了get_Item方法的参数:

0:000> dd 343424h
00343424  01d40168 71060003 20000006 01d40190
00343434  fffffff8 00000004 00000001 00080000
00343444  0000000c 00040011 00000004 5c1e061c
00343454  00342ff8 00343478 0034141c 00000000
00343464  00000000 5c136aa0 5c136ac0 5c136b30
00343474  5c1a7410 00000080 00000000 003434c0
00343484  10000002 90000000 003434c0 00000000
00343494  0034c05c 00020520 00000004 00000004
0:000> !u 01d40168
Normal JIT generated code
TestConsole.MyArrayList.get_Item(Int32)
Begin 01d40168, size 17
>>> 01d40168 55              push    ebp
01d40169 8bec            mov     ebp,esp
// 把MyArrayList对象的m_items字段地址(对象地址偏移4字节)保存至eax寄存器中
01d4016b 8b4104          mov     eax,dword ptr [ecx+4]
// 比较传入的参数(edx寄存器)与数组长度(eax寄存器为数组地址,再偏移4字节)的大小
01d4016e 3b5004          cmp     edx,dword ptr [eax+4]
// 如果参数超过数组长度,则跳转至错误处理代码
01d40171 7306            jae     01d40179
// 把需要的元素地址放置到eax寄存器中
// 从数组地址开始偏移12字节为第一个元素的地址,再偏移“下标 * 4”自然就是我们所需要的元素
01d40173 8b44900c        mov     eax,dword ptr [eax+edx*4+0Ch]
01d40177 5d              pop     ebp
// 返回
01d40178 c3              ret
// 如果参数大于数组长度,就会跳转到此
01d40179 e806c2a15c      call    mscorwks!JIT_RngChkFail (5e75c384)
01d4017e cc              int     3

如果要理解上面的代码,可能需要您再去回味文章上半段的分析。尤其是几个偏移量:

  • MyArrayList对象偏移4字节则为m_items字段地址
  • 数组地址偏移4字节则为其长度
  • 数组地址偏移12字节为其第一个元素的地址

然后,再结合ecx(MyArrayList对象地址),edx(参数)以及eax(保存了方法返回值)几个寄存器的作用,相信理解上面这段代码也并非难事。

MyArrayList的代码分析完了,那么MyList<object>的汇编代码又是如何?

0:000> dd 343594h
00343594  01d401b8 01d401e0 00010001 003435a4
003435a4  5c1e0670 00000000 00000000 00000080
003435b4  00000000 fffffff8 00000004 00000001
003435c4  00080010 0000000c 00040011 00000004
003435d4  5c1e061c 00342ff8 00343610 0034355a
003435e4  00343600 00000000 5c136aa0 5c136ac0
003435f4  5c136b30 5c1a7410 00010001 00343604
00343604  5c1e061c 00000000 00000000 00000080
0:000> !u 01d401b8
Normal JIT generated code
TestConsole.MyList`1[[System.__Canon, mscorlib]].get_Item(Int32)
Begin 01d401b8, size 17
>>> 01d401b8 55              push    ebp
01d401b9 8bec            mov     ebp,esp
01d401bb 8b4104          mov     eax,dword ptr [ecx+4]
01d401be 3b5004          cmp     edx,dword ptr [eax+4]
01d401c1 7306            jae     01d401c9
01d401c3 8b44900c        mov     eax,dword ptr [eax+edx*4+0Ch]
01d401c7 5d              pop     ebp
01d401c8 c3              ret
01d401c9 e8b6c1a15c      call    mscorwks!JIT_RngChkFail (5e75c384)
01d401ce cc              int     3

是否发现,两者的代码除了几个地址之外可以说完全一样?

总结

还需要多说什么吗?我们通过比较汇编代码,已经证明了MyArrayList和MyList<Object>在执行时所经过的指令几乎完全相同。到了这个地步,您是否还认为泛型会影响程序性能?

最后继续强调一句:老赵并不喜欢IL,更不喜欢汇编。除非万不得已,老赵是不会往这方面去思考问题的。我们有太多东西可学,如果不是目标明确,老赵建议您还是不要投身于IL或汇编这类东西为好。

最后附上dump文件