와탭랩스 블로그 오픈 이벤트 😃
자세히 보기
Tech
2023-09-08
.NET Profiler APIの活用
Untitled.png

今回のポストでは、.NET Profiler APIを使用して.NET Coreアプリケーションのパフォーマンスを測定する方法について説明します。.NET Frameworkで開発されたアプリケーションも同じコードで測定できますが、この記事では.NET Coreを中心に説明します。

 

ソースをダウンロード

[GitHub]からソースをダウンロードします。


ビルド及びテトの方法

ELTProfiler、ConsoleTest プロジェクトを Visual Studio 2022 バージョンでオープンしてビルドします。 その後、Binフォルダにあるrun.batファイルを実行すると、基本的なテストが可能になります。

 

run.bat ファイル

 

@echo offSET CORECLR_PROFILER={cf0d821e-299b-5307-a3d8-b283c03916dd}SET CORECLR_ENABLE_PROFILING=1SET CORECLR_PROFILER_PATH=%~dp0ClrProfiler.dllSET PROFILE_BASETIME=1000SET NAMESPACE_PREFIX=ProfilingTestConsoleTest.exe

 

  • CORECLR_PROFILER:ここで提供されるGUIDは、.NET Coreランタイムがどのプロファイラを使用する必要があるかを示します。 つまり、プロファイラの識別IDです。
  • CORECLR_ENABLE_PROFILING:プロファイリングを有効にする必要があることを示します。1 はアクティベーションを意味します。
  • CORECLR_PROFILER_PATH:この環境変数は、.NET Coreランタイムがプロファイラのバイナリの場所を見つけるために使用します。%〜dp0は現在のスクリプトのディレクトリを表しているため、このスクリプトと同じディレクトリにあるClrProfiler.dllを指します。
  • PROFILE_BASETIME:指定された時間以下で実行されたメソッドのプロファイリング情報を無視するように設定されます。
  • NAMESPACE_PREFIX:特定のネームスペースで始まるメソッドのみをプロファイリングするように設定します。 指定しない場合は、すべてのメソッドコールを追跡します。
  • ConsoleTest.exe: モニタリング対象のアプリケーションです。お客様の.NETコアアプリケーションに置き換えることができます。


実行結果

以下はrun.batファイルの実行結果のスクリーンショットです。tick は PerformanceCounter の単位時間です。現在サンプルとして提供されているプログラムの実行時間が短すぎるため、ミリセカンド単位を使用しませんでした。PerformanceCounterのleaveInMilliseconds()メソッドを使用すると、ミリセカンド単位で測定できます。

 

NAMESPACE_PREFIXを指定した場合

Untitled 1.png

 

NAMESPACE_PREFIX を指定しなかった場合

Untitled 2.png


.NET Profiler APIの動作原理

Profiler DLL を環境変数に設定すると、.NetのCLRは、Netアプリケーションで発生したイベントを ICorProfilerCallback インターフェイスを実装したオブジェクトに渡します。Profiler APIを使用して構築されたDLLを環境変数に登録し、その後に.Netアプリケーションを実行すると、CLRはそのDLLで定義されたProfilerオブジェクトを生成します。

また、アプリケーションで発生した特定のイベントを Profiler オブジェクトに渡します。このイベントにより、.Netアプリケーションの内部活動とパフォーマンス関連の指標を得ることができるのです。

リポジトリの提供されるサンプルコードではICorProfilerCallback8を使用しています。.Netバージョンが更新されている間に、以前のインターフェイスが拡張され、後ろに数字が増えています。

 

Untitled 3.png


イベントは.Netアセンブリがロードされたとき、またはメソッドがJITコンパイルを開始する時点など、さまざまに提供されています。例では、.Netアプリケーションが初期化されたときに発生するInitializeイベントを利用して、すべてのメソッドが実行または終了する時点を傍受するようにしています。

 

コードの説明


Initializeイベント処理

 

HRESULT STDMETHODCALLTYPE CorProfiler::Initialize(IUnknown *pICorProfilerInfoUnk){// ICorProfilerInfo8 インターフェイスでクエリを試みます。HRESULT queryInterfaceResult = pICorProfilerInfoUnk->QueryInterface(__uuidof(ICorProfilerInfo8), reinterpret_cast(&this->corProfilerInfo));if (FAILED(queryInterfaceResult)){return E_FAIL;}// イベントマスクを設定して目的のイベントをフィルタリングします。DWORD eventMask = COR_PRF_MONITOR_ENTERLEAVE | COR_PRF_ENABLE_FUNCTION_ARGS | COR_PRF_ENABLE_FUNCTION_RETVAL | COR_PRF_ENABLE_FRAME_INFO;auto hr = this->corProfilerInfo->SetEventMask(eventMask);// 変数名の変更if (hr != S_OK){printf("ERROR: Profiler SetEventMask failed (HRESULT: %d)", hr);}// Enter, Leave, Tailcallのフック設定hr = this->corProfilerInfo->SetEnterLeaveFunctionHooks3WithInfo(EnterNaked, LeaveNaked, TailcallNaked);if (hr != S_OK){printf("ERROR: Profiler SetEnterLeaveFunctionHooks3WithInfo failed (HRESULT: %d)", hr);}return S_OK;}

 

関数の実行/終了を傍受するコード

最後に、関数の実行と終了を傍受するコードは次のとおりです。関数の実行時間とCall-Treeを作成するプロセスは、CallHistoryクラス内にカプセル化されています。

 

PROFILER_STUB EnterStub(FunctionID functionId, COR_PRF_ELT_INFO eltInfo){CallHistory::getInstance().Enter(functionId);}PROFILER_STUB LeaveStub(FunctionID functionId, COR_PRF_ELT_INFO eltInfo){CallHistory::getInstance().Leave(functionId);}PROFILER_STUB TailcallStub(FunctionID functionId, COR_PRF_ELT_INFO eltInfo){CallHistory::getInstance().Tailcall(functionId);}

 

以下のシーケンスダイアグラムは、ELTProfilerプロジェクトの主要な流れを図式化したものです。

 

Untitled 4.png

 

Enter:新しい関数が起動したときに発生するイベントです。

a. CallHistory オブジェクトに新しい関数が実行されたことを伝えます。CallHistoryはスタックを使用して関数呼び出しの深さを計算し、ツリー状の関数呼び出し履歴を出力できるようにします。

b. PerformanceCounter オブジェクトは、関数が進入して終了する時間を測定し、関数の実行にかかる時間を測定します。

c. MethodListオブジェクトはfunctionIdを介して関数名を取得します。一度インポートした関数名はハッシュマップに保存しておき、使用しながらパフォーマンスを最大限に引き上げます。

Leave: 関数の終了時に発生するイベントです。

TailCall: Tailcall 状況で発生するイベントです。

a. Tailcallは、関数の終了時に別の関数を呼び出すときに発生する最適化された呼び出し方法です。これは、関数が返されたときにさらに処理するコードがなく、単に他の関数の結果値をそのまま返す場合に発生します。Tailcallが発生すると、現在の関数のスタックフレームを維持したまま他の関数を呼び出します。そのため、その関数が終了した後は元の関数に戻らず、元の関数の呼び出し元に直接返されます。この動作のため、callDepth は現在の値を保持します。言い換えれば、TailcallはcallDepthに追加の影響を与えません。

 

使用されたライブラリ


Setting Class

用途: 「Settings」クラスはアプリケーション設定値をロードして管理します。 環境変数を介して外部から設定を提供します。

パブリックメンバー:

  • getInstance(): シングルトンインスタンスを返します。
  • getProfileBaseTime(): PROFILE_BASETIME 環境変数の値を返します。
  • getNamespacePrefix():NAMESPACE_PREFIX 環境変数の値を返します。


CallHistory Class

用途:CallHistoryクラスは関数呼び出しの履歴を管理し、各関数呼び出しの開始と終了、およびその呼び出しの継続時間を記録します。このクラスを使用すると、プロファイリングデータに基づいて関数呼び出しツリーを視覚化できます。

パブリックメンバー:

  • getInstance(): シングルトンインスタンスを返します。
  • Enter(FunctionID functionId): 関数呼び出しの開始を記録します。
  • Leave(FunctionID functionId): 関数呼び出しの終了を記録し、持続時間を計算します。
  • Tailcall(FunctionID functionId): Tailcallが発生したことを記録します。


PerformanceCounter Class

用途:PerformanceCounterクラスはパフォーマンス測定のためのプロファイリングツールで、特定のコードの実行時間を測定します。

パブリックメンバー:

  • getIncetance(): シングルトンインスタンスを返します。
  • enter(): パフォーマンス測定を開始するためのタイムスタンプを保存します。
  • leave(): パフォーマンス測定を終了し、開始後の時間(ティック単位)を返します。
  • leaveInMilliseconds(): パフォーマンス測定を終了し、開始後の時間(ミリ秒単位)を返します。


MethodList Class

用途:MethodListクラスは、.NET関数の識別子(FunctionID)をその関数のフルネームにマッピングする役割を果たします。

パブリックメンバー:

  • getIncetance(): シングルトンインスタンスを返します。
  • GetName(FunctionID functionId): 与えられた FunctionID に対応する関数の名前を返します。

 

UdpSocket Class

用途:UdpSocketクラスは、UDPソケットを介してテキストデータを転送する機能を提供します。 プロファイリングデータまたはログメッセージをリモートシステムに送信するために使用できます。

パブリックメンバー:

  • getInstance(): シングルトンインスタンスを返します。
  • sendText(const wstring& message): 指定されたメッセージを UDP ソケット経由で送信します。

 

その他



UDPモニタリング

コンソールで確認できない場合、またはリモートでログを確認したい場合は、udp-monitorプロジェクトをビルドして実行します。その後、run.batファイルを実行すると、すべてのメッセージが下の画像のようにUDPを介して転送されます。

 

Untitled 5.png


naked 関数の理解

「SetEnterLeaveFunctionHooks3WithInfo()」で指定される関数は「naked」と宣言されなければなりません。「naked」として宣言された関数は、コンパイラが生成するスタックフレームコード(prologとepilog)を含まない状態にコンパイルされます。 通常、関数が呼び出されたときにスタックフレームを設定してローカル変数やパラメータなどのスペースを確保し、関数終了時にこれを解放しますが、「naked」関数ではこれらの動作は自動的には行われません。

したがって、「naked」 関数を使うときは関数内部でスタックフレームの管理やレジスタの管理などを直接しなければなりません。これにより、スタックフレームまたはレジスタの状態を細かく制御できます。以下に示すコード例では、「EnterStub」関数が実行される前後に汎用レジスタの状態を保存して復元する作業が行われています。これは 「pushad」と「popad」 命令を使って行われます。

このようなアプローチは、.NET Profilerを作成する際に重要です。Profilerはコードの実行を妨げることなくコードの実行情報を収集する必要があるため、レジスタの状態やスタックフレームの状態を変更しないことが重要です。これを行うには、 naked 関数とアセンブリ命令を使用して、レジスタとスタックの状態を正確に制御します。

 

void __declspec(naked) EnterNaked(FunctionIDOrClientID functionIDOrClientID, COR_PRF_ELT_INFO eltInfo){__asm{PUSH EAXPUSH ECXPUSH EDXPUSH [ESP + 16]CALL EnterStubPOP EDXPOP ECXPOP EAXRET 8}}

 

テールコール(Tail Call)

テールコールは関数呼び出しの特別な形式です。 関数の最後で別の関数を呼び出すと、コンパイラまたはランタイムシステムは、現在の関数のスタックフレームを新しく呼び出された関数のスタックフレームにリサイクルできます。この最適化は主に再帰呼び出しで役に立ち、スタックオーバーフローを防ぎます。

 

なぜテールコールが必要なのですか?

再帰関数は自分自身を呼び出し続けるため、スタックにフレームが積み重ねられます。 したがって、深い再帰呼び出しを実行するとスタックオーバーフローのリスクがあります。 テールコール最適化を使用すると、これらの問題を回避できます。

 

テールコールの条件

すべての関数呼び出しがテールコールで最適化できるわけではありません。 テールコールで処理できる関数呼び出しは、次の条件を満たす必要があります。

  • 呼び出された関数の戻り値をすぐに返すか、何の操作もせずにそのまま返す必要があります。
  • 呼び出し後、現在の関数でさらに実行することはできません。

 

int factorial(int n) {if (n <= 1) return 1;return n * factorial(n-1);}

 

上記の factorial 関数はテールコールで最適化できません。 なぜなら factorial(n-1) の結果に n を掛けて返すからです。ただし、次のようにコードを変更すると、テールコールで最適化できます。

 

int factorial_helper(int n, int acc) {if (n <= 1) return acc;return factorial_helper(n-1, n*acc);}int factorial(int n) {return factorial_helper(n, 1);}

 

テールコールでない場合

 

blog_img_%E3%83%86%E3%83%BC%E3%83%AB%E3%82%B3%E3%83%BC%E3%83%AB%E3%81%A6%E3%82%99%E3%81%AA%E3%81%84%E5%A0%B4%E5%90%88.jpg

  

  • mainからA関数を呼び出します。
  • A 関数のスタックフレームがスタックの上部に追加されます。
  • AからB関数を呼び出します。
  • B 関数のスタックフレームがスタックの上部に追加されます。
  • BからC関数を呼び出します。
  • C 関数のスタックフレームがスタックの上部に追加されます。
  • C 関数が終了すると、C のスタックフレームがスタックから削除されます。
  • 制御はB関数に戻る。
  • B 関数が終了すると、B のスタックフレームがスタックから削除されます。
  • 制御はA関数に戻ります。
  • A 関数が終了すると、A のスタックフレームがスタックから削除されます。
  • 制御はメイン関数に戻ります。
  • main 関数の残りの部分が実行されます。

テールコールの場合

 

blog_img_%E3%83%86%E3%83%BC%E3%83%AB%E3%82%B3%E3%83%BC%E3%83%AB%E3%81%AE%E5%A0%B4%E5%90%88.jpg


  • mainからA関数を呼び出します。
  • A 関数のスタックフレームがスタックの上部に追加されます。
  • Aは、追加の操作なしですぐにB関数を呼び出します。このとき、Aのスタックフレームは再利用され、Bのスタックフレームに置き換えられる。
  • Bでは、追加の操作なしですぐにC関数を呼び出します。同様に、Bのスタックフレームは再利用され、Cのスタックフレームに置き換えられる。
  • C関数が終了し、制御はすぐにmain関数に戻ります。ここで注目すべき点は、AとBの関数に別々の戻りなしですぐにmainに戻ることです。
  • main関数の残りの部分が実行されます。

 

와탭 모니터링을 무료로 체험해보세요!