.NET 性能优化的技巧

2019-08-20 14:52:248851人阅读


最大化内联


内联是将方法体(method body)复制到调用站点的技术,这样我们就可以避免跳转、参数传递和寄存器保存/恢复等繁琐过程。除了节省这些之外,内联还是实现其他优化的必要条件。 不不过Roslyn(C#的编译器)没有内联代码,它是通过JIT实现的,大多数优化也是如此。


使用静态投掷助手(static throw helper)


最近的变化涉及一个重要的重构,在序列化基准的调用持续时间上增加了大约20ns,从~130ns增加到了~150ns。

罪魁祸首是这个助手方法中添加的throw语句:

public static Writer<TBufferWriter> CreateWriter<TBufferWriter>(
    this TBufferWriter buffer,
    SerializerSession session) where TBufferWriter : IBufferWriter<byte>
{
    if (session == null) throw new ArgumentNullException(nameof(session));
    return new Writer<TBufferWriter>(buffer, session);
}

当助手方法中包含throw语句时,JIT不会内联它。解决这个问题的常见技巧是添加一个静态的“throw helper”方法,来完成一些棘手的工作,所以最终结果如下所示:

public static Writer<TBufferWriter> CreateWriter<TBufferWriter>(
    this TBufferWriter buffer,
    SerializerSession session) where TBufferWriter : IBufferWriter<byte>
{
    if (session == null) ThrowSessionNull();
    return new Writer<TBufferWriter>(buffer, session);

    void ThrowSessionNull() => throw new ArgumentNullException(nameof(session));
}

代码库在许多地方使用这个技巧,将throw语句放在一个单独的方法中可能会有其他好处,例如比如改善常用代码路径的位置。


最小化虚拟或接口调用


虚拟调用比直接调用慢,如果你正在编写一个关键系统,那么很可能会在分析器中看到虚拟调用的过程。首先,虚拟调用需要间接调用。

去虚拟化是许多JIT编译器的一个特性,RyuJIT也不例外。然而,这是一个复杂的功能,并且RyuJIT目前可以证明(自身)方法可以被虚拟化并因此成为内联的候选者的情况并不多。以下是利用虚拟化的一些常规技巧,但我确信还有更多。

1. 默认情况下将类标记为sealed,当一个类/方法被标记为sealed时,RyuJIT可以将其考虑在内并且可能能够内联一个方法调用。RyuJIT很可能成为下一代的JIT编译器。64位计算已是大势所趋,即使它并不总是比32位更快或更有效率。当前的.NET JIT编译器就是一个使得64位计算机上有时导致程序速度减慢的的例子。但是,这将会被改变:一个新的,下一代x64的JIT编译器编译代码的速度将加快两倍,它将改变你对64位.NET代码的印象。

2. 如果可能,将覆盖(override)方法标记为sealed。override可以翻译为覆盖,从字面就可以知道,它是覆盖了一个方法并且对其重写,以求达到不同的作用。对我们来说最熟悉的覆盖就是对接口方法的实现,在接口中一般只是对方法进行了声明,而我们在实现时,就需要实现接口声明的所有方法。除了这个典型的用法以外,我们在继承中也可能会在子类覆盖父类中的方法。

3. 使用具体类型而不是接口,具体类型为JIT提供了更多信息,因此它更有可能内联你的调用。

4. 在同一方法中实例化和使用非sealed对象(而不是使用'create'方法),当类型明确已知时,比如构造之后,RyuJIT可以对非sealed方法调用进行虚拟化。

5. 对多态类型使用泛型类型约束,以便可以使用具体类型对它们进行专门处理,并且可以对接口调用进行非虚拟化。在Hagar中,我们的核心编写器类型定义如下:

public ref struct Writer<TBufferWriter> where TBufferWriter : IBufferWriter<byte>
{
    private TBufferWriter output;
    // --- etc ---

所有对CIL中Roslyn发出的输出方法的调用之前都会有一条约束指令,该指令告诉JIT,该调用可以对TBufferWriter上定义的精确方法进行调用,而不是进行虚拟/接口调用。这有助于去虚拟化。结果,所有对在输出上定义的方法的调用都被成功地去虚拟化。下面是由JIT团队的Andy Ayers 编写的CoreCLR线程,它详细描述了当前和未来的去虚拟化工作。


减少分配


.NET的垃圾收集器是一项很伟大的项目, 垃圾收集器是 允许对一些无锁数据结构进行算法优化,并且还可以删除整个类的错误并减轻开发人员的认知负担。总之,垃圾收集是一种非常成功的内存管理技术。

.NET使用bump分配器,其中每个线程通过找到各自的指针来从每个线程上下文中分配对象。因此,当在同一线程上分配和使用短期分配时,可以更好的实现局部缓存(cache locality)机制。

有关.NET 垃圾收集器的更多信息,请点此了解。


对象池(Object Pool) 或缓冲池(Buffer Pool) 


Hagar本身并不管理缓冲区,而是将责任转移给用户。这听起来可能很麻烦,但实际上并不麻烦,因为它与System.IO.Pipelines兼容。因此,我们可以利用默认管道通过System.Buffers.ArrayPool <T>提供的高性能缓冲池。

一般来说,重复使用缓冲区可以减轻垃圾收集器的压力。


避免装箱


尽可能不要通过将值类型转换为引用类型来装箱值类型。这是常见的建议,但在API设计中需要考虑一些因素。在Hagar中,可以接受值类型的接口和方法定义是通用的,以便它们可以专门用于精确类型并避免装箱/拆箱成本。结果,没有热路径拳击。在某些情况下仍然存在拳击,例如异常方法的字符串格式。可以通过对参数的显式. tostring()调用来删除那些特定的装箱分配。


减少关闭分配


只分配闭包一次,并存储结果,就可供多次重复使用。例如,通常将委托传递给ConcurrentDictionary. getoradd。与其将委托编写为内联lambda,还不如将define定义为类中的私有字段。下面是来自Hagar中可选ISerializable支持包的一个例子:

private readonly Func<Type, Action<object, SerializationInfo, StreamingContext>> createConstructor
Delegate;

public ObjectSerializer(SerializationConstructorFactory constructorFactory)
{
    // Other parameters/statements omitted.
    this.createConstructorDelegate = constructorFactory.GetSerializationConstructorDelegate;
}

// Later, on a hot code path:
var constructor = this.constructors.GetOrAdd(info.ObjectType, this.createConstructorDelegate);


尽量减少复制


.NET Core 2.0和2.1以及最近的C#版本,在删除数据复制过程的方面取得了相当大的进步。最值得注意的是Span<T>,但在参数修饰符和只读结构中也值得一提。Span<T> 是ref 结构堆栈,而不是托管堆上分配。


使用Span<T>来避免数组分配并避免数据复制


一个Span<T>表示任意内存的相邻区域, 一个Span<T>实例通常用来保存数组的元素或数组的一部分。

对于.NET Core来说,Span<T>对于性能优化非常重要,它们使用优化的表示来减小它们的大小,这需要添加对内部指针的垃圾收集器的支持。内部指针是指向数组范围内的托管引用,而不是只能指向第一个元素,因此需要一个包含数组偏移量的附加字段。有关Span<T>的更多信息,请点此参考。

Hagar广泛使用Span<T>,因为它允许我们创建可用于较大缓冲区的分段试图。


通过ref传递结构以最小化堆栈上的副本


Hagar使用两个主要结构,Reader 和Writer<TOutputBuffer>。这些结构包含几个字段,几乎每次调用都会传递给序列化或反序列化调用路径。

在没有干预的情况下,使用这些结构进行的每个方法调用都会带来很大的影响,因为每个调用都需要将整个结构复制到堆栈中。

我们可以通过将这些结构作为ref参数传递来避免副本的产生,另外,C#还支持使用ref this作为扩展方法的目标,这非常方便。据我所知,没有办法确保特定的结构类型总是由ref传递,如果你不小心在调用的参数列表中省略了ref,这可能会导致运行错误。


避免保护性拷贝(defensive copy) 


Roslyn有时需要做一些工作来保证一些语言不变量,当结构存储在只读字段中时,编译器将插入一些指令,以避免复制该字段,然后再将其包含到任何能保证不会对其进行修改的操作中。通常,这意味着调用在结构类型本身上定义的方法,因为将结构作为参数传递给在另一类型上定义的方法已经需要将结构复制到堆栈上(除非通过ref或in传递)。

如果将<LangVersion> 7.2 </ LangVersion>添加到csproj文件中,则可以将结构定义为只读结构(这是c# 7.2的语言特性),则可以避免保护性拷贝。

有时,如果你无法将其定义为只读结构,则最好在其他不可变结构字段上省略readonly修饰符。

以Jon Skeet的NodaTime库为例,在这个示例中,Jon使大多数结构变为只读,因此能够将readonly修饰符添加到包含这些结构的字段中,而不会对性能产生负面影响。


减少分支和分支错误预测


现代cpu依赖于长pipeline的指令,这些指令通过并发性进行处理。这涉及到CPU分析指令,以确定哪些指令不依赖于前面的指令,还涉及猜测将采用哪些条件跳转语句。为此,CPU使用一个名为分支预测器(branch predictor)的组件,该组件负责猜测将采用哪个分支。它通常通过读取和写入表中的条目来实现这一点,并根据上次执行条件跳转时发生的情况修改其预测。

当预测正确时,就会加快进程,否则就需要把预测分支的指令排空,重新获取正确分支的指令进入pipeline继续执行。

所以加快进程的最好办法就是减少分支和分支错误预测,首先尝试最小化分支数量,如果无法消除分支,请尽量减少错误预测率,这可能涉及使用排序数据或重构代码,可以用查找的办法来代替分支预测。


其他杂项提示


1. 避免使用LINQ,LINQ在应用程序代码方面很出色,但在库/框架代码中很少被用于路径。LINQ很难对JIT进行优化(IEnumerable<T>..),而且倾向于多多分配。

2. 使用具体类型而不是接口或抽象类型,也许最常见的是,如果你在List <T>上进行迭代,最好不要先将该列表强制转换为IEnumerable <T>(例如,通过使用LINQ或将其作为IEnumerable <T>参数传递给方法)。这样做的原因是使用foreach枚举列表使用非分配List <T> .Enumerator结构,但是当它转换为IEnumerable <T>时,该结构必须被装箱到IEnumerator <T> for foreach。

3. 反射在库代码中特别有用,缓存反射结果,考虑使用IL或Roslyn为访问器生成委托,或者更好的方法是使用现有的库,如Microsoft.Extensions.ObjectMethodExecutor.Sources,Microsoft.Extensions.PropertyHelper.Sources或FastMember。


特定于库的优化


优化生成的代码

Hagar使用Roslyn为要序列化的POCO生成C#代码,这个C#代码在编译时包含在你的项目中。我们可以对生成的代码执行一些优化,以加快速度。

通过跳过对已知类型的编解码器查找来避免虚拟调用

当复杂对象包含众所周知的字段(如int,Guid,string)时,代码生成器将直接插入对这些类型的手动编码编解码器的调用,而不是调用CodecProvider来检索该类型的IFieldCodec <T>实例。这允许JIT内联那些调用,并避免了虚拟/接口间接。

在运行时专门化泛型类型

与上面类似,代码生成器可以生成在运行时使用专门化的代码。

预先计算常数值以消除某些分支

在序列化期间,每个字段都带有一个标头,通常是一个字节。它会告诉解串器哪个字段是编码的。此字段标题包含3条信息:字段的规格(固定宽度、长度前缀、标记分隔、引用等),字段的模式类型(预期、众所周知、以前定义的、编码)用于多态,并将最后3位专用于编码字段id(如果它小于7)。在许多情况下,可以确切地知道在编译时这个标头字节是什么。如果字段具有值类型,那么我们就知道运行时类型永远不能与字段类型不同,并且始终知道字段id。

因此,我们通常可以保存计算标头值所需的所有工作,并可以直接将其作为常量嵌入到代码中。这样可以节省分支并且通常会消除大量的中间语言代码。

选择适当的数据结构

通过切换到结构数组,很大程度上消除了索引和维护集合的成本,并且参考跟踪不再出现在基准测试中。这有一个缺点,对于大型对象图,这种新方法可能较慢。

选择合适的算法

Hagar花费大量时间对可变长度整数进行编码/解码,这种方法被称为varints,varints是用一个或多个字节序列化整形的一种方法,以减小有效载荷的大小。许多二进制序列化器使用这种技术,包括协议缓冲区。甚至.NET的BinaryWriter也使用这种编码。下面是参考资料的一小段:

protected void Write7BitEncodedInt(int value) {
    // Write out an int 7 bits at a time.  The high bit of the byte,
    // when on, tells reader to continue reading more bytes.
    uint v = (uint) value;   // support negative numbers
    while (v >= 0x80) {
        Write((byte) (v | 0x80));
        v >>= 7;
    }
    Write((byte)v);
}

我想指出ZigZag编码对于包含负值的有符号整数可能更有效,而不是强制转换为uint。

这些序列化器中的变量使用称为Little Endian Base-128或LEB128的算法,该算法每字节编码多达7位。它使用每个字节的最高有效位来指示是否跟随另一个字节(1 =是,0 =否)。这是一种简单的格式,但可能不是最快的。不过PrefixVarint更快,使用PrefixVarint,所有来自LEB128的1都是在有效载荷的开头一次性写入的。这可能让我们使用硬件内在函数来提高这种编码和解码的速度。通过将大小信息往前移,我们也可以从有效载荷中一次读取更多字节,从而减少内部压力并提高性能。


本文翻译自:https://reubenbond.github.io/posts/dotnet-perf-tuning

原文地址: https://www.4hou.com/web/19221.html

翻译作者:gejigeji


0
现金券
0
兑换券
立即领取
领取成功