我正在做其他实验,直到这种奇怪的行为引起了我的注意。
代码在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();
}
}
答案 0 :(得分:6)
受到争议的问题可以为你提供人为的答案。
优化应该在编写代码之后完成,而不是之前。以最容易理解和维护的方式编写解决方案。然后,如果程序对于您的用例来说不够快,那么您使用profiling tool并返回查看实际瓶颈的位置,而不是您“认为”的位置。
人们尝试在您的情况下进行的大多数优化是花费6个小时来做一些会将运行时间减少1秒的事情。大多数小型程序运行时间不足以抵消您尝试“优化”它所花费的成本。
据说这是一个奇怪的边缘案例。我修改了一下并通过分析器运行它,但我需要降级我的VS2010安装,以便我可以让.NET框架源退一步。
我使用更大的例子运行了探查器,我发现没有充分的理由为什么需要更长的时间。
答案 1 :(得分:2)
您的问题是您的考验。当您对代码进行基准测试时,您应该始终遵循几个指导原则:
因此,使用这些指南并重写测试我得到以下结果:
运行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;
}
}
以下是我的电脑的结果:
答案 5 :(得分:0)
答案 6 :(得分:0)
在我看来,这是缓存(因为提前阅读)。 第一次访问数组时,它中的许多元素会立即进入缓存(预读)。这种预取机制期望程序可能会访问请求地址附近的内存。
进一步调用已经从中受益(假设数组适合缓存)。当您更改方法时,缓存无效,您需要从内存中再次获取所有内容。
所以调用:list,array,list,array,list,array 应该慢于:列表,列表,列表,数组,数组,数组
但从程序员的角度来看,这不是确定性的,因为您不知道缓存的状态或影响缓存决策的其他单位。