【问题标题】:Why are some iterators faster than others in C#?为什么某些迭代器在 C# 中比其他迭代器快?
【发布时间】:2014-02-22 03:51:59
【问题描述】:

一些迭代器更快。我之所以发现这一点,是因为我在 Channel 9 上收到 Bob Tabor 的来信,告诉我不要复制和粘贴。

我习惯做这样的事情来设置数组值:

testArray[0] = 0;
testArray[1] = 1;

这是一个简化的示例,但为了不复制和粘贴,或者不再次输入内容,我想我应该使用循环。但是我有一种烦人的感觉,即循环比简单地列出命令要慢,而且看起来我是对的:列出东西要快得多。在我的大多数试验中,速度从最快到最慢是列表、do 循环、for 循环,然后是 while 循环。

为什么列出东西比使用迭代器更快,为什么迭代器的速度不同?

如果我没有以最有效的方式使用这些迭代器,请帮助我。

这是我的结果(对于 2 int 数组),下面是我的代码(对于 4 int 数组)。我在 Windows 7 64 位上尝试了几次。

要么我不擅长迭代,要么使用迭代器没有想象中的那么好。请让我知道它是什么。非常感谢。

int trials = 0;

TimeSpan listTimer = new TimeSpan(0, 0, 0, 0);
TimeSpan forTimer = new TimeSpan(0, 0, 0, 0);
TimeSpan doTimer = new TimeSpan(0, 0, 0, 0);
TimeSpan whileTimer = new TimeSpan(0, 0, 0, 0);
Stopwatch stopWatch = new Stopwatch();
long numberOfIterations = 100000000;

int numElements = 4;
int[] testArray = new int[numElements];
testArray[0] = 0;
testArray[1] = 1;
testArray[2] = 2;
testArray[3] = 3;

// List them
stopWatch.Start();
for (int x = 0; x < numberOfIterations; x++)
{
    testArray[0] = 0;
    testArray[1] = 1;
    testArray[2] = 2;
    testArray[3] = 3;
}
stopWatch.Stop();
listTimer += stopWatch.Elapsed;
Console.WriteLine(stopWatch.Elapsed);
stopWatch.Reset();

// for them
stopWatch.Start();
int q;
for (int x = 0; x < numberOfIterations; x++)
{
    for (q = 0; q < numElements; q++)
        testArray[q] = q;
}
stopWatch.Stop();
forTimer += stopWatch.Elapsed;
Console.WriteLine(stopWatch.Elapsed);
stopWatch.Reset();

// do them
stopWatch.Start();
int r;
for (int x = 0; x < numberOfIterations; x++)
{
    r = 0;
    do
    {
        testArray[r] = r;
        r++;
    } while (r < numElements);
}
stopWatch.Stop();
doTimer += stopWatch.Elapsed;
Console.WriteLine(stopWatch.Elapsed);
stopWatch.Reset();

// while
stopWatch.Start();
int s;
for (int x = 0; x < numberOfIterations; x++)
{
    s = 0;
    while (s < numElements)
    {
        testArray[s] = s;
        s++;
    }
}
stopWatch.Stop();
whileTimer += stopWatch.Elapsed;
Console.WriteLine(stopWatch.Elapsed);
stopWatch.Reset();
Console.WriteLine("listTimer");
Console.WriteLine(listTimer);
Console.WriteLine("forTimer");
Console.WriteLine(forTimer);
Console.WriteLine("doTimer");
Console.WriteLine(doTimer);
Console.WriteLine("whileTimer");
Console.WriteLine(whileTimer);

Console.WriteLine("Enter any key to try again the program");
Console.ReadLine();
trials++;

当我尝试使用 4 元素数组时,结果似乎更加明显。

我认为只有通过像其他试验一样的变量分配 listThem 组的值才是公平的。它确实使 listThem 组慢了一点,但它仍然是最快的。以下是几次尝试后的结果:

以下是我实现列表的方式:

int w = 0;
for (int x = 0; x < numberOfIterations; x++)
{
    testArray[w] = w;
    w++;
    testArray[w] = w;
    w++;
    testArray[w] = w;
    w++;
    testArray[w] = w;
    w = 0;
}

我知道这些结果可能是特定于实现的,但您会认为 Microsoft 会警告我们每个循环在速度方面的优缺点。你怎么看?谢谢。

更新:根据我发布的代码和列表仍然比循环更快,但循环在性能上似乎更接近。循环从最快到最慢:for、while 和 do。这有点不同,所以我的猜测是 do 和 while 的速度基本相同,而且 for 循环比 do 和 while 循环快大约 0.5%,至少在我的机器上是这样。以下是一些试验的结果:

【问题讨论】:

  • 您正在展开 List 循环而不是其他循环。这是一种非常基本的优化技术,到处都在使用。当然不是微软隐藏的东西!
  • 您可以尝试将numElements 的用法更改为硬编码数字(或将其更改为const)吗?然后编译器可能会决定展开内部循环。
  • 为了清楚起见,您所说的速度差异超过 1.709 秒 一亿 次迭代。这意味着每个元素的差异是 0.00001709121 MILLISECONDS。换句话说..WHO CARESOne method versus another makes no practical difference.
  • @Dan-o,这是一个玩具程序 - 但像这样的紧密循环可能会深埋在一些真正的算法代码中,它会迭代一些巨大的数据结构。在这种情况下,秒数很快就会增加。
  • @sinelaw:当然……但是您在这里谈论的只是变量分配。实际上并未使用变量。因此,我们必须飞跃到一个用 C# 编写的真实世界应用程序,该应用程序对数组进行超过 1 亿次赋值。有比这更大的问题。在这种情况下,.NET 框架确实是错误的工具。

标签: c# performance iterator


【解决方案1】:

我使用ILDASM 查看了用于 for 循环与直接分配的 IL。

直接赋值的 IL,不使用循环,如下所示,每次赋值重复 3 次以上:

IL_0007:  ldloc.0
IL_0008:  ldc.i4.0
IL_0009:  ldc.i4.0
IL_000a:  stelem.i4

for 循环的 IL 如下所示:

IL_0017:  ldc.i4.0
IL_0018:  stloc.1
IL_0019:  br.s       IL_0023
IL_001b:  ldloc.0
IL_001c:  ldloc.1
IL_001d:  ldloc.1
IL_001e:  stelem.i4
IL_001f:  ldloc.1
IL_0020:  ldc.i4.1
IL_0021:  add
IL_0022:  stloc.1
IL_0023:  ldloc.1
IL_0024:  ldc.i4.4
IL_0025:  blt.s      IL_001b
IL_0027:  ret

对数组的赋值是在行IL_001bIL_001e 上完成的。但除此之外,还有很多事情要做。

循环中发生的第一件事不是赋值 - 它检查循环变量是否在范围内。所以它分支到IL_0023,然后返回到IL_001b 开始分配。

分配后,它必须增加循环计数器(IL_001fIL_0022)。然后它检查循环变量并再次分支。

所以你可以看到循环比直接赋值要多得多。正如其他人所说 - 这是循环展开的好处 - 运行此循环开销的频率较低,或者在您的示例中完全避免它。

Jon 关于 JIT 如何进行优化的观点也很重要。有了这样的微基准测试,CPU 缓存和分支(这是 for 循环正在做的事情)之类的事情看起来会对性能产生严重影响 - 因为您测量的数字非常小。

最终,如果循环的结构比循环内的操作更昂贵并且循环的微小性能开销实际上很重要,那么您可能需要展开循环。但您更有可能拥有可以改进的设计。

【讨论】:

    【解决方案2】:

    一些迭代器更快。

    当然,一些迭代器做不同的事情。执行不同操作的不同代码将以不同的速度运行。

    我习惯做这样的事情来设置数组值:

    首先,这真的是您需要节省的时间吗?从您的测量结果来看(如果是调试版本则毫无意义),您的额外代码似乎为您节省了大约 10 纳秒。如果世界上每个人都使用过您的应用程序,那么您为所有用户节省的总时间仍将少于仅用于输入的额外时间。他们在任何时候都不会想“好吧,有十纳秒我再也回不来了”。

    但你会认为微软会警告我们每个循环在速度方面的优缺点

    不,我真的不会。

    特别是当您进一步概括时。一方面,对于较大的循环,等效的展开代码很可能会更慢,因为循环可能适合指令行缓存,而展开的代码则不适合。

    另一方面,迭代和枚举(平均起来往往比迭代慢,但也不会慢很多)更加灵活。它们将导致更小、更惯用的代码。这适用于很多情况,在这些情况下,你所拥有的那种放松要么不适用,要么不容易适用(因此,由于不得不做一些复杂的事情,你会失去预期的任何节省)。它们的错误范围更小,仅仅是因为它们对任何事情的范围都更小。

    因此,首先,MS 或其他任何人都不能建议总是用重复的复制粘贴语句页面来填充您的代码以节省几纳秒,因为无论如何它并不总是最快的方法,其次他们不会'不要这样做,因为其他代码在所有其他方面都更出色。

    现在,确实存在节省几纳秒非常重要的情况,即我们正在做数十亿次的事情。如果芯片制造商将基本指令的时间缩短到几纳秒,那将是一场真正的胜利。

    就我们可能在 C# 中执行的代码类型而言,我们可能会进行优化以展开,尽管它很少是我们关心运行时间的地方。

    假设我需要做某事x 次。

    首先,我做的是显而易见的:

    for(int i = 0; i != x; ++i)
      DoSomething();
    

    假设我的整个应用程序没有我需要的那么快。我要做的第一件事就是考虑“我需要的快”是什么意思,因为除非这是为了好玩而编码(嘿,追求速度的荒谬努力可能很有趣),这是我想知道的第一件事。我得到了一个答案,或者更可能有几个答案(可接受的最低限度、最低目标、理想和市场营销——吹嘘——这有多快——可能是不同的水平)。

    然后我会发现实际代码时间花费在了哪些位上。当用户在一个应用程序的生命周期中花费 400 毫秒的另一个部分被外部循环调用 1000 次时,优化应用程序生命周期中需要 10 纳秒的东西是没有意义的单击一个按钮,导致 4 秒延迟。

    然后我重新考虑我的整个方法 - 是“做这个 X 次”(这在时间复杂度上本质上是 O(x)),这是实现我的实际目标的唯一方法,或者我可以做一些完全不同的事情,也许是 O (ln x) (也就是说,不是花费与 X 成比例的时间,而是花费与 X 的对数成比例的时间)。我是否可以缓存一些结果,以便获得更长的初始运行时间,我可以节省几毫秒数千次?

    那我看看能不能提高DoSomething()的速度。在 99.9% 的情况下,我会比更改循环做得更好,因为它所花费的时间可能比循环本身花费的几纳秒还要多。

    我可能会在 DoSomething() 中做一些非常可怕的单调和令人困惑的事情,我通常认为这些代码是糟糕的代码,因为我知道这是值得的地方(我会评论不仅解释了这个更令人困惑的代码是如何工作的,而且准确地解释了为什么这样做)。我会测量这些变化,可能几年后我会再次测量它们,因为在当前 CPU 上使用当前框架的最快方法可能不是 .NET 6.5 上最快的方法,因为我们已经移动了将应用程序应用到具有英特尔 2017 年推出的最新芯片的酷炫新服务器上。

    很可能我会直接将 DoSomething() 手动内联到循环中,因为调用函数的成本几乎肯定会大于循环方法的成本(但不完全肯定,可能会有惊喜抖动内联的内容及其影响)。

    也许,也许我会用类似的东西替换实际的循环:

    if(x > 0)
      switch(x & 7)
      {
        case 0:
          DoSomething();
          goto case 7;
        case 7:
          DoSomething();
          goto case 6;
        case 6:
          DoSomething();
          goto case 5;
        case 5:
          DoSomething();
          goto case 4;
        case 4:
          DoSomething();
          goto case 3;
        case 3:
          DoSomething();
          goto case 2;
        case 2:
          DoSomething();
          goto case 1;
        case 1:
          DoSomething();
          if((x -= 8) > 0)
            goto case 0;
          break;
      }
    

    因为这是一种将循环在不占用大量指令内存方面的性能优势与您发现手动展开循环为短循环带来的性能优势相结合的方法;它几乎将您的方法用于 8 个项目的组,并循环通过 8 个块。

    为什么是 8?因为这是一个合理的起点;如果这在我的代码中是一个如此重要的热点,我实际上会测量不同的尺寸。我唯一一次在真实的(不仅仅是为了好玩).NET 代码中完成了这个,我最终做了 16 个块。

    而且每次迭代调用的指令都很短(12 条 IL 指令与 C# 代码 *x++ = *y++ 相对应)并且它是在专为特定目的而设计的代码中让其他代码快速完成某事是我在大多数情况下避免触及的一条,我需要做更多的工作来弄清楚我何时更好地使用或避免它,而不是制作它尽可能快。

    其余时间,要么展开不会节省太多(如果有的话),要么没有节省重要的地方,或者在考虑之前还有其他更紧迫的优化要做。

    我当然不会从这样的代码开始;这就是过早优化的定义。

    通常,迭代很快。其他编码人员都知道这一点。抖动是已知的(在某些情况下可以应用一些优化)。这是可以理解的。它很短。它是灵活的。通常,使用foreach 也很快,虽然不如迭代快,而且更加灵活(有很多方法可以高效地使用IEnumerable 实现)。

    重复代码更脆弱,更容易隐藏愚蠢的错误(我们都编写的错误让我们认为“这太愚蠢了,几乎不足以算作错误”,这些很容易修复,只要你能找到它们)。随着项目的进行,它更难维护,并且更有可能变成更难维护的东西。很难看到全局,只有在全局中才能实现最大的性能改进。

    总而言之,第 9 频道剧集中的那个人没有警告你某些事情可能会使你的程序慢 10ns,在某些情况下,他会被嘲笑的原因。

    【讨论】:

    • 感谢您的回答。绝对值得深思。
    • +1,如果没有别的,教我goto case 是有效的 C#。没想到这么多年又学会了新语法!
    • @JulianR 在少数情况下,在 C# 中禁止掉线会导致问题,这就是我们解决它的方法。当然,有些人会迷信goto,但也许这完全不是一件坏事,因为在 95% 的情况下仍然需要避免(我知道我说 95% 而不是 99.999% 是有争议的)。他们如何使case 充当标签非常好,就好像您确实需要在 C 或 C++ 中的 switch 块中使用 goto 一样,您必须添加另一个标签,而这种方式则更具自我记录性就在你要跳到的地方。
    • @JulianR 并且您会很高兴知道编译器不会添加任何跳转,而只是允许失败(假设它不会将 switch 重写为if...else if... 的集合,尽管用这样的例子不太可能这样做,无论如何)。
    猜你喜欢
    • 2018-11-17
    • 1970-01-01
    • 1970-01-01
    • 2023-04-06
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2015-06-27
    • 2011-02-05
    相关资源
    最近更新 更多