为什么C#执行Math.Sqrt()比VB.NET慢?

51

背景

今天早上在运行基准测试时,我和同事们发现了一些关于C#代码与VB.NET代码性能的奇怪现象。

我们开始比较 C# 和 Delphi Prism 计算质数,结果发现 Prism 大约快30%。我想 CodeGear 在生成 IL 时优化了代码(exe 文件大小是 C# 的两倍,并且有各种不同的 IL)。

我决定在 VB.NET 中写一个测试,假设微软的编译器会为每种语言编写基本相同的 IL。然而,结果更加令人震惊:使用相同操作,在 C# 上的代码运行速度是 VB.NET 的三倍慢!

生成的 IL 稍有不同,但并不是非常明显,我也没有能力阅读它以理解差异。

基准测试

下面分别附上每个代码的内容。在我的计算机上,VB 在大约6.36秒内找到了 348513 个质数。C# 则需要 21.76 秒才能找到同样数量的质数。

计算机规格和注意事项

  • Intel Core 2 Quad 6600 @ 2.4Ghz

在我测试的每台计算机上,C# 和 VB.NET 之间的基准测试结果都有明显差异。

这两个控制台应用程序都是在发布模式下编译的,但除此之外,没有对Visual Studio 2008生成的默认项目设置进行更改。

VB.NET 代码

Imports System.Diagnostics

Module Module1

    Private temp As List(Of Int32)
    Private sw As Stopwatch
    Private totalSeconds As Double

    Sub Main()
        serialCalc()
    End Sub

    Private Sub serialCalc()
        temp = New List(Of Int32)()
        sw = Stopwatch.StartNew()
        For i As Int32 = 2 To 5000000
            testIfPrimeSerial(i)
        Next
        sw.Stop()
        totalSeconds = sw.Elapsed.TotalSeconds
        Console.WriteLine(String.Format("{0} seconds elapsed.", totalSeconds))
        Console.WriteLine(String.Format("{0} primes found.", temp.Count))
        Console.ReadKey()
    End Sub

    Private Sub testIfPrimeSerial(ByVal suspectPrime As Int32)
        For i As Int32 = 2 To Math.Sqrt(suspectPrime)
            If (suspectPrime Mod i = 0) Then
                Exit Sub
            End If
        Next
        temp.Add(suspectPrime)
    End Sub

End Module

C# 代码

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Diagnostics;

namespace FindPrimesCSharp {
    class Program {
        List<Int32> temp = new List<Int32>();
        Stopwatch sw;
        double totalSeconds;


        static void Main(string[] args) {

            new Program().serialCalc();

        }


        private void serialCalc() {
            temp = new List<Int32>();
            sw = Stopwatch.StartNew();
            for (Int32 i = 2; i <= 5000000; i++) {
                testIfPrimeSerial(i);
            }
            sw.Stop();
            totalSeconds = sw.Elapsed.TotalSeconds;
            Console.WriteLine(string.Format("{0} seconds elapsed.", totalSeconds));
            Console.WriteLine(string.Format("{0} primes found.", temp.Count));
            Console.ReadKey();
        }

        private void testIfPrimeSerial(Int32 suspectPrime) {
            for (Int32 i = 2; i <= Math.Sqrt(suspectPrime); i++) {
                if (suspectPrime % i == 0)
                    return;
            }
            temp.Add(suspectPrime);
        }

    }
}

C#中执行Math.Sqrt()为什么比VB.NET慢?


这两个代码示例在一个小点上完全不同,使得这个基准测试完全失真。 - Dykam
1
@I__: http://www.urbandictionary.com/define.php?term=troll - Dykam
1
@I__: 我看到你做的了。 - Matt Winckler
5
@matt,我不明白l_在那里做了什么。 - MarkJ
这个确切的问题(VB.Net的For...To循环的一次性评估行为)最近在这里出现了:https://dev59.com/wVQK5IYBdhLWcg3wBral/ - Max Barraclough
6个回答

82

C# 实现在每次循环时重新计算 Math.Sqrt(suspectPrime),而 VB 只在循环开始时计算。这只是受控制结构本质的影响。在 C# 中,for 只是一个花哨的 while 循环,而在 VB 中则是一个单独的结构。

使用此循环将平衡分数:

        Int32 sqrt = (int)Math.Sqrt(suspectPrime)
        for (Int32 i = 2; i <= sqrt; i++) { 
            if (suspectPrime % i == 0) 
                return; 
        }

2
kjack:我不知道VB6,但在VB.Net中,终止值仅在循环的开头进行评估(根据规范的第10.9.2节),因此无需使用临时变量。 - Gabe
1
@kjack BASIC和(如果我没记错的话)FORTRAN仅计算for边界一次。您需要检查您的实现,但这样做将是微不足道的(具有打印某些内容的函数边界)。 - Pete Kirkham
3
对我来说这非常令人惊讶。 suspectPrime是一个循环不变量,所以优化器难道不应该将计算提升出循环吗? - Peter Ruderman
1
@Peter。如果你像使用foreach(...)一样使用for(...),会怎么样呢?目前可以使用for(; iterator.MoveNext();), 但如果它只评估条件一次,则不可能这样做。在VB.Net中的For是一个范围评估,而在C#中的for是一个条件循环。 - Matthew Whited
8
彼得·鲁德曼:是的,这是一个循环不变式,但优化器需要知道 Math.Sqrt 是一个纯函数(没有副作用并且总是返回相同的值),这可能超出了它的能力范围。 - Gabe
显示剩余4条评论

11

我赞同这个说法,即C#代码在每次迭代中都计算sqrt,以下是来自Reflector的证明:

VB版本:

private static void testIfPrimeSerial(int suspectPrime)
{
    int VB$t_i4$L0 = (int) Math.Round(Math.Sqrt((double) suspectPrime));
    for (int i = 2; i <= VB$t_i4$L0; i++)
    {
        if ((suspectPrime % i) == 0)
        {
            return;
        }
    }
    temp.Add(suspectPrime);
}

C# 版本:

 private void testIfPrimeSerial(int suspectPrime)
{
    for (int i = 2; i <= Math.Sqrt((double) suspectPrime); i++)
    {
        if ((suspectPrime % i) == 0)
        {
            return;
        }
    }
    this.temp.Add(suspectPrime);
}

这有点指向VB生成的代码可以更高效地运行,即使开发者够天真,将sqrt的调用放在循环定义中。


1
我想知道他们进行了什么样的分析,以确保表达式不会引起任何副作用。 - ChaosPandion
8
它们的语法并没有要求在每次运行时都要评估表达式。仅仅是说,从“x到y”,并在每个循环中将递增的值分配给变量“i”。它并没有说,“我会一直执行直到i大于等于表达式”。这与C#的语法含义非常不同。 - Dykam
@Dykam - 但是为什么要在一个明确定义的概念中引入如此令人困惑的方面呢? - ChaosPandion
4
VB的for循环在定义时被规定为在循环开始时仅评估表达式一次。使用复杂表达式作为终止条件是完全安全的,因为保证只运行一次。 - Gabe
1
@ChaosPandion,我曾经使用过几种其他语言,它们采用了这种结构而不是C语言的方式。 - Dykam
显示剩余3条评论

9

以下是 for 循环的反编译 IL。如果您比较两者,就会发现 VB.Net 只在一次循环中执行 Math.Sqrt(...),而 C# 在每次循环中都进行检查。要解决这个问题,您需要像其他人建议的那样执行 var sqrt = (int)Math.Sqrt(suspectPrime);

... VB ...

.method private static void CheckPrime(int32 suspectPrime) cil managed
{
    // Code size       34 (0x22)
    .maxstack  2
    .locals init ([0] int32 i,
         [1] int32 VB$t_i4$L0)
    IL_0000:  ldc.i4.2
    IL_0001:  ldarg.0
    IL_0002:  conv.r8
    IL_0003:  call       float64 [mscorlib]System.Math::Sqrt(float64)
    IL_0008:  call       float64 [mscorlib]System.Math::Round(float64)
    IL_000d:  conv.ovf.i4
    IL_000e:  stloc.1
    IL_000f:  stloc.0
    IL_0010:  br.s       IL_001d

    IL_0012:  ldarg.0
    IL_0013:  ldloc.0
    IL_0014:  rem
    IL_0015:  ldc.i4.0
    IL_0016:  bne.un.s   IL_0019

    IL_0018:  ret

    IL_0019:  ldloc.0
    IL_001a:  ldc.i4.1
    IL_001b:  add.ovf
    IL_001c:  stloc.0
    IL_001d:  ldloc.0
    IL_001e:  ldloc.1
    IL_001f:  ble.s      IL_0012

    IL_0021:  ret
} // end of method Module1::testIfPrimeSerial

... C# ...

.method private hidebysig static void CheckPrime(int32 suspectPrime) cil managed
{
    // Code size       26 (0x1a)
    .maxstack  2
    .locals init ([0] int32 i)
    IL_0000:  ldc.i4.2
    IL_0001:  stloc.0
    IL_0002:  br.s       IL_000e

    IL_0004:  ldarg.0
    IL_0005:  ldloc.0
    IL_0006:  rem
    IL_0007:  brtrue.s   IL_000a

    IL_0009:  ret

    IL_000a:  ldloc.0
    IL_000b:  ldc.i4.1
    IL_000c:  add
    IL_000d:  stloc.0
    IL_000e:  ldloc.0
    IL_000f:  conv.r8
    IL_0010:  ldarg.0
    IL_0011:  conv.r8
    IL_0012:  call       float64 [mscorlib]System.Math::Sqrt(float64)
    IL_0017:  ble.s      IL_0004

    IL_0019:  ret
} // end of method Program::testIfPrimeSerial

8

离题一下,如果你已经开始使用VS2010,你可以利用PLINQ使C#(可能还包括VB.Net)更快。

将那个for循环改成...

var range = Enumerable.Range(2, 5000000);

range.AsParallel()
    .ForAll(i => testIfPrimeSerial(i));

我在自己的机器上将执行时间从7.4秒降至4.6秒。将其转换为发布模式可以进一步缩短一些时间。


一个很好的切入点 - 并行特性实际上是我们之前运行基准测试时的一部分,它们确实令人印象深刻! - Matt Winckler
@Gabe:我有一台速度较慢的双核笔记本电脑。我不知道CPU是什么品牌。 - 48klocs

3
区别在于循环;你的C#代码在每次迭代时都计算平方根。将该行代码从以下内容更改为:
for (Int32 i = 2; i <= Math.Sqrt(suspectPrime); i++) {

to:

var lim = Math.Sqrt(suspectPrime);
for (Int32 i = 2; i <= lim; i++) {

我将机器的运行时间从26秒降低到了7秒左右。


-1
通常不需要。它们都编译成CLR(公共语言运行时)字节码。这类似于JVM(Java虚拟机)。

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