【翻译】.NET 5中的性能改进 (15)


net 5中关于异步的最大变化之一实际上是默认不启用的,但这是另一个获得反馈的实验。net 5中的异步ValueTask池博客更详细地解释,但本质上dotnet/coreclr#26310介绍了异步ValueTask能力和异步ValueTask隐式创建的缓存和重用对象代表一个异步操作完成,使得这些方法amortized-allocation-free的开销。优化目前是可选的,这意味着您需要将DOTNET_SYSTEM_THREADING_POOLASYNCVALUETASKS环境变量设置为1才能启用它。启用这一功能的困难之一是,对于可能要执行比等待SomeValueTaskReturningMethod()更复杂的操作的代码,因为valuetask比任务有更多关于如何使用它们的约束。为了帮助解决这个问题,一种新的UseValueTasksCorrectly分析仪发布了,它将标记大多数此类误用。

[Benchmark] public async Task ValueTaskCost() { for (int i = 0; i < 1_000; i++) await YieldOnce(); } private static async ValueTask YieldOnce() => await Task.Yield(); Method Runtime Mean Ratio Allocated
ValueTaskCost   .NET FW 4.8   1,635.6 us   1.00   294010 B  
ValueTaskCost   .NET Core 3.1   842.7 us   0.51   120184 B  
ValueTaskCost   .NET 5.0   812.3 us   0.50   186 B  


c#编译器中的一些变化为.NET 5中的异步方法带来了额外的好处(在 .NET5中的核心库是用更新的编译器编译的)。每个异步方法都有一个负责生成和完成返回任务的“生成器”,而c#编译器将生成代码作为异步方法的一部分来使用。避免作为代码的一部分生成结构副本,这可以帮助减少开销,特别是对于async ValueTask方法,其中构建器相对较大(并随着T的增长而增长)。同样来自@benaadams的dotnet/roslyn#45262也调整了相同的生成代码,以更好地发挥前面讨论的JIT的零改进。
在特定的api中也有一些改进。dotnet/runtime#35575诞生于一些特定的任务使用Task.ContinueWith,其中延续纯粹用于记录“先行”任务continue from中的异常。通常情况下,任务不会出错,而PR在这种情况下会做得更好。

const int Iters = 1_000_000; private AsyncTaskMethodBuilder[] tasks = new AsyncTaskMethodBuilder[Iters]; [IterationSetup] public void Setup() { Array.Clear(tasks, 0, tasks.Length); for (int i = 0; i < tasks.Length; i++) _ = tasks[i].Task; } [Benchmark(OperationsPerInvoke = Iters)] public void Cancel() { for (int i = 0; i < tasks.Length; i++) { tasks[i].Task.ContinueWith(_ => { }, CancellationToken.None, TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); tasks[i].SetResult(); } } Method Runtime Mean Ratio Allocated
Cancel   .NET FW 4.8   239.2 ns   1.00   193 B  
Cancel   .NET Core 3.1   140.3 ns   0.59   192 B  
Cancel   .NET 5.0   106.4 ns   0.44   112 B  


也有一些调整,以帮助特定的架构。由于x86/x64架构采用了强内存模型,当针对x86/x64时,volatile在JIT时基本上就消失了。ARM/ARM64的情况不是这样,它的内存模型较弱,并且volatile会导致JIT发出围栏。dotnet/runtime#36697删除了每个排队到线程池的工作项的几个volatile访问,使ARM上的线程池更快。dotnet/runtime#34225将ConcurrentDictionary中的volatile访问从一个循环中抛出,这反过来提高了ARM上ConcurrentDictionary的一些成员的吞吐量高达30%。而dotnet/runtime#36976则完全从另一个ConcurrentDictionary字段中删除了volatile。

Collections


多年来,c#已经获得了大量有价值的特性。这些特性中的许多都是为了让开发人员能够更简洁地编写代码,而语言/编译器负责所有样板文件,比如c# 9中的记录。然而,有一些特性更注重性能而不是生产力,这些特性对核心库来说是一个巨大的恩惠,它们可以经常使用它们来提高每个人的程序的效率。来自@benaadams的dotnet/runtime#27195就是一个很好的例子。PR改进了Dictionary<TKey, TValue>,利用了c# 7中引入的ref返回和ref局部变量。>的实现是由字典中的数组条目支持的,字典有一个核心例程用于在其条目数组中查找键的索引;然后在多个函数中使用该例程,如indexer、TryGetValue、ContainsKey等。但是,这种共享是有代价的:通过返回索引并将其留给调用者根据需要从槽中获取数据,调用者将需要重新索引到数组中,从而导致第二次边界检查。有了ref返回,共享例程就可以把一个ref递回给槽,而不是原始索引,这样调用者就可以避免第二次边界检查,同时也避免复制整个条目。PR还包括对生成的程序集进行一些低级调优、重新组织字段和用于更新这些字段的操作,以便JIT能够更好地调优生成的程序集。
字典<TKey,TValue>的性能进一步提高了几个PRs。像许多哈希表一样,Dictionary<TKey,TValue>被划分为“bucket”,每个bucket本质上是一个条目链表(存储在数组中,而不是每个项都有单独的节点对象)。对于给定的键,一个哈希函数(TKey ' s GetHashCode或提供的IComparer ' s GetHashCode)用于计算提供的键的哈希码,然后该哈希码确定地映射到一个bucket;找到bucket之后,实现将遍历该bucket中的条目链,查找目标键。该实现试图保持每个bucket中的条目数较小,并在必要时进行增长和重新平衡以维护该条件。因此,查找的很大一部分开销是计算hashcode到bucket的映射。为了帮助在bucket之间保持良好的分布,特别是当提供的TKey或比较器使用不太理想的哈希代码生成器时,字典使用质数的bucket,而bucket映射由hashcode % numBuckets完成。但是在这里重要的速度,%操作符采用的除法是相对昂贵的。基于Daniel Lemire的工作,dotnet/coreclr#27299(来自@benaadams)和dotnet/runtime#406改变了64位进程中%的使用,而不是使用一对乘法和移位来实现相同的结果,但更快。

private Dictionary<int, int> _dictionary = Enumerable.Range(0, 10_000).ToDictionary(i => i); [Benchmark] public int Sum() { Dictionary<int, int> dictionary = _dictionary; int sum = 0; for (int i = 0; i < 10_000; i++) if (dictionary.TryGetValue(i, out int value)) sum += value; return sum; } Method Runtime Mean Ratio
Sum   .NET FW 4.8   77.45 us   1.00  
Sum   .NET Core 3.1   67.35 us   0.87  
Sum   .NET 5.0   44.10 us   0.57  

内容版权声明:除非注明,否则皆为本站原创文章。

转载注明出处:https://www.heiqu.com/wsssyf.html