代码改变世界

数组排序方法的性能比较(5):对象大小与排序性能

2010-01-29 00:09 by Jeffrey Zhao, ... 阅读, ... 评论, 收藏, 编辑

在我公开测试结果之后,有朋友也进行了其他测试。在测试中我使用的是int数组,经过分析之后我们了解到Array.Sort<T>对于int数组有特殊的优化。于是,某些朋友使用了一些引用类型的数组进行排序,得到Array.Sort<T>方法的性能落后于LINQ排序——虽然由于测试方式的问题,这个结果和结论都不太妥当。不过在讨论的过程中,我们都意识到了一个问题:在其他条件不变的情况下,引用类型的字段越多,Array.Sort<T>方法所需时间就越久。这次我们就来讨论一下这个问题。

性能测试

为了体现字段数量和排序时间的相关性,我们首先来构造一个方法,它可以使用Emit生成包含一定数量的字段:

public abstract class TypeBase
{
    public int ID;
}

class Program
{
    public static ModuleBuilder moduleBuilder = null;

    static Program()
    {
        var assemblyName = new AssemblyName { Name = "SomeAssembly" };

        var domain = Thread.GetDomain();
        var asmBuilder = domain.DefineDynamicAssembly(
            assemblyName, AssemblyBuilderAccess.Run);

        moduleBuilder = asmBuilder.DefineDynamicModule("SomeModule");
    }

    static Type CreateType(int numberOfField)
    {
        var typeName = "TypeWith$" + numberOfField + "$Fields";
        var typeBuilder = moduleBuilder.DefineType(
            typeName, TypeAttributes.Class, typeof(TypeBase));

        for (int i = 0; i < numberOfField; i++)
        {
            typeBuilder.DefineField("Field$" + i, typeof(int), FieldAttributes.Public);
        }

        return typeBuilder.CreateType();
    }
}

方便起见,我让每种动态类型都继承统一的TypeBase类,这样我们排序的目标便可以定为TypeBase数组,而作为比较器的TypeBaseComparer也可以直接访问ID字段。然后便是测试用的方法:

static void Main(string[] args)
{
    CodeTimer.Initialize();

    var random = new Random(DateTime.Now.Millisecond);
    var array = Enumerable.Repeat(0, 1000 * 500).Select(_ => random.Next()).ToArray();

    for (var num = 1; num <= 512; num *= 2)
    {
        var type = CreateType(num);
        var arrayToSort = new TypeBase[array.Length];

        for (var i = 0; i < array.Length; i++)
        {
            var instance = (TypeBase)Activator.CreateInstance(type);
            instance.ID = array[i];
            arrayToSort[i] = instance;
        }

        CodeTimer.Time(
            String.Format("Type with {0} fields (Array.Sort)", num),
            10, () => Sort(CloneArray(arrayToSort)));

        CodeTimer.Time(
            String.Format("Type with {0} fields (ArraySorter)", num),
            10, () => ArraySorter(CloneArray(arrayToSort)));
    }

    Console.ReadLine();
}

static void Sort(TypeBase[] array)
{
    Array.Sort(array, new TypeBaseComparer());
}

static void ArraySorter(TypeBase[] array)
{
    new ArraySorter<TypeBase>().OrderBy(a => a.ID).Sort(array);
}

在比较时,我们将测试从1个字段的类型开始,每次将字段数量翻倍,直至512个字段——虽然夺得有些夸张,但对于试验来说,我还是希望差距能够明显一些。既然说Array.Sort<T>的性能受对象体积影响比较明显,而LINQ排序相对稳定,那么我们就来比较Array.Sort<T>以及与LINQ排序实现类似的ArraySorter的执行时间吧。请注意,除了两种排序方式之外,其他条件都完全相同:相同的数量,相同的类型,相同的初始顺序,以及相同的“比较依据”。

测试结果如下:

绘制成图表:

从图中可以看出明显的差别。当字段数量很少的时候,Array.Sort<T>的性能要比ArraySorter要高,这与我们之前的测试结果相符。但是在字段数量超过4个之后,ArraySorter的性能就开始领先了,且两者的差距随字段数量的增长会越来越大。因此,我们之前的观点是正确的。

这是为什么呢?在阅读以下内容时,您不如先自己思考一下?

原因分析

其实这还是一个和“局部性”有关的问题。局部性是影响性能非常主要的因素之一,我之前也不止一次谈过这个问题。那么在这里,局部性又是如何影响排序效率的呢?其实关键还是在于“比较器”上:

public class TypeBaseComparer : IComparer<TypeBase>
{
    public int Compare(TypeBase x, TypeBase y)
    {
        return x.ID - y.ID;
    }
}

TypeBaseComparer的实现非常简单,只是把两个TypeBase对象的ID值相减而已。显然,系统在执行这段程序的时候,会根据x或y的地址及ID字段的偏移量计算出一个地址,然后再去读取数据。不过我们知道,CPU在读取某个地址的的数据时,还会同时加载“一整条”的数据并缓存起来,这样再次读取附近的数据时会显得更快一些。那么试想一下,对于CPU来说,缓存及每个条目的大小是不变的,因此随着对象的体积增加,缓存中可以同时存在的对象数量便少了,这样虽然读取的次数不变,但是缓存的命中率就会随之下降。于是,对象体积增大,排序所消耗的时间也随之增加。

但是对于ArraySorter来说就完全不同了。根据ArraySorter的实现机制,它会首先根据keySelector得到排序字段——它在上例中就是个int值,IndexComparer然后在排序的时候将这个int数组保存起来,并且在排序时使用。因此,无论对象的体积是多少,在排序时ArraySorter永远只是在访问一个十分紧凑的int数组而已。排序结束后,ArraySorter也只是在操作一个个对象引用,它同样与对象的体积无关。由于排序其他条件不变,因此对象体积增大(造成局部性不佳)最终也只是导致keySelector工作的时候速度变慢,而其他部分统统不受影响。自然从某一时刻开始ArraySorter的性能会超过Array.Sort<T>,并且两者的差距会越来越大。

如果您对现在谈论的内容不很理解的话,可以了解一下与“局部性”有关的内容,并了解一下LINQ排序ArraySorter的实现方式

总结

可见,Array.Sort<T>与ArraySorter的性能高低并非是一句两句话可以说清楚的,在真实条件下选用哪种做法更有优势也不是一件容易确定的事情。不过,我们真需要把性能追求到这个地步吗?事实上,我相信在大部分实际的开发过程中,这点性能差距可以说是微乎其微。对于系统自带的Array.Sort<T>方法以及LINQ排序,其实它们并没有明显的替代关系。其实在某个特定的时候,您会发现其实两者间也只有一种符合条件——别多想,就用吧。

当然,一些明显的错误是需要避免的。例如,您在比较器中反复访问数据库的话,这就是您自己的问题了。

此外,我们这篇文章着重于测试和分析,但是如果可以直观地观察到“局部性”相关的数据岂不是更能说明问题吗?那么我们有什么办法通过实验来证明这一点呢?这问题其实也不大。例如,在使用Visual Studio 2008的Profiler时,我们可以让它同时监视L2 Cache的情况。这个实验的设计和执行就交由您亲自来吧。

经过了这几篇文章,您对于.NET框架中的排序类库,是否还有什么疑惑呢?

本文代码:http://gist.github.com/288765

相关文章

使用Live Messenger联系我