8
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

ASP.NET Core 6 のパフォーマンス向上について

Last updated at Posted at 2022-02-02

みなさんごきげんよう。本日は以下の記事を紹介いたします。結構長いので、おやつでも食べながらごらんくださいませ。
※ 2022 年 1 月 28 日(米国時間)公開のブログです。


こんにちは!僕 Brennan です。Stephen Toub さんの以下のブログ(uikou 注 : 2021 年の 8 月 17 日公開なのですが…)に触発されたので、自分も ASP.NET Core 6.0 上でのパフォーマンスの向上について記事を書いてアピールしてみようと思ったんです。

ベンチマークを設定してみよう

ここでは、BenchmarkDotNet を例としてみてみようとおもいます。

ちなみにこの記事で使っているベンチマークの大半については、以下のリポジトリで提供しています。

殆どのベンチマークの結果については、以下のコマンド ラインで生成しています。

dotnet run -c Release -f net48 --runtimes net48 netcoreapp3.1 net5.0 net6.0

次に、リストから実行する特定のベンチマークを選択しましょう。

  • すべてをリリース構成で構築
  • .NET Framework 4.8 の サーフェス エリアをターゲットに構築
  • 各ベンチマークを、.NET Framework 4.8、.NET Core 3.1、.NET 5、および .NET 6 のそれぞれで実行

いくつかのベンチマークでは、.NET 6 でのみ実行しています。(例 : 同じバージョンでコーディングする際に、2 つの方法を比較する場合など)

dotnet run -c Release -f net6.0 --runtimes net6.0

こんな感じで、バージョンのサブセットのみを実行するのもあります。

dotnet run -c Release -f net5.0 --runtimes net5.0 net6.0

これ以降、各ベンチマークの実行時に使ったコマンドについて都度記載していきますね。

この記事で触れていく結果のほとんどは、.NET Framework 4.8 を結果セットに含めるために Windows 上で上記のベンチマークを実行して得たものになります。しかし、今後特に断りがない限りは、一般的にすべてのベンチマークは Linux あるいは macOS 上で実行しても同等の改善を示しました。
測定したいランタイムがインストールされているか、確認してみてください。ベンチマークは、.NET 6 RC1 の nightly build と、.NET 5 および .NET Core 3.1 の最新リリース版をダウンロードして実行されました。

Span<T>

.NET 2.1 で Span<T> が追加されて以来、パフォーマンスの向上のために公開された API、内部 API それぞれにおいてその一部として、多くのコードで Span<T> を使うように変更を施してきました。本リリースでも同様の対応をしています。

Pull request dotnet/aspnetcore#28855 では、PathString のインスタンスを 2 つ追加する際、string.SubString 由来の PathString のテンポラリ文字列の割り当てを削除し、代わりに Span<char> をテンポラリ文字列に使用します。

以下のベンチマークでは、短い文字列と長い文字列を使用して、一時的な文字列の回避によるパフォーマンスの違いを示しています。

dotnet run -c Release -f net48 --runtimes net48 net5.0 net6.0 --filter *PathStringBenchmark*
private PathString _first = new PathString("/first/");
private PathString _second = new PathString("/second/");
private PathString _long = new PathString("/longerpathstringtoshowsubstring/");

[Benchmark]
public PathString AddShortString()
{
    return _first.Add(_second);
}

[Benchmark]
public PathString AddLongString()
{
    return _first.Add(_long);
}
Method Runtime Toolchain Mean Ratio Allocated
AddShortString .NET Framework 4.8 net48 23.51 ns 1.00 96B
AddShortString .NET 5.0 net5.0 22.73ns 0.97 96B
AddShortString .NET 6.0 net6.0 14.92ns 0.64 56B
AddLongString .NET Framework 4.8 net48 30.89ns 1.00 201B
AddLongString .NET 5.0 net5.0 25.18ns 0.82 192B
AddLongString .NET 6.0 net6.0 15.69ns 0.51 104B

Pull request dotnet/aspnetcore#34001 では、クエリ文字列を列挙するための新しい Span ベースの API が導入されました。この API は、クエリ文字列内にエンコードされた文字が含まれない一般的なケースではアロケーション フリーで、クエリ文字列にエンコードされた文字が含まれている場合には、低めのアロケーションとなります。

dotnet run -c Release -f net6.0 --runtimes net6.0 --filter *QueryEnumerableBenchmark*
# if NET6_0_OR_GREATER
    public enum QueryEnum
    {
        Simple = 1,
        Encoded,
    }

    [ParamsAllValues]
    public QueryEnum QueryParam { get; set; }

    private string SimpleQueryString = "?key1=value1&key2=value2";
    private string QueryStringWithEncoding = "?key1=valu%20&key2=value%20";

    [Benchmark(Baseline  = true)]
    public void QueryHelper()
    {
        var queryString = QueryParam == QueryEnum.Simple ? SimpleQueryString : QueryStringWithEncoding;
        foreach (var queryParam in QueryHelpers.ParseQuery(queryString))
        {
            _ = queryParam.Key;
            _ = queryParam.Value;
        }
    }

    [Benchmark]
    public void QueryEnumerable()
    {
        var queryString = QueryParam == QueryEnum.Simple ? SimpleQueryString : QueryStringWithEncoding;
        foreach (var queryParam in new QueryStringEnumerable(queryString))
        {
            _ = queryParam.DecodeName();
            _ = queryParam.DecodeValue();
        }
    }
# endif

|Method |QueryParam|Mean |Ratio |Allocated |
|---|---|---|---|---|---|
|QueryHelper|Simple |243.13ns|1.00 |360B |
|QueryEnumerable|Simple|91.43ns |0.38 |- |
||
|QueryHelper|Encoded|351.25ns |1.00 |432B |
|QueryEnumerable|Encoded|197.59ns |0.56 |152B|

ここで注意したいのは、何もせずに何かを得られることはないということです。新しい QueryStringEnumerable API のケースでは、クエリ文字列の値を複数回列挙するつもりの場合、実際には QueryHelpers.ParseQuery により解析したクエリ文字列値のディクショナリに保存するよりもコストが高くつく可能性があります。

@paulomorgado による dotnet/aspnetcore#29448 では、string.Create メソッドを使用すると、文字列の最終的なサイズがわかっていれば、作成後に文字列を初期化することができます。これにより、UriHelper.BuildAbsolute でのテンポラリ文字列の割り当てを削除しました。

dotnet run -c Release -f netcoreapp3.1 --runtimes netcoreapp3.1 net6.0 --filter *UriHelperBenchmark*
# if NETCOREAPP
    [Benchmark]
    public void BuildAbsolute()
    {
        _ = UriHelper.BuildAbsolute("https", new HostString("localhost"));
    }
# endif
Method Runtime Toolchain Mean Ratio Allocated
BuildAbsolute .NET Core 3.1 netcoreapp3.1 92.87 ns 1.00 176B
BuildAbsolute .NET 6.0 net6.0 52.88ns 0.57 64B

Pull request dotnet/aspnetcore#31267 は、ContentDispositionHeaderValue の一部の解析ロジックを Span<T> ベースの API に変換し、一般的なケースでテンポラリ文字列やテンポラリの byte[] を避けるように実装しました。

dotnet run -c Release -f net48 --runtimes net48 netcoreapp3.1 net5.0 net6.0 --filter *ContentDispositionBenchmark*
[Benchmark]
public void ParseContentDispositionHeader()
{
    var contentDisposition = new ContentDispositionHeaderValue("inline");
    contentDisposition.FileName = "FileÃName.bat";
}
Method Runtime Toolchain Mean Ratio Allocated
ContentDispositionHeader .NET Framework 4.8 net48 654.9 ns 1.00 570B
ContentDispositionHeader .NET Core 3.1 netcoreapp3.1 581.5 ns 0.89 536B
ContentDispositionHeader .NET 5.0 net5.0 519.2 ns 0.79 536B
ContentDispositionHeader .NET 6.0 net6.0 295.4 ns 0.45 312B

アイドル接続関連の改善

ASP.NET Core の主要コンポーネントの 1 つであるサーバーのホスティングには、最適化すべきさまざまな問題があります。
このトピックでは、6.0 のアイドル接続の改善にフォーカスしたいと思います。.NET 6.0 では、コネクションのデータ待ちの間の使用メモリ量を減らすために多くの変更を行いました。

その 1 つが、コネクションで使用されるオブジェクトのサイズを小さくすることです。これには System.IO.PipelinesSocketConnections、および SocketSender が含まれます。
2 つ目は、一般的にアクセスされるオブジェクトをプールすること、つまり古いインスタンスを再利用して割り当てを節約することです。
3 つ目は、「Zero byte reads」と呼ばれる方法の利用です。利用可能なデータがある場合、読み取りはデータなしで返されますが、利用可能なデータがあることがわかれば、そのデータをすぐに読み取るためのバッファを提供することができます。これにより、将来完了するかもしれない読み込みのために、前もってバッファを確保することを避け、データが利用可能であることがわかるまで、大きな割り当てを避けることができます。

dotnet/runtime#49270 は、System.IO.Pipelines のサイズを ~560 バイトから ~368 バイトまで、34% 削減しました。

dotnet/aspnetcore#31308 は、Kestrel の Socket レイヤをリファクタリングし、いくつかの非同期ステート マシンを回避し、残りのステート マシンのサイズを縮小することで、各接続の割り当てを ~33% 削減しました。

dotnet/aspnetcore#30769 は、接続ごとの PipeOptions の割り当てを削除し、割り当てを接続ファクトリに移しました。

@benaadams による、@dotnet/aspnetcore#31311では、WebSocket リクエストの Well-known ヘッダ値をインターン化文字列に置換。これにより、ヘッダ解析時に割り当てられた文字列をガベージ コレクションすることで、長期間使用される WebSocket 接続のメモリ使用量を削減しました。

dotnet/aspnetcore#30771 は、Kestrel のソケット レイヤをリファクタリングし、まず SocketReceiver オブジェクトと SocketAwaitableEventArgs の割り当てを避け、単一オブジェクトに統合し、数バイトが節約されました。
さらに接続ごとに割り当てられるユニークなオブジェクトを減らしました。また、この Pull request では SocketSender クラスをプールするため、接続ごとに 1 つ作成するのではなく、平均してコア数の SocketSender を作成できるようになりました。
したがって、以下のベンチマークでは、接続数 10,000 の場合、私のマシン上での割り当ては 10,000 ではなく 16 だけが割り当てられてることになります。これにより、約 46MB を節約することができました。

dotnet/runtime#49123 では、SslStream での Zero byte reads のサポートが追加され、10,000 のアイドル接続が SslStream の割り当てにより、約 46 MB から約 2.3 MB のサイズになりました。

こうした多くの変更の集大成として、アイドル接続時のメモリ使用量の大幅削減を達成することに成功しました。

以下の結果は、BenchmarkDotNet アプリではアイドル接続を測定しているため、クライアントとサーバのアプリケーションでセットアップする方が簡単でした。コンソールと WebApplication のコードは以下の gist にあります。

下記の表は、以下のさまざまなフレームワークにおいて、アイドル状態の 10,000 本のセキュアな WebSocket 接続(WSS)がサーバ上で使用しているメモリ量です。

Framework Memory
net48 665.4MB
net5.0 603.1MB
net6.0 160.8MB

.NET 5.0 から 6.0 への移行によって、メモリ使用量は約 4 倍削減されました。

Entity Framework Core (EF Core)

EF Core は 6.0 において大規模な改善を行いました。ランタイムのアップデート、ベンチマークの最適化、そして Entity Framework 自体の改善により、クエリの実行が 31% 高速化、さらに TechEmpower Fortunes ベンチマークは 70% 改善されました。

こうした改善は、オブジェクト プーリングの改善、テレメトリが有効になっているかどうかのインテリジェントなチェック、アプリケーションが DbContext を安全に使用していることがわかっている場合、スレッド セーフのチェックをオプトアウトするオプションの追加などのたまものです。

改善点の詳細については、ブログ Announcing Entity Framework Core 6.0 Preview 4: Performance Edition (※英語)をご覧ください。

Blazor

ネイティブのbyte[] 相互運用について

Blazor は、JavaScript の相互運用を行う際に、バイト配列を効率的にサポートするようになりました。
以前は、JavaScript との間で送受信されるバイト配列は、JSON としてシリアライズできるように Base64 エンコードされていたため、転送サイズと CPU負荷が増大することになりました。.NET 6 では Base64 エンコーディングの処理が最適化されることで、.NET では byte[]、JavaScript では Uint8Array を透過的に扱うことができるようになりました。JavaScript →.NET、.NET → JavaScript の状況において、この機能に関するドキュメントは以下を参照ください。

さてここで、.NET 5 と .NET 6 の byte[] 相互運用の違いを見るために、簡単なベンチマークを見てみたいと思います。
次の Razor コードは、22 KB の byte[] を作成し、JavaScript の receiveAndReturnBytes 関数に送り、即座に byte[] を返しています。このデータの往復は 10,000 回繰り返され、その時のデータが画面に表示されます。
このコードは、.NET 5 でも .NET 6 でも同じものを使用しています。

<button @onclick="@RoundtripData">Roundtrip Data</button>

<hr />

@Message

@code {
    public string Message { get; set; } = "Press button to benchmark";

    private async Task RoundtripData()
    {
        var bytes = new byte[1024*22];
        List<double> timeForInterop = new List<double>();
        var testTime = DateTime.Now;

        for (var i = 0; i < 10_000; i++)
        {
            var interopTime = DateTime.Now;

            var result = await JSRuntime.InvokeAsync<byte[]>("receiveAndReturnBytes", bytes);

            timeForInterop.Add(DateTime.Now.Subtract(interopTime).TotalMilliseconds);
        }

        Message = $"Round-tripped: {bytes.Length / 1024d} kB 10,000 times and it took on average {timeForInterop.Average():F3}ms, and in total {DateTime.Now.Subtract(testTime).TotalMilliseconds:F1}ms";
    }
}

次に、JavaScript の receiveAndReturnBytes 関数を見てみます。.NET 5.0 では まず、Base64 エンコードされたバイト配列を Uint8Array にデコードして、アプリケーション コードで使用できるようにする必要があります。その次に、データをサーバに返す前に、Base64 に再エンコードする必要があります。

function receiveAndReturnBytes(bytesReceivedBase64Encoded) {
    const bytesReceived = base64ToArrayBuffer(bytesReceivedBase64Encoded);

    // Use Uint8Array data in application

    const bytesToSendBase64Encoded = base64EncodeByteArray(bytesReceived);

    if (bytesReceivedBase64Encoded != bytesToSendBase64Encoded) {
        throw new Error("Expected input/output to match.")
    }

    return bytesToSendBase64Encoded;
}

// https://stackoverflow.com/a/21797381
function base64ToArrayBuffer(base64) {
    const binaryString = atob(base64);
    const length = binaryString.length;
    const result = new Uint8Array(length);
    for (let i = 0; i < length; i++) {
        result[i] = binaryString.charCodeAt(i);
    }
    return result;
}

function base64EncodeByteArray(data) {
    const charBytes = new Array(data.length);
    for (var i = 0; i < data.length; i++) {
        charBytes[i] = String.fromCharCode(data[i]);
    }
    const dataBase64Encoded = btoa(charBytes.join(''));
    return dataBase64Encoded;
}

このエンコード / デコード処理は、クライアント、サーバ双方に大きなオーバーヘッドをもたらすだけでなく、大規模なボイラープレート コードも必要です。では、.NET 6 ではどのようにすればよいでしょうか?
…実は、とてもシンプルになるのです。

function receiveAndReturnBytes(bytesReceived) {
    // bytesReceived comes as a Uint8Array ready for use
    // and can be used by the application or immediately returned.
    return bytesReceived;
}

確かに書くのは簡単なようですが…パフォーマンスはどうでしょうか?
これらのスニペットを blazorserver テンプレートで .NET 5 と .NET 6 それぞれで Release 構成で実行してみます。
すると、.NET 6 では byte[] の相互運用パフォーマンスが 78% 向上していることがわかるかと思います。

————— .NET 6(ms) .NET 5(ms) Improvement
Total time 5,273 24,463 78%

さらに、バイト配列の相互運用サポートは、フレームワーク内で活用され、JavaScript と .NET 間で双方向のストリーミング相互運用を可能にします。ユーザーは任意のバイナリ データを転送できるようになりました。
.NET から JavaScript へのストリーミングに関するドキュメントは以下をご参照ください。

Input File

さて、前述の Blazor のストリーミングの相互運用を活用し、InputFile コンポーネントによる大容量ファイルのアップロードがサポートされました(以前はアップロードが 2GB 程度に制限されていました)。

このコンポーネントでは、Base64 エンコーディングを経由せず、ネイティブの byte[] ストリーミングを使用することで、大幅に速度が向上されました。例えば、100MB のファイルをアップロードする場合、.NET 5 と比較して 77% の高速化が実現されました。

.NET 6(ms) .NET 5(ms) Percentage
2,591 10,504 75%
2,607 11,764 78%
2,632 11,821 78%
平均 : 77%

ストリーミング相互運用のサポートにより、(大容量の)ファイルの効率的なダウンロードも可能になっています。詳細については、以下のドキュメントをご参照ください。

InputFile コンポーネントは、dotnet/aspnetcore#33900 により、ストリーミングを利用するようにアップグレードされました。

その他いろいろ

@benaadams さんによる、dotnet/aspnetcore#30320 (https://github.com/dotnet/aspnetcore/pull/30320) は、ウェブサイトの読み込みが速くなるように、Typescript ライブラリをモダナイズし、最適化しました。signalr.min.js ファイルは、圧縮時に 36.8 KB、非圧縮時に 132 KB だったのが、圧縮時に 16.1 KB、非圧縮時に 42.2 KB になりました。
また、blazor.server.js ファイルは、86.7 KB の圧縮ファイルと 276 KB の非圧縮ファイルが、43.9 KB の圧縮ファイルと 130 KB の非圧縮ファイルになります。

同じく、@benaadams さんによる、dotnet/aspnetcore#31322 (https://github.com/dotnet/aspnetcore/pull/31322) では、接続機能コレクションから共通機能を取得する際、いくつかの不要なキャストを削除しました。これにより、コレクションから共通機能にアクセスする際のパフォーマンスが約 50% 向上しました。
残念ながら、たくさんのインターナルの型が必要になるため、ベンチマークで性能向上を確認することは残念ながらできません。そこで、Pull request に掲載されている数値をここに掲載しておきたいと思います。この Pull request にはインターナルのコードに対して実行可能なベンチマークが含まれていますので、もしご興味があればこちらを見てみてください。

Method Mean Op/s Diff
Get<IHttpRequestFeature>* 8.507 ns 117,554,189.6 +50.0%
Get<IHttpResponseFeature>* 9.034 ns 110,689,963.7 --
Get<IHttpResponseBodyFeature>* 9.466 ns 105,636,431.7 +58.7%
Get<IRouteValuesFeature>* 10.007 ns 99,927,927.4 +50.0%
Get<IEndpointFeature>* 10.564 ns 94,656,794.2 +44.7%

またまた @benaadams さんによる、[dotnet/aspnetcore#31519] (https://github.com/dotnet/aspnetcore/pull/31519) は、ヘッダ名にちなんだプロパティを使用し、一般的なヘッダにアクセスするための既定のインタフェース メソッドを IHeaderDictionary 型に追加しています。これによりヘッダの辞書にアクセスする際、共通のヘッダを間違えなくて済むようになります。興味深いことに、この変更により、サーバの実装において、これら新インタフェース・メソッドをより最適に実装したカスタム ヘッダ辞書を返せるようになりました。例えば、キーをハッシュ化してエントリを検索する必要のある内部辞書にヘッダ値を問い合わせる代わりに、サーバはヘッダ値をフィールドに直接格納して、そのフィールドを直接返すことが可能です。
この変更により、ヘッダ値を取得または設定する際の処理を最大で 480% 改善することができました。以下、Pull request の数値となります。

Method Branch Type Mean Op/s Delta
GetHeaders before Plaintext 25.793 ns 38,770,569.6 -
GetHeaders after Plaintext 12.775 ns 78,279,480.0 +101.9%
GetHeaders before Common 121.355 ns 8,240,299.3 -
GetHeaders after Common 37.598 ns 26,597,474.6 +222.8%
GetHeaders before Unknown 366.456 ns 2,728,840.7 -
GetHeaders after Unknown 223.472 ns 4,474,824.0 +64.0%
SetHeaders before Plaintext 49.324 ns 20,273,931.8 -
SetHeaders after Plaintext 34.996 ns 28,574,778.8 +40.9%
SetHeaders before Common 635.060 ns 1,574,654.3 -
SetHeaders after Common 108.041 ns 9,255,723.7 +487.7%
SetHeaders before Unknown 1,439.945 ns 694,470.8 -
SetHeaders after Unknown 517.067 ns 1,933,985.7 +178.4%

dotnet/aspnetcore#31466 は、.NET 6 で導入された新しい CancellationTokenSource.TryReset 関数を使用して、接続がキャンセルされずに閉じた場合に CancellationTokenSource を再利用するようにしました。以下の数字は、Kestrel に対して 125 本の接続で bombardier を実行し、約 100,000 のリクエストを実行し、測定したものです。

Branch Type Allocations Bytes
Before CancellationTokenSource 98,314 4,719,072
After CancellationTokenSource 125 6,000

dotnet/aspnetcore#31528 および dotnet/aspnetcore#34075 は、HTTPS ハンドシェイクおよび HTTP3 ストリーム用の CancellationTokenSource の再利用について、それぞれ同様の変更を施しました。

dotnet/aspnetcore#31660 は、ストリーム アイテムごとに 1 割り当てるのではなく、そのかわりに、ストリーム全体に割り当てられた StreamItem オブジェクトを再利用することによって、SignalR でのサーバーからクライアントへのストリーミングの性能を改善しました。また、dotnet/aspnetcore#31661では、HubCallerClients オブジェクトを Hub のメソッド呼び出しごとに割り当てるのではなく、SignalR の接続に格納します。

@ShreyasJejurkar さんによる、dotnet/aspnetcore#31506 は、テンポラリの List<T> の割り当てを避けるために WebSocket ハンドシェイクの内部をリファクタリングしました。

@benaadams さんの dotnet/aspnetcore#32234 は、HttpRequestHeaders の列挙で使用されていないフィールドを削除しました。これにより、列挙されたすべてのヘッダ フィールドへの割り当てがなくなり、結果としてパフォーマンスが向上しました。

@martincostello さんからの dotnet/aspnetcore#31333 では、Http.Sys を高性能ログ API である LoggerMessage.Define を使用するように変換しました。これにより、Value 型の不必要なボックス化、ログ フォーマット文字列の解析および場合によっては、ログレベルが有効でないときの文字列またはオブジェクトの割り当てを回避することが可能です。

dotnet/aspnetcore#31784 では、ミドルウェアを登録するため、新しく IApplicationBuilder.Use オーバーロードが追加され、ミドルウェア実行時の不要なリクエストごとの割り当てを回避します。

旧コード :

app.Use(async (context, next) =>
{
    await next();
});

新コード :

app.Use(async (context, next) =>
{
    await next(context);
});

以下のベンチマークでは、改善点を示すためにサーバ設置せずにミドルウェアのパイプラインをシミュレートしています。
リクエストには HttpContextの 代わりに int が使用され、ミドルウェアは完了したタスクを返します。

dotnet run -c Release -f net6.0 --runtimes net6.0 --filter *UseMiddlewareBenchmark*
static private Func<Func<int, Task>, Func<int, Task>> UseOld(Func<int, Func<Task>, Task> middleware)
{
    return next =>
    {
        return context =>
        {
            Func<Task> simpleNext = () => next(context);
            return middleware(context, simpleNext);
        };
    };
}

static private Func<Func<int, Task>, Func<int, Task>> UseNew(Func<int, Func<int, Task>, Task> middleware)
{
    return next => context => middleware(context, next);
}

Func<int, Task> Middleware = UseOld((c, n) => n())(i => Task.CompletedTask);
Func<int, Task> NewMiddleware = UseNew((c, n) => n(c))(i => Task.CompletedTask);

[Benchmark(Baseline = true)]
public Task Use()
{
    return Middleware(10);
}

[Benchmark]
public Task UseNew()
{
    return NewMiddleware(10);
}
Method Mean Ratio Allocated
Use 15.832 ns 1.00 96B
UseNew 2.592 ns 0.16 --

まとめ

ASP.NET Core 6.0 で改善されたポイントについて、お楽しみいただけたでしょうか。
ランタイムのパフォーマンスについては、Stephen Toub さんの以下のブログをぜひ併せてご覧ください。


以上、Brennan Conroy さんでした。

img

それではみなさんごきげんよう。

8
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?