Performance Improvements in .NET 8 -- Exceptions & Reflection & Primitives【翻譯】

来源:https://www.cnblogs.com/yahle/archive/2023/11/18/Performance_Improvements_in_NET_8_Exceptions_Reflection_Primitives.html
-Advertisement-
Play Games

.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 啟用了分析器,並修複了許多調用站點。

Alt text

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 來獲取和緩存該方法的委托,然後通過該委托執行所有調用,從而實現最佳性能。然而,如果你在編譯時不知道簽名,你需要依賴更動態的方法,如 MethodBase.Invoke,這在歷史上一直更耗時。一些高級的開發者使用 emit 避免這種開銷,這也是 .NET 7 中採取的優化方法之一。現在在 .NET 8 中,為許多這樣的情況生成的代碼已經改進;以前,emitter 總是生成可以容納 ref/out 參數的代碼,但許多方法不提供這樣的參數,當不需要考慮這些因素時,生成的代碼可以更高效。

// 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 的預設底層類型,但它仍然很有趣:

Alt text

枚舉底層類型的常見程度的圖表

在枚舉如何存儲其數據的設計中有幾個問題。每個操作都在這些 ulong[] 值和特定枚舉使用的實際類型之間進行轉換,而且數組通常比需要的大兩倍(int 是枚舉的預設底層類型,並且,如上圖所示,迄今為止最常使用)。這種方法還導致處理所有近年來添加到 Enum 中的新泛型方法時,會產生大量的彙編代碼膨脹。枚舉是結構體,當結構體被用作泛型類型參數時,JIT 為該值類型專門化代碼(而對於引用類型,它發出一個由所有這些類型使用的單一共用實現)。這種專門化對於吞吐量來說是很好的,但這意味著你得到了它用於的每個值類型的代碼副本;如果你有很多代碼(例如 Enum 格式化)和很多可能被替換的類型(例如每個聲明的枚舉類型),那麼代碼大小可能會大幅增加。

為瞭解決所有這些問題,現代化實現,並使各種操作更快,dotnet/runtime#78580 重寫了 Enum。它不再使用一個非泛型的 EnumInfo 來存儲所有值的 ulong[] 數組,而是引入了一個泛型的 EnumInfo 來存儲 TUnderlyingValue[]。然後,基於枚舉的類型,每個泛型和非泛型的 Enum 方法都會查找底層的 TUnderlyingType,並調用一個帶有該 TUnderlyingType 但不帶有枚舉類型的泛型類型參數的泛型方法,例如 Enum.IsDefined(...) 和 Enum.IsDefined(typeof(TEnum), ...) 都會查找 TEnum 的 TUnderlyingValue,並調用內部的 Enum.IsDefinedPrimitive(typeof(TEnum))。這樣,實現存儲了一個強類型的 TUnderlyingValue[] 值,而不是存儲最壞情況的 ulong[],並且所有的實現都在泛型和非泛型入口點之間共用,而不需要為每個 TEnum 進行完全的泛型專門化:最壞的情況,我們最終得到的是每個底層類型的一個泛型專門化,其中只有前面引用的 8 個在 C# 中可以表示。泛型入口點能夠非常有效地進行映射,這要歸功於 @MichalPetryka 的 dotnet/runtime#71685,它使 typeof(TEnum).IsEnum 成為 JIT 內置(這樣它實際上就成為一個常量),而非泛型入口點使用在各種方法中已經被使用的 TypeCode/CorElementType 的切換。

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。這些在設計上幾乎是相同的,關鍵的區別在於這些介面的實現是寫出 UTF16 字元還是 UTF8 位元組。通過 dotnet/runtime#84587 和 dotnet/runtime#84841,System.Private.CoreLib 中的所有數值基元都實現了新的介面並公開了一個公共的 TryFormat 方法。所以,例如,ulong 暴露了這些:

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 介面,併在原始數值類型上實現了它。就像其格式化對應項一樣,這為 UTF8 提供了與 IParsable 相同的行為,而不是 UTF16。就像其格式化對應項一樣,所有的解析邏輯都在兩種模式之間的通用常式中共用。實際上,這不僅在 UTF16 和 UTF8 解析之間共用邏輯,而且緊隨 dotnet/runtime#84582 的步伐,該 PR 使用相同的通用技巧來去重所有原始類型的解析邏輯,這樣相同的通用常式最終被用於所有類型和 UTF8 和 UTF16。該 PR 從 System.Private.CoreLib 中刪除了近 2000 行代碼:

Alt text

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
作者:Yahle
原載:http://www.cnblogs.com/yahle
版權所有。轉載時必須以鏈接形式註明作者和原始出處。

歡迎贊助:


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • 要知道經典類和新式類的區別,首先要掌握類的繼承 類的繼承的一個優點就是減少代碼冗餘 廣度優先和深度優先,這主要是在多類繼承的時候會使用到 經典類和新式類的主要區別就是類的繼承的方式 經典類遵循深度優先的規則,新式類遵循廣度優先的規則。 至於什麼是深度優先什麼是廣度優先,可以看如下示例: class ...
  • 1.輸入日期,判斷這一天是這一年的第幾天 import datetime def day_of_year(): year = eval(input('請輸入年份:')) month = eval(input('請輸入月份:')) day = eval(input('請輸入天:')) date1 = ...
  • 最近天氣降溫厲害,咱們用Python來分析一下空氣質量如何~ 話不多說,我們直接開始上手。 環境以及模塊 環境使用 Python 3.8 Pycharm nodejs 模塊使用 import requests import execjs import json requests和execjs 都是第 ...
  • Windows Management Instrumentation(WMI)是一種用於管理和監視`Windows`操作系統的框架。它為開發人員、系統管理員和自動化工具提供了一種標準的介面,通過這個介面,可以獲取有關電腦系統硬體、操作系統和應用程式的信息,以及對系統進行管理和控制的能力。WQL 的... ...
  • 在 Go 語言中,panic、recover 和 defer 是用於處理異常情況的關鍵字。它們通常一起使用來實現對程式錯誤的處理和恢復。 1. defer 語句 defer 用於在函數返回之前執行一段代碼。被 defer 修飾的語句或函數會在包含 defer 的函數執行完畢後執行。defer 常用於 ...
  • 建議看看電腦科學速成課,一門很全面的電腦原理入門課程,短短10分鐘可以把大學老師十幾節課講的東西講清楚!整個系列一共41個視頻,B站上有中文字幕版。 每個視頻都是一個特定的主題,例如軟體工程、人工智慧、操作系統等,主題之間都是緊密相連的,比國內很多大學電腦課程強太多! 這門課程通過生動形象的講 ...
  • ✨前言✨ 本片文章,主要在於C#連接MySQL資料庫,由於這之間無法建立直接聯繫,這時候就涉及到了第三方連接工具.NET,以此來建立C#與MySQL資料庫的連接 🍒歡迎點贊 👍 收藏 ⭐留言評論 📝私信必回喲😁 🍒博主將持續更新學習記錄收穫,友友們有任何問題可以在評論區留言 目錄🍊 一, ...
  • SciPy庫的optimize模塊主要用於執行各種優化任務。優化是尋找特定函數的最小值或最大值的過程,通常用於機器學習、數據分析、工程和其他領域。 scipy.optimize提供了多種優化演算法,包括梯度下降法、牛頓法、最小二乘法等,可以解決各種複雜的優化問題。該模塊還包含一些特定的函數,用於解決某 ...
一周排行
    -Advertisement-
    Play Games
  • 1、預覽地址:http://139.155.137.144:9012 2、qq群:801913255 一、前言 隨著網路的發展,企業對於信息系統數據的保密工作愈發重視,不同身份、角色對於數據的訪問許可權都應該大相徑庭。 列如 1、不同登錄人員對一個數據列表的可見度是不一樣的,如數據列、數據行、數據按鈕 ...
  • 前言 上一篇文章寫瞭如何使用RabbitMQ做個簡單的發送郵件項目,然後評論也是比較多,也是準備去學習一下如何確保RabbitMQ的消息可靠性,但是由於時間原因,先來說說設計模式中的簡單工廠模式吧! 在瞭解簡單工廠模式之前,我們要知道C#是一款面向對象的高級程式語言。它有3大特性,封裝、繼承、多態。 ...
  • Nodify學習 一:介紹與使用 - 可樂_加冰 - 博客園 (cnblogs.com) Nodify學習 二:添加節點 - 可樂_加冰 - 博客園 (cnblogs.com) 介紹 Nodify是一個WPF基於節點的編輯器控制項,其中包含一系列節點、連接和連接器組件,旨在簡化構建基於節點的工具的過程 ...
  • 創建一個webapi項目做測試使用。 創建新控制器,搭建一個基礎框架,包括獲取當天日期、wiki的請求地址等 創建一個Http請求幫助類以及方法,用於獲取指定URL的信息 使用http請求訪問指定url,先運行一下,看看返回的內容。內容如圖右邊所示,實際上是一個Json數據。我們主要解析 大事記 部 ...
  • 最近在不少自媒體上看到有關.NET與C#的資訊與評價,感覺大家對.NET與C#還是不太瞭解,尤其是對2016年6月發佈的跨平臺.NET Core 1.0,更是知之甚少。在考慮一番之後,還是決定寫點東西總結一下,也回顧一下.NET的發展歷史。 首先,你沒看錯,.NET是跨平臺的,可以在Windows、 ...
  • Nodify學習 一:介紹與使用 - 可樂_加冰 - 博客園 (cnblogs.com) Nodify學習 二:添加節點 - 可樂_加冰 - 博客園 (cnblogs.com) 添加節點(nodes) 通過上一篇我們已經創建好了編輯器實例現在我們為編輯器添加一個節點 添加model和viewmode ...
  • 前言 資料庫併發,數據審計和軟刪除一直是數據持久化方面的經典問題。早些時候,這些工作需要手寫複雜的SQL或者通過存儲過程和觸發器實現。手寫複雜SQL對軟體可維護性構成了相當大的挑戰,隨著SQL字數的變多,用到的嵌套和複雜語法增加,可讀性和可維護性的難度是幾何級暴漲。因此如何在實現功能的同時控制這些S ...
  • 類型檢查和轉換:當你需要檢查對象是否為特定類型,並且希望在同一時間內將其轉換為那個類型時,模式匹配提供了一種更簡潔的方式來完成這一任務,避免了使用傳統的as和is操作符後還需要進行額外的null檢查。 複雜條件邏輯:在處理複雜的條件邏輯時,特別是涉及到多個條件和類型的情況下,使用模式匹配可以使代碼更 ...
  • 在日常開發中,我們經常需要和文件打交道,特別是桌面開發,有時候就會需要載入大批量的文件,而且可能還會存在部分文件缺失的情況,那麼如何才能快速的判斷文件是否存在呢?如果處理不當的,且文件數量比較多的時候,可能會造成卡頓等情況,進而影響程式的使用體驗。今天就以一個簡單的小例子,簡述兩種不同的判斷文件是否... ...
  • 前言 資料庫併發,數據審計和軟刪除一直是數據持久化方面的經典問題。早些時候,這些工作需要手寫複雜的SQL或者通過存儲過程和觸發器實現。手寫複雜SQL對軟體可維護性構成了相當大的挑戰,隨著SQL字數的變多,用到的嵌套和複雜語法增加,可讀性和可維護性的難度是幾何級暴漲。因此如何在實現功能的同時控制這些S ...