奇怪的表现行为

Weird performance behavior

所以我有这 2 种方法,它们假设将 1000 项长整数数组乘以 2。 第一种方法:

[MethodImpl(MethodImplOptions.NoOptimization)]
Power(int[] arr)
{
    for (int i = 0; i < arr.Length; i++)
    {
        arr[i] = arr[i] + arr[i];
    }
}

第二种方法:

[MethodImpl(MethodImplOptions.NoOptimization)]
PowerNoLoop(int[] arr)
{
    int i = 0;
    arr[i] = arr[i] + arr[i];
    i++;
    arr[i] = arr[i] + arr[i];
    i++;
    arr[i] = arr[i] + arr[i];
    i++;
    ............1000 Times........
    arr[i] = arr[i] + arr[i];
}

请注意,我仅将此代码用于性能研究,这就是它看起来如此恶心的原因。

令人惊讶的结果是 PowerPowerNoLoop 快了近 50%,尽管我已经检查了它们的反编译 IL 源代码和 for循环与PowerNoLoop中的每一行完全相同。 怎么可能?

因为 c# jit 编译器经过优化以消除边界检查,如果它可以推断出变量不会超出 for 循环的范围。

for (int i = 0; i < arr.Length; i++) 的情况被优化器捕获,其他情况没有。

这是一篇关于它的博客post,它很长但值得一读:http://blogs.msdn.com/b/clrcodegeneration/archive/2009/08/13/array-bounds-check-elimination-in-the-clr.aspx

我在测试中没有看到这些结果。我怀疑您的测试可能被垃圾收集扭曲了。

我的发布版本测试结果如下(使用 Visual Studio 2015,.Net 4.6,Windows 10):

x64:

Power() took 00:00:01.5277909
PowerNoLoop() took 00:00:01.4462461
Power() took 00:00:01.5403739
PowerNoLoop() took 00:00:01.4038312
Power() took 00:00:01.5327902
PowerNoLoop() took 00:00:01.4318121
Power() took 00:00:01.5451933
PowerNoLoop() took 00:00:01.4252743

x86:

Power() took 00:00:01.1769501
PowerNoLoop() took 00:00:00.9933677
Power() took 00:00:01.1557201
PowerNoLoop() took 00:00:01.0033348
Power() took 00:00:01.1119558
PowerNoLoop() took 00:00:00.9588702
Power() took 00:00:01.1167853
PowerNoLoop() took 00:00:00.9553292

和代码:

using System;
using System.Diagnostics;
using System.Runtime.CompilerServices;

namespace ConsoleApplication1
{
    internal class Program
    {
        private static void Main()
        {
            Stopwatch sw = new Stopwatch();

            int count = 200000;
            var test = new int[1000];

            for (int trial = 0; trial < 4; ++trial)
            {
                sw.Restart();

                for (int i = 0; i < count; ++i)
                    Power(test);

                Console.WriteLine("Power() took " + sw.Elapsed);
                sw.Restart();

                for (int i = 0; i < count; ++i)
                    PowerNoLoop(test);

                Console.WriteLine("PowerNoLoop() took " + sw.Elapsed);
            }
        }

        [MethodImpl(MethodImplOptions.NoOptimization)]
        public static void Power(int[] arr)
        {
            for (int i = 0; i < arr.Length; i++)
            {
                arr[i] = arr[i] + arr[i];
            }
        }

        [MethodImpl(MethodImplOptions.NoOptimization)]
        public static void PowerNoLoop(int[] arr)
        {
            int i = 0;
            arr[i] = arr[i] + arr[i];
            ++i;
            <snip> Previous two lines repeated 1000 times.
        }
    }
}

我机的一个样例测量,运行测试10次,PowerNoLoop第一:

00:00:00.0277138 00:00:00.0001553
00:00:00.0000142 00:00:00.0000057
00:00:00.0000106 00:00:00.0000053
00:00:00.0000084 00:00:00.0000053
00:00:00.0000080 00:00:00.0000053
00:00:00.0000075 00:00:00.0000053
00:00:00.0000080 00:00:00.0000057
00:00:00.0000080 00:00:00.0000053
00:00:00.0000080 00:00:00.0000053
00:00:00.0000075 00:00:00.0000053

是的,大约慢了 50%。值得注意的是第一次通过测试时的抖动开销,显然它会消耗更多的核心来尝试编译这个巨大的方法。请记住,当您不禁用优化器时,测量结果会大不相同,无循环版本会慢 800%。

首先要寻找解释的地方是生成的机器代码,您可以通过调试 > Windows > 反汇编查看它。主要的麻烦点是 PowerNoLoop() 方法的序言。在 x86 代码中看起来像这样:

067E0048  push        ebp                       ; setup stack frame
067E0049  mov         ebp,esp  
067E004B  push        edi                       ; preserve registers
067E004C  push        esi  
067E004D  sub         esp,0FA8h                 ; stack frame size = 4008 bytes  
067E0053  mov         esi,ecx  
067E0055  lea         edi,[ebp-0ACCh]           ; temp2 variables
067E005B  mov         ecx,2B1h                  ; initialize 2756 bytes
067E0060  xor         eax,eax                   ; set them to 0
067E0062  rep stos    dword ptr es:[edi] 

请注意非常大的堆栈大小,4008 字节。对于只有一个局部变量的方法来说太多了,它应该只需要 8 个字节。多出的4000个是临时变量,我给它们取名为temp2。它们由 rep stos 指令初始化为 0,这需要一段时间。我无法解释 2756.

在未优化的代码中,个人添加是一件非常乏味的事情。我将为您节省机器代码转储并用等效的 C# 代码编写它:

if (i >= arr.Length) goto throwOutOfBoundsException
var temp1 = arr[i];
if (i >= arr.Length) goto throwOutOfBoundsException
var temp2 = temp1 + arr[i];
if (i >= arr.Length) goto throwOutOfBoundsException
arr[i] = temp2

一遍又一遍,一千遍。 temp2 变量是麻烦制造者,每个单独的语句都有一个。因此堆栈帧大小增加了 4000 字节。如果有人猜出 2756 那么我很乐意在评论中听到它。

在方法启动之前必须将它们全部设置为 0 运行 大致是产生 50% 减速的原因。可能还有一些指令获取和解码开销,它不能轻易地从测量中分离出来。

值得注意的是,当您删除 [MethodImpl] 属性并允许优化器完成其工作时,它们 不会 被消除。该方法实际上根本没有优化,肯定是因为它不想处理这么大的代码块。


您应该得出的结论是始终让抖动优化器为您展开循环。它知道得更多。

Hans Passant 似乎抓住了主要问题的要点,但遗漏了一些要点。

首先,正如 Mark Jansen 所说,代码生成器(在 JIT 中)有一个特殊情况,可以删除在简单 for 循环中检查简单数组访问的绑定。 [MethodImpl(MethodImplOptions.NoOptimization)] 很可能不会影响这一点。您展开的循环必须执行此检查 3000 次!

下一个问题是,从内存中读取数据(或代码)比读取 运行 已经在处理器一级缓存中的指令要花费更长的时间。从 CPU 到 RAM 的带宽也有限,因此每当 CPU 从内存中读取指令时,它就不能从数组中读取(或更新)。一旦 Power 中的循环第一次执行,所有处理器指令都将在一级缓存中——它们甚至可能以部分解码的形式存储。

更新 1000 个不同的 tempN 变量,会给 CPU 缓存甚至 RAM 带来负载(因为 CPU 不知道它们不会被再次读取,所以必须将它们保存到 RAM 中)(没有 MethodImplOptions.NoOptimization,JIT 可能会将 tempN 变量组合成几个变量,然后将它们放入寄存器中。)

现在大多数 CPU 可以同时 运行 多条指令 (Superscalar), therefore it is very likely that all the loop checks (1 < arr.Length) etc are being executed at the same time as the store/load from the array. Even the conditional GoTo at the end of the loop with be hidden by Speculative execution (and/or Out-of-order execution)。

任何合理的 CPU 都能够 运行 您的循环大约需要 read/write 从内存中获取值的时间。

如果您在 20 年前曾在 PC 上做过同样的测试,那么您很可能会得到预期的结果。