はじめに
この記事はC# Advent Calendar 2023の13日目の記事です。
12日目の記事は @aneuf による【C#】DefaultInterpolatedStringHandler を StringBuilder 的に使うでした。
ISpanFormattableとは
ISpanFormattableとはString Interpolationで呼ばれるノーアロケーションな文字列生成インターフェースです。
このインターフェースを実装していない場合はstring ToString()
が呼ばれてstringクラスのインスタンスがアロケートされてしまうのですが、そのアロケーションがない分高速に動作します。
残念なことにStringBuilderでは使うことができないようなのですが、それでも依然として強力な機能です。
(AppendSpanFormattableというものはinternal状態で存在するのでUnsafeAccessor
あたりを使えば呼べなくはなさそうですがオフィシャルには使用不可能です)
12日目の記事中にもISpanFormattable
についての記載がありますので合わせてご確認ください!
https://aneuf.hatenablog.com/entry/2023/12/12/000000#%E5%80%A4%E3%81%AE%E6%9B%B8%E3%81%8D%E8%BE%BC%E3%81%BF--ISpanFormattable
使い方
ISpanFormattableを実装するだけで動作します。
使い道
ログの出力など繰り返し呼ばれるような箇所で利用することで積もり積もったアロケーションコストを削減することができます。
具体例
以下のようなクラスを例に挙げてISpanFormattableに対応してみます。
record class KVP(int Key, int Value)
{
public override string ToString() => $"{Key:000000} : {Value}";
}
以下のような呼び出しを行った際にはToString
が呼ばれていることがわかります。
var kvp = new KVP(101, 10);
Console.WriteLine($"{kvp}"); // 000101 : 10
これをISpanFormattableに対応すると以下のようになります。
record class KVP(int Key, int Value) : ISpanFormattable
{
// これはなくてもよい
// public override string ToString() => $"{Key:000000} : {Value}";
public string ToString(string? format, IFormatProvider? formatProvider)
{
var buffer = (stackalloc char[128]);
if (!TryFormat(buffer, out var charsWritten, [], formatProvider)) return "";
return new(buffer[..charsWritten]);
}
public bool TryFormat(Span<char> destination, out int charsWritten, ReadOnlySpan<char> format, IFormatProvider? provider)
{
charsWritten = 0;
int cw;
if (!Key.TryFormat(destination, out cw, "000000", provider)) return false;
destination = destination[cw..];
charsWritten += cw;
if (destination.Length < 3) return false;
destination[0] = ' ';
destination[1] = ':';
destination[2] = ' ';
destination = destination[3..];
charsWritten += 3;
if (!Value.TryFormat(destination, out cw, format, provider)) return false;
//destination = destination[cw..];
charsWritten += cw;
return true;
}
}
これを先ほどと同じように呼び出すと同じ結果になることがわかります。
var kvp = new KVP(101, 10);
Console.WriteLine($"{kvp}"); // 000101 : 10
計測
手元でベンチマークを取ってみたところ以下のようにAllocatedが2倍ほど違う状態でした。
BenchmarkDotNet v0.13.11, Windows 11 (10.0.22621.2715/22H2/2022Update/SunValley2)
11th Gen Intel Core i7-11700 2.50GHz, 1 CPU, 16 logical and 8 physical cores
.NET SDK 8.0.100
[Host] : .NET 8.0.0 (8.0.23.53103), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI
DefaultJob : .NET 8.0.0 (8.0.23.53103), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI
| Method | Mean | Error | StdDev | Gen0 | Allocated |
|------------------- |---------:|--------:|--------:|-----------:|----------:|
| KVPString | 178.0 ms | 3.51 ms | 4.04 ms | 17333.3333 | 140.9 MB |
| KVPSpanFormattable | 113.2 ms | 1.61 ms | 1.43 ms | 9200.0000 | 74.26 MB |
検証コード : https://gist.github.com/the9ball/5165c63aff3aa9ff23ae66e9946f8f91
Note
上記ベンチマークではISpanFormattable
の効果に絞って計測するためKVPをclassではなくstructにして使用しています。
まとめ
ISpanFormattableを積極的に使ってノーアロケーションな世界を作りましょう!
参考リンク
- ISpanFormattable インターフェイス
- dotnet/runtime - ISpanFormattable.cs
- ZString – Unity/.NET CoreにおけるゼロアロケーションのC#文字列生成
追記
どうも丁寧に書き込まなくてもdestination.TryWrite($"{Key:000000} : {Value}", out charsWritten)
とするだけでスタックを使ってくれるのでアロケーションが少ないみたいです。
BenchmarkDotNet v0.13.11, Windows 11 (10.0.22621.2861/22H2/2022Update/SunValley2)
11th Gen Intel Core i7-11700 2.50GHz, 1 CPU, 16 logical and 8 physical cores
.NET SDK 8.0.100
[Host] : .NET 8.0.0 (8.0.23.53103), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI
DefaultJob : .NET 8.0.0 (8.0.23.53103), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI
| Method | Mean | Error | StdDev | Gen0 | Allocated |
|-------------------- |---------:|--------:|--------:|-----------:|----------:|
| KVPString | 184.4 ms | 3.61 ms | 5.93 ms | 17333.3333 | 140.89 MB |
| KVPSpanFormattable | 127.3 ms | 2.49 ms | 3.33 ms | 9200.0000 | 74.27 MB |
| KVPSpanFormattable2 | 128.5 ms | 2.55 ms | 3.32 ms | 9200.0000 | 74.26 MB |
検証コード : https://gist.github.com/the9ball/24dcb9cb5036c059ec39bf249d87f205