11

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

代码在 x64 版本中编译。

如果键入 1,则 List 方法的第三次运行比第一次 2 花费 40% 的时间。输出是

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

如果键入 2,则 Array 方法的第 3 次运行比第 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();
    }
}
4

7 回答 7

6

人为的问题会给你人为的答案。

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

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


话虽如此,这是一个奇怪的边缘案例。我对其进行了一些修改,并通过探查器运行它,但我需要降级我的 VS2010 安装,这样我才能让 .NET 框架源退后一步。


我使用较大的示例运行了探查器,但找不到需要更长时间的充分理由。

于 2012-04-12T15:22:35.400 回答
2

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

  1. 处理器亲和性:仅使用单个处理器,通常不是#1。
  2. 热身:总是预先进行少量测试。
  3. 持续时间:确保您的测试持续时间至少为 500 毫秒。
  4. 平均:对多次运行进行平均以消除异常。
  5. 清理:强制 GC 在测试之间收集分配的对象。
  6. 冷却:让进程休眠一小段时间。

因此,使用这些指南并重写您的测试,我得到以下结果:

运行 1

输入测试编号 (1|2): 1
ListSum 平均为 776
ListSum 平均 753
ArraySum 平均 1102
ListSum 平均 753
按任意键继续 。. .

运行 2

输入测试编号 (1|2):2
ArraySum 平均 1155
ArraySum 平均 1102
ListSum 平均 753
ArraySum 平均 1067
按任意键继续 。. .

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

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);
}

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

于 2012-04-19T17:09:04.343 回答
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();
    }
}
于 2012-04-12T15:41:32.977 回答
1

列表在 .NET 中使用数组实现,因此平均性能应该相同(因为您不更改任何一个的长度)。

看起来您已经充分平均了 sum()s 的时间,这可能是 sum() 方法中使用的迭代器的 GC 问题。

于 2012-04-25T15:37:29.407 回答
1

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

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

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

所以所有实现IEnumerable<int>的类型都可以调用这个扩展方法,包括int[]List<int>。关键字foreach只是通过IEnumerable< T >.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(虽然Current实际上是属性,而不是方法,从属性中读取值只是调用相应的 getter 方法)。

GetEnumerator()通常会创建某个辅助类的新实例,该类实现IEnumerator< T >,因此能够一一返回所有值。需要注意的是,在int[]List< int >的情况下, GetEnumerator() ot 这两个类返回的枚举数类型是不同的。如果参数sourceint[]类型,则GetEnumerator()返回SZGenericArrayEnumerator< int >类型的实例,如果sourceList< int >类型,则返回List< int >+Enumerator< int >类型的实例。

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

CLR 试图通过一个小技巧来避免每次调用都进行这种耗时的查找。当第一次调用特定方法(例如MoveNext())时,CLR 会找到进行此调用的实例的实际类型(例如SZGenericArrayEnumerator< int >,如果您在int[]上调用Sum)并找到方法,实现该类型对应的方法(即方法SZGenericArrayEnumerator< int >.MoveNext()的地址)。然后它使用此信息生成辅助调度方法,该方法只是检查实际实例类型是否与第一次调用时相同(即SZGenericArrayEnumerator< int >) 如果是,则直接跳转到之前找到的方法地址。因此,在后续调用中,只要实例类型保持不变,就不会进行复杂的方法查找。但是当调用不同类型的枚举器时(如List< int >+Enumerator< int >计算List< int >的总和),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< T >接口(因为它仍然会带来明显的开销)。例如:

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
  • FasterSumExtensions6149、6445、754、6145
  • EvenFasterSumExtensions1557、1561、553、1574
于 2012-04-26T23:09:04.973 回答
0

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

于 2012-04-24T15:30:49.783 回答
0

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

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

所以调用:list,array,list,array,list,array应该比:list,list,list,array,array,array慢

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

于 2012-04-25T12:32:34.363 回答