本記事はサムザップ #1 AdventCalendar 2020の12/2の記事です。
12/1の記事は@ohbashunsukeさんの【Unity】新規ゲームのUI開発で気をつけた39のTips前編 - Qiitaでした。
モチベーション
P/Invokeと各種String作成ベンチマーク - Qiitaの記事で紹介されているベンチマークでは、
StringBuilderは他の手法に比べて頭一つ遅くなっていました。
他と比べて遅いのはマネージド・ネイティブ間のデータの受け渡し(マーシャリング)コストが上乗せされた結果のような感じがしますが、
実際に何が行われているのか、気になったので調べてみました。
準備
まずはネイティブ関数呼び出しを行う、ミニマムなコンソールアプリケーションを作成します。
$ dotnet new console
ネイティブ関数呼び出しの実装(`Program.cs`)
using System;
using System.Runtime.InteropServices;
using System.Text;
namespace pinvoke
{
internal class Program
{
[DllImport("Kernel32", CharSet = CharSet.Unicode, EntryPoint = "GetTempPathW")]
public static extern int GetTempPath(uint nBufferLength, StringBuilder sb);
public static void Main(string[] args)
{
var sb = new StringBuilder(capacity: 260 + 1);
GetTempPath(260, sb);
Console.WriteLine(sb.ToString());
}
}
}
動作確認のためにコードをビルドして実行します。
$ dotnet build
$ dotnet run
C:\Users\user\AppData\Local\Temp\
想定通りに一時ディレクトリのパスが出力されました、ネイティブ関数呼び出しの実装は問題なさそうです。
調査
C#ではMashalAs属性を使って、ネイティブ関数呼び出し時のデータの受け渡し方法をコントロールすることができます。
設定によって文字コードの変換(ANSI → UTF-16など)もかかる、これらのマーシャリングを行うコードはいつ作られるのでしょうか。
まずコンパイル時の可能性を考えますが、これはビルドしたDLLをIL DASMで見てみてもC#のコードとほぼ変わらないため、違うことが分かります。
IL DASM 出力結果
MainメソッドからのGetTempPath
関数呼び出しは、普通の関数呼び出しと変わらない
IL_0006: newobj instance void [System.Runtime]System.Text.StringBuilder::.ctor(int32)
IL_000b: stloc.0
IL_000c: ldc.i4 0x104
IL_0011: ldloc.0
IL_0012: call int32 pinvoke.Program::GetTempPath(uint32,
class [System.Runtime]System.Text.StringBuilder)
GetTempPath
のメソッド本体は空になっている
.method public hidebysig static pinvokeimpl("Kernel32" as "GetTempPathW" unicode winapi)
int32 GetTempPath(uint32 nBufferLength,
class [System.Runtime]System.Text.StringBuilder sb) cil managed preservesig
{
}
ではコンパイル時ではないとすると、コードが生成されるのは実行時になるのでしょうか。
結論から言うとそうなります。
こちらの記事1によるとランタイム(CLR)によって実行時にマーシャリングのコードが生成されること、
また、その生成されるコードの詳細をETWイベント経由で確認できるツールが紹介されています。
このIL Stub Diagnosticsというツール 2 ですが、残念ながら私のPCでは動作しなかったので、
直接実行時のETWイベントから詳細を取得してみることにします。
ドキュメント3を参照するとdotnet-trace
コマンドでプロバイダーをMicrosoft-Windows-DotNETRuntime
に、
フラグを0x2000
、レベルを4
にすると、スタブ生成のETWイベントが捕捉できそうです。
$ dotnet-trace collect --providers Microsoft-Windows-DotNETRuntime:0x2000:4 -- ./bin/Debug/net5.0/pinvoke.exe
Provider Name Keywords Level Enabled By
Microsoft-Windows-DotNETRuntime 0x0000000000002000 Informational(4) --providers
Process : C:\Users\user\...\pinvoke\bin\Debug\net5.0\pinvoke.exe
Output File : C:\Users\user\...\pinvoke\trace.nettrace
[00:00:00:00] Recording trace 0.00 (B)
Press <Enter> or <Ctrl+C> to exit...
Trace completed.
トレースが完了したら trace.nettrace
というファイルが出力されますので、これをPerfViewで開いてみます。
trace.nettrace
のEventsをダブルクリックすると、新しいウィンドウでETWイベントの一覧が表示されます。
今回の実装に使ったGetTempPath
でフィルタリングすると、確かにILコードがありました。
ILにはあまり詳しくないので雰囲気で読み解いていくと、
スタブコードのネイティブ関数呼び出し前後で呼ばれてそうなメソッドは下記でした。
コードには条件分岐も含まれていましたので正確ではありませんが、
Marshal.AllocCoTaskMem
StringBuilder.InternalCopy
// ネイティブ関数呼び出し
StringBuilder.ReplaceBufferInternal
Marshal.FreeCoTaskMem
メソッド名から察するに、一時バッファを用意しそこにStringBuilderの内容をコピーし、
ネイティブ関数呼び出し後書き戻して割り当てたバッファを解放する、みたいな挙動でしょうか。
なるほど他の手法に比べて遅くなりそうな雰囲気がします。
まとめ
- .NET Coreではネイテイブ関数呼び出し時に、マネージドとネイティブの橋渡し(マーシャリング)をするスタブコードを生成している
- スタブコードは生成のETWイベントにより、その詳細を知ることができる
以上になります。
明日は@phasmatodeanさんの記事です。
環境
Windows 10 Pro 20H2
$ dotnet --version
5.0.100
$ dotnet-trace --version
5.0.152202+4d281c71a14e6226ab0bf0c98687db4a5c4217e3