为什么这个Java代码比相同的C#代码快6倍?

Why is this Java code 6x faster than the identical C# code?

对于ProjectEuler问题5,我有几个不同的解决方案,但是在这个特定的实现中,两种语言/平台之间的执行时间差异让我很感兴趣。我没有使用编译器标志进行任何优化,只使用简单的javac(通过命令行)和csc(通过Visual Studio)。

这里是Java代码。在55毫秒内完成。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class Problem005b
{
    public static void main(String[] args)
    {
        long begin = System.currentTimeMillis();
        int i = 20;
        while (true)
        {
            if (
                    (i % 19 == 0) &&
                    (i % 18 == 0) &&
                    (i % 17 == 0) &&
                    (i % 16 == 0) &&
                    (i % 15 == 0) &&
                    (i % 14 == 0) &&
                    (i % 13 == 0) &&
                    (i % 12 == 0) &&
                    (i % 11 == 0)
                )
            {
                break;
            }
            i += 20;
        }
        long end = System.currentTimeMillis();
        System.out.println(i);
        System.out.println(end-begin +"ms");
    }
}

这是相同的C代码。在320ms内完成

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
using System;

namespace ProjectEuler05
{
    class Problem005
    {
        static void Main(String[] args)
        {
            DateTime begin = DateTime.Now;
            int i = 20;
            while (true)
            {
                if (
                        (i % 19 == 0) &&
                        (i % 18 == 0) &&
                        (i % 17 == 0) &&
                        (i % 16 == 0) &&
                        (i % 15 == 0) &&
                        (i % 14 == 0) &&
                        (i % 13 == 0) &&
                        (i % 12 == 0) &&
                        (i % 11 == 0)
                    )
                    {
                        break;
                    }
                i += 20;
            }
            DateTime end = DateTime.Now;
            TimeSpan elapsed = end - begin;
            Console.WriteLine(i);
            Console.WriteLine(elapsed.TotalMilliseconds +"ms");
        }
    }
}


  • 为了计时代码执行,您应该使用StopWatch类。
  • 此外,您还必须考虑到JIT、运行时等,因此让测试运行足够的次数(比如100000次),并获得某种平均值。多次运行代码是很重要的,而不是程序。所以写一个方法,并在主方法中循环得到你的测量结果。
  • 从程序集中删除所有调试内容,并让代码在发布版本中独立运行。

  • 可以进行一些优化。也许JavaJIT正在执行它们,而CLR不是。

    优化1:

    1
    (x % a == 0) && (x % b == 0) && ... && (x % z == 0)

    等于

    1
    (x % lcm(a, b, ... z) == 0)

    因此在您的示例中,比较链可以替换为

    1
    if (i % 232792560 == 0) break;

    (当然,如果您已经计算了LCM,那么首先运行程序没有什么意义!)

    优化2:

    这也相当于:

    1
    if (i % (14549535 * 16)) == 0 break;

    1
    if ((i % 16 == 0) && (i % 14549535 == 0)) break;

    第一个除法可以用一个掩码替换,并与零进行比较:

    1
    if (((i & 15) == 0) && (i % 14549535 == 0)) break;

    第二个除法可以用模逆乘法代替:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    final long LCM = 14549535;
    final long INV_LCM = 8384559098224769503L; // == 14549535**-1 mod 2**64
    final long MAX_QUOTIENT = Long.MAX_VALUE / LCM;
    // ...
    if (((i & 15) == 0) &&
        (0 <= (i>>4) * INV_LCM) &&
        ((i>>4) * INV_LCM < MAX_QUOTIENT)) {
        break;
    }

    JIT不太可能使用这种方法,但它并不像您想象的那样牵强——一些C编译器以这种方式实现指针减法。


    使这两者更接近的关键是确保比较是公平的。

    首先要确保与运行调试构建、像您一样加载PDB符号相关的成本。

    接下来,您需要确保不计算初始成本。显然,这些都是真正的成本,对某些人来说可能很重要,但在本例中,我们对循环本身很感兴趣。

    接下来,您需要处理特定于平台的行为。如果您在64位Windows计算机上,则可能正在32位或64位模式下运行。在64位模式下,JIT在许多方面都不同,通常会对生成的代码进行较大的修改。具体地说,我会有针对性地猜测,您可以访问的通用寄存器数量是普通用途寄存器的两倍。

    在这种情况下,当幼稚地转换为机器代码时,循环的内部部分需要加载到寄存器中模块测试中使用的常量。如果没有足够的内存来保存循环中需要的所有内容,那么它必须将它们从内存中推入。即使来自一级缓存,与将其全部保存在寄存器中相比,这也是一个重大的打击。

    在vs 2010中,ms将默认目标从anycpu更改为x86。我没有什么能比得上MSFT的资源或面向客户的知识,所以我不会再去猜测。但是,任何关注您正在进行的性能分析的人都应该同时尝试这两种方法。

    一旦这些差距消除,这些数字似乎就更加合理了。任何进一步的差异可能需要比有根据的猜测更好的猜测,相反,它们需要调查生成的机器代码中的实际差异。

    我认为对于一个优化的编译器来说,这有几个方面是有趣的。

    • 芬诺已经提到的那些:
      • LCM选项很有意思,但我看不到编译器编写器有问题。
      • 将除法简化为乘法和掩蔽法。
        • 我对此知之甚少,但其他人也注意到,他们称最近英特尔芯片的分频器明显更好。
        • 也许你甚至可以用SSE2来安排一些复杂的事情。
        • 当然,模16操作已经成熟,可以转换为掩码或移位。
      • 编译器可以发现所有测试都没有副作用。
        • 它可以推测地尝试一次评估其中的几个,在超标量处理器上,这可以使事情发展得更快,但很大程度上取决于编译器布局与OO执行引擎的交互效果。
      • 如果寄存器压力很紧,可以将常量实现为单个变量,在每个循环的开始处设置,然后在执行过程中递增。

    这些都是彻头彻尾的猜测,应该被视为闲散的曲流。如果你想知道拆卸它。


    这项任务太短,无法为其进行适当的时间安排。您需要同时运行至少1000次,然后看看会发生什么。看起来像是从命令行运行这些命令,在这种情况下,您可能会比较两种类型的JIT编译器。尝试将两个按钮都放在一个简单的图形用户界面中,在返回经过的时间之前,至少让那个按钮在这个界面上循环几百次。即使忽略了JIT编译,操作系统调度器的粒度也可能导致时间延迟。

    哦,因为准时…只计算按下按钮的第二个结果。:)


    (从OP移动)

    将目标从x86更改为anycpu已将每次运行的平均执行时间从282ms降低到84ms。也许我应该将其拆分为第二个线程?

    更新:感谢下面的FEMAREF谁指出了一些测试问题,事实上,在遵循他的建议后,时间较低,这表明VM设置时间在Java中是重要的,但可能不在C.*中。在C中,调试符号非常重要。

    我更新了我的代码以运行每个循环10000次,并且只输出结束时的平均毫秒数。我所做的唯一重大改变是C版本,在C版本中,我切换到[秒表类][3]以获得更高的分辨率。我坚持毫秒是因为它足够好。

    结果:测试的变化并不能解释为什么Java仍然比C语言快得多。C性能更好,但可以通过删除调试符号来完全解释这一点。如果您阅读了[Mike 2][4]并与我交换了此操作附带的评论,您将看到,仅通过从调试切换到发布,我在五次C代码运行中平均获得约280ms。

    数字:

    • 未修改的Java代码的10000计数循环使我的平均值为45毫秒(从55毫秒下降)。
    • 使用秒表类对C代码进行10000次计数循环,平均为282毫秒(低于320ms)。

    所有这些都无法解释两者之间的差异。实际上,差分变得更糟了。Java从-5.8x快到6.2x快。


    在Java中,我将使用Stase.NeimTime[()。任何不到2秒的测试都应运行更长时间。值得注意的是,Java很擅长优化效率低下的代码或代码。更有趣的测试是,如果您优化了代码。

    您试图得到一个不使用循环就可以确定的解决方案。也就是说,用另一种方法可以做得更好的问题。

    你需要11到20的因子的乘积,即2,2,2,3,3,5,7,11,13,17,19。把它们相乘,你就得到了答案。


    也许是因为建造DateTime物体比System.currentTimeMillis昂贵得多。