.NET8發佈後,Blazor支持四種渲染方式 靜態渲染,這種頁面只可顯示,不提供交互,可用於網頁內容展示 使用Blazor Server托管的通過Server交互方式 使用WebAssembly托管的在瀏覽器端交互方式 使用Auto自動交互方式,最初使用 Blazor Server,併在隨後訪問時 ...
Exceptions
在 .NET 6 中,ArgumentNullException 增加了一個 ThrowIfNull 方法,我們開始嘗試提供“拋出助手”。該方法的目的是簡潔地表達正在驗證的約束,讓系統在未滿足約束時拋出一致的異常,同時也優化了成功和99.999%的情況,無需拋出異常。該方法的結構是這樣的,執行檢查的快速路徑被內聯,儘可能少的工作在該路徑上,然後其他所有的事情都被委托給一個執行實際拋出的方法(JIT 不會內聯這個拋出方法,因為它會看到該方法的實現總是拋出異常)。
public static void ThrowIfNull(
[NotNull] object? argument,
[CallerArgumentExpression(nameof(argument))] string? paramName = null)
{
if (argument is null)
Throw(paramName);
}
[DoesNotReturn]
internal static void Throw(string? paramName) => throw new ArgumentNullException(paramName);
在 .NET 7 中,ArgumentNullException.ThrowIfNull 增加了另一個重載,這次是針對指針,還引入了兩個新方法:ArgumentException.ThrowIfNullOrEmpty 用於字元串,和 ObjectDisposedException.ThrowIf。
現在在 .NET 8 中,添加了一大批新的助手方法。多虧了 dotnet/runtime#86007,ArgumentExc
public static void ThrowIfNullOrWhiteSpace([NotNull] string? argument, [CallerArgumentExpression(nameof(argument))] string? paramName = null);
多虧了 @hrrrrustic 的 dotnet/runtime#78222 和 dotnet/runtime#83853,ArgumentOutOfRangeException 增加了 9 個新方法:
public static void ThrowIfEqual<T>(T value, T other, [CallerArgumentExpression(nameof(value))] string? paramName = null) where T : System.IEquatable<T>?;
public static void ThrowIfNotEqual<T>(T value, T other, [CallerArgumentExpression(nameof(value))] string? paramName = null) where T : System.IEquatable<T>?;
public static void ThrowIfLessThan<T>(T value, T other, [CallerArgumentExpression(nameof(value))] string? paramName = null) where T : IComparable<T>;
public static void ThrowIfLessThanOrEqual<T>(T value, T other, [CallerArgumentExpression(nameof(value))] string? paramName = null) where T : IComparable<T>;
public static void ThrowIfGreaterThan<T>(T value, T other, [CallerArgumentExpression(nameof(value))] string? paramName = null) where T : IComparable<T>;
public static void ThrowIfGreaterThanOrEqual<T>(T value, T other, [CallerArgumentExpression(nameof(value))] string? paramName = null) where T : IComparable<T>;
public static void ThrowIfNegative<T>(T value, [CallerArgumentExpression(nameof(value))] string? paramName = null) where T : INumberBase<T>;
public static void ThrowIfZero<T>(T value, [CallerArgumentExpression(nameof(value))] string? paramName = null) where T : INumberBase<T>;
public static void ThrowIfNegativeOrZero<T>(T value, [CallerArgumentExpression(nameof(value))] string? paramName = null) where T : INumberBase<T>;
這些 PR 在一些地方使用了這些新方法,然後 dotnet/runtime#79460,dotnet/runtime#80355,dotnet/runtime#82357,dotnet/runtime#82533,和 dotnet/runtime#85858 在核心庫中更廣泛地推出了它們的使用。為了瞭解這些方法的實用性,以下是我寫這段文字時,每個方法在 dotnet/runtime 的核心庫的 src 中被調用的次數:
方法 | 計數 |
---|---|
ANE.ThrowIfNull(object) | 4795 |
AOORE.ThrowIfNegative | 873 |
AE.ThrowIfNullOrEmpty | 311 |
ODE.ThrowIf | 237 |
AOORE.ThrowIfGreaterThan | 223 |
AOORE.ThrowIfNegativeOrZero | 100 |
AOORE.ThrowIfLessThan | 89 |
ANE.ThrowIfNull(void*) | 55 |
AOORE.ThrowIfGreaterThanOrEqual | 39 |
AE.ThrowIfNullOrWhiteSpace | 32 |
AOORE.ThrowIfLessThanOrEqual | 20 |
AOORE.ThrowIfNotEqual | 13 |
AOORE.ThrowIfZero | 5 |
AOORE.ThrowIfEqual | 3 |
這些新方法也在拋出部分做了更多的工作(例如,用無效的參數格式化異常消息),這有助於更好地說明將所有這些工作移出到一個單獨的方法的好處。例如,這是直接從 System.Private.CoreLib 複製的 ThrowIfGreaterThan:
public static void ThrowIfGreaterThan<T>(T value, T other, [CallerArgumentExpression(nameof(value))] string? paramName = null) where T : IComparable<T>
{
if (value.CompareTo(other) > 0)
ThrowGreater(value, other, paramName);
}
private static void ThrowGreater<T>(T value, T other, string? paramName) =>
throw new ArgumentOutOfRangeException(paramName, value, SR.Format(SR.ArgumentOutOfRange_Generic_MustBeLessOrEqual, paramName, value, other));
這裡有一個基準測試,顯示瞭如果拋出表達式直接作為 ThrowIfGreaterThan 的一部分,消耗會是什麼樣子:
// dotnet run -c Release -f net8.0 --filter "*"
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Runtime.CompilerServices;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Error", "StdDev", "Median", "RatioSD", "value1", "value2")]
[DisassemblyDiagnoser]
public class Tests
{
[Benchmark(Baseline = true)]
[Arguments(1, 2)]
public void WithOutline(int value1, int value2)
{
ArgumentOutOfRangeException.ThrowIfGreaterThan(value1, 100);
ArgumentOutOfRangeException.ThrowIfGreaterThan(value2, 200);
}
[Benchmark]
[Arguments(1, 2)]
public void WithInline(int value1, int value2)
{
ThrowIfGreaterThan(value1, 100);
ThrowIfGreaterThan(value2, 200);
}
public static void ThrowIfGreaterThan<T>(T value, T other, [CallerArgumentExpression(nameof(value))] string? paramName = null) where T : IComparable<T>
{
if (value.CompareTo(other) > 0)
throw new ArgumentOutOfRangeException(paramName, value, SR.Format(SR.ArgumentOutOfRange_Generic_MustBeLessOrEqual, paramName, value, other));
}
internal static class SR
{
public static string Format(string format, object arg0, object arg1, object arg2) => string.Format(format, arg0, arg1, arg2);
internal static string ArgumentOutOfRange_Generic_MustBeLessOrEqual => GetResourceString("ArgumentOutOfRange_Generic_MustBeLessOrEqual");
[MethodImpl(MethodImplOptions.NoInlining)]
static string GetResourceString(string resourceKey) => "{0} ('{1}') must be less than or equal to '{2}'.";
}
}
方法 | 平均值 | 比率 | 代碼大小 |
---|---|---|---|
WithOutline | 0.4839 ns | 1.00 | 118 B |
WithInline | 2.4976 ns | 5.16 | 235 B |
生成的彙編代碼中,最相關的亮點來自 WithInline 情況:
; Tests.WithInline(Int32, Int32)
push rbx
sub rsp,20
mov ebx,r8d
mov ecx,edx
mov edx,64
mov r8,1F5815EA8F8
call qword ptr [7FF99C03DEA8]; Tests.ThrowIfGreaterThan[[System.Int32, System.Private.CoreLib]](Int32, Int32, System.String)
mov ecx,ebx
mov edx,0C8
mov r8,1F5815EA920
add rsp,20
pop rbx
jmp qword ptr [7FF99C03DEA8]; Tests.ThrowIfGreaterThan[[System.Int32, System.Private.CoreLib]](Int32, Int32, System.String)
; Total bytes of code 59
因為 ThrowIfGreaterThan 方法中有更多的雜項,系統決定不將其內聯,所以即使值在範圍內,我們也會有兩個方法調用(第一個是調用,第二個是 jmp,因為這個方法中沒有後續的工作需要返回控制流)。
為了更容易地推廣這些助手的使用,dotnet/roslyn-analyzers#6293 添加了新的分析器,用於查找可以由 ArgumentNullException、ArgumentException、ArgumentOutOfRangeException 或 ObjectDisposedException 上的 throw helper 方法替換的參數驗證。dotnet/runtime#80149 為 dotnet/runtime 啟用了分析器,並修複了許多調用站點。
Reflection 反射
在 .NET 8 的反射堆棧中,有各種各樣的改進,主要圍繞減少分配和緩存信息,以便後續訪問更快。例如,dotnet/runtime#87902 調整了 GetCustomAttributes 中的一些代碼,以避免分配一個object[1]數組來設置屬性的值。
// dotnet run -c Release -f net7.0 --filter "*" --runtimes net7.0 net8.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Error", "StdDev", "Median", "RatioSD")]
[MemoryDiagnoser(displayGenColumns: false)]
public class Tests
{
[Benchmark]
public object[] GetCustomAttributes() => typeof(C).GetCustomAttributes(typeof(MyAttribute), inherit: true);
[My(Value1 = 1, Value2 = 2)]
class C { }
[AttributeUsage(AttributeTargets.All)]
public class MyAttribute : Attribute
{
public int Value1 { get; set; }
public int Value2 { get; set; }
}
}
方法 | 運行時 | 平均值 | 比率 | 分配 | 分配比率 |
---|---|---|---|---|---|
GetCustomAttributes | .NET 7.0 | 1,287.1 ns | 1.00 | 296 B | 1.00 |
GetCustomAttributes | .NET 8.0 | 994.0 ns | 0.77 | 232 B | 0.78 |
像 dotnet/runtime#76574,dotnet/runtime#81059,和 dotnet/runtime#86657 這樣的其他改變也減少了反射堆棧中的分配,特別是通過更自由地使用 spans。而來自 @lateapexearlyspeed 的 dotnet/runtime#78288 改進了 Type 上泛型信息的處理,從而提升了各種與泛型相關的成員,特別是對於 GetGenericTypeDefinition,其結果現在被緩存在 Type 對象上。
// dotnet run -c Release -f net7.0 --filter "*" --runtimes net7.0 net8.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
private readonly Type _type = typeof(List<int>);
[Benchmark] public Type GetGenericTypeDefinition() => _type.GetGenericTypeDefinition();
}
方法 | 運行時 | 平均值 | 比率 |
---|---|---|---|
GetGenericTypeDefinition | .NET 7.0 | 47.426 ns | 1.00 |
GetGenericTypeDefinition | .NET 8.0 | 3.289 ns | 0.07 |
然而,在 .NET 8 中,反射性能的最大影響來自 dotnet/runtime#88415。這是在 .NET 7 中改進 MethodBase.Invoke 性能的工作的延續。當你在編譯時知道你想通過反射調用的目標方法的簽名時,你可以通過使用 CreateDelegate
// If you have .NET 6 installed, you can update the csproj to include a net6.0 in the target frameworks, and then run:
// dotnet run -c Release -f net6.0 --filter "*" --runtimes net6.0 net7.0 net8.0
// Otherwise, you can run:
// dotnet run -c Release -f net7.0 --filter "*" --runtimes net7.0 net8.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Reflection;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
private MethodInfo _method0, _method1, _method2, _method3;
private readonly object[] _args1 = new object[] { 1 };
private readonly object[] _args2 = new object[] { 2, 3 };
private readonly object[] _args3 = new object[] { 4, 5, 6 };
[GlobalSetup]
public void Setup()
{
_method0 = typeof(Tests).GetMethod("MyMethod0", BindingFlags.NonPublic | BindingFlags.Static);
_method1 = typeof(Tests).GetMethod("MyMethod1", BindingFlags.NonPublic | BindingFlags.Static);
_method2 = typeof(Tests).GetMethod("MyMethod2", BindingFlags.NonPublic | BindingFlags.Static);
_method3 = typeof(Tests).GetMethod("MyMethod3", BindingFlags.NonPublic | BindingFlags.Static);
}
[Benchmark] public void Method0() => _method0.Invoke(null, null);
[Benchmark] public void Method1() => _method1.Invoke(null, _args1);
[Benchmark] public void Method2() => _method2.Invoke(null, _args2);
[Benchmark] public void Method3() => _method3.Invoke(null, _args3);
private static void MyMethod0() { }
private static void MyMethod1(int arg1) { }
private static void MyMethod2(int arg1, int arg2) { }
private static void MyMethod3(int arg1, int arg2, int arg3) { }
}
方法 | 運行時 | 平均值 | 比率 |
---|---|---|---|
Method0 | .NET 6.0 | 91.457 ns | 1.00 |
Method0 | .NET 7.0 | 7.205 ns | 0.08 |
Method0 | .NET 8.0 | 5.719 ns | 0.06 |
Method1 | .NET 6.0 | 132.832 ns | 1.00 |
Method1 | .NET 7.0 | 26.151 ns | 0.20 |
Method1 | .NET 8.0 | 21.602 ns | 0.16 |
Method2 | .NET 6.0 | 172.224 ns | 1.00 |
Method2 | .NET 7.0 | 37.937 ns | 0.22 |
Method2 | .NET 8.0 | 26.951 ns | 0.16 |
Method3 | .NET 6.0 | 211.247 ns | 1.00 |
Method3 | .NET 7.0 | 42.988 ns | 0.20 |
Method3 | .NET 8.0 | 34.112 ns | 0.16 |
然而,這裡每次調用都涉及到一些開銷,並且每次調用都會重覆。如果我們可以提前提取這些工作,一次性完成,併進行緩存,我們可以實現更好的性能。這正是新的 MethodInvoker 和 ConstructorInvoker 類型在 dotnet/runtime#88415 中實現的功能。這些並沒有包含所有 MethodBase.Invoke 處理的不常見錯誤(如特別識別和處理 Type.Missing),但對於其他所有情況,它為優化在構建時未知簽名的方法的重覆調用提供了一個很好的解決方案。
// dotnet run -c Release -f net8.0 --filter "*"
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Reflection;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
private readonly object _arg0 = 4, _arg1 = 5, _arg2 = 6;
private readonly object[] _args3 = new object[] { 4, 5, 6 };
private MethodInfo _method3;
private MethodInvoker _method3Invoker;
[GlobalSetup]
public void Setup()
{
_method3 = typeof(Tests).GetMethod("MyMethod3", BindingFlags.NonPublic | BindingFlags.Static);
_method3Invoker = MethodInvoker.Create(_method3);
}
[Benchmark(Baseline = true)]
public void MethodBaseInvoke() => _method3.Invoke(null, _args3);
[Benchmark]
public void MethodInvokerInvoke() => _method3Invoker.Invoke(null, _arg0, _arg1, _arg2);
private static void MyMethod3(int arg1, int arg2, int arg3) { }
}
方法 | 平均值 | 比率 |
---|---|---|
MethodBaseInvoke | 32.42 ns | 1.00 |
MethodInvokerInvoke | 11.47 ns | 0.35 |
根據 dotnet/runtime#90119,這些類型然後被 Microsoft.Extensions.DependencyInjection.Abstractions 中的 ActivatorUtilities.CreateFactory 方法使用,以進一步提高 DI 服務構建性能。dotnet/runtime#91881 通過添加額外的緩存層進一步改進,進一步避免每次構建時的反射。
Primitives 基礎類型
令人難以置信的是,經過二十年,我們仍然有機會改進 .NET 的核心基元類型,然而我們就在這裡。其中一些來自於驅動優化進入不同地方的新場景;一些來自於基於新支持的新機會,使得可以採用不同的方法來解決同一個問題;一些來自於新的研究,突出瞭解決問題的新方法;還有一些簡單地來自於許多新的眼睛看一個磨損的空間(好開源!)無論原因如何,在 .NET 8 中這裡有很多值得興奮的地方。
枚舉
讓我們從枚舉開始。枚舉顯然自從 .NET 的早期就開始存在,並且被廣泛使用。儘管枚舉的功能和實現已經演變,也獲得了新的 API,但核心在於,數據如何存儲在枚舉中多年來基本上保持不變。在 .NET Framework 的實現中,有一個內部的 ValuesAndNames 類,它存儲一個 ulong[] 和一個 string[],在 .NET 7 中,有一個 EnumInfo 用於同樣的目的。那個 string[] 包含所有枚舉值的名稱,ulong[] 存儲它們的數字對應項。它是一個 ulong[],以容納 Enum 可以是的所有可能的底層類型,包括 C# 支持的(sbyte,byte,short,ushort,int,uint,long,ulong)和運行時額外支持的(nint,nuint,char,float,double),儘管實際上沒有人使用這些(部分 bool 支持也曾經在這個列表上,但在 .NET 8 中在 dotnet/runtime#79962 中被 @pedrobsaila 刪除)。
順便說一句,作為所有這些工作的一部分,我們檢查了廣泛的適當許可的 NuGet 包,尋找它們使用枚舉的最常見的底層類型。在找到的大約 163 百萬個枚舉中,這是它們底層類型的分佈。結果可能並不令人驚訝,考慮到 Enum 的預設底層類型,但它仍然很有趣:
枚舉底層類型的常見程度的圖表
在枚舉如何存儲其數據的設計中有幾個問題。每個操作都在這些 ulong[] 值和特定枚舉使用的實際類型之間進行轉換,而且數組通常比需要的大兩倍(int 是枚舉的預設底層類型,並且,如上圖所示,迄今為止最常使用)。這種方法還導致處理所有近年來添加到 Enum 中的新泛型方法時,會產生大量的彙編代碼膨脹。枚舉是結構體,當結構體被用作泛型類型參數時,JIT 為該值類型專門化代碼(而對於引用類型,它發出一個由所有這些類型使用的單一共用實現)。這種專門化對於吞吐量來說是很好的,但這意味著你得到了它用於的每個值類型的代碼副本;如果你有很多代碼(例如 Enum 格式化)和很多可能被替換的類型(例如每個聲明的枚舉類型),那麼代碼大小可能會大幅增加。
為瞭解決所有這些問題,現代化實現,並使各種操作更快,dotnet/runtime#78580 重寫了 Enum。它不再使用一個非泛型的 EnumInfo 來存儲所有值的 ulong[] 數組,而是引入了一個泛型的 EnumInfo
Enum 也進行了其他改進。dotnet/runtime#76162 提高了各種方法(如 ToString 和 IsDefined)的性能,在所有枚舉的定義值從 0 開始連續的情況下。在這種常見情況下,查找 EnumInfo
所有這些更改的最終結果是一些非常好的性能提升:
// dotnet run -c Release -f net7.0 --filter "*" --runtimes net7.0 net8.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Error", "StdDev", "Median", "RatioSD")]
[MemoryDiagnoser(displayGenColumns: false)]
public class Tests
{
private readonly DayOfWeek _dow = DayOfWeek.Saturday;
[Benchmark] public bool IsDefined() => Enum.IsDefined(_dow);
[Benchmark] public string GetName() => Enum.GetName(_dow);
[Benchmark] public string[] GetNames() => Enum.GetNames<DayOfWeek>();
[Benchmark] public DayOfWeek[] GetValues() => Enum.GetValues<DayOfWeek>();
[Benchmark] public Array GetUnderlyingValues() => Enum.GetValuesAsUnderlyingType<DayOfWeek>();
[Benchmark] public string EnumToString() => _dow.ToString();
[Benchmark] public bool TryParse() => Enum.TryParse<DayOfWeek>("Saturday", out _);
}
方法 | 運行時 | 平均值 | 比率 | 分配 | 分配比率 |
---|---|---|---|---|---|
IsDefined | .NET 7.0 | 20.021 ns | 1.00 | - | NA |
IsDefined | .NET 8.0 | 2.502 ns | 0.12 | - | NA |
GetName | .NET 7.0 | 24.563 ns | 1.00 | - | NA |
GetName | .NET 8.0 | 3.648 ns | 0.15 | - | NA |
GetNames | .NET 7.0 | 37.138 ns | 1.00 | 80 B | 1.00 |
GetNames | .NET 8.0 | 22.688 ns | 0.61 | 80 B | 1.00 |
GetValues | .NET 7.0 | 694.356 ns | 1.00 | 224 B | 1.00 |
GetValues | .NET 8.0 | 39.406 ns | 0.06 | 56 B | 0.25 |
GetUnderlyingValues | .NET 7.0 | 41.012 ns | 1.00 | 56 B | 1.00 |
GetUnderlyingValues | .NET 8.0 | 17.249 ns | 0.42 | 56 B | 1.00 |
EnumToString | .NET 7.0 | 32.842 ns | 1.00 | 24 B | 1.00 |
EnumToString | .NET 8.0 | 14.620 ns | 0.44 | 24 B | 1.00 |
TryParse | .NET 7.0 | 49.121 ns | 1.00 | - | NA |
TryParse | .NET 8.0 | 30.394 ns | 0.62 | - | NA |
然而,這些更改也使枚舉與字元串插值更加融洽。
首先,枚舉現在具有一個新的靜態 TryFormat 方法,可以直接將枚舉的字元串表示格式化為 Span
public static bool TryFormat<TEnum>(TEnum value, Span<char> destination,
out int charsWritten,
[StringSyntax(StringSyntaxAttribute.EnumFormat)] ReadOnlySpan<char> format = default)
where TEnum : struct, Enum
第二,枚舉現在實現了 ISpanFormattable,因此任何使用值 ISpanFormattable.TryFormat 方法的代碼現在也可以在枚舉上使用。然而,儘管枚舉是值類型,但它們在引用類型 Enum 派生,這意味著調用實例方法(如 ToString 或 ISpanFormattable.TryFormat)時,會將枚舉值進行裝箱。
所以,第三,System.Private.CoreLib 中的各種插值字元串處理程式已更新為特殊處理 typeof(T).IsEnum,如前所述,現在由於即時編譯(JIT)優化,這個操作實際上是開銷為0。直接使用 Enum.TryFormat 以避免裝箱。我們可以通過運行以下基準測試來查看這種情況的影響:
// dotnet run -c Release -f net7.0 --filter "*" --runtimes net7.0 net8.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Error", "StdDev", "Median", "RatioSD")]
[MemoryDiagnoser(displayGenColumns: false)]
public class Tests
{
private readonly char[] _dest = new char[100];
private readonly FileAttributes _attr = FileAttributes.Hidden | FileAttributes.ReadOnly;
[Benchmark]
public bool Interpolate() => _dest.AsSpan().TryWrite($"Attrs: {_attr}", out int charsWritten);
}
方法 | 運行時 | 平均值 | 比率 | 分配 | 分配比率 |
---|---|---|---|---|---|
Interpolate | .NET 7.0 | 81.58 ns | 1.00 | 80 B | 1.00 |
Interpolate | .NET 8.0 | 34.41 ns | 0.42 | - | 0.00 |
Numbers
這樣的格式化改進並不僅僅局限於枚舉。在 .NET 8 中,數字格式化的性能也獲得了一組不錯的改進。Daniel Lemire 有一篇 2021 年的博客文章,討論了各種計算整數中數字位數的方法。數字位數與數字格式化密切相關,因為我們需要知道數字將占用多少個字元,以便分配合適長度的字元串進行格式化,或確保目標緩衝區具有足夠的長度。dotnet/runtime#76519 將在 .NET 的數字格式化內部實現這一點,為計算格式化值中的數字位數提供了一種無分支、基於表的查找解決方案。
dotnet/runtime#76726 進一步提高了性能,它使用了其他格式化庫使用的技巧。格式化十進位數中最昂貴的部分之一是除以 10 來獲取每個數字;如果我們可以減少除法的數量,我們就可以減少整個格式化操作的總體開銷。這裡的技巧是,我們不是為數字中的每個數字除以 10,而是為數字中的每對數字除以 100,然後有一個預先計算的查找表,用於所有 0 到 99 的值的基於字元的表示。這讓我們可以將除法的數量減半。
dotnet/runtime#79061 還擴展了 .NET 中已經存在的一個先前的優化。格式化代碼包含了一個預先計算的單個數字字元串的表,所以如果你要求等效於 0.ToString(),實現不需要分配一個新的字元串,它只需要從表中獲取 "0" 並返回。這個 PR 將這個緩存從單個數字擴展到所有 0 到 299 的數字(它也使緩存變得懶惰,這樣我們不需要為從未使用的值的字元串付費)。選擇 299 有些隨意,如果需要的話,將來可以提高,但在檢查各種服務的數據時,這解決了來自數字格式化的大部分分配。巧合的是,它也包括了 HTTP 協議的所有成功狀態代碼。
// dotnet run -c Release -f net7.0 --filter "*" --runtimes net7.0 net8.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Error", "StdDev", "Median", "RatioSD")]
[MemoryDiagnoser(displayGenColumns: false)]
public class Tests
{
[Benchmark]
[Arguments(12)]
[Arguments(123)]
[Arguments(1_234_567_890)]
public string Int32ToString(int i) => i.ToString();
}
方法 | 運行時 | i | 平均值 | 比率 | 分配 | 分配比率 |
---|---|---|---|---|---|---|
Int32ToString | .NET 7.0 | 12 | 16.253 ns | 1.00 | 32 B | 1.00 |
Int32ToString | .NET 8.0 | 12 | 1.985 ns | 0.12 | - | 0.00 |
Int32ToString | .NET 7.0 | 123 | 18.056 ns | 1.00 | 32 B | 1.00 |
Int32ToString | .NET 8.0 | 123 | 1.971 ns | 0.11 | - | 0.00 |
Int32ToString | .NET 7.0 | 1234567890 | 26.964 ns | 1.00 | 48 B | 1.00 |
Int32ToString | .NET 8.0 | 1234567890 | 17.082 ns | 0.63 | 48 B | 1.00 |
在 .NET 8 中,數字還獲得了作為二進位格式化(通過 dotnet/runtime#84889)和從二進位解析(通過 dotnet/runtime#84998)的能力,通過新的“b”指定符。例如:
// dotnet run -f net8.0
int i = 12345;
Console.WriteLine(i.ToString("x16")); // 16 hex digits
Console.WriteLine(i.ToString("b16")); // 16 binary digits
outputs:
0000000000003039
0011000000111001
然後,該實現被用來重新實現現有的 Convert.ToString(int value, int toBase) 方法,使其也現在被優化:
// dotnet run -c Release -f net7.0 --filter "*" --runtimes net7.0 net8.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
private readonly int _value = 12345;
[Benchmark]
public string ConvertBinary() => Convert.ToString(_value, 2);
}
方法 | 運行時 | 平均值 | 比率 |
---|---|---|---|
ConvertBinary | .NET 7.0 | 104.73 ns | 1.00 |
ConvertBinary | .NET 8.0 | 23.76 ns | 0.23 |
在對基本類型(數字和其他)的重大增加中,.NET 8 還引入了新的 IUtf8SpanFormattable 介面。ISpanFormattable 在 .NET 6 中引入,以及許多類型上的 TryFormat 方法,使這些類型能夠直接格式化到 Span
public interface ISpanFormattable : IFormattable
{
bool TryFormat(Span<char> destination, out int charsWritten, ReadOnlySpan<char> format, IFormatProvider? provider);
}
現在在 .NET 8 中,我們也有了 IUtf8SpanFormattable 介面:
public interface IUtf8SpanFormattable
{
bool TryFormat(Span<byte> utf8Destination, out int bytesWritten, ReadOnlySpan<char> format, IFormatProvider? provider);
}
這使得類型可以直接格式化到 Span
public bool TryFormat(Span<char> destination, out int charsWritten, [StringSyntax(StringSyntaxAttribute.NumericFormat)] ReadOnlySpan<char> format = default, IFormatProvider? provider = null);
public bool TryFormat(Span<byte> utf8Destination, out int bytesWritten, [StringSyntax(StringSyntaxAttribute.NumericFormat)] ReadOnlySpan<char> format = default, IFormatProvider? provider = null);
它們具有完全相同的功能,支持完全相同的格式字元串,具有相同的一般性能特性,等等,只是在寫出 UTF16 或 UTF8 上有所不同。我怎麼能這麼確定它們是如此相似呢?因為,鼓聲,它們共用相同的實現。多虧了泛型,上面的兩個方法都委托給了完全相同的幫助器:
public static bool TryFormatUInt64<TChar>(ulong value, ReadOnlySpan<char> format, IFormatProvider? provider, Span<TChar> destination, out int charsWritten)
只是其中一個 TChar 為 char,另一個為 byte。所以,當我們運行像這樣的基準測試時:
// dotnet run -c Release -f net8.0 --filter "*"
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
private readonly ulong _value = 12345678901234567890;
private readonly char[] _chars = new char[20];
private readonly byte[] _bytes = new byte[20];
[Benchmark] public void FormatUTF16() => _value.TryFormat(_chars, out _);
[Benchmark] public void FormatUTF8() => _value.TryFormat(_bytes, out _);
}
我們得到的結果幾乎是完全相同的:
方法 | 平均值 |
---|---|
FormatUTF16 | 12.10 ns |
FormatUTF8 | 12.96 ns |
現在,基本類型本身能夠以完全保真的 UTF8 格式化,Utf8Formatter 類在很大程度上變得過時了。實際上,前面提到的 PR 也刪除了 Utf8Formatter 的實現,並將其重新定位在基本類型的相同格式化邏輯之上。所有之前引用的數字格式化的性能改進不僅適用於 ToString 和 TryFormat 的 UTF16,不僅適用於 UTF8 的 TryFormat,而且還適用於 Utf8Formatter(此外,刪除重覆的代碼和減少維護負擔讓我感到興奮)。
// dotnet run -c Release -f net7.0 --filter "*" --runtimes net7.0 net8.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Buffers.Text;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
private readonly byte[] _bytes = new byte[10];
[Benchmark]
[Arguments(123)]
[Arguments(1234567890)]
public bool Utf8FormatterTryFormat(int i) => Utf8Formatter.TryFormat(i, _bytes, out int bytesWritten);
}
方法 | 運行時 | i | 平均值 | 比率 |
---|---|---|---|---|
Utf8FormatterTryFormat | .NET 7.0 | 123 | 8.849 ns | 1.00 |
Utf8FormatterTryFormat | .NET 8.0 | 123 | 4.645 ns | 0.53 |
Utf8FormatterTryFormat | .NET 7.0 | 1234567890 | 15.844 ns | 1.00 |
Utf8FormatterTryFormat | .NET 8.0 | 1234567890 | 7.174 ns | 0.45 |
不僅所有這些類型都直接支持 UTF8 格式化,而且還支持解析。dotnet/runtime#86875 添加了新的 IUtf8SpanParsable
DateTime
解析和格式化在其他類型上也得到了改進。以 DateTime 和 DateTimeOffset 為例。dotnet/runtime#84963 改進了 DateTime{Offset} 格式化的各種方面:
- 格式化邏輯具有通用支持作為後備,並支持任何自定義格式,但然後有專用的常式用於最流行的格式,允許它們被優化和調整。對於非常流行的 "r"(RFC1123 模式)和 "o"(往返日期/時間模式)格式,已經存在專用的常式;此 PR 為預設格式("G")添加了專用常式,當與不變文化一起使用時,"s" 格式(可排序的日期/時間模式),和 "u" 格式(通用可排序的日期/時間模式),所有這些在各種領域中都經常使用。
- 對於 "U" 格式(通用完整日期/時間模式),實現最終總是會分配新的 DateTimeFormatInfo 和 GregorianCalendar 實例,即使只在罕見的後備情況下需要,也會導致大量的分配。這修複了它,只有在真正需要時才分配。
- 當沒有專用的格式化常式時,格式化是在一個名為 ValueListBuilder<T> 的內部 ref 結構中完成的,該結構以提供的 span 緩衝區開始(通常從 stackalloc 中構建),然後根據需要使用 ArrayPool 記憶體增長。格式化完成後,該構建器要麼被覆制到目標 span,要麼被覆制到新的字元串,這取決於觸發格式化的方法。然而,如果我們只是用目標 span 構建器,我們可以避免對目標 span 的複製。然後,如果構建器在格式化完成時仍包含初始 span(沒有超出它的增長),我們知道所有數據都適合,我們可以跳過複製,因為所有數據已經在那裡。
以下是一些示例影響:
// dotnet run -c Release -f net7.0 --filter "*" --runtimes net7.0 net8.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Globalization;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Error", "StdDev", "Median", "RatioSD")]
[MemoryDiagnoser(displayGenColumns: false)]
public class Tests
{
private readonly DateTime _dt = new DateTime(2023, 9, 1, 12, 34, 56);
private readonly char[] _chars = new char[100];
[Params(null, "s", "u", "U", "G")]
public string Format { get; set; }
[Benchmark] public string DT_ToString() => _dt.ToString(Format);
[Benchmark] public string DT_ToStringInvariant() => _dt.ToString(Format, CultureInfo.InvariantCulture);
[Benchmark] public bool DT_TryFormat() => _dt.TryFormat(_chars, out _, Format);
[Benchmark] public bool DT_TryFormatInvariant() => _dt.TryFormat(_chars, out _, Format, CultureInfo.InvariantCulture);
}
方法 | 運行時 | 格式 | 平均值 | 比率 | 分配 | 分配比率 |
---|---|---|---|---|---|---|
DT_ToString | .NET 7.0 | ? | 166.64 ns | 1.00 | 64 B | 1.00 |
DT_ToString | .NET 8.0 | ? | 102.45 ns | 0.62 | 64 B | 1.00 |
DT_ToStringInvariant | .NET 7.0 | ? | 161.94 ns | 1.00 | 64 B | 1.00 |
DT_ToStringInvariant | .NET 8.0 | ? | 28.74 ns | 0.18 | 64 B | 1.00 |
DT_TryFormat | .NET 7.0 | ? | 151.52 ns | 1.00 | – | NA |
DT_TryFormat | .NET 8.0 | ? | 78.57 ns | 0.52 | – | NA |
DT_TryFormatInvariant | .NET 7.0 | ? | 140.35 ns | 1.00 | – | NA |
DT_TryFormatInvariant | .NET 8.0 | ? | 18.26 ns | 0.13 | – | NA |
DT_ToString | .NET 7.0 | G | 162.86 ns | 1.00 | 64 B | 1.00 |
DT_ToString | .NET 8.0 | G | 109.49 ns | 0.68 | 64 B | 1.00 |
DT_ToStringInvariant | .NET 7.0 | G | 162.20 ns | 1.00 | 64 B | 1.00 |
DT_ToStringInvariant | .NET 8.0 | G | 102.71 ns | 0.63 | 64 B | 1.00 |
DT_TryFormat | .NET 7.0 | G | 148.32 ns | 1.00 | – | NA |
DT_TryFormat | .NET 8.0 | G | 83.60 ns | 0.57 | – | NA |
DT_TryFormatInvariant | .NET 7.0 | G | 145.05 ns | 1.00 | – | NA |
DT_TryFormatInvariant | .NET 8.0 | G | 79.77 ns | 0.55 | – | NA |
DT_ToString | .NET 7.0 | s | 186.44 ns | 1.00 | 64 B | 1.00 |
DT_ToString | .NET 8.0 | s | 29.35 ns | 0.17 | 64 B | 1.00 |
DT_ToStringInvariant | .NET 7.0 | s | 182.15 ns | 1.00 | 64 B | 1.00 |
DT_ToStringInvariant | .NET 8.0 | s | 27.67 ns | 0.16 | 64 B | 1.00 |
DT_TryFormat | .NET 7.0 | s | 165.08 ns | 1.00 | – | NA |
DT_TryFormat | .NET 8.0 | s | 15.53 ns | 0.09 | – | NA |
DT_TryFormatInvariant | .NET 7.0 | s | 155.24 ns | 1.00 | – | NA |
DT_TryFormatInvariant | .NET 8.0 | s | 15.50 ns | 0.10 | – | NA |
DT_ToString | .NET 7.0 | u | 184.71 ns | 1.00 | 64 B | 1.00 |
DT_ToString | .NET 8.0 | u | 29.62 ns | 0.16 | 64 B | 1.00 |
DT_ToStringInvariant | .NET 7.0 | u | 184.01 ns | 1.00 | 64 B | 1.00 |
DT_ToStringInvariant | .NET 8.0 | u | 26.98 ns | 0.15 | 64 B | 1.00 |
DT_TryFormat | .NET 7.0 | u | 171.73 ns | 1.00 | – | NA |
DT_TryFormat | .NET 8.0 | u | 16.08 ns | 0.09 | – | NA |
DT_TryFormatInvariant | .NET 7.0 | u | 158.42 ns | 1.00 | – | NA |
DT_TryFormatInvariant | .NET 8.0 | u | 15.58 ns | 0.10 | – | NA |
DT_ToString | .NET 7.0 | U | 1,622.28 ns | 1.00 | 1240 B | 1.00 |
DT_ToString | .NET 8.0 | U | 206.08 ns | 0.13 | 96 B | 0.08 |
DT_ToStringInvariant | .NET 7.0 | U | 1,567.92 ns | 1.00 | 1240 B | 1.00 |
DT_ToStringInvariant | .NET 8.0 | U | 207.60 ns | 0.13 | 96 B | 0.08 |
DT_TryFormat | .NET 7.0 | U | 1,590.27 ns | 1.00 | 1144 B | 1.00 |
DT_TryFormat | .NET 8.0 | U | 190.98 ns | 0.12 | – | 0.00 |
DT_TryFormatInvariant | .NET 7.0 | U | 1,560.00 ns | 1.00 | 1144 B | 1.00 |
DT_TryFormatInvariant | .NET 8.0 | U | 184.11 ns | 0.12 | – | 0.00 |
解析也有了顯著的改進。例如,dotnet/runtime#82877 改進了自定義格式字元串中“ddd”(一周中某天的縮寫名稱)、“dddd”(一周中某天的全名)、“MMM”(月份的縮寫名稱)和“MMMM”(月份的全名)的處理;這些在各種常用格式字元串中都有出現,比如在 RFC1123 格式的擴展定義中:ddd, dd MMM yyyy HH':'mm':'ss 'GMT'。當通用解析常式在格式字元串中遇到這些時,它需要查閱提供的 CultureInfo / DateTimeFormatInfo,以獲取該語言區域設置的相關月份和日期名稱,例如 DateTimeFormatInfo.GetAbbreviatedMonthName,然後需要對每個名稱和輸入文本進行語言忽略大小寫的比較;開銷很大。然而,如果我們得到的是一個不變的語言區域設置,我們可以做得更快,快得多。以“MMM”為例,代表縮寫的月份名稱。我們可以讀取接下來的三個字元(uint m0 = span[0], m1 = span[1], m2 = span[2]),確保它們都是 ASCII ((m0 | m1 | m2) <= 0x7F),然後將它們全部合併成一個單獨的 uint,使用之前討論過的相同的 ASCII 大小寫技巧 ((m0 << 16) | (m1 << 8) | m2 | 0x202020)。我們可以對每個月份名稱做同樣的事情,這些對於不變的語言區域設置我們提前知道,整個查找變成了一個單一的數字切換:
switch ((m0 << 16) | (m1 << 8) | m2 | 0x202020)
{
case 0x6a616e: /* 'jan' */ result = 1; break;
case 0x666562: /* 'feb' */ result = 2; break;
case 0x6d6172: /* 'mar' */ result = 3; break;
case 0x617072: /* 'apr' */ result = 4; break;
case 0x6d6179: /* 'may' */ result = 5; break;
case 0x6a756e: /* 'jun' */ result = 6; break;
case 0x6a756c: /* 'jul' */ result = 7; break;
case 0x617567: /* 'aug' */ result = 8; break;
case 0x736570: /* 'sep' */ result = 9; break;
case 0x6f6374: /* 'oct' */ result = 10; break;
case 0x6e6f76: /* 'nov' */ result = 11; break;
case 0x646563: /* 'dec' */ result = 12; break;
default: maxMatchStrLen = 0; break; // undo match assumption
}
非常巧妙,而且速度快得多。
// dotnet run -c Release -f net7.0 --filter "*" --runtimes net7.0 net8.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Globalization;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Error", "StdDev", "Median", "RatioSD")]
[MemoryDiagnoser(displayGenColumns: false)]
public class Tests
{
private const string Format = "ddd, dd MMM yyyy HH':'mm':'ss 'GMT'";
private readonly string _s = new DateTime(1955, 11, 5, 6, 0, 0, DateTimeKind.Utc).ToString(Format, CultureInfo.InvariantCulture);
[Benchmark]
public void ParseExact() => DateTimeOffset.ParseExact(_s, Format, CultureInfo.InvariantCulture, DateTimeStyles.AllowInnerWhite | DateTimeStyles.AssumeUniversal);
}
方法 | 運行時 | 平均值 | 比率 | 分配 | 分配比率 |
---|---|---|---|---|---|
ParseExact | .NET 7.0 | 1,139.3 ns | 1.00 | 80 B | 1.00 |
ParseExact | .NET 8.0 | 318.6 ns | 0.28 | – | 0.00 |
其他各種 PR 也有所貢獻。上一個基準測試中分配的減少要歸功於 dotnet/runtime#82861,它移除了當格式字元串包含引號時可能發生的字元串分配;PR 簡單地用 span 替換了字元串分配。dotnet/runtime#82925 進一步通過移除一些最終被證明是不必要的工作,移除虛擬調度,並對代碼路徑進行一般性的精簡,降低了使用“r”和“o”格式解析的成本。而 dotnet/runtime#84964 移除了在使用某些文化進行 ParseExact 解析時發生的一些 string[] 分配,特別是那些使用了月份名詞所有格的文化。如果解析器需要檢索 MonthGenitiveNames 或 AbbreviatedMonthGenitiveNames 數組,它會通過 DateTimeFormatInfo 上的這些公共屬性來實現;然而,由於擔心代碼可能會改變這些數組,這些公共屬性返回的是副本。這意味著每次解析器訪問其中一個時,它都會分配一個副本。解析器可以直接訪問底層的原始數組,並保證不改變它。
// dotnet run -c Release -f net7.0 --filter "*" --runtimes net7.0 net8.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Globalization;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Error", "StdDev", "Median", "RatioSD")]
[MemoryDiagnoser(displayGenColumns: false)]
public class Tests
{
private readonly CultureInfo _ci = new CultureInfo("ru-RU");
[Benchmark] public DateTime Parse() => DateTime.ParseExact("вторник, 18 апреля 2023 04:31:26", "dddd, dd MMMM yyyy HH:mm:ss", _ci);
}
方法 | 運行時 | 平均值 | 比率 | 分配 | 分配比率 |
---|---|---|---|---|---|
Parse | .NET 7.0 | 2.654 us | 1.00 | 128 B | 1.00 |
Parse | .NET 8.0 | 2.353 us | 0.90 | – | 0.00 |
DateTime 和 DateTimeOffset 也實現了 IUtf8SpanFormattable,這要歸功於 dotnet/runtime#84469,就像數值類型一樣,這些實現都在 UTF16 和 UTF8 之間共用;因此,前面提到的所有優化都適用於這兩者。同樣,Utf8Formatter 對 DateTimeOffset 格式化的支持也是基於這同樣的共用邏輯。
// dotnet run -c Release -f net7.0 --filter "*" --runtimes net7.0 net8.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Buffers.Text;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
private readonly DateTime _dt = new DateTime(2023, 9, 1, 12, 34, 56);
private readonly byte[] _bytes = new byte[100];
[Benchmark] public bool TryFormatUtf8Formatter() => Utf8Formatter.TryFormat(_dt, _bytes, out _);
}
方法 | 運行時 | 平均值 | 比率 |
---|---|---|---|
TryFormatUtf8Formatter | .NET 7.0 | 19.35 ns | 1.00 |
TryFormatUtf8Formatter | .NET 8.0 | 16.24 ns | 0.83 |
既然我們在談論 DateTime,那就簡單談談 TimeZoneInfo。TimeZoneInfo.FindSystemTimeZoneById 可以獲取指定標識符的 TimeZoneInfo 對象。.NET 6 引入的一個改進是,FindSystemTimeZoneById 支持 Windows 時間區集和 IANA 時間區集,無論在 Windows、Linux 還是 macOS 上運行。然而,只有當 TimeZoneInfo 的 ID 與當前操作系統匹配時,它才會被緩存,因此,解析到其他集的調用不會被緩存滿足,而是回退到從操作系統重新讀取。dotnet/runtime#85615 確保在兩種情況下都可以使用緩存。它還允許直接返回不可變的 TimeZoneInfo 對象,而不是在每次訪問時克隆它們。dotnet/runtime#88368 還改進了 TimeZoneInfo,特別是在 Linux 和 macOS 上的 GetSystemTimeZones,通過延遲載入幾個屬性。dotnet/runtime#89985 則在此基礎上進行了改進,提供了一個新的 GetSystemTimeZones 重載,允許調用者跳過實現在結果上執行的排序。
// dotnet run -c Release -f net7.0 --filter "*" --runtimes net7.0 net8.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Error", "StdDev", "Median", "RatioSD")]
[MemoryDiagnoser(displayGenColumns: false)]
public class Tests
{
[Benchmark]
[Arguments("America/Los_Angeles")]
[Arguments("Pacific Standard Time")]
public TimeZoneInfo FindSystemTimeZoneById(string id) => TimeZoneInfo.FindSystemTimeZoneById(id);
}
方法 | 運行時 | id | 平均值 | 比率 | 分配 | 分配比率 |
---|---|---|---|---|---|---|
FindSystemTimeZoneById | .NET 7.0 | America/Los_Angeles | 1,503.75 ns | 1.00 | 80 B | 1.00 |
FindSystemTimeZoneById | .NET 8.0 | America/Los_Angeles | 40.96 ns | 0.03 | – | 0.00 |
FindSystemTimeZoneById | .NET 7.0 | Pacific Standard Time | 3,951.60 ns | 1.00 | 568 B | 1.00 |
FindSystemTimeZoneById | .NET 8.0 | Pacific Standard Time | 57.00 ns | 0.01 | – | 0.00 |
Guid
格式化和解析的改進不僅限於數值和日期類型。Guid 也參與其中。多虧了 dotnet/runtime#84553,Guid 實現了 IUtf8SpanFormattable,就像所有其他情況一樣,它在 UTF16 和 UTF8 支持之間共用完全相同的常式。然後 dotnet/runtime#81650,dotnet/runtime#81666 和 dotnet/runtime#87126 由 @SwapnilGaikwad 提供的向量化格式化支持。
// dotnet run -c Release -f net7.0 --filter "*" --runtimes net7.0 net8.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
private readonly Guid _guid = Guid.Parse("7BD626F6-4396-41E3-A491-4B1DC538DD92");
private readonly char[] _dest = new char[100];
[Benchmark]
[Arguments("D")]
[Arguments("N")]
[Arguments("B")]
[Arguments("P")]
public bool TryFormat(string format) => _guid.TryFormat(_dest, out _, format);
}
方法 | 運行時 | 格式 | 平均值 | 比率 |
---|---|---|---|---|
TryFormat | .NET 7.0 | B | 23.622 ns | 1.00 |
TryFormat | .NET 8.0 | B | 7.341 ns | 0.31 |
TryFormat | .NET 7.0 | D | 22.134 ns | 1.00 |
TryFormat | .NET 8.0 | D | 5.485 ns | 0.25 |
TryFormat | .NET 7.0 | N | 20.891 ns | 1.00 |
TryFormat | .NET 8.0 | N | 4.852 ns | 0.23 |
TryFormat | .NET 7.0 | P | 24.139 ns | 1.00 |
TryFormat | .NET 8.0 | P | 6.101 ns | 0.25 |
在從基元和數值類型轉向其他主題之前,讓我們快速看一下 System.Random,它有一些方法可以生成偽隨機數值。
Random
dotnet/runtime#79790 來自 @mla-alm,它在 Random 中提供了一個基於 @lemire 的無偏範圍函數的實現。當調用像 Next(int min, int max) 這樣的方法時,它需要提供在範圍 [min, max) 內的值。為了提供一個無偏的答案,.NET 7 的實現生成一個 32 位的值,將範圍縮小到包含最大值的最小的 2 的冪(通過取最大值的 log2 併進行移位以丟棄位),然後檢查結果是否小於最大值:如果是,它返回結果作為答案。但如果不是,它會拒絕該值(這個過程被稱為“拒絕採樣”)並迴圈重新開始整個過程。雖然當前方法產生每個樣本的開銷並不會很高,但這種判斷方法的性質使得隨機出來的樣本可能會無效,這意味著需要不斷迴圈和重試。使用新的方法,它實際上實現了模數減少(例如 Next() % max),除了用更簡便的乘法和移位替換昂貴的模數操作;雖然還使用了一個“拒絕採樣”,但它糾正的偏差發生的頻率更低,因此更耗時的可能性發生的頻率也更低。最終的結果是,Random 的方法的吞吐量平均上有了很好的提升(Random 也可以從動態 PGO 中獲得提升,因為 Random 使用的內部抽象可以被去虛擬化,所以我在這裡展示了啟用和未啟用 PGO 的影響。)
// dotnet run -c Release -f net7.0 --filter "*"
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Configs;
using BenchmarkDotNet.Environments;
using BenchmarkDotNet.Jobs;
using BenchmarkDotNet.Running;
var config = DefaultConfig.Instance
.AddJob(Job.Default.WithId(".NET 7").WithRuntime(CoreRuntime.Core70).AsBaseline())
.AddJob(Job.Default.WithId(".NET 8 w/o PGO").WithRuntime(CoreRuntime.Core80).WithEnvironmentVariable("DOTNET_TieredPGO", "0"))
.AddJob(Job.Default.WithId(".NET 8").WithRuntime(CoreRuntime.Core80));
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args, config);
[HideColumns("Error", "StdDev", "Median", "RatioSD", "EnvironmentVariables")]
public class Tests
{
private static readonly Random s_rand = new();
[Benchmark]
public int NextMax() => s_rand.Next(12345);
}
方法 | 運行時 | 平均值 | 比率 |
---|---|---|---|
NextMax | .NET 7.0 | 5.793 ns | 1.00 |
NextMax | .NET 8.0 w/o PGO | 1.840 ns | 0.32 |
NextMax | .NET 8.0 | 1.598 ns | 0.28 |
dotnet/runtime#87219 由 @MichalPetryka 提出,然後進一步對此進行了優化,以適用於長值。演算法的核心部分涉及將隨機值乘以最大值,然後取乘積的低位部分:
UInt128 randomProduct = (UInt128)maxValue * xoshiro.NextUInt64();
ulong lowPart = (ulong)randomProduct;
這可以通過不使用 UInt128 的乘法實現,而是使用 Math.BigMul 來提高效率,
ulong randomProduct = Math.BigMul(maxValue, xoshiro.NextUInt64(), out ulong lowPart);
它是通過使用 Bmi2.X64.MultiplyNoFlags 或 Armbase.Arm64.MultiplyHigh 內部函數來實現的,當其中一個可用時。
// dotnet run -c Release -f net7.0 --filter "*" --runtimes net7.0 net8.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Error", "StdDev", "Median", "RatioSD", "EnvironmentVariables")]
public class Tests
{
private static readonly Random s_rand = new();
[Benchmark]
public long NextMinMax() => s_rand.NextInt64(123456789101112, 1314151617181920);
}
方法 | 運行時 | 平均值 | 比率 |
---|---|---|---|
NextMinMax | .NET 7.0 | 9.839 ns | 1.00 |
NextMinMax | .NET 8.0 | 1.927 ns | 0.20 |
最後,我要提到 dotnet/runtime#81627。Random 本身是一個常用的類型,同時也是一個抽象概念;Random 上的許多 API 是虛擬的,這樣派生類型可以實現完全替換使用的演算法。 | |||
因此,例如,如果您想要實現一個從 Random 派生的 MersenneTwisterRandom,並通過重寫每個虛擬方法完全替換基演算法,您可以這樣做,將您的實例作為 Random 傳遞,讓大家都很高興... 除非您經常創建派生類型並且關心分配。 | |||
實際上,Random 包含了多個偽隨機生成器。在 .NET6中,它賦予 Random 實現了 xoshiro128/xoshiro256 演算法,當你只是創建一個新的 Random() 時使用。 | |||
然而,如果您實例化一個派生類型,實現會回退到自 Random 誕生以來一直使用的相同演算法(是一種變異的 Knuth 減法隨機數生成演算法),因為它不知道派生類型會做什麼,也不知道它可能採用了哪種演算法的依賴關係。 | |||
這種演算法攜帶一個 56 元素的 int[] 數組,這意味著即使派生類從未使用它,它們最終也會實例化和初始化這個數組。通過這個 PR,創建該數組的過程被延遲,只有在使用時才會初始化。有了這個改進,希望避免這種開銷的派生實現就可以實現。 |
// dotnet run -c Release -f net7.0 --filter "*" --runtimes net7.0 net8.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Error", "StdDev", "Median", "RatioSD")]
[MemoryDiagnoser(displayGenColumns: false)]
public class Tests
{
[Benchmark] public Random NewDerived() => new NotRandomRandom();
private sealed class NotRandomRandom : Random { }
}
方法 | 運行時 | 平均值 | 比率 | 分配 | 分配比率 |
---|---|---|---|---|---|
NewDerived | .NET 7.0 | 1,237.73 ns | 1.00 | 312 B | 1.00 |
NewDerived | .NET 8.0 | 20.49 ns | 0.02 | 72 B | 0.23 |
原載:http://www.cnblogs.com/yahle
版權所有。轉載時必須以鏈接形式註明作者和原始出處。
歡迎贊助: