如何解释这两个简单循环的性能差异?

9

如果您对我如何进行基准测试感兴趣,请看这里,我只是在“Loop 1K”中添加/替换了几个方法。

抱歉,我忘了说我的测试环境。.Net 4.5 x64(不要选择32位),x86下两种方法的时间都要慢5倍。

Loop2的时间比Loop多3倍。我认为x++/x+=y不应该会变慢,因为无论如何都需要1或2个CPU指令。

是由于参考位置的局限性导致的吗?然而,我认为在Loop2中没有太多变量,它们都应该靠近彼此……

    public long Loop(long testSize)
    {
        long ret = 0;
        for (long i = 0; i < testSize; i++)
        {
            long p = 0;
            for (int j = 0; j < 1000; j++)
            {
                p+=10;
            }
            ret+=p;
        }
        return ret;
    }

    public long Loop2(long testSize)
    {
        long ret = 0;
        for (long i = 0; i < testSize; i++)
        {
            for (int j = 0; j < 1000; j++)
            {
                ret+=10;
            }
        }
        return ret;
    }

更新: 循环展开在何时仍然有用? 是有用的


4
请展示您如何设置性能测量。 - Kirk Woll
testSize是多少? - astef
可能是一个优化问题。在“循环”中,优化器可以安全地假设变量p的类型为int而不是long。因此,内部循环中的增量可以更快地进行。 - Axel Kemper
我得到了类似的结果(但是当我尝试时,它只需要2倍的时间)。我使用测试大小为10000000、发布版本进行了尝试。在循环中运行了几次试验,使用计时器进行计时。 - Matthew Watson
我刚刚使用这个方法进行了验证 https://dev59.com/5XNA5IYBdhLWcg3wNrAy#1048708 - 两种方法得到了类似的结果。 - outcoldman
显示剩余4条评论
7个回答

6
之前已经有人说过,当涉及到优化时,x86 JIT比x64 JIT做得更好,看起来在这种情况下也是如此。虽然循环执行的基本相同,但JITer创建的x64汇编代码根本不同,我认为这就是你看到的速度差异的原因。
两种方法之间的汇编代码在关键的内部循环中有所不同,该循环被调用了1000*N次。我认为这就是速度差异的原因。
循环1:
000007fe`97d50240 4d8bd1 mov r10,r9 000007fe`97d50243 4983c128 add r9,28h 000007fe`97d50247 4183c004 add r8d,4 ; 当j < 1000d时循环 000007fe`97d5024b 4181f8e8030000 cmp r8d,3E8h 000007fe`97d50252 7cec jl 000007fe`97d50240
循环2:
; rax = ret ; ecx = j
; 将ret加10四次 000007fe`97d50292 48050a000000 add rax,0Ah 000007fe`97d50298 48050a000000 add rax,0Ah 000007fe`97d5029e 48050a000000 add rax,0Ah 000007fe`97d502a4 48050a000000 add rax,0Ah 000007fe`97d502aa 83c104 add ecx,4 ; 将j加4
; 当j < 1000d时循环 000007fe`97d502ad 81f9e8030000 cmp ecx,3E8h 000007fe`97d502b3 7cdd jl 000007fe`97d50292
你会注意到JIT正在展开内部循环,但实际上在循环中的代码差别很大,特别是指令数。循环1被优化为一个单一的add语句,增加了40,而循环2则是4个add语句,每个增加10。
我(猜测)认为JITer可以更好地优化变量p,因为它在第一个循环的内部范围中定义。由于它可以检测到p从未在该循环之外使用并且确实是临时的,因此可以应用不同的优化。在第二个循环中,您正在操作一个在两个循环的范围之外定义和使用的变量,并且x64 JIT中使用的优化规则无法将其识别为具有相同优化的相同代码。

我曾经认为x64 Jit比x86更具攻击性。例如,在消除范围检查,尾递归等方面,x64明显更好。 - colinfang
我确信这是正确的答案(或接近正确)。我发现,如果将内部循环中使用的增量作为参数传递给方法,则两种方法的运行速度几乎相同。这支持了这样一个观点,即这是内部循环中的一种优化,可能依赖于编译器在编译时知道增量是什么。 - Matthew Watson

2

我没有看到性能上的任何明显差异。使用这个LinqPad脚本(并包含您的那两种方法):

void Main()
{
    // Warmup the vm
    Loop(10);
    Loop2(10);

    var stopwatch = Stopwatch.StartNew();
    Loop(10 * 1000 * 1000);
    stopwatch.Stop();
    stopwatch.Elapsed.Dump();

    stopwatch = Stopwatch.StartNew();
    Loop2(10 * 1000 * 1000);
    stopwatch.Stop();
    stopwatch.Elapsed.Dump();
}

在LinqPad中打印输出:

00:00:22.7749976
00:00:22.6971114

当反转Loop / Loop2调用顺序时,结果类似:

00:00:22.7572688
00:00:22.6758102

这似乎表明性能相同。也许您没有预热虚拟机?


尝试x64构建,我忘了在x86中它们是相似的。而且LinqPad默认是x86(我已经尝试过了)。 - colinfang
@colinfang,我刚刚尝试了一下,在控制台应用程序中明确指定了x64。它的表现完全相同。你有没有尝试过将我的代码作为自己的控制台应用程序运行? - Kirk Woll
我用了你的。你想要截图吗? - colinfang
@KirkWoll 22秒?你测试的是发布版本还是调试版本?你在调试器外运行了它吗?我在我的电脑上运行了你的代码,只花了2.5秒。我怀疑我的电脑不会比你的快8倍,所以我认为你没有计时发布版本。 - Matthew Watson
你说得对,我之前没有使用发布版本。现在我使用了发布版本,分别得到了6.1917275和6.1968686。 - Kirk Woll

1

通过查看IL代码本身,循环2应该更快(在我的电脑上确实更快)

循环IL

.method public hidebysig 
instance int64 Loop (
    int64 testSize
) cil managed 
{
// Method begins at RVA 0x2054
// Code size 48 (0x30)
.maxstack 2
.locals init (
    [0] int64 'ret',
    [1] int64 i,
    [2] int64 p,
    [3] int32 j
)

IL_0000: ldc.i4.0
IL_0001: conv.i8
IL_0002: stloc.0
IL_0003: ldc.i4.0
IL_0004: conv.i8
IL_0005: stloc.1
IL_0006: br.s IL_002a
// loop start (head: IL_002a)
    IL_0008: ldc.i4.0
    IL_0009: conv.i8
    IL_000a: stloc.2
    IL_000b: ldc.i4.0
    IL_000c: stloc.3
    IL_000d: br.s IL_0019
    // loop start (head: IL_0019)
        IL_000f: ldloc.2
        IL_0010: ldc.i4.s 10
        IL_0012: conv.i8
        IL_0013: add
        IL_0014: stloc.2
        IL_0015: ldloc.3
        IL_0016: ldc.i4.1
        IL_0017: add
        IL_0018: stloc.3

        IL_0019: ldloc.3
        IL_001a: ldc.i4 1000
        IL_001f: blt.s IL_000f
    // end loop

    IL_0021: ldloc.0
    IL_0022: ldloc.2
    IL_0023: add
    IL_0024: stloc.0
    IL_0025: ldloc.1
    IL_0026: ldc.i4.1
    IL_0027: conv.i8
    IL_0028: add
    IL_0029: stloc.1

    IL_002a: ldloc.1
    IL_002b: ldarg.1
    IL_002c: blt.s IL_0008
// end loop

IL_002e: ldloc.0
IL_002f: ret
} // end of method Program::Loop

循环2 IL

.method public hidebysig 
instance int64 Loop2 (
    int64 testSize
) cil managed 
{
// Method begins at RVA 0x2090
// Code size 41 (0x29)
.maxstack 2
.locals init (
    [0] int64 'ret',
    [1] int64 i,
    [2] int32 j
)

IL_0000: ldc.i4.0
IL_0001: conv.i8
IL_0002: stloc.0
IL_0003: ldc.i4.0
IL_0004: conv.i8
IL_0005: stloc.1
IL_0006: br.s IL_0023
// loop start (head: IL_0023)
    IL_0008: ldc.i4.0
    IL_0009: stloc.2
    IL_000a: br.s IL_0016
    // loop start (head: IL_0016)
        IL_000c: ldloc.0
        IL_000d: ldc.i4.s 10
        IL_000f: conv.i8
        IL_0010: add
        IL_0011: stloc.0
        IL_0012: ldloc.2
        IL_0013: ldc.i4.1
        IL_0014: add
        IL_0015: stloc.2

        IL_0016: ldloc.2
        IL_0017: ldc.i4 1000
        IL_001c: blt.s IL_000c
    // end loop

    IL_001e: ldloc.1
    IL_001f: ldc.i4.1
    IL_0020: conv.i8
    IL_0021: add
    IL_0022: stloc.1

    IL_0023: ldloc.1
    IL_0024: ldarg.1
    IL_0025: blt.s IL_0008
// end loop

IL_0027: ldloc.0
IL_0028: ret
} // end of method Program::Loop2

2
IL分析本身并不是全部。许多不同的IL可以被编译成相同的高效结果。 - Kirk Woll
令人意外的是,优化器无法进一步加快速度。 - Axel Kemper
2
@AxelKemper 大多数优化是由JIT编译器而不是C#编译器完成的。 - Matthew Watson

1

我可以在我的系统上确认这个结果。

我的测试结果如下:

x64 Build

00:00:01.1490139 Loop
00:00:02.5043206 Loop2

x32 Build

00:00:04.1832937 Loop
00:00:04.2801726 Loop2

这是在调试器之外运行的发布版本。
using System;
using System.Diagnostics;

namespace Demo
{
    internal class Program
    {
        private static void Main()
        {
            new Program().test();
        }

        private void test()
        {
            Stopwatch sw = new Stopwatch();

            int count = 10000000;

            for (int i = 0; i < 5; ++i)
            {
                sw.Restart();
                Loop(count);
                Console.WriteLine(sw.Elapsed + " Loop");
                sw.Restart();
                Loop2(count);
                Console.WriteLine(sw.Elapsed + " Loop2");
                Console.WriteLine();
            }
        }


        public long Loop(long testSize)
        {
            long ret = 0;
            for (long i = 0; i < testSize; i++)
            {
                long p = 0;
                for (int j = 0; j < 1000; j++)
                {
                    p++;
                }
                ret += p;
            }
            return ret;
        }

        public long Loop2(long testSize)
        {
            long ret = 0;
            for (long i = 0; i < testSize; i++)
            {
                for (int j = 0; j < 1000; j++)
                {
                    ret++;
                }
            }
            return ret;
        }
    }
}

1
循环应该比循环2快,我能想到的唯一解释是编译器优化起作用并将long p = 0; for (int j = 0; j < 1000; j++){p++;}简化为类似于long p = 1000;的东西,检查生成的汇编代码会带来清晰度。

那将提供大约 1000 倍的加速。 - usr
我将 p++ 改为 p+=10,问题仍然存在。我认为编译器不够聪明,无法优化 +=10... 我使用 ILSPY 在 C# 中进行了反汇编,但没有发现优化。 - colinfang

0

我已经运行了自己的测试,没有看到任何显著的差异。你也可以试试:

using System;
using System.Diagnostics;

namespace ConsoleApplication1
{
    class Program
    {
        static void Main(string[] args)
        {
            Stopwatch sw = new Stopwatch();
            while (true)
            {
                sw.Start();
                Loop(5000000);
                sw.Stop();
                Console.WriteLine("Loop: {0}ms", sw.ElapsedMilliseconds);
                sw.Reset();

                sw.Start();
                Loop2(5000000);
                sw.Stop();
                Console.WriteLine("Loop2: {0}ms", sw.ElapsedMilliseconds);
                sw.Reset();

                Console.ReadLine();
            }
        }

        static long Loop(long testSize)
        {
            long ret = 0;
            for (long i = 0; i < testSize; i++)
            {
                long p = 0;
                for (int j = 0; j < 1000; j++)
                {
                    p++;
                }
                ret += p;
            }
            return ret;
        }

        static long Loop2(long testSize)
        {
            long ret = 0;
            for (long i = 0; i < testSize; i++)
            {
                for (int j = 0; j < 1000; j++)
                {
                    ret++;
                }
            }
            return ret;
        }
    }

}

所以,我的答案是:原因在于您过于复杂的测量系统。

尝试使用x64构建,我忘了在x86中它们是相似的。 - colinfang

0

两种情况下的外部循环是相同的,但这就是阻止编译器优化第二种情况下的代码的原因。

问题在于变量ret没有被声明得足够靠近内部循环,因此它不在外部循环的主体中。ret变量在外部循环之外,这意味着它超出了编译器优化器的范围,无法通过2个循环来优化代码。

然而,变量p在内部循环之前被声明,这就是为什么它能够被很好地优化的原因。


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