2 行代码,将 .NET 执行时间降低 87%!(附代码)

2020-10-26 14:58:54 浏览数 (1)

授权自AI科技大本营(ID:rgznai100)

本文约3200字,建议阅读6分钟

本文介绍了性能优化通过简单的修改也能在提升性能上有出色的表现。

以下为译文:

长期以来,我一直在致力于提高性能,并且努力避免在关键代码路径中进行内存分配。例如,使用Span<T>在解析数据时避免内存分配,以及使用ArrayPool避免为临时缓冲区分配数组。这样的修改虽然对性能有好处,但会增加新版本代码的维护难度。

在本文中,我想展示的性能优化并不需要大量复杂的代码修改。有时候,有些简单的修改也能在提升性能上有出色的表现。下面我们就来看一个这样的例子。

1、找出优化的对象

最近,我在研究Elasticsearch.NET客户端代码库。我对库中某些热路径的性能感到好奇。

给应用程序性能分析方面的新手解释一下,热路径就是在正常的使用过程中被频繁调用的一系列方法。例如,Web应用程序中可能有一个端点,与所有其他端点相比,该端点在生产环境中被调用的频率更高。那么,该端点对应的方法很可能是应用程序中热路径的开始。相应地,它调用的各种方法也可能位于热路径上。再举一个例子,循环内的代码,如果循环执行数百或数千次,则可能会对其他方法产生大量调用。

在优化应用程序性能时,通常首先应该关注热路径,由于被调用的频率很高,因此对它们做出的改进能够给性能带来最显著的影响。改进调用次数仅占10%的代码,产生的收益也要小得多。

.NET有两个相关的Elasticsearch客户端。NEST是支持强类型查询的高级客户端,位于底层客户端Elasticsearch.NET之上。

NEST命名空间内有一个抽象的RequestBase类,该类派生出的子类都是强类型的请求类型。每个可以用的Elasticsearch HTTP API端点都有一个强类型的请求类。请求的主要特征是它包含与其相关的API端点的一个或多个URL。

定义多个URL的原因是,许多ElasticSearch的API都可以使用基本路径或包含特定资源标识符的路径进行调用。例如,Elasticsearch中有一个端点可以查询集群运行状况。该端点可以通过URL“_cluster/health”执行整个集群的一般健康检查;也可以在路径中加入索引名称“_cluster/health/{索引}”来针对特定索引执行健康检查。

在逻辑上,这些URL由库中的同一个请求类处理。在创建请求时,消费者可以提供一个可选的请求值,以指定特定索引。在这种情况下,必须在运行时构建URL,通过用户提供的索引名称替换URL中的{索引}部分。如果请求没有提供索引名称,则使用较短的URL “_cluster/health”。

因此,在请求被发送的时候,最终的URL必须已经确定并且构建好了。首先从可能的URL列表中找出要使用的URL模式。这个过程需要使用强类型请求对象指定的请求值。在URL模式匹配完成后,就可以生成最终的URL了。必要时还可以使用带有标记的URL模式,利用调用者代码提供的路由值替换可选的标记,从而创建最终的URL字符串。

该URL构建的核心主要包含在UrlLookup类中,该类包括一个ToUrl方法,如下所示:

代码语言:javascript复制
public string ToUrl(ResolvedRouteValuesvalues)
{
   var sb = new StringBuilder(_length);
   var i = 0;
   for (var index = 0; index < _tokenized.Length; index  )
   {
       var t = _tokenized[index];
       if (t[0] == '@')
       {
            if (values.TryGetValue(_parts[i],out var v))
            {
                if (string.IsNullOrEmpty(v))
                    throw newException($"'{_parts[i]}' defined but is empty on url: {_route}");

               sb.Append(Uri.EscapeDataString(v));
           }
            else throw new Exception($"Novalue provided for '{_parts[i]}' on url: {_route}");

            i  ;
       }
       else sb.Append(t);
   }
   return sb.ToString();
}

上述代码首先创建了StringBuilder实例。然后,遍历带有标记的URL中的每个字符串。URL路径中的标记元素存储在字符串数组字段“_tokenized”中。在每次迭代中,如果字符串值以“@”字符开头,则表明需要用相应的值替换它。然后搜索路由的值,找出与当前标记名称匹配的值,保存在“_parts”数组中。如果找到匹配项,则在对URI进行转义后将其值附加到URL StringBuilder中(第15行)。

对于不需要替换路径中的任何部分,则无需修改即可将它们直接附加到StringBuilder上(第21行)。

当所有带有标记的值都被添加并替换之后,就可以调用StringBuilder的ToString方法,返回最终的字符串。每次客户端发送请求时,这段代码都会被调用,因此是库中的热路径。

下面我们来考虑:如何对其进行优化,以提高执行速度,并减少资源分配?

现在这段代码使用的是StringBuilder,这是良好的实践,在需要将补丁数量的字符串连接到一起时,可以避免字符串分配。有几种使用Span<T>的方法可以减少字符串分配的次数。但是,添加Span<T>或其他技巧(如利用ArrayPools提供零分配缓冲区),会增加代码复杂度。由于这个库被许多调用者使用,因此这种做法也许值得。

在日常的编程工作中,除非你的服务处于极端的使用/负载状态,否则这种优化可能有点过。如果你熟悉Span<T>之类的高性能技巧,那么可能会情不自禁朝着最佳优化(即零分配)努力。这样的想法会让你对应该优先考虑的简单改动视而不见。

当回顾ToUrl方法并通过逻辑流程进行思考时,我有了一个想法。对于某些情况,可以有另外两种方法,实现简单但能有效地提升性能。再看一下上面的代码,你能否找到简单的提升性能的改进?提示:只需在方法开头加上几行。

让我们再次考虑集群健康的示例,它有两个URL模式;“ _cluster/health”和“ _cluster/health/{index}”。

后者要求路径的最后一部分使用用户提供的索引名称替换,但是前者并没有任何替换的要求。对于绝大多数端点来说,只有一小部分情况需要使用路由的值替换路径中的一部分。明白我的意思了吗?

我的想法是,某些情况下ToUrl方法完全不需要构建URL,这样就根本不需要使用(更不需要内存分配)StringBuilder示例,也不需要生成新的URL字符串。既然URL不需要替换,那么其中就只包含完整的原始URL路径字符串,直接返回就可以了。

2、优化代码

在进行任何优化之前,我需要先做两件事。首先,我需要检查现有代码是否有足够的单元测试。任何重构都有可能破坏当前的行为。如果没有测试,我就会先根据目前的行为编写一些测试。在优化之后,如果测试依然能够通过,就说明没有破坏任何东西。为了简洁起见,本文将省略测试,相信许多开发人员都已经非常熟悉了。

优化之前需要做的第二件事就是,在已有代码上建立评测基准,这样之后就可以确定代码改动是否能够提升性能,并定量地测量性能的提升。对性能做出假设是危险的,最安全的做法就是用科学的方法来确保。首先建立理论,测量已有的行为,然后进行试验(代码优化),最终再次测量,以验证假设。编写性能测试脚本的方法也许你并不熟悉,你可以参考我关于.NET性能测试的文章(https://www.stevejgordon.co.uk/

introduction-to-benchmarking-csharp-code-with-benchmark-dot-net)。

在此ToUrl示例中,基准测试非常直观。

代码语言:javascript复制
namespace BenchmarksDev
{
   internal class Program =>
       private static void Main(string[] args) =>BenchmarkRunner.Run<UrlLookupBenchmarks>();

   [MemoryDiagnoser]
   public class UrlLookupBenchmarks
   {
       private static readonly UrlLookup ClusterHealth = newUrlLookup("_cluster/health");
       private static readonly UrlLookup ClusterHealthIndex = newUrlLookup("_cluster/health/{index}");
       private static readonly ResolvedRouteValues EmptyRouteValues = newResolvedRouteValues();
       private static readonly ResolvedRouteValues IndexRouteValue = newResolvedRouteValues()
       {
            { "index", "a"}
       };
       private string _url;

       [Benchmark]
       public void Health() => _url = ClusterHealth.ToUrl(EmptyRouteValues);

       [Benchmark]
       public void HealthIndex() => _url =ClusterHealthIndex.ToUrl(IndexRouteValue);
   }
}

‍‍‍

其中一些静态字段用于设置性能测试的类型,以及需要的输入。我们不希望测量性能的测试产生额外的开销。接下来是两个性能测试,分别用于两个URL模式。我们希望优化那个不需要替换路由值的模式,但也有必要对另一种情况进行测试。我们不希望在改进一个的同时对另一个产生负面影响。

更改任何代码之前,首次运行的结果如下:

代码语言:javascript复制
|     Method |     Mean |   Error |   StdDev |  Gen 0 | Gen 1 | Gen 2 | Allocated |
|------------|---------:|---------:|---------:|-------:|------:|------:|----------:|
|     Health | 41.60 ns | 0.637 ns | 0.596 ns | 0.0381 |     - |    - |     160 B |
| HealthIndex | 85.60 ns | 0.851 ns |0.796 ns | 0.0457 |     - |     - |    192 B |

这为我们提供了一个基准,供我们完成工作后进行比较。

在ToUrl方法中,我们希望在不需要进行替换时,略过根据路径构建URL的过程,只需要添加两行代码即可实现。

代码语言:javascript复制
if (values.Count == 0 &&_tokenized.Length == 1 && _tokenized[0][0] != '@')
   return _tokenized[0];

只需要在方法开头添加这两行(如果你喜欢在return语句周围添加大括号,那么就添加4行)。这段代码执行三个逻辑检查,如果它们都返回true,我们就知道不需要任何替换,可以直接返回。第一个检查可以确保用户没有提供路由值。如果用户提供了路由值,就应该假设需要进行某种替换。接下来我们检查标记的数字是否包含一个元素,以及该元素的首字母不是“@”字符。

标准的集群健康检查请求不会提供索引名称,那么这些条件就会满足,可以直接从标记数组的0号位置返回“_cluster/health”字符串。

这些额外的代码并不复杂。大多数开发人员都可以顺利阅读并理解其目的。为了完整起见,我们还可以将所有条件重构成一个小的方法或局部函数,这样就可以给它起一个名字,让代码不言自明。本文省略这些内容。

现在代码修改完了,而且单元测试仍然能够通过,下面我们重新运行基准测试来比较一下结果。

代码语言:javascript复制
|     Method |      Mean |     Error |   StdDev |  Gen 0 | Gen 1 | Gen 2 |Allocated |
|------------|----------:|----------:|----------:|-------:|------:|------:|----------:|
|     Health |  5.352 ns | 0.0611 ns |0.0510 ns |      - |     - |    - |         - |
| HealthIndex | 84.470 ns | 0.5005 ns |0.4437 ns | 0.0457 |     - |     - |    192 B

第二个性能测试“HealthIndex”没有发生任何变化,因为部分URL需要替换,所以像以前一样整个方法都会执行。但是,第一个性能测试“Health”中更直接的情况改进了许多。该代码路径上不再有任何分配,因此减少了100%!我们不再分配StringBuilder,也不创建新字符串,而是直接返回原始字符串,在这里,原始字符串的内存已经分配过了。

节省160个字节似乎并没有太让人兴奋,但是考虑到客户端每发送一个请求这段代码都会调用一次,因此节省的量非常可观。10个请求(不需要替换的请求)就可以节省1Kb无用的内存分配。如果客户非常频繁地使用Elasticsearch,这个改进就非常值得。

执行时间也减少了87%,因为在这种情况下唯一需要执行的代码就是条件检查和返回。这些改进在热路径上非常成功,对于所有调用该方法的人都有益。由于这是一个客户端库,所以客户也会看到好处,只需要使用包含此优化的最新版客户端即可。

3、总结

在本文中,我们介绍了并非所有性能优化都需要复杂的实现,在文中的示例中,我们通过条件检查避免执行需要分配内存的代码,从而优化了NEST库的ToUrl方法。尽管可以使用Span<T>从理论上进行一些更广泛的优化,但我们优先考虑了可以快速获得性能提升的方法,这不会带来复杂性,也不会加重维护代码的负担。为了确保示例中的代码改动确实可以提升性能,我们使用了基准来衡量代码变更前后的效果。尽管例子中没有介绍,但我们应该运行单元测试,以避免在这个方法中引入回归问题。

希望通过这个示例,你可以在自己的代码中找出只需简单的修改就能快速提升性能的地方。在寻求值得优化的代码时,请优先考虑热路径,并从简单的地方开始,尝试解决能快速提升性能的问题,然后再转向更复杂的优化。对于大多数代码库来说,类似于本文的某些修改应该是合理的,而更高级的优化可能会加重维护的负担。就像本文的示例一样,某些优化工作可能非常简单,只需使用条件检查避免某些代码的执行即可。

原文:

https://www.stevejgordon.co.uk/dotnet-performance-optimisations-dont-have-to-be-complex

作者:STEVE GORDON,微软MVP。

本文为 CSDN 翻译,转载请注明来源出处。

编辑:于腾凯

校对:王欣

0 人点赞