为什么这个 Java 代码比相同的 C# 代码快 6 倍
本文关键字:代码 Java 为什么 | 更新日期: 2023-09-27 17:56:04
我对欧拉项目问题 5 有几种不同的解决方案,但在这个特定的实现中,两种语言/平台之间的执行时间差异引起了我的兴趣。我没有对编译器标志进行任何优化,只是简单的javac
(通过命令行)和csc
(通过Visual Studio)。
这是Java代码。它在 55 毫秒内完成。
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内完成
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、运行时等,因此让测试运行足够的次数(如 10,000、100,000 次)并获得某种平均值。重要的是多次运行代码,而不是程序。因此,编写一个方法,并在main方法中循环以获取测量值。
- 从程序集中删除所有调试内容,并让代码在发布版本中独立运行
有一些可能的优化。 也许Java JIT正在执行它们,而CLR没有。
优化#1:
(x % a == 0) && (x % b == 0) && ... && (x % z == 0)
相当于
(x % lcm(a, b, ... z) == 0)
因此,在您的示例中,比较链可以替换为
if (i % 232792560 == 0) break;
(当然,如果您已经计算了LCM,那么首先运行该程序就没有意义了!
优化#2:
这也是等效的:
if (i % (14549535 * 16)) == 0 break;
或
if ((i % 16 == 0) && (i % 14549535 == 0)) break;
第一个除法可以用掩码代替并与零进行比较:
if (((i & 15) == 0) && (i % 14549535 == 0)) break;
第二除法可以用乘以模逆代替:
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 在许多方面都不同,通常会大大改变生成的代码。 具体来说,我猜中肯地猜,你可以访问两倍的通用寄存器。
在这种情况下,当循环的内部部分被天真地转换为机器代码时,需要将模测试中使用的常量加载到寄存器中。如果没有足够的资源来容纳循环中所需的所有内容,那么它必须将它们从内存中推入。即使来自 1 级缓存,与将其全部保存在寄存器中相比,这也是一个重大打击。
在VS 2010中,MS将默认目标从anycpu更改为x86。我没有像MSFT的资源或面向客户的知识,所以我不会尝试猜测。但是,任何查看您正在进行的性能分析的人都应该尝试两者。
一旦这些差距得到解决,这些数字似乎就更加合理了。任何进一步的差异可能需要比有根据的猜测更好的,相反,他们需要调查生成的机器代码中的实际差异。
我认为有几件事对于优化编译器来说会很有趣。
- 芬诺已经提到的那些:
- lcm 选项很有趣,但我看不到编译器编写器打扰。
- 将除法简化为乘法和掩码。
- 我对此了解不够,但其他人尝试过注意,他们在最近的英特尔芯片上调用分频器明显更好。
- 也许你甚至可以用SSE2安排一些复杂的东西。 当然,模
- 16 操作已经成熟,可以转换为掩模或移位。
- 编译器可以发现所有测试都没有副作用。
- 它可以推测性地尝试一次评估其中的几个,在超级标量处理器上,这可以更快地推动事情,但在很大程度上取决于编译器布局与 OO 执行引擎的交互程度。
- 如果寄存器压力很紧,您可以将常量实现为单个变量,在每个循环开始时设置,然后随着您的进行而递增。
这些都是完全的猜测,应该被视为闲散的蜿蜒曲折。如果你想知道,拆开它。
(移自OP)
将目标从 x86 更改为 anycpu 已将每次运行的平均执行时间从 282 毫秒降低到 84 毫秒。也许我应该将其拆分为第二个线程?
更新:
感谢下面的 Femaref 指出了一些测试问题,事实上,在遵循他的建议后,时间更短,表明 VM 设置时间在 Java 中很重要,但在 C# 中可能不是。在 C# 中,调试符号很重要。
我更新了我的代码以运行每个循环 10,000 次,并且最后只输出平均毫秒。我所做的唯一重大更改是 C# 版本,我切换到 [StopWatch 类][3] 以获得更高的分辨率。我坚持使用毫秒,因为它已经足够好了。
结果:
测试更改并不能解释为什么Java(仍然)比C#快得多。C# 性能更好,但这完全可以通过删除调试符号来解释。如果你读了[Mike Two][4],并且我就这个OP附带的评论进行了交流,你会发现我仅仅通过从调试切换到发布,在五次C#代码运行中平均得到~280ms。
数字:
- 未修改的 Java 代码的 10,000 计数循环平均给了我 45 毫秒(低于 55 毫秒)
- 使用 StopWatch 类的 C# 代码的 10,000 计数循环平均给了我 282 毫秒(低于 320 毫秒)
所有这些都无法解释差异。事实上,差异变得更糟。Java从~5.8倍的速度提高到~6.2倍。
这是一个太短的任务,无法进行适当的计时。您需要至少运行 1000 次,看看会发生什么。看起来您正在从命令行运行这些,在这种情况下,您可能会比较两者的 JIT 编译器。尝试将两者放在一个简单的 GUI 中的按钮后面,并在返回经过的时间之前让该按钮循环至少几百次。即使忽略 JIT 编译,操作系统调度程序的粒度也可能会破坏计时。
哦,因为JIT...只计算按下按钮的第二个结果。:)
也许是因为DateTime
对象的构造比System.currentTimeMillis
贵得多。
在Java中,我会使用System.nanoTime()。任何少于 2 秒的测试都应运行更长时间。值得注意的是,Java非常擅长优化低效代码或什么都不做的代码。 一个更有趣的测试是,如果你优化了代码。
您正在尝试获得一个无需使用循环即可确定的解决方案,即另一种方式会做得更好的问题。
您需要 11 到 20 的因子的乘积,即 2,2,2,2,3,3,5,7,11,13,17,19。 将这些相乘,你就有了答案。