为什么在这个简单的测试中,方法的速度与触发的顺序有关?

时间:2012-04-12 15:12:07

标签: c# performance

我正在做其他实验,直到这种奇怪的行为引起了我的注意。

代码在x64版本中编译。

如果键入1,第3次运行List方法的成本比前2个多40%。输出

List costs 9312
List costs 9289
Array costs 12730
List costs 11950

如果键入2,第3次运行Array方法的成本比前2个多30%。输出

Array costs 8082
Array costs 8086
List costs 11937
Array costs 12698

你可以看到模式,完整的代码附在下面(只是编译和运行): {提供的代码是运行测试的最小代码。用于获得可靠结果的实际代码更复杂,我将方法包装好并在适当预热后测试了100次以上}

class ListArrayLoop
{
    readonly int[] myArray;
    readonly List<int> myList;
    readonly int totalSessions;

    public ListArrayLoop(int loopRange, int totalSessions)
    {
        myArray = new int[loopRange];
        for (int i = 0; i < myArray.Length; i++)
        {
            myArray[i] = i;
        }
        myList = myArray.ToList();
        this.totalSessions = totalSessions;
    }
    public  void ArraySum()
    {
        var pool = myArray;
        long sum = 0;
        for (int j = 0; j < totalSessions; j++)
        {
            sum += pool.Sum();
        }
    }
    public void ListSum()
    {
        var pool = myList;
        long sum = 0;
        for (int j = 0; j < totalSessions; j++)
        {
            sum += pool.Sum();
        }
    }

}
class Program
{
    static void Main(string[] args)
    {
        Stopwatch sw = new Stopwatch();
        ListArrayLoop test = new ListArrayLoop(10000, 100000);

        string input = Console.ReadLine();


        if (input == "1")
        {
            sw.Start();
            test.ListSum();
            sw.Stop();
            Console.WriteLine("List costs {0}",sw.ElapsedMilliseconds);
            sw.Reset();
            sw.Start();
            test.ListSum();
            sw.Stop();
            Console.WriteLine("List costs {0}", sw.ElapsedMilliseconds);
            sw.Reset();
            sw.Start();
            test.ArraySum();
            sw.Stop();
            Console.WriteLine("Array costs {0}", sw.ElapsedMilliseconds);
            sw.Reset();
            sw.Start();
            test.ListSum();
            sw.Stop();
            Console.WriteLine("List costs {0}", sw.ElapsedMilliseconds);
        }
        else
        {
            sw.Start();
            test.ArraySum();
            sw.Stop();
            Console.WriteLine("Array costs {0}", sw.ElapsedMilliseconds);
            sw.Reset();
            sw.Start();
            test.ArraySum();
            sw.Stop();
            Console.WriteLine("Array costs {0}", sw.ElapsedMilliseconds);
            sw.Reset();
            sw.Start();
            test.ListSum();
            sw.Stop();
            Console.WriteLine("List costs {0}", sw.ElapsedMilliseconds);
            sw.Reset();
            sw.Start();
            test.ArraySum();
            sw.Stop();
            Console.WriteLine("Array costs {0}", sw.ElapsedMilliseconds);
        }

        Console.ReadKey();
    }
}

7 个答案:

答案 0 :(得分:6)

受到争议的问题可以为你提供人为的答案。

优化应该在编写代码之后完成,而不是之前。以最容易理解和维护的方式编写解决方案。然后,如果程序对于您的用例来说不够快,那么您使用profiling tool并返回查看实际瓶颈的位置,而不是您“认为”的位置。

人们尝试在您的情况下进行的大多数优化是花费6个小时来做​​一些会将运行时间减少1秒的事情。大多数小型程序运行时间不足以抵消您尝试“优化”它所花费的成本。


据说这是一个奇怪的边缘案例。我修改了一下并通过分析器运行它,但我需要降级我的VS2010安装,以便我可以让.NET框架源退一步。


我使用更大的例子运行了探查器,我发现没有充分的理由为什么需要更长的时间。

答案 1 :(得分:2)

您的问题是您的考验。当您对代码进行基准测试时,您应该始终遵循几个指导原则:

  1. 处理器亲和力:仅使用一个处理器,通常不是#1。
  2. 热身:总是预先进行少量的测试。
  3. 持续时间:确保您的测试持续时间至少为500毫秒。
  4. 平均值:一起平均多次运行以消除异常。
  5. 清理:强制GC在测试之间收集分配的对象。
  6. 冷却时间:允许进程短时间睡眠。
  7. 因此,使用这些指南并重写测试我得到以下结果:

    运行1

    Enter test number (1|2): 1
    ListSum averages 776
    ListSum averages 753
    ArraySum averages 1102
    ListSum averages 753
    Press any key to continue . . .
    

    运行2

    Enter test number (1|2): 2
    ArraySum averages 1155
    ArraySum averages 1102
    ListSum averages 753
    ArraySum averages 1067
    Press any key to continue . . .
    

    所以这是最终使用的测试代码:

    static void Main(string[] args)
    {
        //We just need a single-thread for this test.
        Process.GetCurrentProcess().ProcessorAffinity = new IntPtr(2);
        System.Threading.Thread.BeginThreadAffinity();
    
        Console.Write("Enter test number (1|2): ");
        string input = Console.ReadLine();
    
        //perform the action just a few times to jit the code.
        ListArrayLoop warmup = new ListArrayLoop(10, 10);
        Console.WriteLine("Performing warmup...");
        Test(warmup.ListSum);
        Test(warmup.ArraySum);
        Console.WriteLine("Warmup complete...");
        Console.WriteLine();
    
        ListArrayLoop test = new ListArrayLoop(10000, 10000);
    
        if (input == "1")
        {
            Test(test.ListSum);
            Test(test.ListSum);
            Test(test.ArraySum);
            Test(test.ListSum);
        }
        else
        {
            Test(test.ArraySum);
            Test(test.ArraySum);
            Test(test.ListSum);
            Test(test.ArraySum);
        }
    }
    
    private static void Test(Action test)
    {
        long totalElapsed = 0;
        for (int counter = 10; counter > 0; counter--)
        {
            try
            {
                var sw = Stopwatch.StartNew();
                test();
                totalElapsed += sw.ElapsedMilliseconds;
            }
            finally { }
    
            GC.Collect(0, GCCollectionMode.Forced);
            GC.WaitForPendingFinalizers();
            //cooldown
            for (int i = 0; i < 100; i++)
                System.Threading.Thread.Sleep(0);
        }
        Console.WriteLine("{0} averages {1}", test.Method.Name, totalElapsed / 10);
    }
    

    注意:有些人可能会讨论冷却的有用性;然而,每个人都同意,即使它没有帮助,也没有害处。我发现在一些测试中它可以产生更可靠的结果;但是,在上面的例子中,我怀疑它有什么不同。

答案 2 :(得分:1)

评论的方式太多,所以它是CW - 随意合并,我会删除它。给出的代码对我来说有点不对,但问题仍然很有趣。如果混合调用,性能会变差。这段代码突出了它:

static void Main(string[] args)
{
    var input = Console.ReadLine();

        var test = new ListArrayLoop(10000, 1000);

        switch (input)
        {
            case "1":
                Test(test.ListSum);
                break;
            case "2":
                Test(test.ArraySum);
                break;
            case "3":
                // adds about 40 ms
                test.ArraySum();
                Test(test.ListSum);
                break;
            default:
                // adds about 35 ms
                test.ListSum();
                Test(test.ArraySum);
                break;
        }

}

private static void Test(Action toTest)
{
    for (int i = 0; i < 100; i++)
    {
        var sw = Stopwatch.StartNew();
        toTest();
        sw.Stop();
        Console.WriteLine("costs {0}", sw.ElapsedMilliseconds);
        sw.Reset();
    }
}

答案 3 :(得分:1)

Lists are implemented in .NET with arrays所以平均表现应该相同(因为你不会改变其长度)。

看起来你已经将sum()s'充分平均,这可能是一个GC问题,并且在sum()方法中使用了迭代器。

答案 4 :(得分:1)

简短回答 :这是因为CRL针对在interface-type上调用的调度方法进行了优化。只要特定接口的方法调用是在同一类型(实现此接口)上进行的,CLR就使用快速调度例程(只有3条指令),它只检查实际的实例类型,如果匹配,它会直接跳转到特定的预计算地址方法。但是当在另一个类型的实例上进行相同接口的方法调用时,CLR会切换到较慢的例程(可以为任何实际实例类型调度方法)。

答案很长 : 首先,看一下如何声明方法System.Linq.Enumerable.Sum()(我省略了source参数的有效性检查,因为在这种情况下它并不重要):

public static int Sum(this IEnumerable<int> source)
{
    int num = 0;
    foreach (int num2 in source)
        num += num2;
    return num;
}

因此,所有实现IEnumerable< int >的类型都可以调用此扩展方法,包括 int [] List&lt; int&gt; 。关键字foreach只是通过 IEnumerable&lt;获取枚举器的缩写。 T&gt; .GetEnumerator()并迭代所有值。所以这种方法实际上是这样做的:

    public static int Sum(this IEnumerable<int> source)
    {
        int num = 0;
        IEnumerator<int> Enumerator = source.GetEnumerator();
        while(Enumerator.MoveNext())
            num += Enumerator.Current;
        return num;
    }

现在您可以清楚地看到,该方法体对接口类型变量包含三个方法调用: GetEnumerator() MoveNext() Current < / em>(虽然 Current 实际上是属性,而不是方法,从属性读取值只是调用相应的getter方法)。

GetEnumerator()通常创建一些辅助类的新实例,它实现 IEnumerator&lt; T&gt; 因此能够一个接一个地返回所有值。重要的是要注意,在 int [] List&lt;的情况下int&gt; GetEnumerator()返回的枚举器类型这两个类是不同的。如果参数 source 的类型为 int [] ,那么 GetEnumerator()将返回类型 SZGenericArrayEnumerator&lt;的实例。 int&gt; 以及 source 的类型是 List&lt; int&gt; ,然后返回 List&lt;类型的实例int&gt; + Enumerator&lt; int&gt;

其他两种方法( MoveNext() Current )在紧密循环中重复调用,因此它们的速度对整体性能至关重要。对接口类型变量(例如 IEnumerator&lt; int&gt; )的不幸调用方法并不像普通的实例方法调用那样简单。 CLR必须动态找出变量中实际的对象类型然后找出,哪个对象的方法实现了相应的接口方法。

CLR尝试通过一些小技巧避免在每次调用上进行这段时间的查询。当第一次调用特定方法(例如 MoveNext())时,CLR会查找进行此调用的实际实例类型(例如 SZGenericArrayEnumerator&lt; int&gt; 如果你在 int [] 上调用了 Sum 并找到了方法的地址,那就实现了这种类型的相应方法(即方法的地址 SZGenericArrayEnumerator&lt; int &GT; .MoveNext()的)。然后它使用此信息生成辅助调度方法,该方法只是检查实际实例类型是否与第一次调用时相同(即 SZGenericArrayEnumerator&lt; int&gt; ),如果是,则为直接跳转到前面找到的方法地址。因此,在后续调用中,只要实例类型保持不变,就不会进行复杂的方法查找。但是当对不同类型的枚举器进行调用时(例如 List&lt; int&gt; + Enumerator&lt; int&gt; ,如果计算 List&lt; int&gt; 的总和) ,CLR不再使用这种快速调度方法。而是使用另一个(通用)和更慢的调度方法。

因此,只要在数组上调用 Sum(),CLR就会调度对 GetEnumerator() MoveNext()和 Current 使用快速方法。当列表上也调用 Sum()时,CLR会切换到较慢的调度方法,因此性能会下降。

如果您需要考虑性能,请为每个要调用 Sum()的类型实现自己单独的 Sum()扩展方法。这确保了CLR将使用快速调度方法。例如:

public static class FasterSumExtensions
{
    public static int Sum(this int[] source)
    {
        int num = 0;
        foreach (int num2 in source)
            num += num2;
        return num;
    }

    public static int Sum(this List<int> source)
    {
        int num = 0;
        foreach(int num2 in source)
            num += num2;
        return num;
    }
}

甚至更好,避免使用 IEnumerable&lt; T&gt; 接口(因为它仍然会带来明显的开销)。例如:

public static class EvenFasterSumExtensions
{
    public static int Sum(this int[] source)
    {
        int num = 0;
        for(int i = 0; i < source.Length; i++)
            num += source[i];
        return num;
    }

    public static int Sum(this List<int> source)
    {
        int num = 0;
        for(int i = 0; i < source.Count; i++)
            num += source[i];
        return num;
    }
}

以下是我的电脑的结果:

  • 您的原始计划:9844,9841,12545, 14384
  • FasterSumExtensions :6149,6445,754, 6145
  • EvenFasterSumExtensions :1557,1561,553, 1574

答案 5 :(得分:0)

嗯,真的很奇怪...... 我猜:你在var类型的变量池上调用.sum()。只要您只处理一种类型(列表或数组),对sum()的调用就是明确的,并且可以进行优化。通过使用新类var是不明确的并且必须解决,因此进一步调用将导致性能损失。 我没有编译器,所以尝试加载另一个支持sum()的类并比较时间。如果我是对的,我会再次期待性能上升,但这次不是那么多。

答案 6 :(得分:0)

在我看来,这是缓存(因为提前阅读)。 第一次访问数组时,它中的许多元素会立即进入缓存(预读)。这种预取机制期望程序可能会访问请求地址附近的内存。

进一步调用已经从中受益(假设数组适合缓存)。当您更改方法时,缓存无效,您需要从内存中再次获取所有内容。

所以调用:list,array,list,array,list,array 应该慢于:列表,列表,列表,数组,数组,数组

但从程序员的角度来看,这不是确定性的,因为您不知道缓存的状态或影响缓存决策的其他单位。