C# DllExportでWindowスタイルなAPIをC#風に扱う - Qiita という記事を見かけたので、コメントついでに関連ネタです。
Win32APIで結果が文字列の場合、呼び出し側が確保した領域のポインタを渡してそこに書き込んでもらうという手段が一般的です。(代表例:GetWindowTextやGetTempPath)
一方、C#の文字列(System.String)は基本的に不変であり、そのメモリ領域に書き込みを行ってはなりません。(参考 → String クラス (System) )
そこで、C#からP/Invokeで書き換えが必要なAPIを呼び出すときは書き換え可能な領域を確保して渡し、そこからstringを作成するという手順が取られます。それぞれのパフォーマンスを比較してみました。
ソースコード
実行可能なサンプルはこちらに上げました。
https://github.com/KageShiron/StringPinvokePeformance
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, EntryPoint = "GetTempPathW")]
static extern int GetTempPath1(uint nBufferLength, StringBuilder sb);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, EntryPoint = "GetTempPathW")]
static extern int GetTempPath2(uint nBufferLength, ref char sb);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, EntryPoint = "GetTempPathW")]
static extern unsafe int GetTempPath3(uint nBufferLength, char* sb);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, EntryPoint = "GetTempPathW")]
static extern unsafe int GetTempPath4(uint nBufferLength, string sb);
public string StringBuilder()
{
StringBuilder sb = new StringBuilder(261);
GetTempPath1(260, sb);
return sb.ToString();
}
public string StackAllocSpan()
{
Span<char> buff = stackalloc char[261];
GetTempPath2(260, ref buff.GetPinnableReference());
return new string(buff);
}
public string StackAllocSpanToString()
{
Span<char> buff = stackalloc char[261];
GetTempPath2(260, ref buff.GetPinnableReference());
return buff.ToString();
}
public unsafe string StackAllocPointer()
{
char* buff = stackalloc char[261];
GetTempPath3(260, buff);
return new string(buff);
}
public string StackAllocCreate()
{
return string.Create(260, 0,
(b, _) => { GetTempPath2(260, ref b.GetPinnableReference()); }
);
}
public string DangerousNewString()
{
// Dangerous
string buff = new string('\0', 260);
GetTempPath4(260, buff);
return buff;
}
ベンチマーク結果
BenchmarkDotNet=v0.12.0, OS=Windows 10.0.18362
Intel Core i7-2600K CPU 3.40GHz (Sandy Bridge), 1 CPU, 8 logical and 4 physical cores
.NET Core SDK=3.0.100
[Host] : .NET Core 3.0.0 (CoreCLR 4.700.19.46205, CoreFX 4.700.19.46214), X64 RyuJIT
ShortRun : .NET Core 3.0.0 (CoreCLR 4.700.19.46205, CoreFX 4.700.19.46214), X64 RyuJIT
Job=ShortRun IterationCount=3 LaunchCount=1
WarmupCount=3
Method | Mean | Error | StdDev | Gen 0 | Gen 1 | Gen 2 | Allocated |
---|---|---|---|---|---|---|---|
StringBuilder | 625.0 ns | 354.39 ns | 19.43 ns | 0.1659 | - | - | 696 B |
DangerousNewString | 352.5 ns | 89.41 ns | 4.90 ns | 0.1297 | - | - | 544 B |
StringCreate | 349.4 ns | 30.39 ns | 1.67 ns | 0.1297 | - | - | 544 B |
StackAllocSpan | 388.4 ns | 40.02 ns | 2.19 ns | 0.1297 | - | - | 544 B |
StackAllocSpanToString | 392.8 ns | 65.17 ns | 3.57 ns | 0.1297 | - | - | 544 B |
StackAllocPointer | 338.0 ns | 24.29 ns | 1.33 ns | 0.0229 | - | - | 96 B |
※解説のしやすさのために表の順番を入れ替えています
解説
StringBuilder
まず、StringBuilderを使う方法はググるとよく出てきますが、StringBuilder自体をヒープに確保する必要があるため論外に近いです。スタックに乗らないほど巨大なバッファが必要な場合には使える・・・といいたいところですが、その場合はおそらくArrayPoolからchar[]バッファをもらってくるのが正攻法でしょう。
DangerousNewString
名前の通り、危険なやり方
new stringしたメモリ領域に直接書き込んでいます。無駄は無いのですが、new stringしたメモリ領域は本来不変なので別の状況では予期しない動作やクラッシュを招く可能性があるはずです。安全性を投げ捨てた割に、速度的にもメリットがでませんでした。
StringCreate
String.Createは.NET Core 2.1から追加された新しいメソッドです。こちらはstringを作成し、そのバッファの中身をコールバックで直に書き換えられるという最近の.NETの方向性を示すかのようなメソッドです。うまく使うと非常に効率的に文字列を生成できます。ただ、今回の場合はあまり刺さる使い方になっておらず、ラムダ式を書くのが面倒な割に他と大差ありません。
ラムダ式を使う分余分なヒープ確保があるかと思いましたが、その心配は無用のようです。
StackAllocSpan/StackAllocSpanToString
やはり早くて楽ちんなのがスタックメモリを確保するstackallocを使う方法です。スタックメモリはヒープと違い解放が非常に低コストです。さらにC# 7.2からunsafeを使うことなくSpanで受け取れるようになりました。
GetPinnableReferenceで参照を取得できるのも扱いやすいですね。new stringとToStringも比較してみましたが、どちらも処理は同じようです。メモリ確保が566Bytesなのは、ToStringした際にNULL文字も含まれてしまったためです。
StackAllocPointer
古来のC#から使えた方法です。unsafeを付ける必要があり、コンパイルオプションでも/unsafe
が必要です。C#ではnew stringにchar*を受け取るオーバーロードがあるので、実は手軽に使用可能です。
まとめ
- stackallocをchar*で直接使う方法がnull文字の分のヒープ確保がいらない分お得っぽい
- メモリのブロックコピーはCPUに専用命令がある等で意外と低コスト
- バッファサイズが巨大になる(=メモリコピーコストが増大する)場合にはString.Createも勝ち目が出てくるかも
- StringBuilderは論外!(stackallocを差し置いて使う理由があれば教えて下さい)