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

关于 .NET 类设计和通用编码的性能考量

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.96/5 (164投票s)

2014 年 8 月 28 日

Ms-PL

32分钟阅读

viewsIcon

144146

downloadIcon

570

了解设计类时的性能权衡,以及在使用 .NET 中的各种语言和 CLR 功能时需要注意的事项。

背景

本文基于我的书《编写高性能 .NET 代码》的第 5 章。

本文引用了一些有助于衡量和诊断性能问题的工具,具体包括 PerfViewMeasureItILSpy(或您选择的任何 IL 反射工具)。要了解使用这些工具的入门指南,请参阅 此处 [PDF]。

引言

本文涵盖了通用编码和类型设计原则。.NET 包含适用于多种场景的功能,其中许多功能顶多对性能没有影响,但有些则会对性能造成严重损害。另一些则不分好坏,只是不同,您必须决定在特定情况下哪种方法是正确的。

如果我必须总结一条贯穿本文的原则,那就是:

深入的性能优化往往会违背代码抽象。

这意味着,在追求极佳性能时,您将需要了解并可能依赖于所有层级的实现细节。本文将介绍其中许多内容。

类 vs. 结构体

类的实例始终分配在堆上,并通过指针解引用进行访问。传递它们很便宜,因为只是复制指针(4 或 8 字节)。但是,对象也有一些固定的开销:32 位进程为 8 字节,64 位进程为 16 字节。此开销包括指向方法表的指针和同步块字段。如果您创建一个没有字段的对象并在调试器中查看它,您会注意到其大小实际上是 12 字节,而不是 8 字节。对于 64 位进程,对象将为 24 字节。这是因为最小尺寸基于对齐。幸运的是,这 4 字节的“额外”空间将被一个字段使用。

结构体没有任何开销,其内存使用量是所有字段大小的总和。如果结构体在方法中声明为局部变量,则结构体在栈上分配。如果结构体声明为类的一部分,则结构体的内存将成为该类内存布局的一部分(因此存在于堆上)。当您将结构体传递给方法时,它会逐字节复制。因为它不在堆上,所以分配结构体永远不会导致垃圾回收。

因此,这里存在权衡。您可以找到关于结构体最大推荐大小的各种建议,但我不会纠结于确切的数字。在大多数情况下,您会希望将结构体的大小保持得很小,尤其是在它们被传递时,但您也可以通过引用传递结构体,这样大小可能对您来说就不重要了。唯一能确切知道它是否对您有益的方法是考虑您的使用模式并进行自己的性能分析。

在某些情况下,效率存在巨大差异。虽然对象的开销可能看起来不多,但考虑一个对象数组并将其与结构体数组进行比较。假设数据结构包含 16 字节数据,数组长度为 1,000,000,并且这是一个 32 位系统。

对于对象数组,总空间使用量为:

12 字节数组开销 +
(4 字节指针大小 × 1,000,000)+
((8 字节开销 + 16 字节数据)× 1,000,000)
= 28 MB

对于结构体数组,结果截然不同:

12 字节数组开销 +
(16 字节数据 × 1,000,000)
= 16 MB

对于 64 位进程,对象数组占用超过 40 MB,而结构体数组仍仅需 16 MB。

如您所见,在结构体数组中,相同大小的数据占用的内存更少。由于对象的开销,您还会因为增加的内存压力而导致更高的垃圾回收率。

除了空间,还有 CPU 效率的问题。CPU 有多级缓存。最靠近处理器的缓存非常小,但速度极快,并针对顺序访问进行了优化。

结构体数组在内存中有许多连续的值。访问结构体数组中的项非常简单。一旦找到正确的项,所需的值已经就在那里了。这可能意味着在迭代大型数组时访问时间有巨大差异。如果值已经在 CPU 缓存中,那么它的访问速度可能比在 RAM 中快一个数量级。

访问对象数组中的项需要访问数组内存,然后解引用该指针以获取堆上其他位置的项。迭代对象数组会解引用额外的指针,在堆上跳转,并且更频繁地驱逐 CPU 缓存,可能浪费更多有用数据。

CPU 和内存的这种缺乏开销是许多情况下优先选择结构体的首要原因——由于改进的内存局部性,在明智地使用时,它可以为您带来显著的性能提升。

由于结构体总是按值复制,如果您不小心,可能会遇到一些有趣的情况。例如,请看这段有错误的、无法编译的代码:

struct Point
{
  public int x;
  public int y;
}

public static void Main()
{
  List<Point> points = new List<Point>();
  points.Add(new Point() { x = 1, y = 2 });
  points[0].x = 3;
}

问题出在最后一行,它试图修改列表中现有的 `Point`。这不可能,因为调用 `points[0]` 返回的是原始值的副本,该副本未存储在任何永久位置。修改 `Point` 的正确方法是:

Point p = points[0];
p.x = 3;
points[0] = p;

然而,采取更严格的策略可能是明智的:使您的结构体不可变。一旦创建,它们的值就永远不会改变。这消除了上述情况的可能性,并通常简化了结构体的使用。

我之前提到结构体应该保持得很小以避免花费大量时间复制它们,但有时也会使用大型结构体。考虑一个跟踪商业流程大量详细信息的对象,例如许多时间戳。

class Order
{
  public DateTime ReceivedTime {get;set;}
  public DateTime AcknowledgeTime {get;set;}
  public DateTime ProcessBeginTime {get;set;}
  public DateTime WarehouseReceiveTime {get;set;}
  public DateTime WarehouseRunnerReceiveTime {get;set;}
  public DateTime WarehouseRunnerCompletionTime {get;set;}
  public DateTime PackingBeginTime {get;set;}
  public DateTime PackingEndTime {get;set;}
  public DateTime LabelPrintTime {get;set;}
  public DateTime CarrierNotifyTime {get;set;}
  public DateTime ProcessEndTime {get;set;}
  public DateTime EmailSentToCustomerTime {get;set;}
  public DateTime CarrerPickupTime {get;set;}
  
  // lots of other data ...
}

为了简化代码,将所有这些时间放入自己的子结构体中会很好,仍然可以通过 `Order` 类通过类似以下代码的代码进行访问:

Order order = new Order();
Order.Times.ReceivedTime = DateTime.UtcNow;

您可以将它们都放入自己的类中。

class OrderTimes
{
  public DateTime ReceivedTime {get;set;}
  public DateTime AcknowledgeTime {get;set;}
  public DateTime ProcessBeginTime {get;set;}
  public DateTime WarehouseReceiveTime {get;set;}
  public DateTime WarehouseRunnerReceiveTime {get;set;}
  public DateTime WarehouseRunnerCompletionTime {get;set;}
  public DateTime PackingBeginTime {get;set;}
  public DateTime PackingEndTime {get;set;}
  public DateTime LabelPrintTime {get;set;}
  public DateTime CarrierNotifyTime {get;set;}
  public DateTime ProcessEndTime {get;set;}
  public DateTime EmailSentToCustomerTime {get;set;}
  public DateTime CarrerPickupTime {get;set;}
}

class Order
{
  public OrderTimes Times;
}

然而,这会为每个 `Order` 对象引入额外的 12 或 24 字节开销。如果您需要将 `OrderTimes` 对象整体传递给各种方法,也许这有意义,但为什么不直接传递整个 `Order` 对象本身的引用呢?如果您同时处理成千上万的 `Order` 对象,这可能会导致更多的垃圾回收。它还是额外的内存解引用。

相反,将 `OrderTimes` 改为 `struct`。通过 `Order` 上的属性访问 `OrderTimes struct` 的各个属性(例如 `order.Times.ReceivedTime`)不会导致结构体副本(.NET 会优化这种情况)。这样,`OrderTimes struct` 就几乎像没有子结构体时一样,成为 `Order` 类内存布局的一部分,并且您还可以获得更美观的代码。

此技术确实违反了不可变结构体的原则,但这里的技巧是将 `OrderTimes struct` 的字段视为 `Order` 对象上的字段一样。您不需要将 `OrderTimes struct` 作为独立实体传递——它只是一个组织机制。

为结构体重写 Equals 和 GetHashCode

使用结构体的一个极其重要的部分是重写 `Equals` 和 `GetHashCode` 方法。如果不这样做,您将获得默认版本,这对于性能来说一点都不好。为了了解它有多糟糕,请使用 IL 查看器并查看 `ValueType.Equals` 方法的代码。它涉及对结构体中所有字段的反射。但是,对于可直接映射类型(blittable types)存在优化。可直接映射类型是指在托管代码和非托管代码中具有相同内存表示的类型。它们仅限于基本数值类型(例如 `Int32`、`UInt64`,但不是 `Decimal`,因为它不是基本类型)以及 `IntPtr`/`UIntPtr`。如果结构体由所有可直接映射类型组成,那么 `Equals` 实现可以执行等效于整个结构体的逐字节内存比较。只需避免这种不确定性,并实现您自己的 `Equals` 方法。

如果您只重写 `Equals(object other)`,那么性能仍然会比必要的差,因为该方法涉及值类型的装箱和拆箱。而是实现 `Equals(T other)`,其中 `T` 是结构体的类型。这就是 `IEquatable<T>` 接口的作用,所有结构体都应该实现它。在编译时,编译器将在可能的情况下优先选择更强类型的版本。以下代码片段向您展示了一个示例。

struct Vector : IEquatable<Vector>
{
  public int X { get; set; }
  public int Y { get; set; }
  public int Z { get; set; }

  public int Magnitude { get; set; }

  public override bool Equals(object obj)
  {
    if (obj == null)
    {
      return false;
    }
    if (obj.GetType() != this.GetType())
    {
      return false;
    }
    return this.Equals((Vector)obj);
  }

  public bool Equals(Vector other)
  {
    return this.X == other.X
      && this.Y == other.Y
      && this.Z == other.Z
      && this.Magnitude == other.Magnitude;
  }

  public override int GetHashCode()
  {
    return X ^ Y ^ Z ^ Magnitude;
  }
}

如果一个类型实现了 `IEquatable<T>`,那么 .NET 的泛型集合将检测到它的存在并使用它来执行更有效的搜索和排序。

您可能还希望在值类型上实现 `==` 和 `!=` 运算符,并让它们调用现有的 `Equals(T)` 方法。

即使您从不比较结构体或将它们放入集合中,我仍然鼓励您实现这些方法。您将永远不知道它们将来会如何使用,并且实现这些方法的成本仅仅是您花费的几分钟时间和几字节的 IL,这些 IL 甚至永远不会被 JIT 编译。

覆盖类上的 `Equals` 和 `GetHashCode` 不那么重要,因为默认情况下它们只根据对象引用计算相等性。只要这对您的对象来说是一个合理的假设,您就可以保留默认实现。

虚方法和密封类

不要默认将方法标记为虚方法,“以防万一”。但是,如果虚方法对于程序中的连贯设计是必需的,您可能不应该费很大力去删除它们。

将方法设为虚方法会阻止 JIT 编译器进行某些优化,特别是内联它们的能力。只有当编译器 100% 知道将调用哪个方法时,才能内联方法。将方法标记为虚会消除这种确定性,尽管还有其他因素可能更有可能使此优化失效。

与虚方法密切相关的是密封类的概念,如下所示:

public sealed class MyClass {}

标记为密封的类声明其他类不能继承自它。理论上,JIT 可以利用此信息进行更积极的内联,但目前它不会这样做。无论如何,您应该默认将类标记为密封,并且除非必要,否则不要将方法设为虚方法。这样,您的代码将能够利用当前以及理论上未来的 JIT 编译器改进。

如果您正在编写一个旨在在各种情况下使用(尤其是在组织外部)的类库,您需要更加小心。在这种情况下,拥有虚 API 可能比原始性能更重要,以确保您的库足够可重用和可自定义。但对于您经常更改且仅在内部使用的代码,请选择更好的性能。

接口分派

第一次通过接口调用方法时,.NET 必须弄清楚要调用哪个类型和方法。它将首先调用一个存根,该存根查找适当的实现接口的对象来调用正确的方法。一旦发生几次,CLR 就会识别出始终调用相同的具体类型,并且这种通过存根的间接调用会减少为一个仅包含少量汇编指令的存根,该存根直接调用正确的方法。这组指令称为单态存根(monomorphic stub),因为它知道如何调用针对单个类型的实现的方法。这非常适合调用站点始终每次都调用相同类型的接口方法的场景。

单态存根也可以检测何时出错。如果某个时候调用站点使用了不同类型的对象,那么最终 CLR 将用另一个针对新类型的单态存根替换该存根。

如果情况更复杂,涉及多种类型且可预测性较低(例如,您有一个接口类型的数组,但该数组中有多种具体类型),那么存根将被更改为多态存根(polymorphic stub),该存根使用哈希表来选择要调用的方法。表查找速度很快,但不如单态存根快。此外,此哈希表的大小受到严格限制,如果您有太多类型,您可能会回退到最初的通用类型查找代码。这可能非常昂贵。

如果这成为您的问题,您可以选择以下几种方法:

  1. 避免通过通用接口调用这些对象
  2. 选择您的通用基接口,并用抽象基类替换它

这种类型的问题并不常见,但如果您有一个庞大的类型层次结构,所有这些都实现了通用接口,并且您通过根接口调用方法,它可能会击中您。您会注意到此问题表现为调用站点上这些方法的 CPU 使用率很高,而这些方法本身的工作量并不能解释这一点。

故事:在一个大型系统的设计过程中,我们知道将会有数千种类型可能都继承自一个通用类型。我们知道会有几个地方我们需要从基类型访问它们。因为我们团队中有人了解如此大规模问题下的接口分派问题,所以我们选择使用抽象基类而不是根接口。

要了解更多关于接口分派的信息,请参阅 Vance Morrison 关于该主题的 博客文章

避免装箱

装箱是将值类型(如原始类型或结构体)包装到堆上的对象中,以便可以将其传递给需要对象引用的方法。拆箱是将其原始值取回的过程。

装箱会消耗 CPU 时间用于对象分配、复制和转换,但更严重的是,它会导致 GC 堆的压力增加。如果您不注意装箱,它可能导致大量分配,所有这些分配 GC 都必须处理。

当您执行以下操作时,会出现明显的装箱:

int x = 32;
object o = x;

IL 代码如下:

IL_0001: ldc.i4.s 32
IL_0003: stloc.0
IL_0004: ldloc.0
IL_0005: box [mscorlib]System.Int32
IL_000a: stloc.1

这意味着查找代码中的大多数装箱来源相对容易——只需使用 ILDASM 将所有 IL 转换为文本并进行搜索即可。

一种非常常见的意外装箱方式是使用接受 `object` 或 `object[]` 作为参数的 API,最明显的是 `String.Format` 或旧式集合,它们仅存储对象引用,并且出于这些和其他原因应完全避免使用。

当将结构体分配给接口时,也可能发生装箱,例如:

interface INameable
{
  string Name { get; set; }
}

struct Foo : INameable
{
  public string Name { get; set; }      
}

void TestBoxing()
{          
  Foo foo = new Foo() { Name = "Bar" };
  // This boxes!
  INameable nameable = foo;
  ...
}

如果您自己测试此代码,请注意,如果您实际上没有使用装箱变量,那么编译器将优化掉装箱指令,因为它从未被实际触及。一旦您调用一个方法或以其他方式使用该值,装箱指令就会出现。

装箱发生时需要注意的另一件事是以下代码的结果:

int val = 13;
object boxedVal = val;
val = 14;

此时 `boxedVal` 的值是多少?

装箱会复制值,两者之间不再有任何关系。在此示例中,`val` 的值变为 `14`,但 `boxedVal` 保持其原始值 `13`。

有时您可以在 CPU 配置文件中捕获到装箱,但许多装箱调用是内联的,因此这不是查找它的可靠方法。CPU 配置文件中过度的装箱会显示为通过 `new` 进行的大量内存分配。

如果您确实有大量的结构体装箱,并且发现无法摆脱它,那么您可能应该将结构体转换为类,这总体上可能更便宜。

最后,请注意,通过引用传递值类型不是装箱。检查 IL,您会看到没有发生装箱。值类型的地址被发送到方法。

for vs. foreach

使用 **背景** 部分描述的 `MeasureIt` 程序,亲自查看使用 `for` 循环或 `foreach` 迭代集合的差异。在所有情况下,标准 `for` 循环都明显更快。但是,如果您进行自己的简单测试,您可能会注意到在某些场景下性能相当。在许多情况下,.NET 实际上会将简单的 `foreach` 语句转换为标准 `for` 循环。

看一下 `ForEachVsFor` 示例项目,其中包含这段代码:

int[] arr = new int[100];
for (int i = 0; i < arr.Length; i++)
{
  arr[i] = i;
}

int sum = 0;
foreach (int val in arr)
{
  sum += val;
}

sum = 0;
IEnumerable<int> arrEnum = arr;
foreach (int val in arrEnum)
{
  sum += val;
}

构建完成后,使用 IL 反射工具进行反编译。您会看到第一个 `foreach` 实际上被编译为 `for` 循环。IL 代码如下:

// loop start (head: IL_0034)
IL_0024: ldloc.s CS$6$0000
IL_0026: ldloc.s CS$7$0001
IL_0028: ldelem.i4
IL_0029: stloc.3
IL_002a: ldloc.2
IL_002b: ldloc.3
IL_002c: add
IL_002d: stloc.2
IL_002e: ldloc.s CS$7$0001
IL_0030: ldc.i4.1
IL_0031: add
IL_0032: stloc.s CS$7$0001
IL_0034: ldloc.s CS$7$0001
IL_0036: ldloc.s CS$6$0000
IL_0038: ldlen
IL_0039: conv.i4
IL_003a: blt.s IL_0024
// end loop

有很多存储、加载、加法和分支——都很简单。但是,一旦我们将数组转换为 `IEnumerable<int>` 并执行相同操作,成本就会高得多:

IL_0043: callvirt instance class [mscorlib]System.Collections.Generic.IEnumerator`1<!0> class [mscorlib]System.Collections.Generic.IEnumerable`1<int32>::GetEnumerator()
IL_0048: stloc.s CS$5$0002
.try
{
  IL_004a: br.s IL_005a
  // loop start (head: IL_005a)
    IL_004c: ldloc.s CS$5$0002
    IL_004e: callvirt instance !0 class [mscorlib]System.Collections.Generic.IEnumerator`1<int32>::get_Current()
    IL_0053: stloc.s val
    IL_0055: ldloc.2
    IL_0056: ldloc.s val
    IL_0058: add
    IL_0059: stloc.2

    IL_005a: ldloc.s CS$5$0002
    IL_005c: callvirt instance bool [mscorlib]System.Collections.IEnumerator::MoveNext()
    IL_0061: brtrue.s IL_004c
  // end loop

  IL_0063: leave.s IL_0071
} // end .try
finally
{
  IL_0065: ldloc.s CS$5$0002
  IL_0067: brfalse.s IL_0070

  IL_0069: ldloc.s CS$5$0002
  IL_006b: callvirt instance void [mscorlib]System.IDisposable::Dispose()

  IL_0070: endfinally
} // end handler

我们有 4 个虚方法调用、一个 `try`-`finally` 块,以及(此处未显示)一个用于局部枚举器变量的内存分配,该变量跟踪枚举状态。这比简单的 `for` 循环昂贵得多。它使用更多的 CPU 和更多的内存!

请记住,底层数据结构仍然是数组——`for` 循环是可能的——但我们通过转换为 `IEnumerable` 来混淆这一点。这里重要的教训是文章开头提到的那个:深入的性能优化往往会违背代码抽象。`foreach` 是循环的抽象,`IEnumerable` 是集合的抽象。两者结合起来,它们定义了违背数组上 `for` 循环的简单优化的行为。

类型转换

通常,您应该尽可能避免类型转换。类型转换通常表明类设计不佳,但有时是必需的。例如,在不同第三方 API 之间转换无符号和有符号整数相对常见。对象类型转换应该少得多。

对象类型转换永远不是免费的,但成本因对象之间的关系而异。将对象转换为其父类相对便宜。将父对象转换为正确的子类成本更高,并且随着层次结构的增加,成本也会增加。转换为接口比转换为具体类型更昂贵。

您绝对必须避免无效的类型转换。这将导致抛出 `InvalidCastException` 异常,该异常的成本将比实际类型转换高出许多数量级。

请参阅随附源代码中的 `CastingPerf` 示例项目,该项目对多种不同类型的类型转换进行了基准测试。在我计算机的一次测试运行中,它产生了以下输出:

JIT (ignore): 1.00x
No cast: 1.00x
Up cast (1 gen): 1.00x
Up cast (2 gens): 1.00x
Up cast (3 gens): 1.00x
Down cast (1 gen): 1.25x
Down cast (2 gens): 1.37x
Down cast (3 gens): 1.37x
Interface: 2.73x
Invalid Cast: 14934.51x
as (success): 1.01x
as (failure): 2.60x
is (success): 2.00x
is (failure): 1.98x

“`is`”运算符是一种测试结果并返回布尔值的类型转换。“`as`”运算符类似于标准类型转换,但在类型转换失败时返回 `null`。从上面的结果可以看出,这比抛出异常快得多。

切勿使用此模式,它执行两次类型转换:

if (a is Foo)
{
  Foo f = (Foo)a;
}

而是使用“`as`”进行类型转换并缓存结果,然后测试返回值:

Foo f = a as Foo;
if (f != null)
{
  ...
}

如果您需要测试多种类型,请将最常见的类型放在前面。

注意:我经常看到一个烦人的类型转换是使用 `MemoryStream.Length`,它是一个 `long`。大多数使用它的 API 使用底层缓冲区的引用(从 `MemoryStream.GetBuffer` 方法获取)、一个偏移量和一个长度,后者通常是 `int`,因此需要从 `long` 进行向下转换。这类转换可能很常见且不可避免。

P/Invoke

P/Invoke 用于从托管代码调用本机方法。它涉及一些固定开销加上封送参数的成本。封送是将类型从一种格式转换为另一种格式的过程。

您可以使用文章开头提到的 `MeasureIt` 程序来比较 P/Invoke 调用成本与正常托管函数调用成本的简单基准测试。在我的计算机上,P/Invoke 调用所需的时间大约是调用空 `static` 方法所需时间的 6-10 倍。如果您有托管的等效方法,您不想在紧密循环中调用 P/Invoke 的方法,并且绝对要避免在本机和托管代码之间进行多次转换。但是,单次 P/Invoke 调用并非昂贵到在所有情况下都不能使用。

有几种方法可以最大限度地降低 P/Invoke 调用成本:

  1. 首先,避免“健谈式”接口。进行一次调用即可处理大量数据,其中处理数据的时间远远超过 P/Invoke 调用的固定开销。
  2. 尽可能使用可直接映射类型。回想一下关于结构体的讨论,可直接映射类型是指在托管代码和本机代码中具有相同二进制值的类型,主要是数值和指针类型。这些是最有效的参数传递方式,因为封送过程基本上是内存复制。
  3. 避免调用 Windows API 的 ANSI 版本。例如,`CreateProcess` 函数实际上是一个宏,它解析为两个实际函数之一:`CreateProcessA` 用于 ANSI 字符串,`CreateProcessW` 用于 Unicode 字符串。哪个版本是有效的取决于本机代码的编译设置。您希望确保始终调用 API 的 Unicode 版本,因为所有 .NET 字符串已经是 Unicode,并且此处存在不匹配将导致昂贵且可能发生数据丢失的转换。
  4. 不要不必要地固定。原始类型永远不会被固定,封送层会自动固定字符串和原始类型的数组。如果您确实需要固定其他内容,请将对象固定在尽可能短的时间内。通过固定,您将需要在这种短期需求与避免健谈式接口的第一个建议之间进行权衡。在所有情况下,您都希望本机代码尽快返回。
  5. 如果您需要将大量数据传输到本机代码,请考虑固定缓冲区并让本机代码直接对其进行操作。这会固定内存中的缓冲区,但如果函数足够快,这可能比大型复制操作更有效。如果您能确保缓冲区在 Gen 2 或大型对象堆中,那么固定就不会那么麻烦,因为 GC 不太可能需要移动对象。

最后,您可以通过禁用 P/Invoke 方法声明中的某些安全检查来降低 P/Invoke 的一些成本。

[DllImport("kernel32.dll", SetLastError=true)]
[System.Security.SuppressUnmanagedCodeSecurity]
static extern bool GetThreadTimes(IntPtr hThread, out long lpCreationTime, out long lpExitTime, out long lpKernelTime, out long lpUserTime);

此属性声明该方法可以完全信任地运行。这会产生一些代码分析 (FxCop) 警告,因为它禁用 .NET 安全模型的很大一部分。但是,如果您的应用程序只运行受信任的代码,您会净化输入,并防止公共 API 调用 P/Invoke 方法,那么它可以为您带来一些性能提升,如下面的 `MeasureIt` 输出所示:

名称 平均
PInvoke:10 FullTrustCall()(平均 10 次调用)[count=1000 scale=10.0] 6.945
PInvoke:PartialTrustCall()(平均 10 次调用)[count=1000 scale=10.0] 17.778

完全信任运行的方法可以执行大约 2.5 倍的速度。

委托(Delegates)

使用委托有两个成本:构造和调用。幸运的是,在几乎所有情况下,调用都与普通方法调用相当,但委托是对象,并且它们的构造可能非常昂贵。您应该只付一次这个成本并缓存结果。考虑以下代码:

private delegate int MathOp(int x, int y);
private static int Add(int x, int y) { return x + y; }
private static int DoOperation(MathOp op, int x, int y) { return op(x, y); }

以下哪个循环更快?

选项 1

for (int i = 0; i < 10; i++)
{
  DoOperation(Add, 1, 2);
}

选项 2

MathOp op = Add;
for (int i = 0; i < 10; i++)
{
  DoOperation(op, 1, 2);
}

看起来选项 2 只是将 `Add` 函数别名为本地委托变量,但这实际上涉及内存分配!如果查看各自循环的 IL,就会很清楚:

选项 1

// loop start (head: IL_0020)
IL_0004: ldnull
IL_0005: ldftn int32 DelegateConstruction.Program::Add(int32, int32)
IL_000b: newobj instance void DelegateConstruction.Program/MathOp::.ctor(object, native int)
IL_0010: ldc.i4.1
IL_0011: ldc.i4.2
IL_0012: call int32 DelegateConstruction.Program::DoOperation(class DelegateConstruction.Program/MathOp, int32, int32)
...

而选项 2 具有相同的内存分配,但它在循环之外:

L_0025: ldnull
IL_0026: ldftn int32 DelegateConstruction.Program::Add(int32, int32)
IL_002c: newobj instance void DelegateConstruction.Program/MathOp::.ctor(object, native int)
...
// loop start (head: IL_0047)
IL_0036: ldloc.1
IL_0037: ldc.i4.1
IL_0038: ldc.i4.2
IL_0039: call int32 DelegateConstruction.Program::DoOperation(class DelegateConstruction.Program/MathOp, int32, int32)
...

这些示例可以在 `DelegateConstruction` 示例项目中找到。

异常

在 .NET 中,将 `try` 块放在代码周围很便宜,但抛出异常非常昂贵。这在很大程度上是因为 .NET 异常包含丰富的状态。异常必须保留给真正异常的情况,此时原始性能不再重要。

要了解抛出异常对性能的毁灭性影响,请参阅 `ExceptionCost` 示例项目。其输出应与以下内容类似:

Empty Method: 1x
Exception (depth = 1): 8525.1x
Exception (depth = 2): 8889.1x
Exception (depth = 3): 8953.2x
Exception (depth = 4): 9261.9x
Exception (depth = 5): 11025.2x
Exception (depth = 6): 12732.8x
Exception (depth = 7): 10853.4x
Exception (depth = 8): 10337.8x
Exception (depth = 9): 11216.2x
Exception (depth = 10): 10983.8x
Exception (catchlist, depth = 1): 9021.9x
Exception (catchlist, depth = 2): 9475.9x
Exception (catchlist, depth = 3): 9406.7x
Exception (catchlist, depth = 4): 9680.5x
Exception (catchlist, depth = 5): 9884.9x
Exception (catchlist, depth = 6): 10114.6x
Exception (catchlist, depth = 7): 10530.2x
Exception (catchlist, depth = 8): 10557.0x
Exception (catchlist, depth = 9): 11444.0x
Exception (catchlist, depth = 10): 11256.9x

这表明了三个简单的事实:

  1. 抛出异常的方法比简单的空方法慢数千倍。
  2. 抛出异常的堆栈越深,速度越慢(尽管它已经非常慢了,所以无关紧要)。
  3. 有多个 `catch` 语句会产生轻微但显著的影响,因为需要找到正确的 `catch` 语句。

另一方面,虽然捕获异常可能很便宜,但访问 `Exception` 对象上的 `StackTrace` 属性可能非常昂贵,因为它会从指针重构堆栈并将其转换为可读文本。在高性能应用程序中,您可能希望通过配置使这些堆栈跟踪的日志记录成为可选的,并且仅在需要时使用。

重申一下:异常应该是真正异常的。常规使用它们会严重损害您的性能。

动态

这大概不言而喻,但需要明确:任何使用 `dynamic` 关键字或动态语言运行时 (DLR) 的代码都不会得到高度优化。性能调整通常是剥离抽象,但使用 DLR 是增加了一个巨大的抽象层。它有其用武之地,但绝不是快速系统。

当您使用动态时,看起来简单的代码实际上并非如此。以一个简单但人为的例子为例:

static void Main(string[] args)
{
  int a = 13;
  int b = 14;

  int c = a + b;

  Console.WriteLine(c);      
}

这段代码的 IL 同样很简单:

.method private hidebysig static 
  void Main (
    string[] args
  ) cil managed 
{
  // Method begins at RVA 0x2050
  // Code size 17 (0x11)
  .maxstack 2
  .entrypoint
  .locals init (
    [0] int32 a,
    [1] int32 b,
    [2] int32 c
  )

  IL_0000: ldc.i4.s 13
  IL_0002: stloc.0
  IL_0003: ldc.i4.s 14
  IL_0005: stloc.1
  IL_0006: ldloc.0
  IL_0007: ldloc.1
  IL_0008: add
  IL_0009: stloc.2
  IL_000a: ldloc.2
  IL_000b: call void [mscorlib]System.Console::WriteLine(int32)
  IL_0010: ret
} // end of method Program::Main

现在,让我们只将那些 `int` 变为动态:

static void Main(string[] args)
{
  dynamic a = 13;
  dynamic b = 14;

  dynamic c = a + b;

  Console.WriteLine(c);      
}

为了节省空间,我实际上不在这里展示 IL,但当您将其转换回 C# 时,它看起来是这样的:

private static void Main(string[] args)
{
  object a = 13;
  object b = 14;
  if (Program.<Main>o__SiteContainer0.<>p__Site1 == null)
  {
    Program.<Main>o__SiteContainer0.<>p__Site1 = 
      CallSite<Func<CallSite, object, object, object>>.
      Create(Binder.BinaryOperation(CSharpBinderFlags.None, 
                      ExpressionType.Add, 
                      typeof(Program), 
                      new CSharpArgumentInfo[]
    {
      CSharpArgumentInfo.Create(CSharpArgumentInfoFlags.None, null),
      CSharpArgumentInfo.Create(CSharpArgumentInfoFlags.None, null)
    }));
  }
  object c = Program.<Main>o__SiteContainer0.
    <>p__Site1.Target(Program.<Main>o__SiteContainer0.<>p__Site1, a, b);
  if (Program.<Main>o__SiteContainer0.<>p__Site2 == null)
  {
    Program.<Main>o__SiteContainer0.<>p__Site2 = 
      CallSite<Action<CallSite, Type, object>>.
      Create(Binder.InvokeMember(CSharpBinderFlags.ResultDiscarded, 
                     "WriteLine", 
                     null, 
                     typeof(Program), 
                     new CSharpArgumentInfo[]
    {
      CSharpArgumentInfo.Create(
        CSharpArgumentInfoFlags.UseCompileTimeType |  
        CSharpArgumentInfoFlags.IsStaticType, 
        null),
      CSharpArgumentInfo.Create(CSharpArgumentInfoFlags.None, null)
    }));
  }
  Program.<Main>o__SiteContainer0.<>p__Site2.Target(
    Program.<Main>o__SiteContainer0.<>p__Site2, typeof(Console), c);
}

即使是 `WriteLine` 的调用也不是直截了当的。从简单、直接的代码,它已经变成了内存分配、委托、动态方法调用和称为 `CallSites` 的对象的混合体。

JIT 统计数据是可预测的:

版本 JIT 时间 IL 大小 本机大小
int 0.5ms 17 字节 25 字节
dynamic 10.9ms 209 字节 389 字节

我并非故意贬低 DLR。它是一个非常好的快速开发和脚本框架。它为动态语言与 .NET 之间的接口提供了极大的可能性。如果您对它提供的功能感兴趣,可以阅读一篇不错的概述 此处

代码生成

如果您发现自己需要处理动态加载的类型(例如,扩展或插件模型),那么您需要仔细衡量与这些类型交互时的性能。理想情况下,您可以通过通用接口与这些类型进行交互,从而避免动态加载代码的大多数问题。如果此方法不可行,请使用本节来解决调用动态加载代码的性能问题。

Microsoft .NET Framework 支持使用 `Activator.CreateInstance` 方法进行动态类型分配,以及使用 `MethodInfo.Invoke` 进行动态方法调用。下面是一个使用这些方法的示例:

Assembly assembly = Assembly.Load("Extension.dll");
Type type = assembly.GetType("DynamicLoadExtension.Extension");
object instance = Activator.CreateInstance(type);  

MethodInfo methodInfo = type.GetMethod("DoWork");
bool result = (bool)methodInfo.Invoke(instance, new object[] 
      { argument });

如果您只偶尔这样做,那不是什么大问题,但如果您需要分配大量动态加载的对象或调用许多动态函数调用,这些函数可能会成为严重的瓶颈。`Activator.CreateInstance` 不仅消耗大量 CPU,而且可能导致不必要的分配,给垃圾回收器带来额外压力。如果您在函数参数或返回值中使用值类型(如上面的示例所示),也可能发生装箱。

如果可能,尝试将这些调用隐藏在扩展和执行程序都已知的接口后面。如果不行,代码生成可能是一个合适的选择。

幸运的是,生成代码来完成相同的事情非常容易。要弄清楚要生成什么代码,请使用模板作为示例来生成您要模仿的 IL。例如,请参阅 `DynamicLoadExtension` 和 `DynamicLoadExecutor` 示例项目。`DynamicLoadExecutor` 动态加载扩展,然后执行 `DoWork` 方法。`DynamicLoadExecutor` 项目通过生成后步骤和解决方案构建依赖关系配置(而不是项目级依赖关系)来确保 `DynamicLoadExtension.dll` 位于正确的位置,以确保代码确实被动态加载和执行。

让我们开始创建一个新的扩展对象。要创建模板,首先要理解您需要完成什么。您需要一个没有参数且返回所需类型实例的方法。您的程序将不知道 Extension 类型,因此它将仅将其作为 object 返回。该方法如下所示:

object CreateNewExtensionTemplate()
{
  return new DynamicLoadExtension.Extension();
}

看一眼 IL,它看起来像这样:

IL_0000: newobj instance void [DynamicLoadExtension]DynamicLoadExtension.Extension::.ctor()
IL_0005: ret

有了这些知识,您可以创建一个 `System.Reflection.Emit.DynamicMethod`,以编程方式向其添加一些 IL 指令,并将其分配给一个委托,然后您可以重复使用该委托来按需生成新的 Extension 对象。

private static T GenerateNewObjDelegate<T>(Type type) where T:class
{
  // Create a new, parameterless (specified 
  // by Type.EmptyTypes) dynamic method.
  var dynamicMethod = new DynamicMethod("Ctor_" + type.FullName, type, Type.EmptyTypes, true);
  var ilGenerator = dynamicMethod.GetILGenerator();

  // Look up the constructor info for the 
  // type we want to create
  var ctorInfo = type.GetConstructor(Type.EmptyTypes);
  if (ctorInfo != null)
  {
    ilGenerator.Emit(OpCodes.Newobj, ctorInfo);
    ilGenerator.Emit(OpCodes.Ret);

    object del = dynamicMethod.CreateDelegate(typeof(T));
    return (T)del;
  }
  return null;
}

您会注意到发出的 IL 完全对应于我们的模板方法。

要使用它,您需要加载扩展程序集,检索相应的类型,并将其传递给生成函数。

Type type = assembly.GetType("DynamicLoadExtension.Extension");
Func<object> creationDel = GenerateNewObjDelegate<Func<object>>(type);
object extensionObj = creationDel();

一旦委托被构造,您就可以缓存它以供重用(可能以 `Type` 对象为键,或根据您的应用程序的适用方案)。

您可以使用完全相同的技巧来生成对 `DoWork` 方法的调用。由于存在类型转换和方法参数,它会稍微复杂一些。IL 是一种基于堆栈的语言,因此在函数调用之前必须按正确的顺序将参数推入堆栈。实例方法调用的第一个参数必须是对象正在操作的隐藏 this 参数。请注意,仅仅因为 IL 仅使用堆栈,它与 JIT 编译器如何将这些函数调用转换为汇编代码无关,而汇编代码通常使用处理器寄存器来保存函数参数。

与对象创建一样,首先创建一个模板方法作为 IL 的基础。由于我们将只能使用一个对象参数调用此方法(这是我们程序中拥有的唯一参数),因此函数参数将扩展指定为仅 object。这意味着在调用 `DoWork` 之前,我们必须将其转换为正确的类型。在模板中,我们硬编码了类型信息,但在生成器中,我们可以以编程方式获取类型信息。

static bool CallMethodTemplate(object extensionObj, string argument)
{
  var extension = (DynamicLoadExtension.Extension)extensionObj;
  return extension.DoWork(argument);
}

此模板生成的 IL 如下所示:

.locals init (
  [0] class [DynamicLoadExtension]DynamicLoadExtension.Extension extension
)

IL_0000: ldarg.0
IL_0001: castclass [DynamicLoadExtension]DynamicLoadExtension.Extension
IL_0006: stloc.0
IL_0007: ldloc.0
IL_0008: ldarg.1
IL_0009: callvirt instance bool [DynamicLoadExtension]DynamicLoadExtension.Extension::DoWork(string)
IL_000e: ret

请注意,声明了一个局部变量。它保存了类型转换的结果。稍后我们将看到它可以被优化掉。此 IL 可以直接转换为 `DynamicMethod`:

private static T GenerateMethodCallDelegate<T>(
  MethodInfo methodInfo, 
  Type extensionType, 
  Type returnType, 
  Type[] parameterTypes) where T : class
{
  var dynamicMethod = new DynamicMethod("Invoke_" + methodInfo.Name, returnType, parameterTypes, true);
  var ilGenerator = dynamicMethod.GetILGenerator();
  
  ilGenerator.DeclareLocal(extensionType);
  // object's this parameter
  ilGenerator.Emit(OpCodes.Ldarg_0);
  // cast it to the correct type
  ilGenerator.Emit(OpCodes.Castclass, extensionType);
  // actual method argument      
  ilGenerator.Emit(OpCodes.Stloc_0);
  ilGenerator.Emit(OpCodes.Ldloc_0);
  ilGenerator.Emit(OpCodes.Ldarg_1);
  ilGenerator.EmitCall(OpCodes.Callvirt, methodInfo, null);
  ilGenerator.Emit(OpCodes.Ret);

  object del = dynamicMethod.CreateDelegate(typeof(T));
  
  return (T)del;
}

要生成动态方法,我们需要 `MethodInfo`,它从扩展的 `Type` 对象中查找。我们还需要返回对象的 `Type` 以及方法的所有参数的 `Types`,包括隐式 this 参数(它与 `extensionType` 相同)。

此方法完美运行,但请仔细查看其作用,并回忆 IL 指令的基于堆栈的性质。以下是此方法的工作方式:

  1. 声明局部变量
  2. 将 `arg0`(this 指针)推入堆栈(`LdArg_0`)
  3. 将 `arg0` 转换为正确的类型并将结果推入堆栈
  4. 弹出堆栈顶部的值并将其存储在局部变量中(`Stloc_0`)
  5. 将局部变量推入堆栈(`Ldloc_0`)
  6. 将 `arg1`(string 参数)推入堆栈
  7. 调用 `DoWork` 方法(`Callvirt`)
  8. 返回

其中存在一些明显的冗余,特别是与局部变量有关。我们栈上有已转换的对象,将其弹出然后再推回。我们可以通过删除与局部变量相关的任何内容来优化此 IL。JIT 编译器可能会自动优化掉这一点,但进行优化无害,并且可能有助于我们拥有数百或数千个动态方法,所有这些方法都需要 JIT 编译。

另一个优化是认识到 `Callvirt` 操作码可以更改为更简单的 `Call` 操作码,因为我们知道这里没有虚方法。现在我们的 IL 看起来像这样:

var ilGenerator = dynamicMethod.GetILGenerator();

// object's this parameter
ilGenerator.Emit(OpCodes.Ldarg_0);
// cast it to the correct type
ilGenerator.Emit(OpCodes.Castclass, extensionType);
// actual method argument      
ilGenerator.Emit(OpCodes.Ldarg_1);
ilGenerator.EmitCall(OpCodes.Call, methodInfo, null);
ilGenerator.Emit(OpCodes.Ret);
To use our delegate, we just need to call it like this:
Func<object, string, bool> doWorkDel =
    GenerateMethodCallDelegate<Func<object, string, bool>>(methodInfo, type, typeof(bool), new Type[] { typeof(object), typeof(string) });
    
bool result = doWorkDel(extension, argument);

那么我们的生成代码的性能如何?这是其中一次测试运行:

==CREATE INSTANCE==
Direct ctor: 1.0x
Activator.CreateInstance: 14.6x
Codegen: 3.0x

==METHOD INVOKE==
Direct method: 1.0x
MethodInfo.Invoke: 17.5x
Codegen: 1.3x

以直接方法调用为基准,您可以看到反射方法差了很多。我们的生成代码并没有完全恢复,但接近了。这些数字是针对一个实际上不执行任何操作的函数调用的,因此它们代表了函数调用的纯开销,这不是一个非常现实的情况。如果我添加一些最小的工作(字符串解析和平方根计算),数字会发生一些变化:

==CREATE INSTANCE==
Direct ctor: 1.0x
Activator.CreateInstance: 9.3x
Codegen: 2.0x

==METHOD INVOKE==
Direct method: 1.0x
MethodInfo.Invoke: 3.0x
Codegen: 1.0x

最终,这表明如果您依赖 `Activator.CreateInstance` 或 `MethodInfo.Invoke`,您可以通过一些代码生成获得显著的好处。

故事:我曾在一个项目中工作,这些技术将动态加载代码的调用 CPU 开销从 10% 以上降低到约 0.1%。您还可以将代码生成用于其他用途。如果您的应用程序执行大量字符串解释或有任何状态机,这是代码生成的良好候选者。.NET 本身通过正则表达式和 XML 序列化来执行此操作。

预处理

如果数据在运行时有用之前需要进行转换,请确保尽可能多地在之前完成转换,如果可能,甚至在离线过程中完成。

换句话说,如果有什么东西可以预处理,那么它就必须被预处理。弄清楚哪些处理可以移到离线状态可能需要一些创造力和打破常规的思维,但努力通常是值得的。从性能的角度来看,这是一种通过完全移除代码来实现 100% 优化的形式。

测量

本文中的每个主题都需要不同的性能方法,使用 PerfView 或文章开头提到的其他分析工具。CPU 分析将揭示昂贵的 `Equals` 方法、低效的循环迭代、糟糕的互操作封送性能以及其他低效区域。

内存跟踪将显示装箱作为对象分配,而通用的 .NET 事件跟踪将显示异常在哪里被抛出,即使它们被捕获和处理。

ETW 事件

`ExceptionThrown` - 发生了异常。无论该异常是否被处理,都无关紧要。字段包括:

  • Exception Type - 异常的类型
  • Exception Message - 异常对象的 Message 属性
  • `EIPCodeThrow` - 抛出点的指令指针
  • `ExceptionHR` - 异常的 HRESULT
  • ExceptionFlags
    • 0x01 - 有内部异常
    • 0x02 – 是嵌套异常
    • 0x04 – 是重新抛出的异常
    • 0x08 – 是损坏的状态异常
    • 0x10 – 是符合 CLS 的异常

有关更多信息,请参阅 此处

查找装箱指令

有一个名为 `box` 的特定 IL 指令,这使得在您的代码库中发现它相对容易。要在一个方法或类中找到它,只需使用任何一个可用的 IL 反编译器(我选择 `ILSpy`)并选择 IL 视图。

如果您想检测整个程序集中的装箱,使用 ILDASM 及其灵活的命令行选项会更简单。ILDASM.exe 随 Windows SDK 一起提供。在我的计算机上,它位于 *C:\Program Files (x86)\Microsoft SDKs\Windows\v8.0A\bin\NETFX 4.0 Tools\*。您可以从 此处 获取 Windows SDK。

ildasm.exe /out=output.txt Boxing.exe

查看 Boxing 示例项目,它演示了装箱可能发生的几种不同方式。如果您在 `Boxing.exe` 上运行 ILDASM,您应该会看到类似以下的输出:

.method private hidebysig static void  Main(string[] args) cil managed
{
.entrypoint
// Code size     98 (0x62)
.maxstack  3
.locals init ([0] int32 val,
     [1] object boxedVal,
     [2] valuetype Boxing.Program/Foo foo,
     [3] class Boxing.Program/INameable nameable,
     [4] int32 result,
     [5] valuetype Boxing.Program/Foo '<>g__initLocal0')
IL_0000:  ldc.i4.s   13
IL_0002:  stloc.0
IL_0003:  ldloc.0
IL_0004:  box    [mscorlib]System.Int32
IL_0009:  stloc.1
IL_000a:  ldc.i4.s   14
IL_000c:  stloc.0
IL_000d:  ldstr    "val: {0}, boxedVal:{1}"
IL_0012:  ldloc.0
IL_0013:  box    [mscorlib]System.Int32
IL_0018:  ldloc.1
IL_0019:  call     string [mscorlib]System.String::Format(string,
                              object,
                              object)
IL_001e:  pop
IL_001f:  ldstr    "Number of processes on machine: {0}"
IL_0024:  call     class [System]System.Diagnostics.Process[] [System]System.Diagnostics.Process::GetProcesses()
IL_0029:  ldlen
IL_002a:  conv.i4
IL_002b:  box    [mscorlib]System.Int32
IL_0030:  call     string [mscorlib]System.String::Format(string,
                              object)
IL_0035:  pop
IL_0036:  ldloca.s   '<>g__initLocal0'
IL_0038:  initobj  Boxing.Program/Foo
IL_003e:  ldloca.s   '<>g__initLocal0'
IL_0040:  ldstr    "Bar"
IL_0045:  call     instance void Boxing.Program/Foo::set_Name(string)
IL_004a:  ldloc.s  '<>g__initLocal0'
IL_004c:  stloc.2
IL_004d:  ldloc.2
IL_004e:  box    Boxing.Program/Foo
IL_0053:  stloc.3
IL_0054:  ldloc.3
IL_0055:  call     void Boxing.Program::UseItem(class Boxing.Program/INameable)
IL_005a:  ldloca.s   result
IL_005c:  call     void Boxing.Program::GetIntByRef(int32&)
IL_0061:  ret
} // end of method Program::Main

您也可以通过 `PerfView` 间接发现装箱。通过 CPU 跟踪,您可以发现 `JIT_new` 函数被过度调用。

图 1. 装箱将在 CPU 跟踪中显示在 JIT_New 方法下,这是标准的内存分配方法。

如果您查看内存分配跟踪,它会更明显,因为您知道值类型和原始类型不应需要内存分配:

图 2. 在此跟踪中,您可以看到 Int32 是通过 new 分配的,这应该不太对劲。

发现首次机会异常

`PerfView` 可以轻松显示哪些异常被抛出,无论它们是否被捕获。

  1. 在 `PerfView` 中,收集 .NET 事件。默认设置还可以,但不需要 CPU,因此如果您需要分析超过几分钟,请取消选中它。
  2. 收集完成后,双击“Exception Stacks”节点。
  3. 从列表中选择所需的进程。
  4. Name 视图将显示顶级异常列表。CallTree 视图将显示当前选定异常的堆栈。

图 3. PerfView 使查找异常来源变得轻而易举。

摘要

请记住,深入的性能优化将违背代码抽象。您需要了解您的代码将如何转换为 IL、汇编代码和硬件操作。花时间理解这些层级。

当数据相对较小时,您希望开销最小,或者您将在数组中使用它们并希望获得最佳内存局部性时,请使用 `struct` 而不是类。考虑使结构体不可变,并始终在它们上实现 `Equals`、`GetHashCode` 和 `IEquatable<T>`。通过防止分配给对象引用来避免值类型和原始类型的装箱。

通过不将集合转换为 `IEnumerable` 来保持迭代速度。尽可能避免类型转换,特别是可能导致 `InvalidCastException` 的实例。

通过每次调用发送尽可能多的数据来最大限度地减少 P/Invoke 调用次数。尽可能短暂地固定内存。

如果您需要大量使用 `Activator.CreateInstance` 或 `MethodInfo.Invoke`,请考虑使用代码生成。

有关 .NET 性能的更多信息,请查阅完整书籍《编写高性能 .NET 代码》。

历史

  • 2014 年 8 月 29 日 - 澄清了对象开销与最小对象大小,并更新了计算。
© . All rights reserved.