このブログではたびたび「.NET Core 2.1 上で動かすだけで、アプリ側には何も手を加えなくても 2.0 の頃より1・2割高速になる」みたいな話をしています。
今月に入ってからは、DevirtualizationみたいなJIT時の最適化手法や、
逆にもっと小手先の細かな最適化の話も書いてきました。
.NET Core 2.1 ではこういういろいろな最適化が入っているんですが、
その中でも一番パフォーマンス改善に効いていそうなのがSpan<T>
構造体の導入です。
Span<T>
構造体自体の説明は何度かしていますが、
Span<T>
を使ってどういう修正をしているかについてはあんまり書いていないので、
今日は実例をいくつか挙げていこうかと。
ヒープ使用量の削減
Span<T>
を使うと速くなる理由は単純で、
ヒープの使用量を減らせるからです。
string.Substring
などで新しい文字列を作らなくて済むstackallock
で、一時バッファーにヒープを使わなくて済む- ネイティブ メモリを直接読めるようになったことで、マネージ配列にコピーしなくて済む
いずれも、unsafe コードでポインターを使えばこれまでも十分に実現できたものです。
しかし、安全性・生産性を犠牲にしたコードは書くのも使うのも神経を使うので大規模には導入しにくですし、
ガベコレ都合の制限もあって、
Span<T>
なしでは難しい最適化です。
Span<T>
もいろいろと制限の掛かった特殊な型(ref struct)ですが、それでもポインターよりは適用可能な範囲が広いです。
Substring
.NET の string.Substring
は、新しい string
型インスタンス(もちろんヒープを使う)を作ってそれを戻り値に返します。
下手に仮想呼び出しが増えるよりは、無駄にヒープを使っちゃう方が高速だからそういう作りなんですが、
Span<char>
があればヒープを使わず似たようなことができます。
ということで、Substring
をAsSpan
にちまちま変更していくようなプルリクエストが。
- Avoid substring allocations in WebUtility.HtmlDecode #29402
- Replace easy Substrings with AsSpan/Slices #17916
Substring
に限らず文字列操作がらみはかなりSpan<T>
の恩恵を受けていて、
倍以上速くなったメソッド何かもあるみたいです。
stackalloc
極々短い範囲で、小さいデータを持っておくだけの一時バッファーを必要とすることは結構あります。
そんな時、これまでだと配列(ヒープを使う)を使っていたんですが、
Span<T>
があれば stackalloc
が安全になるので、
ヒープ利用を避けることができます。
以下の修正では、固定長で2文字のchar
のためにnew char[2]
していたものをstackalloc
に置き換えています。
//before
char[] chars = new char[2] { highSurr, lowSurr };
//after
ReadOnlySpan<char> chars = stackalloc char[2] { highSurr, lowSurr };
ただし、.NET の実装では、メモリのスタック領域は固定長で 1MB くらい(確か)なので、 あんまり大きなデータをスタックに置こうとすると簡単に stack overflow を起こしたりします。 先ほどのような固定長で短いデータはいいんですが、可変長の場合にはひと工夫必要です。
具体的には、要するに「一定サイズ以下の時にだけstackalloc
を使う」という分岐を挟むだけなんですが。
以下のプルリクエストなんかはわかりやすいです。
- Add datetime read span path for netcore #31044
- Improve performance of BigInteger.ToString("x") #25353
以下のような条件演算子は結構頻出です。
Span<byte> datetimeBuffer = ((uint)length <= 16) ? stackalloc byte[16] : new byte[length];
ちなみに、以下のような型もあります(今のところ internal ですが)。
StringBuilder
相当の処理を、
初期バッファーをstackalloc
、その後容量を増やすときにはArrayPool
を使う実装。
これも、「一定サイズ以下の時にだけstackalloc
を使う」最適化の一種です。
ネイティブ メモリを直接
Span<T>
を使うと、は配列でも、stackalloc
で確保したスタック領域でも、
ネイティブ メモリでも共通処理が書けます。
なので、ネイティブ相互運用時に、
C# 側で一時配列を確保してネイティブ コードにポインターを渡す以外に、
ネイティブ側からポインターを返してもらってそれを C# 側でSpan<T>
を介して処理するということもできます。
これも、一時バッファーの確保が不要になるのでパフォーマンス改善につながったりします。
最近だと、ML.NET内で、
TensorFlorとの相互運用でもネイティブ メモリの読み書きに Span<T>
を使っていたりします。