各种托管和非托管语言性能变化的统计分析






3.92/5 (59投票s)
本文使用素数生成函数作为通用基准,比较和对比了原生 C++、Visual Basic 6、C#、VB.NET、托管 C++、MC++ 和原生代码混合、ngen'd 程序集等各种语言的相对性能。
引言
这个项目最初由 Rama 启动,他完成了几乎所有的编码工作。由于个人事务,他的进度受阻,于是他将其交给了 Nish,由 Nish 从 Rama 离开的地方继续。Nish 完成了工作并对所得结果进行了一些统计分析。我们想了解不同语言和工具在性能方面如何相互比较。衡量速度和性能的类别有很多,但首先想到的是计算,因此选择了素数生成作为衡量标准。
接下来的任务是决定如何实现一个可以在各种语言中进行性能比较的东西。首先必须选择各种常见的选项。我们选择了以下十种通用的 Microsoft 程序员可用的不同语言选项。
参与者
- Visual C++ 7
- Visual Basic 6
- C#
- VB.NET
- 完全编译成 IL 的托管 C++
- 将算术密集型代码放入非托管代码的托管 C++
- C# ngen'd
- VB.NET ngen'd
- 完全编译成 IL 并 ngen'd 的托管 C++
- 将算术密集型代码放入非托管代码并 ngen'd 的托管 C++
目标是使用单个测试应用程序来运行和测量时间。因此,用所有 10 种语言选项开发了组件 DLL。我们忽略了考虑 .NET 调用中 COM 带来的开销,因为我们不认为它会非常显著。
代码
我们使用了一个简单的 COM 接口,当给定要计算的素数数量时,它会计算这些素数。IComputePrimes 接口如下所示:-
interface IComputePrimes : IDispatch 
{ 
    HRESULT CalculatePrimes([in] int numPrimes);
};
这是通过使用 ATL 对象向导的默认选项生成的。任何实现此接口的对象都应计算并存储由 numPrimes 指定的素数数量。
现在让我们看看各种情况下的代码是什么样子的。
C++ 代码
STDMETHODIMP CComputePrimes::CalculatePrimes(int numPrimes) { if (m_rgPrimes != NULL) delete [] m_rgPrimes; m_rgPrimes = new int[numPrimes]; m_rgPrimes[0] = 2; m_rgPrimes[1] = 3; int i = 2; int nextPrimeCandidate = 5; while(i < numPrimes) { int maxNumToDivideWith = (int)sqrt(nextPrimeCandidate); bool isPrime = true; for(int j = 0; (j < i) && (maxNumToDivideWith >= m_rgPrimes[j]); j++) { if ((nextPrimeCandidate % m_rgPrimes[j]) == 0) { isPrime = false; break; } } if (isPrime) m_rgPrimes[i++] = nextPrimeCandidate; nextPrimeCandidate += 2; } return S_OK; }
计算出的素数存储在一个整数数组 m_rgPrimes 中。上面的代码尝试将一个奇数除以所有小于其平方根的素数,以确定该数是否为素数。如果是,则将其存储在数组中。
C# 和 MC++
C# 和托管 C++ 的代码类似,不同之处在于,在两种将本机代码混入托管代码的托管 C++ 情况中,代码被拆分为两个单独的函数,如下所示。
void CalculatePrimes(int numPrimes)
{
    primes = new int __gc[numPrimes];
    int __pin* rgPrimes = &primes[0];
    UnmanagedComputePrimes (rgPrimes, numPrimes);
}
该数组是一个托管数组,我们固定该数组并调用一个非托管函数,该函数计算素数并填充数组。
VB/VB.NET 代码
Private Sub IComputePrimes_CalculatePrimes(ByVal numPrimes As Long)
    ReDim Primes(numPrimes)
    Primes(1) = 2
    Primes(2) = 3
    Dim NextPrimeCandidate As Long
    NextPrimeCandidate = 5
    
    Dim i As Long
    Dim j As Long
    Dim MaxNumToDivideWith As Long
    Dim IsPrime As Boolean
    i = 3
    Do While i <= numPrimes
        MaxNumToDivideWith = Sqr(NextPrimeCandidate)
        IsPrime = True
        j = 1
        Do While (j <= i) And (MaxNumToDivideWith >= Primes(j))
            If NextPrimeCandidate Mod Primes(j) = 0 Then
                IsPrime = False
                Exit Do
            End If
            j = j + 1
        Loop
        If IsPrime Then
            Primes(i) = NextPrimeCandidate
            i = i + 1
        End If
        NextPrimeCandidate = NextPrimeCandidate + 2
    Loop
End Sub
VB.NET 代码看起来类似,只是 Sqr 被替换为 System.Math.Sqrt 函数。VB6 代码经过优化编译,将与生成的 C++ 代码非常相似,例如移除所有整数溢出检查。
测试客户端
所有情况都编译成 DLL。所有程序集都注册用于 COM 互操作。我们有两个测试客户端,一个托管客户端和一个本机客户端。本机客户端用 VC++ 编码,并使用 #import 关键字。
__int64 ComputeAndGetResults(
    ATLPrimesLib::IComputePrimesPtr spComputePrimes, 
    int numPrimes)
{
    LARGE_INTEGER li1, li2;
    li1.QuadPart = 0;
    li2.QuadPart = 0;
    QueryPerformanceCounter(&li1);
    spComputePrimes->CalculatePrimes(numPrimes);
    QueryPerformanceCounter(&li2);  
    return li2.QuadPart - li1.QuadPart;
}
int _tmain(int argc, _TCHAR* argv[])
{
    try
    {
        //...   
        ATLPrimesLib::IComputePrimesPtr spComputePrimes(argv[1]);       
        int numPrimes = atol(argv[2]);
        LARGE_INTEGER f;
        QueryPerformanceFrequency(&f);
        std::cout << ComputeAndGetResults(spComputePrimes, numPrimes);
    }
    catch(_com_error& e)
    {
        //...
    }
    return 0;
}
托管客户端使用 C# 编写。
try
{
    Assembly assem = Assembly.Load(args[0]);
    IComputePrimes primes = 
        (IComputePrimes)assem.CreateInstance(args[1]);
    int numPrimes = Int32.Parse(args[2]);
    long t1 = 0, t2 = 0;
    //So that the thunk is generated
    QueryPerformanceCounter(ref t1);
    primes.CalculatePrimes(numPrimes);
    QueryPerformanceCounter(ref t2);
    long freq = 0;
    QueryPerformanceFrequency(ref freq);
    Console.Write(t2 - t1);
}
catch(Exception e)
{
    Console.Error.WriteLine(e.ToString());
}
两个客户端都使用 QueryPerformanceCounter API 调用来衡量性能。值越小越好。我们有一个名为 RunMultipleTests [C#] 的程序,它为 10 种 DLL 类型中的每一种调用两个客户端。请查看 Main.cs 文件了解其实现方式。我们分别调用了所有 10 种实现一次,以生成 10 个素数,然后是 100、1,000、10,000、100,000,最后是 1,000,000(一百万)。
结果
我选择了一些生成的结果在这里讨论。数字越小表示性能越高。
| 语言 | 素数 | 本地被调用者 | 托管被调用者 | 
|---|---|---|---|
| ATLPrimes | 10 | 18,241 | 192,538 | 
| VBPrime | 10 | 21,057 | 191,597 | 
| CSharpPrimes | 10 | 1,201,258 | 1,003,710 | 
| CSharpPrimes (ngen'd) | 10 | 99,017 | 20,357 | 
| VBNetPrimes | 10 | 1,680,241 | 1,440,198 | 
| VBNetPrimes (ngen'd) | 10 | 101,201 | 21,644 | 
| MCPPPrimes1 | 10 | 1,443,943 | 1,117,279 | 
| MCPPPrimes1 (ngen'd) | 10 | 107,362 | 29,574 | 
| MCPPPrimes2 | 10 | 977,667 | 699,355 | 
| MCPPPrimes2 (ngen'd) | 10 | 127,969 | 53,861 | 
上表显示了生成 10 个素数时获得的各种结果。正如您所观察到的,从原生 C++ 客户端调用的 ATL DLL 性能最快。但您可能会惊讶地发现,当通过 .NET COM 互操作从托管客户端调用相同的 DLL 时,性能下降了近 900%。COM 互操作及其所谓的效率真是令人失望。看到从原生客户端调用的 VB DLL 显示出比托管 C++ DLL 优越得多的性能,我的自尊心受到了很大的打击。有趣的是,托管 DLL 在原生调用和托管调用之间没有显示出显著的性能差异。例外是 MC++ DLL 版本 2,它是非托管-托管混合版本。所有托管 DLL 在 ngen'd 后都显示出惊人的性能提升。也许我们都应该开始更认真地对待 ngen 了。令人非常惊讶的是,ngen'd C# DLL 是所有组合中第二快的。奇怪的是,VB.NET DLL 是其中最慢的。这是上表的图表。

但是 10 个素数太少了,无法得出这样的结论。因此,我们现在将转到 1000 个素数的结果。下载中的 Excel 表格将列出感兴趣的人的完整表格。您也可以随时调整示例项目以获得其他组合和排列。
| 语言 | 素数 | 本地被调用者 | 托管被调用者 | 
|---|---|---|---|
| ATLPrimes | 1000 | 1,674,822 | 1,843,077 | 
| VBPrime | 1000 | 1,659,063 | 1,830,014 | 
| CSharpPrimes | 1000 | 2,951,717 | 2,665,328 | 
| CSharpPrimes (ngen'd) | 1000 | 1,755,078 | 1,655,643 | 
| VBNetPrimes | 1000 | 3,606,253 | 3,400,125 | 
| VBNetPrimes (ngen'd) | 1000 | 2,108,643 | 1,954,464 | 
| MCPPPrimes1 | 1000 | 3,110,415 | 2,742,913 | 
| MCPPPrimes1 (ngen'd) | 1000 | 1,719,734 | 1,642,938 | 
| MCPPPrimes2 | 1000 | 2,678,031 | 2,359,011 | 
| MCPPPrimes2 (ngen'd) | 1000 | 1,748,994 | 1,742,121 | 
哎呀,哎呀,哎呀!突然间,性能比较似乎不像我们生成 10 个素数时那么对比鲜明了。现在,性能最佳的组合是 ngen'ing 后的完全托管的 MC++ DLL。令人痛苦的是,VB6 DLL 在托管和本地调用中都超越了 ATL DLL。VB.NET 再次表现出糟糕的性能。但您会再次看到 ngen'ing 对托管程序集具有惊人的性能提升效果。现在让我们跳过几张表格,直接查看一百万素数的结果。
| 语言 | 素数 | 本地被调用者 | 托管被调用者 | 
|---|---|---|---|
| ATLPrimes | 1000000 | 19,389,792,910 | 19,400,345,304 | 
| VBPrime | 1000000 | 19,334,822,911 | 19,340,626,315 | 
| CSharpPrimes | 1000000 | 19,371,408,155 | 19,426,052,083 | 
| CSharpPrimes (ngen'd) | 1000000 | 19,386,294,992 | 19,325,672,507 | 
| VBNetPrimes | 1000000 | 19,870,238,968 | 19,980,902,937 | 
| VBNetPrimes (ngen'd) | 1000000 | 20,007,201,165 | 19,900,407,405 | 
| MCPPPrimes1 | 1000000 | 19,363,699,234 | 19,346,647,324 | 
| MCPPPrimes1 (ngen'd) | 1000000 | 19,339,817,493 | 19,317,645,432 | 
| MCPPPrimes2 | 1000000 | 19,450,368,014 | 19,325,875,844 | 
| MCPPPrimes2 (ngen'd) | 1000000 | 19,345,122,911 | 19,429,232,591 | 
Rama 和 Nish 都惊喜地发现,随着素数生成数量的增加,性能上的显著差异开始明显减弱,直到最终达到一百万素数时,它们的性能都非常相似。同样,ngen'd 的完全托管 MC++ DLL 表现最佳,而 VB.NET DLL 表现最差。最令人好奇的是,ngen'ing 实际上对 VB.NET DLL 产生了负面影响。这是图形表示。

这是另一张显示 ngen 对托管程序集影响的图表

你会注意到 ngen 对 VB.NET 程序的影响最大,而对包含原生代码块的 MC++ 代码的影响最小。你还会注意到,ngen 的影响似乎随着我们生成更高数量的素数而减小。这在下图清楚地显示出来

到目前为止,我们只看到了方法被调用一次的情况。因此,托管版本因 JIT 编译开销而受损。所以我们进行了多次调用,试图查看托管版本在第一次调用后是否会变快。我们循环调用了三次。以下是一些示例测试结果。请不要对结果与上表中的差异感到惊讶。第一组测试是在双 P-III 550 MHz,384 Mb RAM 的机器上运行的。因此,第一组结果的数字更高,因为双处理器机器的性能计数器频率相当高。多方法调用测试都是在单 P-III 800 MHz,384 Mb RAM 的机器上运行的。显然,性能频率较低,因此数字也较小。但你会注意到,比率大致保持不变。
| 语言 | 素数 | 本地被调用者 #1, #2 & #3 | 托管被调用者 #1, #2 & #3 | ||||
|---|---|---|---|---|---|---|---|
| CSharpPrimes | 10 | 5973 | 35 | 25 | 4848 | 56 | 46 | 
| CSharpPrimes (ngen'd) | 10 | 476 | 32 | 276 | 95 | 60 | 45 | 
| VBNetPrimes | 10 | 7663 | 38 | 29 | 8144 | 59 | 50 | 
| VBNetPrimes (ngen'd) | 10 | 489 | 35 | 29 | 101 | 63 | 51 | 
| MCPPPrimes1 | 10 | 6270 | 34 | 26 | 5383 | 57 | 46 | 
| MCPPPrimes1 (ngen'd) | 10 | 499 | 31 | 24 | 127 | 56 | 46 | 
| MCPPPrimes2 | 10 | 4466 | 38 | 25 | 3646 | 61 | 47 | 
| MCPPPrimes2 (ngen'd) | 10 | 624 | 31 | 25 | 247 | 65 | 47 | 
你会注意到,第二次调用和后续调用的性能有了惊人的提升。最显著的性能提升是非 ngen'd 的 DLL。ngen'd 的 C# DLL 在第三次运行时显示出轻微的异常,但这可能是由于某些操作系统活动恰好与那一刻重合。这仅仅是一个异常,你可以安全地忽略它。因此,无论你是否 ngen,从第二次运行开始,你的方法都会像原生调用一样快,因为没有 JIT 开销。但显然不会像原生调用那样快,因为还有其他开销,比如垃圾回收。你还会注意到,第三次调用实际上比第二次调用有所改进,但这种跨调用改进随着我们增加调用循环次数而急剧下降。现在让我们看看生成大量素数的结果。
| 语言 | 素数 | 本地被调用者 #1, #2 & #3 | 托管被调用者 #1, #2 & #3 | ||||
|---|---|---|---|---|---|---|---|
| CSharpPrimes | 10000 | 165346 | 162135 | 158838 | 159857 | 157004 | 156279 | 
| CSharpPrimes (ngen'd) | 10000 | 155593 | 154611 | 156586 | 157266 | 156629 | 154440 | 
| VBNetPrimes | 10000 | 180720 | 172494 | 173198 | 175535 | 171634 | 170705 | 
| VBNetPrimes (ngen'd) | 10000 | 172432 | 173577 | 172076 | 173416 | 175305 | 173921 | 
| MCPPPrimes1 | 10000 | 165775 | 159783 | 160712 | 161040 | 158640 | 157350 | 
| MCPPPrimes1 (ngen'd) | 10000 | 155954 | 164162 | 159695 | 155283 | 159554 | 155928 | 
| MCPPPrimes2 | 10000 | 160007 | 154570 | 154990 | 171823 | 158746 | 156686 | 
| MCPPPrimes2 (ngen'd) | 10000 | 156243 | 153972 | 154144 | 154966 | 157720 | 167443 | 
啊,现在 ngen 的性能改进不再那么明显了。这再次证实了这样一个事实:从长远来看,JIT 的瓶颈会慢慢消失,最终几乎完全消失。
一些结论
- 使用 ngen 对您的托管代码性能有巨大的提升。当从托管客户端调用时,这种提升比从原生 C++ 客户端调用时更高。
- 托管/非托管转换效率低下。而且非托管到托管的转换比托管到非托管的转换慢得多。因此,尽可能避免托管/非托管转换是最好的选择。
- 如果托管代码被重复调用,其性能会有显著提升,因为 JITing 只会在第一次执行。
- 随着素数数量的增加,各种语言之间的性能差异开始缩小,这再次强调了在没有 JIT 开销的情况下,托管代码与原生代码同样出色。
- 在所有 .NET 编译器中,VB.NET 编译器似乎生成最慢的代码。我们认为这是因为 VB.NET 会检查所有算术操作的溢出(使用 ILDasm 验证)。
- C# 编译器似乎明显优于 MC++ 编译器(纯托管代码)。
- 使用 ngen 对 VB.NET 程序集影响最大,对 MC++ 程序集影响最小。
- 在 C++ 中混合使用非托管和托管代码比纯 MC++ 效率高得多。事实上,对于完全托管的项目,纯 MC++ 比 C# 慢得多。因此,除非您计划集成 MFC 或 ATL,否则 C# 是比 MC++ 更好的选择。
更新和修复
- 2002 年 8 月 10 日 - 修复了一个重大错误。在循环方法测试中,我们在错误的地方进行了循环。我们实际上循环了客户端进程的执行,而不是循环方法。此问题已修复,表格和 Excel 工作表已更新。


