65.9K
CodeProject 正在变化。 阅读更多。
Home

头对头基准测试:C++ vs .NET

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.64/5 (336投票s)

2011年6月17日

LGPL3

56分钟阅读

viewsIcon

1315841

downloadIcon

2353

C++ 与 C# 相比有多快?让我们比较一下直接在两种语言之间移植的代码。

引言

我非常喜欢 .NET Framework、C# 和 IntelliSense,但仍有很多人说它比 C++ 慢,并指出视频游戏和“主要应用程序”仍然是用 C++ 编写的。反对者经常指出,像 Office、Internet Explorer 和 Windows Media Player 这样的应用程序仍然是用 C++ 编写的,这似乎暗示 C++ 仍然是性能的最佳选择。但等等,这主要是因为这些应用程序比 .NET Framework 本身更老,或者因为 .NET Framework 对 DirectShow 的支持很糟糕,或者对 Direct3D 的支持不够理想吗?

嗯,我已经使用 C# 多年了,很少对其性能有任何抱怨——也就是说,直到我为一个 Pocket PC 编写了一个程序,并震惊地发现它需要 35 秒才能启动。

通过本文,我将尝试公平地比较 C# 和 C++(非托管)在桌面和移动(Windows CE)场景中的性能。在这篇文章的第一个版本中,我首先用 C# 编写了大部分代码,然后手动逐行移植到 C++,用 STL 对应项替换标准 .NET 类(其中一些代码是反向移植的)。由于这非常耗时,因此这些基准测试的多样性和范围非常有限。对于这个更新版本,我添加了三个由两个人编写的额外跨语言基准测试,以及一个使用排序映射的哈希表基准测试的变体。

我的主要目标是衡量编译器/JIT 和最常用标准库部分之间的性能差异。我主要将我的测试限制在 C++ 标准库,因此我们无法比较 XML 解析或 JPG 加载的速度,因为 .NET Framework BCL 可以做到而 C++ 标准库做不到。不,我的基准测试将仅限于以下领域

  • 字符串处理:std::string vs System.String
  • 哈希表:hash_map<K,V> vs Dictionary<K,V>
  • 二叉树:map<K,V> vs SortedDictionary<K,V>
  • 简单结构:在我的工作中,我经常创建小型性能关键型结构,例如仅包含一个整数字段的定点类型。
  • 数学泛型:你需要 付出相当大的努力 才能使用 .NET 泛型编写数学代码。你是否也为此承受了性能损失?
  • 简单算术:加减法;乘以常数、除以常数和模常数,用于不同的数据类型。我还尝试了一个整数平方根算法(以及它的浮点等效项,以便完整性)。
  • 64 位整数:一些编译器对此处理得相当糟糕。
  • 文本文件扫描:我们能多快地逐行读取文本文件?
  • 排序
  • P/Invoke 和无操作方法(仅限 C#)

本文更新

原始文章在 Visual Studio 2008 中编译了代码;除了这些结果之外,我还添加了 Visual Studio 2010(包括 .NET 4)和 Mono 的结果,因此你可以看到差异。Microsoft 在 VS 2010 中放弃了对 Windows CE 的支持,这就是我最初使用 VS 2008 的原因。当然,我欢迎读者尝试使用其他编译器或平台(如 GCC 或 Windows Phone 7)编译代码。请注意,你观察到的任何差异都可能由使用不同的处理器和编译器引起。

我的测试工作站是 Quad-core 64-bit Intel Q9500 @ 2.83 GHz(但所有测试都是单线程的),运行 Windows 7。移动设备是 Mentor Ranger 4,配备 600MHz ARM 处理器和 .NET Compact Framework 3.5。

许多人给本文的原始版本投了“1”票。其中大多数人不愿意或无法具体说明我为什么应该得到最低分数,但我会回应一些具体的批评。我衷心感谢那些发现不完善之处但没有因此惩罚我的人!

  • 在 C++ 中,您没有禁用已检查的迭代器:疏忽。我没有意识到已检查的迭代器在 Release 构建中是启用的。事实证明,VC9 会做两件不同的事情来减慢你的 C++ 代码。一个称为“迭代器调试”(仅在调试构建中启用),它有时会让代码慢得离谱;另一个称为“迭代器检查”或“已检查迭代器”效果较小,但在 Release 构建中默认启用。(一位 1 票投票者报告称,它们在 VS2010 中默认禁用。)我知道第一个,并将其与第二个混淆了。抱歉。这次除了一个场景外,它们将在所有场景中禁用。
  • 您逐行移植了代码:那又怎样?你们就没有什么具体可说的吗?你们有人看过代码吗?我选择了一部分 C++ 和 C#,使其可以在两种语言中以相同的方式完成相同的任务。我只收到了一项*具体的*批评,关于 GenericSum 在 C++ 中的写法。我试图解释 C++ 编译器配备了名为“优化器”的工具,它可以执行一项称为“内联”的技巧,该技巧(正如我的图表所示)使基准测试结果与没有不必要的函数调用一样好。但是,根据投票情况,有些人并不信服。所以这次我更改了 C++ 代码以消除额外的函数调用,但我被迫删除了一个 C++ 测试(显示当函数是虚拟的时性能会怎样)。当然,里面仍然有两个函数调用,但我认为这相当标准的 C++ 代码,而且优化器仍然存在。
  • 在 C++ 中,您没有启用 SSE2 或“快速”浮点模型:是的。我必须承认,我的主要目标是测试 ARM 性能,它没有神奇的编译器开关来提高代码运行速度。这次我将单独进行 VC9 测试,一次使用默认编译器开关的 x86,另一次使用 architecture=SSE2、floating point model="fast" 和 no checked iterators,这样你们就可以看到差异,第三次使用 x64 和所有所有功能。我仍然认为在没有 SSE2 的情况下进行基准测试是合理的,因为一些旧电脑无法运行依赖 SSE2 的可执行文件(顺便说一句,我无法确定非 SSE2 处理器何时停止生产或 SSE2 的市场份额是多少)。但是,据我所知,所有 x64 系统都支持 SSE2(如果我错了,请纠正我!)。因此,在进行 x64 构建时,你最好启用 SSE2。如果你将自己限制在当前一代系统(或如果你分发程序的单独构建版本给旧 PC),启用 SSE2 是个好主意。
  • 您没有使用 VS2010:你仅仅因为这个就给了我 1 票?呃。好吧,我也会测试 VS2010。而且我将为所有 VC10 测试启用 SSE2 和快速浮点模型。但你将被排除在我的生日派对之外。
  • 关于定向优化 (PGO):嗯,我现在有 6 组不同的 C++ 基准测试结果,我不确定 PGO 结果的现实性。毕竟,每次试验的输入数据都相同,而且一些基准测试是人为的。因此,PGO 在这些基准测试上可能不会像在实际代码中那样带来相同的性能提升。我有一个更现实(但源代码是闭源的)的 C++ 基准测试,可以带 PGO 和不带 PGO 运行;如果你有兴趣,请留言,或者自己对现有基准测试运行 PGO。
  • 比较语言而不是它们库的实现:好像每个人都从头开始编写自己的哈希表、列表、文件管理代码和字符串类!而且别忘了,内存管理是标准库的一部分;内存分配是否也属于禁区?看,**我的某些基准测试根本不使用任何标准库**,而且大多数分配很少或没有分配。如果你出于某种原因对使用标准库的测试感到不舒服,请忽略它们。
  • 使用更复杂的实际算法:你想让我用两种不同的语言编写一个复杂的大型实际算法两次吗?当然!说出算法的名字,告诉我你会为我的时间支付多少钱……给你说吧,我找到了一些第三方多语言基准测试。它们不是超复杂的,但我可以使用它们吗?

一些非 1 票投票者建议 C++ 中应避免使用 ifstream,因为它性能差且设计不佳,无法实现高性能。鉴于结果,他们显然是正确的!在此基准测试中,我添加了 FILE* 测试(不是直接的 Win32 API 调用,因为我更喜欢可移植的代码)。

还有一些其他 1 票的批评,我根本无法用于改进本文,例如

  • 微软的技术人员(公司中的最高技术职位!)说 .NET 不利于系统编程:所以不要用 C# 编写你的下一个操作系统。这与我有什么关系?
  • 太主观:为什么,因为我承认我喜欢 C#?那又怎样?你能指出我的代码有什么*具体*错误吗?
  • 方法论存在严重问题:具体是什么?!?
  • 您的文章包含大量虚假和误导性陈述:我请你举一个例子,但你没有。
  • 付出的努力可得一分:嗯,是的。
  • 你真的思维扭曲:我努力不让它影响结果。不过,我会投票给 Ron Paul/Dennis Kucinich 组合。
  • 众所周知,原生代码更快:哦,当然,我测量差异是错误的!好吧,我很抱歉我的 CPU 辜负了你的信任。但我的结果表明,C++ 通常速度更快(*如果*你选择高质量的 C++ 库),并且在 ARM 上速度要快得多。所以我们意见一致,但你还是给了我 1 票!此外,C++ 在新的数独测试中以绝对优势获胜,而且我发现了一些导致 x64 JIT 缓慢的新原因。这是否意味着 C++ 爱好者现在会给我 5 票,而 C# 爱好者会突然改投 1 票?
  • 来自微软的营销宣传?:不是。
  • 请考虑大量评论并更新您的基准测试:请考虑大量更新并更新您的投票。
  • 我最喜欢的一句是:学习 C++ 可能是尝试在基准测试中使用它之前的一个好的起点:我从 1994 年购买第一台 PC 开始就一直在编写 C++。除其他外,我从头用 C++ 编写了一个 SNES 模拟器和一个用于 ARM 的 GPS 系统,其中包含自定义优化的绘图基元。接下来,你是不是要告诉我,除非我用汇编语言编写代码,否则我就不是一个“真正的” C++ 程序员……

希望这次能有更合理的讨论。

在开始之前,我应该提到,没有简单的方法可以将垃圾回收与基准测试匹配起来,所以我会在每次 C# 基准测试之前执行一次 GC,并且不尝试将 GC 时间计入总时间。我本来想在最后打印出总 GC 时间,但似乎不可能。没有用于总 GC 时间的属性或性能计数器;有一个“瞬时”的“% GC 时间”测量值,但没有明显的聚合方法。我可以禁用测试间的 GC,但那样一些 GC 时间就会被错误地归因。我可以将强制 GC 时间添加到每个测试的测量时间中,但这会人为地夸大 C# 的时间数字(因为强制的第 2 代集合比“自然”的 GC 浪费时间)。但是,GC 时间在这里不应该太重要,因为大多数这些基准测试不会分配大量的堆对象(除了涉及字符串和 SortedDictionary 的测试)。

 

 

测试场景

很明显,对于桌面软件,目标 CPU、目标 JIT(在 C# 中)和编译器开关(在 C++ 中)有时会对性能产生很大影响。因此,对于本文的新版本,我将测试几个不同的场景(总共 11 个),以便您可以看到编译器开关带来的差异。我可以测试更多场景,但图表正在变得拥挤。

  1. "x86 VC9 default": 优化后的 Release 构建,使用默认编译器开关,x86,VC++ 2008
  2. "x86 VC9 NCI x86": 无已检查迭代器,Release 构建,默认编译器开关,x86,VC++ 2008
  3. "x86 VC9 SSE2 x86": Release 构建,无已检查迭代器,SSE2,快速 FP 模型,完全优化,x86
  4. "x64 VC9 SSE2 x64": Release 构建,无已检查迭代器,SSE2,快速 FP 模型,完全优化,x64
  5. "x86 VC10 SSE2 x64": Release 构建,无已检查迭代器,SSE2,快速 FP 模型,完全优化,x64,VC++ 2010
  6. "x64 VC10 SSE2 x64": Release 构建,无已检查迭代器,SSE2,快速 FP 模型,完全优化,x64,VC++ 2010
  7. "x86 Mono": 在 VS2008 中构建,使用 Mono 运行,默认编译器开关
  8. "x86 .NET3": VS2008,Microsoft .NET Framework 3.5,x86(JIT 与 .NET 2.0 相同)
  9. "x64 .NET3": VS2008,Microsoft .NET Framework 3.5,x64(JIT 与 .NET 2.0 相同)
  10. "x86 .NET4": VS2010,Microsoft .NET Framework 4.0,x64
  11. "x64 .NET4": VS2010,Microsoft .NET Framework 4.0,x64

我还使用 "VC9 NCI x86" 设置加上 /Ox(完全优化)进行了基准测试,以查看它与默认的 /O2(最大化速度)是否有显著差异。差异在每个基准测试中都很小,不值得作为单独的条形包含在条形图中。

一些人想知道 Mono 是否会因为 LLVM JIT(--llvm 命令行开关)而更快。我发现 LLVM 在当前版本的 Windows(Mono 2.10.2)中没有显著差异。一些测试运行得稍快;大多数测试运行得稍慢。并且第一次运行(JIT 发生时)和后续运行之间只有一个小的差异,这表明 JIT 时间不是速度未增加的原因。LLVM 开关之间差异很小,以至于我认为不值得将其包含在条形图中。

我将在稍后进行“实际”基准测试。但首先……

Debug 与 Release 构建

 

一些 C++ 开发人员最终会分发 Debug 构建,以便“断言”语句可以继续工作,或者以便在需要时调试生产机器。不幸的是,正如下面的图表所示,这往往会杀死性能

注意:我没有为新文章更新这些图表。我认为新图表不会教会我们关于 Debug 构建的新知识。)

BenchmarkCppVsDotNet/DebugVsReleaseC__.png

 

在此图中,比例尺已调整,使得 x86 Release 构建花费一个单位时间。一些操作,尤其是函数调用和任何涉及 STL 的操作,在桌面 Debug 构建中速度会慢很多。我怀疑“Generic sum”速度如此之慢主要是因为 STL 向量扫描速度较慢;你可能会发现 hash_map 在调试构建中几乎无法使用,正如你将看到的。

当我第一次运行 x86 Debug 基准测试时,我注意到它似乎“卡住了”。我在调试器中停止了它,发现它正在运行“string hash_map: 1 Adding items”测试。此测试将哈希表填充 250 万个项目,清除列表,然后再填充三次。嗯,当在 clear() 命令后插入第一个项目时,hash_map 会卡住,不知道在做什么,耗时约 4 分钟,总运行时间为 16 分 20 秒(比 Release 构建长 78 倍)。我让它整夜运行,并发现删除测试更糟糕。此测试尝试删除 1000 万个项目,但列表大小限制为 250 万,因此大多数请求都失败了。无论出于何种原因,此测试耗时 10 小时 37 分钟,比 Release 构建长 9400 倍!

事实证明,这是由 Visual C++ 的“迭代器调试”错误功能引起的。#define _HAS_ITERATOR_DEBUGGING 0 使运行基准测试成为可能,但哈希表测试在调试构建中仍然非常慢。

 

此图还表明,相同的一段代码可以以不同的速度运行,具体取决于你的平台和编译器设置。例如,一些任务,尤其是涉及 64 位整数(或程度较低的浮点数)的任务,在以 x64 为目标时运行速度更快。偶尔,x64 会更慢地处理任务(出于某种原因,使用 ifstream::read() 读取文件在 64 位模式下慢得多)。

 

C# 的结果更一致

 

BenchmarkCppVsDotNet/DebugVsReleaseC_.png

事实上,只有少数操作在 C# Debug 构建中比 Release 构建慢。其中一个原因是 .NET 标准库(称为 BCL - Base Class Library)即使在运行 Debug 构建时也是经过优化的。这解释了为什么 Dictionary<K,V>、文件和字符串解析测试(主要测试 BCL)的速度在 Debug 和 Release 构建中几乎相同。当 C# Debug 构建在调试器外部运行时(本基准测试就是这样),似乎会启用其他优化。事实上,C# x86 Debug 构建在所有测试中都优于 C++ x86 Debug 构建,只有两个例外。

但当然,我们真正关心的是 Release 构建。本文的其余部分将仅考察 Release 构建(使用 Visual Studio 的默认编译器设置)。

第三方基准测试

为了增加我的基准测试的“真实性”,我纳入了另外两个人编写的三个基准测试。我稍微重构了他们的代码,以使代码更适合我的基准测试框架,并在(两个基准测试中)通过消除二维数组来改进 C# 版本,因为性能敏感的 C# 代码应该避免它们。我没有时间为这些新测试(或任何其他测试)准备 ARM 条形图。

我添加的前两个基准测试是由 Attractive Chaos 先生编写的。他的测试中实际上有 4 个基准测试,但有一个测试字典,我已经有一个基准测试了,还有一个测试特定的 C 正则表达式库,而我更愿意在这里坚持使用标准库。我保留的第一个基准测试是直接的 O(n^3) 矩阵乘法,第二个是高质量但(对我来说)难以理解的数独求解器。再说,我从没玩过数独。

两个基准测试最初都是用 C# 的二维数组编写的。现在, 在谈论矩阵测试时,作者说:“这是我第一次写 C#”。更有经验的 C# 开发人员已经知道 .NET 的多维数组在性能方面不好。它们不是为了快速而设计的。相反,使用一维数组或“锯齿状”二维数组(double[][] 而不是 double[,])来模拟二维数组可能更好。消除代码中多维数组的最简单方法是引入一个包装器,如下所示

public struct Array2D<T> // use "class" if you prefer
{
  readonly T[] _a;
  readonly int _cols, _rows;
  
  public Array2D(int rows, int cols)
  {
    _a = new T[rows * cols];
    _cols = cols;
    _rows = rows;
  }
  public Array2D(T[] array, int cols)
  {
    _a = array;
    _cols = cols;
    _rows = _a.Length / cols;
  }
  
  public T[] InternalArray { get { return _a; } }
  public int Cols { get { return _cols; } }
  public int Rows { get { return _rows; } }
  
  // Add a range check on "col" if you want to sacrifice performance for safety
  public T this[int row, int col]
  {
    get { return _a[row * _cols + col]; }
    set { _a[row * _cols + col] = value; }
  }
};

在某些条件下,包装器比标准的 .NET 二维数组(type[n,n])快得多,但正如基准测试所示,这不再是可靠的优化,而且你通常会从锯齿状数组获得更好的结果。

矩阵乘法测试

在此基准测试中,两个 1000x1000 的大矩阵相乘。C 风格的乘法例程直接且可能无争议;内循环经过“a_i”和“c_j”优化,以避免不必要的乘法,并且“b”矩阵被转置,以便可以顺序访问。原始版本只能处理方阵,但我自作主张将其扩展到处理矩形矩阵(但未经测试)。

double* MultiplyMatrix(double* a, double* b, int aRows, int aCols, int bCols)
{
  // int m = aRows, n = bCols, p = aCols; 
  // Note: as is conventional in C#/C/C++, a and b are in row-major order.
  // Note: bRows (the number of rows in b) must equal aCols.
  int bRows = aCols;
  double* x = new double[aRows * bCols]; // result
  double* c = new double[bRows * bCols];

  for (int i = 0; i < aCols; ++i) // transpose (large-matrix optimization)
    for (int j = 0; j < bCols; ++j)
      c[j*bRows + i] = b[i*bCols + j];

  for (int i = 0; i < aRows; ++i) {
    double* a_i = &a[i*aCols];
    for (int j = 0; j < bCols; ++j)
    {
      double* c_j = &c[j*bRows];
      double s = 0.0;
      for (int k = 0; k < aCols; ++k)
        s += a_i[k] * c_j[k];
      x[i*bCols + j] = s;
    }
  }
  delete[] c;
  return x;
}

C# 变体

在 C# 中,完成同一项任务有多种方法。原始版本使用了多维数组(double[n,n]);我添加了三种方法,按速度递增的顺序排列

  1. Array2D<double>:如上所述,是一个一维数组,充当二维数组。
  2. double[n][n]:一个“锯齿状”数组,它只是一个数组的数组。因此,它需要更多的内存(与 x86 上的 Array2D<double> 相比,每行多 16 字节),以及更多的初始化时间,但总体速度可能更快。
  3. double[n*n]:一个一维数组,通过指针算术访问,就像 C++ 版本一样。这在不需要额外内存的情况下获得了良好的性能。

选择哪种方法会产生很大影响

正如你所见,基于指针算术的版本(double[n*n])是最快的,而锯齿状(double[n][n])版本仅略慢。

当你查看 x86 结果时(忽略 Mono),double[,] 明显是最慢的,约 5.8 秒。Array2D 速度快了近一倍,为 3.2 秒。而 double[n][n] 的速度又快了一倍,为 1.6 秒。然而,x64 的结果非常奇怪。显然,Microsoft 为 x64 优化了多维数组,因此它们甚至比 Array2D 更快。同时,x64/.NET4 中有一个“故障”,使得 Array2D 在那个特定的 JIT 版本上运行缓慢。

注意:从现在开始,当我提到“x86”时,我将排除 Mono,除非另有说明。Mono 目前表现不佳,并且在许多这些测试中将继续表现不佳。事实上,最初我为 Mono 使用了深灰色。但是那些极长的 Mono 条形图开始分散注意力,所以我将其改为了浅灰色。如我所说,如果你不使用 Mono,只需忽略那些长的灰色条。如果你确实使用 Mono,那么,你看那个超出了图表范围的 Array2D 条了吗?它需要 11.75 秒。

唯一不变的是,锯齿状数组比多维数组快。我还没有弄清楚如何查看汇编代码来了解发生了什么,但我可以想到三个原因说明为什么锯齿状数组会更快。第一, .NET 会对所有数组访问进行范围检查,*除非*是在一个从 0 到数组末尾迭代的 for 循环中,而锯齿状实现(大部分)能够做到这一点。第二,在使用二维数组时,不聪明的 JIT 会在内循环中无法剔除乘法。第三,即使它确实剔除了乘法,它也可能无法预先计算指向正在检查的数组部分的指针(事实上,GC 可能会使其难以做到这一点)。相比之下,锯齿状版本不需要乘法,我们可以显式地缓存对矩阵当前行的引用,就像 C++ 版本一样。

使用指针的版本可以避免内循环中单个额外的范围检查,因此它应该稍微快一些。

整个讨论指出了 C# 性能与 C++ 相比的一个普遍问题:.NET JIT 的优化能力不如 C++ 编译器。这是自然的,因为 JIT 在运行时执行,所以它们不能像 C++ 编译器那样激进地优化(因为担心程序启动太慢)。将来可以想象更智能的 JIT,它们会生成代码的慢版本,然后(在循环执行数千次后)花费一些时间来像 C++ 编译器一样优化代码。它们已经拥有这种用于接口分派的优化技术。但对于其他所有东西呢?不。

所以,如果你需要快速的 C# 代码,你可能需要自己优化:手动将表达式从内循环中剔除,切换到锯齿状数组,甚至引入指针。然而,手动优化可能非常痛苦,就像本例中内循环乘法隐藏在对索引器的调用中一样。所以你可以尝试 NGEN,其结果未包含在本文中。也许下次。

泛型 vs 模板

现在,主要的矩阵乘法测试使用了双精度算术,但并非所有程序员都主要关心双精度算术的速度。因此,我还编写了测试的泛型版本,以便你看到不同数据类型的影响,特别是 intfloat

在这篇文章的第一个版本中,我测试了 C# 泛型,以查看 JIT 是否能像 C++ 编译器优化模板一样优化它们。结果似乎是肯定的,但测试非常简单。对于这个矩阵乘法测试的扩展,我比较了两种数据类型(doubleint)和 5 种不同的 JIT 引擎,在某些情况下 JIT 会搞砸。看一下(<尖括号> 代表泛型)

(注意:我加入了泛型浮点测试,以便你可以与 C++ 浮点测试进行比较,但我太懒了,没手动编写其非泛型版本。)

当你查看 x86 使用整数的结果时,它们是好的:泛型 <int> 的速度与非泛型 int 完全相同。然而,如果你查看 x64/.NET3 的结果,你会发现泛型双精度测试花费的时间是 2.14 倍,而泛型整数测试花费的时间是 3.45 倍。 .NET 4 JIT 没那么糟糕,但整数测试有点搞砸了。而且 Mono 严重搞砸了泛型测试,尤其是涉及浮点数的测试(<double> 的结果是 9.7 秒,<float> 的结果是 11.3 秒)。

另一方面,C++ 在模板 <double> 版本和原始非模板版本之间完全没有显示出任何差异

请注意,double[n*n]<double>[n*n] 的条形图完全相同。C++ 编译器在编译时使用令牌替换来评估模板,因此没有理由期望有任何差异。因此,我没有费心编写非模板的 int 或 float 版本;数字仅供你与 C# 进行比较。

注意:对于泛型测试,我消除了矩阵中的小数(以防数据类型为“int”)。整数溢出确实会在乘法过程中发生,但希望这不会弄乱结果。

C# vs C++

撇开泛型问题,并避免那些不可预测的二维数组,让我们看看 C# 与 C++ 的比较。此图显示了原始 C++ 版本及其直接的 C# 等效项,加上更适合 C# 的 [n][n] 版本

结果很清楚。首先,C++ 在所有情况下都获胜。如果你使用 x86 JIT,C# 的速度只比 C++ 慢一点。但 x64 和 Mono JIT 在此测试中表现相当糟糕。请注意,锯齿状数组版本(double[n][n])是 C# 中进行大型矩阵乘法的最合理方法,因为指针算术在 C# 文化中不受欢迎。与指针算术版本相比,使用锯齿状数组版本只损失一点速度,但内存成本(每行 16B 和更慢的 GC)应牢记在心。

数独

这是 Attractive Chaos 的第二个基准测试的代码,太长无法在此处发布。基本上,它涉及大量的数组访问、if 语句和循环,但很少进行算术运算且不涉及浮点数。因此,此测试会考验编译器/JIT 优化数组访问、寄存器分配和流程控制的能力。

此测试使用许多一维数组和两个二维数组。如前所述,我将 C# 的二维数组替换为 Array2D 包装器,这使得 x86 C# 的速度从 6 秒提高到 5.2 秒,但可能损害了 x64 的速度(我没有检查)。以下是结果

理论上,C++ 在此测试中应该具有优势,因为它大量使用数组,而且它不像矩阵测试那样简单地从头到尾扫描数组。在这种情况下,.NET 会为所有数组访问插入范围检查。此外,C++ 只支持固定大小的次要数组维度,而 C# 只支持动态的次要数组维度,因此,例如,.NET 无法根据数组“C”每行始终为 16 字节的知识来优化代码。

总之,VC++ 这次大获全胜,通常比 .NET 快两倍,甚至更多(Mono 以 8.6 秒垫底)。我的经验表明,数组范围检查无法解释如此大的差异,但调查受到阻碍,因为我仍然不知道如何查看 C# 的优化汇编。好吧,尽管笑话我吧。哈!

多项式

第三个基准测试来自 A.D. Corlan。这是一个非常简单的测试,使用 float 数学计算一个 100 次多项式的值。

我第一次在 C# 和 C++(VS 2008,x86)中运行此“多项式”基准测试时,结果是 C++ 在 0.034 秒内完成,C# 在 7.085 秒内完成,使 C++ 快了 208 倍!当然,除了 C++ DEBUG hash_map 测试花费了 10 多个小时之外,这是我见过的最极端的 C++ 结果。C++ 的结果似乎不可能:外层循环进行 1000 万次迭代,两个内层循环各进行 100 次迭代,总共 20 亿次迭代。在 2.83 GHz 的处理器上,0.034 秒内不可能完成如此多的工作。实际上,查看外层循环的汇编代码,你可以看到编译器做了一些惊人的事情

		for(int i=0; i<Iterations; i++)
			pu += dopoly(x);
00CCB740  push        ecx  
00CCB741  fld         dword ptr [__real@3e4ccccd (0CD2D54h)] 
00CCB747  mov         dword ptr [esp+40h],0 
00CCB74F  fstp        dword ptr [esp] 
00CCB752  call        Polynomials::dopoly (0CCB650h) 
00CCB757  fstp        dword ptr [esp+40h] 
00CCB75B  fld         dword ptr [esp+40h] 
00CCB75F  add         esp,4 
00CCB762  mov         eax,989680h 
00CCB767  sub         eax,1 
00CCB76A  fld         st(0) 
00CCB76C  fadd        dword ptr [esp+38h] 
00CCB770  fstp        dword ptr [esp+38h] 
00CCB774  jne         Polynomials::Test+37h (0CCB767h) 

请注意,jne 分支跳转到 0x0CCB767,这*在*调用 Polynomials::dopoly 之后;该函数只被调用了一次!C++ 优化器不知何故弄清楚了 dopoly() 是*函数式*的——也就是说,给定相同的输入,它总是返回相同的结果——因此它将函数调用从循环中剔除了!

虽然我对优化器的聪明才智印象深刻,但这不适合基准测试。我的想法是弄清楚 1000 万次调用 dopoly() 需要多长时间,而不是只调用一次。所以我做了一个小改动:dopoly() 内部声明了一个数组在堆栈上。如果我将该数组移到调用函数中,并将它作为参数传递,优化器就不再认为它可以剔除 dopoly(),因此它会按预期调用 1000 万次。此更改将运行时间增加了 289 倍,从 0.034 秒增加到 9.813 秒。

总之,这是图表

平均 C++ SSE2 版本比平均 C# 版本(忽略 Mono 的可怜的 13 秒)完成时间快约 24%。然而,C++ x87 版本相当慢。在我调查上面的“智能优化器”问题时,我似乎看到了原因。我不是 x87 专家,但看看这个。编译器将 dopoly() 的第二个循环展开了 10 次,但展开后的循环看起来像这样

		for (j=0; j<100; j++)
			s = x*s + pol[j];
000DB696  fld         dword ptr [esp] 
000DB699  add         eax,28h 
000DB69C  sub         ecx,1 
000DB69F  fmul        st,st(1) 
000DB6A1  fadd        dword ptr [eax-30h] 
000DB6A4  fstp        dword ptr [esp] 
000DB6A7  fld         dword ptr [esp] 
000DB6AA  fmul        st,st(1) 
000DB6AC  fadd        dword ptr [eax-2Ch] 
000DB6AF  fstp        dword ptr [esp] 
000DB6B2  fld         dword ptr [esp] 
000DB6B5  fmul        st,st(1) 
000DB6B7  fadd        dword ptr [eax-28h] 
000DB6BA  fstp        dword ptr [esp] 
000DB6BD  fld         dword ptr [esp] 
000DB6C0  fmul        st,st(1) 
000DB6C2  fadd        dword ptr [eax-24h] 
000DB6C5  fstp        dword ptr [esp] 
...

它反复存储和加载循环变量 s。糟糕!

C++ vs C#:哈希表

此测试对比了两种 .NET Dictionary 类型与等效的 Microsoft C++ hash_maps(或 VS2010 中的 unordered_map)。首先我尝试了一个 <int, int> 哈希表,然后是一个 <string, string> 哈希表。

字符串测试使用与整数测试相同的键,但转换为字符串。每个测试将 1000 万个整数转换为字符串(这就是测试“0”所做的),所以你可能想在脑海中从所有其他测试中减去测试“0”。

结果可能会让你惊讶

偶尔出现例外情况,C# 以压倒性优势获胜!一个特定的 C++ 场景,x86 VC10 with SSE2,运行得比其他场景好,并且在处理字符串时优于 C#。然而,在大多数情况下,VC++ 损失惨重。例如,第一个测试在 .NET4 x64 中的运行速度比 C++ VC10 SSE2 快 9 倍以上。

为什么差异如此之大?嗯,我怀疑 Microsoft 的(Dinkumware)hash_map 简直太糟糕了。我不知道它是如何实现的;代码太丑陋了,我甚至不想看它。老实说,谁会像这样缩进代码?

		// TEMPLATE CLASS _Hash
template<class _Traits>
	class _Hash
		: public _Traits	// traits serves as base class
	{	// hash table -- list with vector of iterators for quick access
public:
	typedef _Hash<_Traits> _Myt;
	typedef typename _Traits::_Val_type _Val_type;

	typedef typename _Traits::key_type key_type;
	typedef typename _Traits::key_compare key_compare;
	typedef typename _Traits::value_compare value_compare;
	enum
		{	// various constants
		_Bucket_size = key_compare::bucket_size,
		_Min_buckets = 8,	// min_buckets = 2 ^^ N, 0 < N
		_Multi = _Traits::_Multi};
	typedef list<typename _Traits::value_type,
		typename _Traits::allocator_type> _Mylist;

至少他们在 VS 2010 版本中添加了一些注释。

总之,我确信这不是 C++ 编译器的错。我知道这一点,因为我为我的公司编写了自己的 hash_map 版本,而我的实现速度大约快十倍(使用 int 键)。我一直知道我的哈希表性能更好,但直到看到数字我才意识到差异有多么巨大。以下是我的实现结果

正如你所看到的,C++ 现在更有竞争力了。它赢了一些比赛,也输了一些比赛。我的 hash_map 的设计严重受到 .NET Dictionary 的启发,我无法猜测为什么我的版本在进行 int 查询和删除时优于 C#,但在插入时却不是。

说到字符串,值得首先注意的是,无论使用哪种编程语言,它们都比整数慢得多,对于长期程序员来说,这应该不足为奇。我相信每当字符串存储在集合中时,C++ 都处于根本劣势。原因是 C# 使用不可变字符串,并且不需要工作来构造或解构字符串的“副本”。然而,C++ STL 使用可变字符串,需要额外的工作来复制字符串——取决于 std::string 的设计方式,要么需要维护计数以跟踪多少 std::string 指向相同的字符串(这种方法使修改字符串更昂贵),要么必须复制整个字符串(因此更改字符串更便宜,但复制更昂贵)。无论哪种情况,当向哈希表中添加像"12345"这样的字符串时,都必须做一些工作来创建一个副本。

 

另一方面,STL 在性能上确实有一个小优势,即它的模板类是针对每种特定数据类型在编译时进行特化和优化的。因此,编译器可以专门为字符串优化 hash_map<string,string>。相比之下,.NET Framework 会在运行时特化泛型(如果它们基于值类型,如 Dictionary<int,int>),但如果它们基于引用类型(如 Dictionary<string,string>)则不会。因此,当 .NET 哈希表调用 string.GetHashCodestring.Equals 时会有一个小的开销。总的来说,我认为 C# 的设计是一个更好的平衡,因为它避免了代码膨胀——某些类型的 Dictionary 可以共享相同的机器语言代码——同时仍然为“int”之类的简单类型和用户定义的结构提供更高的性能。

最后,让我们看看 Compact Framework 与 VC++ for ARM 的性能比较

BenchmarkCppVsDotNet/HashtablesArm.png

 

 

 

哎呀!在整数情况下,C++ 的哈希表查询和删除速度快了 3 倍以上,尽管插入仍然神秘地慢。字符串测试才是糟糕的地方,对此我并不感到惊讶。我在开头提到的 .NETCF 程序,那个需要 35 秒启动的程序,正在忙于解析字符串并将它们添加到列表中。C# 中的字符串测试 1 运行速度慢 3.5 倍;测试 2 慢 9.1 倍;测试 3 慢 7 倍。

免责声明:以上测试使用顺序整数键。因此,哈希表几乎没有冲突,这意味着这些测试结果可能比实际结果要好。

C# vs C++:排序映射

有人指出,C++ 中 map<K,V> 的使用比 hash_map<K,V> 更常见。这是事实,因为很长一段时间 C++ 没有提供哈希表。现在理论上哈希表应该更快,因为它应该支持 O(1) 常数时间操作,而 map 是排序的,并且必然需要 O(log N) 时间操作。

奇怪的是,MS STL 的 map<K,V> 实际上比它的哈希表(hash_mapunordered_map)快,但不如我的 hash_map 快。在 .NET 世界中,SortedDictionaryDictionary 慢,正如你所期望的……但它需要*这么*慢吗?

C++ 以压倒性优势获胜!C++ 的 map<K,V> 相当快,而 C# 的 SortedDictionary<K,V> 相当慢。现在,我已经部分开发了一个 C# B+ 树,它可能比 SortedDictionary 更快;如果你有兴趣,请留言(我怀疑没有什么能像同行压力一样激励我写代码!)。

C# vs C++:泛型求和

C++ 仍然广泛用于数值应用程序——即大部分时间花费在应用程序代码、而不是标准库或等待 I/O 上的应用程序——因为人们认为其编译器在优化方面非常出色。作为奖励,C++ 模板编程允许你编写一个支持多种数值数据类型的数值算法。

C# 中的泛型求和测试基于 Rüdiger Klaehn 在 2004 年编写的一个库 的增强版本,该库指出,通过一些额外的开发者努力,*应该*能够(尽管不特别容易)编写和使用 C# 泛型来编写快速的数学代码,支持任意数量类型。

在本文前面,我谈到了矩阵乘法基准测试的泛型版本,以及 C# 并不总是像人们期望的那样优化泛型的事实。我实际上是为本文的第一个版本编写了这个求和基准测试,它在版本 2 中可能看起来有点多余,但仍然可以做出一些有趣的观察。例如,我在这里测试了定点结构,我在 C++ ARM 代码中大量依赖它们。

除了 intint64floatdouble,我还测试了 FPI8FPL16。这些是我自己创建的定点数据类型。许多,如果不是大多数,移动设备都有较差的浮点支持,所以我经常在这些设备上使用定点而不是浮点。C++ 或 C# 中的定点类型就是一个包含整数的结构(FPI8 包含一个 int,FPL16 包含一个 64 位 int)。一些低位被视为分数位。FPI8 中的“8”表示有 8 个分数位,因此 FPI8 可以存储介于 -16777216 和 16777215.996 之间的数字,分辨率约为 0.004。涉及“struct”这一事实是基准测试的一个重要组成部分:历史上,编译器无法像单独处理该值一样处理包含单个值的结构(在结构外部),但如今大多数编译器做得很好。

 

我测试了以下泛型 C# 求和方法

 

public static T GenericSum<T, Math>(List<T> list, Math M) where Math : IMath<T>
{
	T sum = M.Zero;
	for (int i = 0; i < list.Count; i++)
		sum = M.Add(sum, list[i]);
	return sum;
} 

当然,在 C++ 中,自然的实现更简单

template<typename T>
T GenericSum(const vector<T>& list)
{
	T sum = (T)0;
	for(int i = 0; i < (int)list.size(); i++)
		sum += list[i];
	return sum; 
} 

然而,许多 C++ 模板库(例如 Boost::GIL)严重依赖编译器内联微小函数并消除空结构,因此起初我直接移植了 C# 的复杂代码到 C++,并相信编译器会使其快速运行。但是,为了避免某些 1 票投票者的未来捣乱,我将代码更改为您所见的样子(尽管我不得不因此删除了一个虚函数测试)。里面仍然有两个函数调用,1 票投票者可能没怎么注意。你可以通过使用原始数组而不是 STL 来消除它们,但有什么必要呢?编译器确实会优化它。

由于加法是一项非常简单的工作,我为此测试执行的迭代次数是大多数其他测试的 10 倍。作为参考,我还包含了 C++ 和 C# 中非泛型的整数求和方法。这是桌面结果

注意:我将在下方给出 ARM 结果。

注意:我刚注意到我忘记用 FPI16 做这个测试了。抱歉,我没时间重新运行所有 11 个基准测试。

有趣的故事。在我准备本文的第 2 版时,我注意到 C++ 泛型求和测试的所有结果都有点奇怪:它们都显示为 0.000!什么鬼?在调试器中,很明显优化器已经完全删除了调用 GenericSum() 的循环!这*确实*是优化器的一个合理操作,因为外层循环忽略了返回值。但谜团是,为什么优化器的行为*改变*了?我的一个测试,非泛型求和测试(它操作的是 vector<int> 而不是 <T>),没有被改变。我下载了旧代码并检查以确保。不,非泛型求和代码与之前完全相同,但现在它运行时间为零。发生了什么变化?我使用的是同一个编译器(VC9)。我检查了所有的编译器开关——完全相同!那时我真的放弃了,回家了。

第二天早上,我突然想到了。已检查迭代器!我禁用了它们,但我好几天都没注意到我的 GenericSum 结果是零。为什么不是?嗯,基准测试是按随机顺序运行的,但我没有随机化随机数种子。碰巧,第一个 GenericSum 测试是在基准测试中比较晚的,而且,显然,我从来没有运行程序足够长的时间来看到结果!

总之,不知何故,禁用已检查迭代器使优化器完全删除了我的基准测试。为了修复这个问题,我更改了外层循环以计算所有求和的总和并返回它,这样优化器就不会那么激进了。然后我遇到了一个新的“问题”:基准测试运行得异常快,1 亿次加法不到 0.05 秒。那是什么意思?我检查了汇编。这次结果是合法的。*禁用已检查迭代器使此基准测试的速度提高了一倍以上*。

通过禁用已检查迭代器,你消除了对向量访问的范围检查。显然,这使得优化器更加激进,测试运行得非常非常快。因此,前提是已检查迭代器被禁用,C++ 版本完全碾压 C#。但是,如果你看“C++ x86 VC9 default”条形图,你会看到如果不禁用它们会发生什么。在这种情况下,一些 C# 测试并没有比 C++ 差多少。

C# 结果值得注意的一点是它们高度可变。Microsoft 的 x86 JITs 表现还不错,前提是你只进行整数或 FPI8 结构的加法;但一旦你尝试双精度,所有 5 个 JITs 都很糟糕。x64 .NET3 JIT 对于所有数据类型尤其糟糕,而且两个 x64 JITs 的表现都比它们各自的 x86 版本差。而且,出于某种原因,Mono 搞砸了所有测试,包括非泛型测试。嗯,我怀疑 Mono 在优化涉及结构的 C# 代码时遇到了一些问题,因为所有泛型数学都依赖于一个特殊的“Math”结构,它处理 FPI8 结构比普通“int”差得多(Mono FPI8 测试,它超出了图表范围,需要 1.57 秒)。

虽然 C++ 在此测试中以绝对优势获胜,但应注意,大多数 C++ 测试是在没有数组访问范围检查的情况下执行的,而 C# 测试是*带有*范围检查运行的。我快速测试了一下,看看如果 NonGenericSum 编写为使用 int[] 而不是 List<int> 会发生什么

 

在这种情况下,.NET 会优化掉范围检查,C# 的运行速度提高了一倍多(在 x64 上提高到四倍多!),而且速度也不比 C++ 版本慢多少。然而,应注意,在实际生活中,你不能总是使用数组代替 List<T>,也不能总是遍历整个数组,当你这样做时,.NET 不会优化掉范围检查。消除范围检查的唯一其他方法是重写代码以通过指针访问数组。

在 C++ 中,这要方便得多,因为你可以使用 STL 并直接 #define _SECURE_SCL 0;但是,这也带来一个缺点,即范围检查*在整个程序中*都被禁用(请注意,更改 _SECURE_SCL 为每个 cpp 文件是*非法的*,这会违反单个定义规则)。嘿 Microsoft,如果你正在收听,你可以在 C# 中使用一个特殊的内在函数来在循环之前显式执行范围检查来解决这个问题

Array.RangeCheck(array1, a, b); // JIT would see this as an optimization directive
Array.RangeCheck(array2, a, b);
for (int i = a; i < b; i++) 
    array1[i] = array2[i]; // No range checks
for (int i = b-1; i >= a; i--)
    ... // No range checks in a reverse loop either

结合一个智能的优化器和一个 List<T>.RangeCheck()List<T> 也能达到很高的速度。而且那是可验证的代码;在“不安全”方法中,一个特殊的属性可以禁用*所有*范围检查。当然,MS 可能听不到我的话。但如果你正在听,我要求知道为什么 MS .NET 仍然缺乏 SIMD 结构!(VC++ 有它们,尽管它们是愚蠢的处理器特定的。) 但我跑题了……

C# vs C++:简单算术

“简单算术”测试,不可否认,有点太简单了;两个质量大致相当的编译器可能会以不同的方式优化代码片段,因此你不应该太相信这些结果。这是我为每种数字类型所做的

double total = 0;
for (int i = 0; i < Iterations; i++)
	total = total - total / 4.0 + (double)i % 100000 * 3.0; 

此测试的一部分是查看编译器是否可以优化乘以常数和模常数。这是桌面结果

 

此测试没有明显的赢家。通常,C# 在“double”测试中做得更好(即使 C++ 可以使用 SSE2),但在 FPL8FPL16 测试中做得更差。C# 在浮点测试中与 C++ 并列,直到你启用 SSE,这使得 C++ 获胜。在 int64FPL16 测试中,指令集(x64 或 x86)比编程语言的影响更大。然后是 Mono。在处理 int64FPI8/FPI16 结构方面,它的表现相当糟糕,但在双精度测试中,它的表现优于所有其他。

平方根

我敢打赌你从未尝试在不使用浮点数学的情况下计算平方根。但在 ARM 上,这是首选方式。算法如下
public static uint Sqrt(uint value)
{
	if (value == 0)
		return 0;

	uint g = 0;
	int bshft = Log2Floor(value) >> 1;
	uint b = 1u << bshft;
	do {
		uint temp = (g + g + b) << bshft;
		if (value >= temp)
		{
			g += b;
			value -= temp;
		}
		b >>= 1;
	} while (bshft-- > 0);
	return g;
}

它混合了典型的算术和流程控制。作为参考,我包含了标准的 double 平方根(x87 和 SSE2 提供专用指令)。这是桌面结果

C++ 在此测试中获胜,但差距不大(如果你不计算 Mono)。在 64 位测试中,指令集(x86 或 x64)比编程语言更重要。

请注意,“平方根:FPL16”比 uint64ulong)慢,在 C++ 和 C# 中都是如此。原因*不是* ulong 被包装在 FPL16 结构中。而是因为 FPL16 有 16 个分数位而 uint64 没有,所以平方根算法(在原始 uint64 上运行)需要更多的迭代才能计算出平方根。

ARM 算术测试

正如我所说,我没有为本文 V2 更新 ARM 图表。我觉得没有多大意义,因为结果非常明确

BenchmarkCppVsDotNet/NumericCodeArm.png

对我来说不幸的是,Compact Framework 在所有三个算术测试中的表现都差得离谱。

 

对于这张图,我在右侧添加了比例条,这样你就可以看到 CF 的表现有多糟糕(不容易;我不得不绕过 Excel 中的一个 bug)。

事实上,在大多数这些测试中,C++ 的性能比 Compact Framework 好得多。表现“最好”(.NETCF 仅稍慢)的结果涉及双精度浮点类型,但这只是因为大部分时间都花费在系统浮点仿真库中,与你的编程语言无关。这是一个巨大的失望;它表明 Compact Framework 无法快速进行数学运算或算法,并且在使用 FPI8FPL16 时结果更糟。此外,它可能无法很好地优化泛型;请注意,C#“int (non-generic)”测试比“int”(generic)测试快。

文件读取、解析和排序

在此测试中,我尝试了两种不同的方法来读取一个 400KB 的文本文件,其中包含约 17000 行:一种是将整个文件一次性读入缓冲区,另一种是逐行读取。文件已预先加载,因此它在磁盘缓存中。此基准测试不测量磁盘性能,我也不想测量。

在 C++ 中,我最初使用的是 std::ifstream,但事实证明 ifstream 速度很慢,因此在这个基准测试的第二个版本中,我添加了经典的 FILE* 作为替代。在 C# 中,我使用了 System.IO.File 包装在 StreamReader 中。

文本文件包含“字典条目”:由“:=”分隔的键值对。值可以跨越多行;如果一行不包含 :=,则假定它是上一行的值的延续。字典条目被收集到 C# List 或 C++ vector 中,然后进行一些随机化(以确保排序需要一些工作),并按键不区分大小写进行排序。首先,让我们看看两种语言读取文本文件的速度

“x20”表示文件被扫描了 20 次,只是为了消耗时钟时间。

显然,C++ ifstream 在逐行读取文件方面做得非常糟糕。所以我们把它忽略掉,只关注 FILE* 的结果。顺便说一下,VC10 和 x64 比 VC9 x86 在此任务上做得更好。SSE2 优化功不可没,因为 x86 VC9 with SSE2 没有得到任何改进。

显然,如果你将整个文件作为一个块读取,C++ 比 C# 快。这很有意义,因为 .NET 在这些测试中实际上需要做更多的工作:它在将 UTF-8 解码为 UTF-16。相比之下,标准 C++ 不支持文本编码之间的转换,并且这些测试使用“char”字符串(而不是 wchar_t),因此 C++ 需要做的唯一额外工作是转换行尾(\r\n 到 \n),C# 也做了这项工作。

因此,C# 在第二个测试中获胜,逐行读取(C# 中的 ReadLine() vs C++ 中的 getline()fgets())是很奇怪的。我想我本可以更好地优化 FILE* 版本;例如,fgets() 不返回字符串长度。所以,为了消除字符串末尾的“\n”(这对于使代码的行为与其他两个版本等效是必需的),我调用 strlen() 来获取字符串长度,然后将最后一个字符更改为“\0”。也许先转换为 std::string(它确定字符串长度),然后删除最后一个字符会更快。

总之,让我们看看解析和排序的结果

和任何聪明的 C++ 程序员一样,我通过在 vector 末尾添加空字符串,然后就地创建字符串来优化测试 2 和 3,这样就不需要将它们复制到 vector 中了。即便如此,C# 在这些测试中仍然很有竞争力。在“Parse”测试(涉及调用 IndexOfSubstringstd::string 中的等效项)中,获胜者取决于平台(x86 或 x64)。C++ 绝对获胜的地方是在不区分大小写的字符串排序(std::sort vs List.Sort)期间。我想知道 C# 对 Unicode 的排序能力(É < õ),而 _stricmp 可能无法做到,这是否与此结果有关。

 

如果有人在记分,Mono 对字符串排序需要 5.666 秒。

Compact Framework 的性能可能比 ifstream

BenchmarkCppVsDotNet/SimpleParsingAndSortingArm.png

 

 

 

但同样,与 C++ 相比,Compact Framework 中的解析测试要慢 8 倍,排序测试要慢 15 倍。如果能查看反汇编代码,找出它为何如此缓慢,那将是很好的,但调试器报告 Compact Framework 不支持反汇编。

P/Invoke

在看到 Compact Framework 测试的糟糕结果后,一个问题出现了:如果部分代码是用 C++ 编写并从 C# 调用,会怎么样?这可以弥补 Compact Framework 的部分缓慢。但是,如果你大量使用 P/Invoke,考虑跨越 C# 和 C++ 边界需要多长时间是很重要的。

 

我通过调用我自己制作的一个非常简单的 DLL 来测试 P/Invoke 性能。该 DLL 中的函数执行一些极其简单的事情,例如将两个数字相加或返回字符串的第一个字符。下面你可以看到 .NET 桌面和 Compact 框架。

正如我的测试所示,即使参数和返回值是简单的整数,跨越那个边界也算不上便宜。

BenchmarkCppVsDotNet/PInvoke.png

 

 

 

在我的工作站上,你每秒可以调用 Add(int,int) 大约 2500 万次(.NET 的 x86 和 x64 版本)(在 2.83 GHz 时为 112 个周期/调用)。Mono 的速度快了两倍多(45 个周期),Compact Framework 每秒可以处理 274 万次调用(在 600 MHz 时为 219 个周期/调用)。现在,这些结果*并不可怕*。但非 P/Invoke 调用要快得多,如下所示。

在某些情况下(但并非所有情况),传递字符串或结构会使 P/Invoke 调用慢得多。如果 C++ 方法返回一个字符串,那么 .NET 必须将其转换为 System.String,这当然需要额外的时间。如果 C++ 字符串类型为 char*,则还会发生类型转换;但由于 .NET 不支持 UTF-8 转换,因此此转换可能是简单的截断(从 2 字节到 1 字节,或返回时从 1 字节扩展到 2 字节)。然而,你可以使用 wchar_t* 非常快速地将字符串传递给 C++,因为字符串不会被复制。(注意:如果 C++ 函数修改字符串,请务必使用 StringBuilder 而不是 string,否则不可变字符串将被修改,这是非法且危险的。)

我测试了 P/Invoke 如何处理结构,通过在 .NET 的 System.Drawing.Point 结构和 Win32 的 POINT 结构之间进行封送。MakePoint() 接受两个整数并返回一个 Point,而 GetPointY 接收一个 Point 并返回其 Y 坐标。这些方法的有趣之处在于 x86 封送器处理它们非常慢,但所有其他 CLR 版本都很快。MakePointIndirect() 函数返回一个 Point 的指针(函数内部的一个静态变量),C# 调用者必须使用 Marshal.PtrToStructure 来解包。你可以看到这也很慢。如果你想确保结构能快速地传递或返回到 C++ 代码,请务必通过引用传递它,GetPointYIndirect() 函数就是这样做的,并且不要按值返回结构。

 

 

.NET Compact Framework 存在一些封送限制

 

  • 传递或返回浮点值是不允许的(如果我没记错的话,这个限制没有意义,因为在 ARM 上浮点值的传递方式与整数值没有区别)。
  • 传递或返回 ANSI(每字符 1 字节)字符串是不允许的。
  • 传递或返回结构是不允许的。

 

如果你必须传递浮点值或结构,你必须通过引用传递它们,而不是按值传递。这就是为什么我为添加一对 double* 进行了额外的测试。由于转换返回的 double*double 会产生额外成本,所以最好使用“out double”参数而不是 IntPtr(即 double*)返回值;同样的指导方针也适用于结构。

那么,正常的非 P/Invoke 调用有多快?看看无操作的 NoOp() 函数的调用速度有多快

BenchmarkCppVsDotNet/TrivialMethodCalls.png

 

您可以看到,在桌面平台上,1000 万次非内联调用大约需要 24 毫秒,而 NextInt()(一个简单的 C 函数,用于递增和返回计数器)大约需要 400 毫秒,如果 NextInt() 从 Mono 调用,则需要 160 毫秒。因此,简单的 P/Invoke 调用比 .NET 中的非内联方法调用慢约 16 倍(比虚方法调用慢 18-23 倍),比 Mono 慢约 6 倍,比 Compact Framework 慢 9 倍(380 毫秒 vs 42 毫秒)。

这意味着您永远不能通过使用 P/Invoke 调用一个工作量极少anmoins的函数来“优化” .NET 代码。例如,如果您的 C++ 代码版本比 C# 版本快两倍,而 P/Invoke 最低需要 110 个时钟周期(CF 中为 210 个),那么每个 C++ 函数调用平均必须执行 110 个(或 210 个)时钟周期的工作才能与您的 C# 速度“持平”。

那么内联呢?如果一个方法的工作量很小,那么这些结果表明,如果将其内联,它会更快。 “静态无操作”测试允许内联一个什么都不做的静态函数,这意味着该测试实际上只是测量一个空的 for 循环。显然,for 循环的一次迭代比非内联方法调用快得多(在 Compact Framework 中除外)。

实际上,x64 的内联版本似乎快得几乎不可能,1000 万次调用只需 0.001 秒。为了确定,我暂时将循环迭代次数增加了 100 倍,发现 .NET x64 在 89.5 毫秒内完成了 10 亿次迭代,即每秒 112 亿次迭代,相当于每个时钟周期 4 次迭代。也许 x64 JIT 执行了一些高级的循环展开,但出于某种原因,调试器不允许我查看汇编代码。

在此“琐碎方法调用”测试中,我最初测试的是一个 Add(i, i) 方法(其中 i 是循环计数器),但事实证明 Add() 和无操作之间的差异非常小(桌面平台上为 2-3 毫秒)。换句话说,调用本身比传递“int”参数或将参数相加慢得多。所以我去掉了参数,这样您就可以单独看到基本的调用开销,除了 for 循环没有任何东西阻碍。

“no-inline”测试使用 [MethodImplAttribute(MethodImplOptions.NoInlining)] 属性来确保静态无操作方法不会被内联。奇怪的是,使用此属性的方法(它是非虚拟的)在所有桌面 CLR 中都比“virtual”无操作方法慢。

只有 Compact Framework 同意普遍认为 virtual 函数更慢的观点,而且,虚函数在该平台上的确非常慢!结果似乎表明 Compact Framework 对虚拟分派和接口分派使用相同的机制,这很可惜,因为其接口分派显然非常慢(每秒 900 万次调用,每次调用 66.6 个周期,而对于非虚拟无操作方法为 25.2 个周期)。

 

 

CLR 之间的相对性能

我做了一张您可能会觉得有趣的图表。这是不同 CLR 实现的相对性能。所有基准测试都已缩放到 .NET x86 使用 1 个单位时间(我必须以某种方式缩放结果,以便在同一张图上展示所有测试)。

我很高兴地报告,所有基准测试在 Mono 下均无需修改即可运行。Mono 结果仅显示 x86 版本,因为 下载 Mono 页面未提供 Windows 的 x64 版本(或 WinCE 的 ARM 版本)。

BenchmarkCppVsDotNet/RelativePerformanceBetweenCLRs.png

 

 

当然,Compact Framework 由于运行在不同的处理器上,因此无法直接进行比较,但我将其包含在内以求完整性,工作量为十分之一(根据我大量的基准测试经验,我估计处理器本身在单线程代码方面最多只能达到我工作站的 1/7 的速度,或者在运行复杂或内存密集型代码时速度更慢)。

如我之前提到的,一些 P/Invoke 测试在 x86 CLR 下非常慢,因此其他平台的测量时间与之相比微不足道。x64 在其他方面也优于 x86,例如“long”算术和字符串处理,但出于某种原因,“Generic sum”测试在 x64 和 Mono 下较慢,并且浮点处理似乎也稍慢一些。

Mono 总体表现尚可,尤其擅长 P/Invoke,但在大多数其他测试中落后于 Microsoft 的 x86 CLR。不区分大小写的排序、FPI8 算术、long 算术和 Generic Sum 测试在 Mono 下运行速度相当慢,但哈希表(Dictionary)和平方根测试的结果尚可。

Compact Framework 当然出现了许多糟糕的结果。即使忽略浮点测试(使用软件模拟),也有三个测试超出了图表的右边界。例如,Sort 测试比 x86 慢 13.0 倍,但由于移动基准测试执行的排序次数减少了 10%,这实际上意味着在移动设备上,C# 字符串 Sort 的速度将比工作站上的 *相同* C# 代码慢 130 倍。事实上,我怀疑字符串排序操作是导致文章开头提到的 35 秒启动时间的主要原因。在 Compact Framework 中,只有 P/Invoke 测试和“Ints to strings”转换相对较快。

结论 

在这个基准测试的第二版中,我们可以得出一些新的结论。原始文章测试了默认优化设置下的 VS2008,结果很明确:如果您正在为桌面开发软件,并且(像我一样)您精通 C# 和 C++,那么利用 C# 更简单的语法、丰富的标准库、出色的 IntelliSense 和更少的开发工作是安全的。那些最初的结果显示,C# 与使用 STL 的 C++ 相比,性能损失很小。在第二版中,这个结论需要一些修改。首先,我们来谈谈 C++。编译开关和特殊的 #defines 在 VC++ 中有多重要?

  • 有时,如果启用 SSE/SSE2(例如,多项式测试),循环会带来显着更好的性能。但是,通常不会有太大区别。我没有将“快速”浮点模型与默认的“精确”模型分开测试,但看起来没有什么太大的区别需要担心。
  • /Ox 的性能与默认优化级别 /O2 几乎相同。
  • _HAS_ITERATOR_DEBUGGING=1 会导致调试版本出现极其糟糕的性能(在 Release 版本中默认禁用)。
  • _SECURE_SCL=0 在某些情况下可以显着提高性能,尤其是在遍历 vector 元素的紧密循环中。它使 hash_map/unordered_map 更快,但仍然比常规的 map<K,V> 慢,因为 Microsoft 为我们提供了一个非常糟糕的哈希表。当然,这会移除对越界索引和迭代器的保护,但它会让您更强壮,对吧?

那么 C# 呢?没有特殊的 #defines 或开关可以使 C# 代码运行得更快,但当然,您编写代码的方式会影响其速度。

  • 一般来说,应避免使用二维数组(在 x64 上相对较快,但在 x86 上则不然)。如果需要最佳性能,通常最好使用交错数组,或者(如果您的代码不是部分信任代码)使用指针算术。请注意,指针应谨慎使用,不仅因为它不符合 C# 的习惯,还因为垃圾收集器无法移动指向它的任何 C 风格指针的数组(请记住,您首先使用“fixed”语句来获取指针)。编辑:新版本的 .NET Framework 处理二维数组更快。
  • 您可以从数组获得比 List<T> 更好的性能,因为(1)如果您从 0 到 Length-1 访问数组,则消除了范围检查,并且(2)如果您需要更快的速度,您可以使用指针。这些优化相当于 C# 中的 _SECURE_SCL=0。有可能同时获得 List<T> 和 T[] 的最佳性能;我为此目的编写了一个名为 InternalList<T> 的结构(未包含在基准测试中)。
  • 与 C++ 相比,.NET 优化器的智能程度较低。通过手动执行公共子表达式分解和循环展开,您可能会获得更好的性能。好的一面是,所有 C# 编译器都需要执行常量折叠。
  • .NET 无法可靠地优化用于数学的泛型。x86 JIT 的表现比 x64 的好,但总的来说,如果性能很重要,您将不得不针对特定的数字数据类型编写代码。仍然可以使用 LeMP T4 模板 进行技巧性处理
  • 内存管理问题(此处未进行基准测试)与 C# 中的 C++ 不同。特别是,C# 的内存分配速度极快,但垃圾收集器是不可预测的:它可能快速也可能缓慢,具体取决于您的内存分配模式。Microsoft 没有提供任何衡量 GC 性能的好方法。有各种优化 GC 的指南:对象应该非常短命或长命(中等寿命的对象最慢),避免使用复杂的指针树(例如链表和 SortedDictionary)等等,但这超出了本文的范围。

最令人好奇的新结果是数独测试,其 C++ 版本比 C# 快一倍。不知道为什么。

同样,Microsoft (Dinkumware) STL 的某些“糟糕”部分,如 hash_map/unordered_map,比等效的 C# 慢得多,应避免使用。在 C# 世界中,可以说情况更糟,因为虽然您可以编写(或找到)另一个库来规避不好的库,但规避优化不佳的编译器却更难。现在,.NET JITs 并不差,但它们无法完全赶上 C++,而且某些 JITs 在处理某些任务时表现不佳(例如,x64 JIT 在某些测试中慢得多,但在处理 double 的二维数组时比 x86 快)。有时可以通过操作指针来获得更好的性能,但并非总是如此,而且谁想这样做呢?

与此同时,Compact Framework *总是* 缓慢,除了本机代码别无选择。为什么这么慢? Ryan Chapman 的这篇文章揭示了原因。特别值得注意的是,.NET Compact Framework 似乎在每次循环迭代时都会插入三个指令。我只是猜测,因此手动循环展开可能会显着提高性能。并非 CF 完全没有优化,但您可以看到它缺少一些重要的优化,例如将循环常量保存在寄存器中,或将“add”和“shift”组合成一个指令。

当然,C# 程序员的编程方式往往与 C++ 程序员不同:他们使用 LINQ-to-objects(如果使用不当可能会非常慢),他们可能不优化算法,他们可能使用 WPF 等高开销库,偏爱 XML 文件而不是纯文本文件,大量使用反射等等。这些差异会导致程序变慢,但我相信一个 *注重性能* 的 C# 程序员可以编写出性能与同等编写精良的 C++ 代码相当的程序。.NET 显然还没有 C++ 快,但原则上,考虑到 NGen 和 Microsoft 的大量 TLC,我看不出它为什么不能做到。

如果您使用 Mono,会有额外的速度损失,但根据您的用途,这不算太糟。这些测试中有许多对 Mono 来说很难,因为它们测试了 Mono 表现不佳的项目,例如结构处理和通用数学。也许最相关的测试是平方根方法、数独求解器和矩阵乘法器。在这些测试中,Mono 的速度大约是 C++ 的一半,可能比 MS .NET 慢 50%,除了数独,Mono 的速度大约是 C++ 的 27%。另一方面,它在 Dictionary<int,int> 测试中表现相当不错(几乎和 MS .NET 一样好)。

如果您碰巧为 Windows CE 开发,通常会因使用 .NET 而遭受巨大的性能损失。Compact Framework 可以足够好地处理某些任务(例如从文件读取),但字符串处理、普通循环和算术、结构操作和其他基本任务似乎相当未优化。如果您不进行任何复杂算法或繁重的字符串处理,C# 仍然可以接受。可能需要一种混合解决方案,即 Compact Framework 代码通过 P/Invoke 调用性能关键的 C++ 代码,以克服其糟糕的性能。为 ARM 构建的 Mono 也可能有所改进,但目前没有适用于 Windows CE 的版本。

历史

  • 2011 年 6 月 17 日:初始版本
  • 2011 年 7 月 4 日:第二版。添加了四个新基准测试(矩阵乘法、数独、多项式、排序映射)。添加了五个新平台。添加了十多个新图表。添加/编辑了大部分评论。又在洞穴里住了六天,其中一个有舒适的椅子和谷歌。 
© . All rights reserved.