奇怪的性能行为

14

我有两种方法,都可以将一个包含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];
}

请注意,我只是将此代码用于性能研究,这就是为什么它看起来如此丑陋的原因。

令人惊讶的结果是,即使我已经检查了它们两个的反编译 IL 源代码和 for 循环中的每行代码与 PowerNoLoop 中的完全相同,但 Power 的速度几乎比 PowerNoLoop 快了 50%。 这是怎么回事呢?


尝试了Debug和Release两种模式。使用StopWatch测试了超过200000个包含1000个项目的数组。 - Tamir Vered
6
第二个可能不会从CPU指令缓存中受益? - user585968
你能展示两者的IL代码吗? - Anton Malyshev
我期望 arg[i] <<= 1 的速度更快。 - Richard Schneider
我稍后会发布IL代码,因为我正在手机上使用Stack Exchange。我使用了本地变量而不是1000个常量,因为它使用ld.loc.0而不是ldc.i4.s 0xXXX。 - Tamir Vered
显示剩余4条评论
4个回答

11

我从我的机器上进行了一项测试,测试运行了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%。

总是要查找解释的第一位置是生成的机器代码,可以在“调试>窗口>反汇编”中看到它。主要问题出现在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]属性并允许优化器去做它的工作时,它们并没有被消除。实际上,该方法根本没有经过优化,肯定是因为它不想处理这么大的代码块。


你应该得出的结论是:始终将循环展开的任务留给JIT优化器完成,它知道得更好。


哦,你这个无望的愤世嫉俗者。循环展开不再是一种轻松优化了,指令解码器正在成为一个相当重要的瓶颈。让x86抖动器现代化以适应当前的微架构并不可行。 - Hans Passant
我想要的是一个超级快速的第一层JIT或解释器和高质量的第二层JIT。应该能够减少.NET启动时间。不确定为什么他们没有这样做。Java似乎很喜欢它。编写一个IL解释器有多昂贵?这条路似乎比重写JIT更便宜。编写一个解释器并插入MSVC或LLVM作为第二层JIT。后者现在正在实现。 - usr
嗯,考虑到他们已经有一个,这不会花费太多。在.NET Micro中使用。我得把“超快解释器”留给想象力。如果你喜欢JVM,那就用Java吧,但我没有听到很多来自那个角落的“.NET jitters suck”的贬低声音。 - Hans Passant
"超快速的解释器" = 没有初始化成本,吞吐量低; 对于Java人来说,.NET甚至不存在。这是文化问题。他们正在讨论C#已经解决了5年前的语言设计问题,因为他们没有注意到C#已经解决了这个问题。我相信对于JIT,情况也是如此,此外,.NET JIT真的没有必要去看它。从我所见,Hotspot JIT非常出色。它在消除托管语言中产生的抽象成本方面表现出色。分层JIT,vcalls消失,范围检查很少,对象在堆栈上分配,... - usr
1
@IanRingrose,实际上是将一个普通值写入“ECX”寄存器,但稍后由“rep stos”指令用作要写入的双字数的计数器 - 从存储在“es:edi”寄存器中的地址开始。您应该将Hans的注释视为多行注释,解释了最后三条指令的整体目的 =) - BlueStrat
显示剩余3条评论

2

@SriramSakthivel 你是对的,它就是JIT编译器。 - KeyNone
3
等等。JIT无法同时优化这两种方法,[MethodImpl(MethodImplOptions.NoOptimization)]告诉JIT“闭嘴,不要优化我的代码”。因此,这不是问题的答案。 - Sriram Sakthivel
3
仅根据官方MSDN的说法,Sriram是完全正确的。除非OP使用自己的属性,否则此处不会进行JIT优化。 - KeyNone
1
我刚在 Visual Studio 2008 中检查了(是的,我知道它有点老),即使该函数具有该属性,边界检查也被消除了。 - Mark Jansen
@BastiM 这不是我的属性 :) - Tamir Vered
显示剩余2条评论

2
汉斯·帕桑特似乎已经抓住了主要问题,但是错过了一些要点。
首先,正如马克·詹森所说,在简单的for循环中,代码生成器(在JIT中)有一个特殊情况,可以删除简单数组访问中的边界检查。非常可能的是[MethodImpl(MethodImplOptions.NoOptimization)]不会影响此功能。您的展开循环必须执行此检查3000次!
下一个问题是从内存中读取数据(或代码)需要比运行已经存在于处理器第一级缓存中的指令花费更长时间。而且从CPU到RAM的带宽也是有限的,因此每当CPU从内存读取指令时,它就无法从(或更新)数组中读取。一旦Power中的循环第一次执行,所有处理器指令都将位于第一级缓存中-它们甚至可以以部分解码的形式存储。
更新1000个不同的tempN变量将对CPU缓存产生负载,甚至可能还涉及RAM(因为CPU不知道它们不再被读取,因此必须将它们保存到RAM中)(没有MethodImplOptions.NoOptimization,JIT可能会将tempN变量合并为一些变量,然后将其放入寄存器中)。
现在大多数CPU可以同时运行许多指令(超标量),因此非常可能所有循环检查(1 < arr.Length等)都与从数组中进行的存储/加载同时执行。即使是循环末尾的条件GoTo也会被推测执行(和/或乱序执行)隐藏。 任何合理的CPU都能够在读/写内存值的时间内运行您的循环。 如果您20年前在PC上进行了同样的测试,则很可能会得到您期望的结果。

1

我在我的测试中没有看到这些结果。我怀疑你的测试可能受到垃圾回收的干扰。

我的发行版本测试结果如下(使用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.
        }
    }
}

我复制并粘贴了你的代码,但结果仍然像之前一样奇怪。我使用的是.NET 4.5.2。 - Tamir Vered
1
@Some1Pr0 很有趣看看其他人得到什么结果。 - Matthew Watson

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接