닷넷 프로파일러 - IL 코드 재작성
지난번에, 프로파일러가 제대로 응용이 된 예를 하나 소개해드렸지요.
닷넷 프로파일러의 또 다른 응용: Visual Studio 2010 Historical Debugging
; https://www.sysnet.pe.kr/2/0/759
이에 대해서 좀 더 알아볼까요?
일반적으로 닷넷 프로파일러는 느리다는 인식이 있습니다. 사실, "Historical Debugging" 기능 역시 켜 놓고 있으면 최초 디버깅 시작 시에 속도가 눈에 띌 정도로 느립니다.
하지만, "모든 프로파일러"가 이와 같이 느린 것은 아니고, 경우에 따라서는 보통의 응용 프로그램 속도와 유사할 수 있습니다. 왜냐하면, 프로파일러는 다음과 같은 종류의 콜백을 취사선택해서 받을 수 있으며 이 중에서 특정 기능만이 응용 프로그램 속도를 느리게 만들기 때문입니다.
enum __MIDL___MIDL_itf_corprof_0000_0002
{ COR_PRF_MONITOR_NONE = 0,
COR_PRF_MONITOR_FUNCTION_UNLOADS = 0x1,
COR_PRF_MONITOR_CLASS_LOADS = 0x2,
COR_PRF_MONITOR_MODULE_LOADS = 0x4,
COR_PRF_MONITOR_ASSEMBLY_LOADS = 0x8,
COR_PRF_MONITOR_APPDOMAIN_LOADS = 0x10,
COR_PRF_MONITOR_JIT_COMPILATION = 0x20,
COR_PRF_MONITOR_EXCEPTIONS = 0x40,
COR_PRF_MONITOR_GC = 0x80,
COR_PRF_MONITOR_OBJECT_ALLOCATED = 0x100,
COR_PRF_MONITOR_THREADS = 0x200,
COR_PRF_MONITOR_REMOTING = 0x400,
COR_PRF_MONITOR_CODE_TRANSITIONS = 0x800,
COR_PRF_MONITOR_ENTERLEAVE = 0x1000,
COR_PRF_MONITOR_CCW = 0x2000,
COR_PRF_MONITOR_REMOTING_COOKIE = 0x4000 | COR_PRF_MONITOR_REMOTING,
COR_PRF_MONITOR_REMOTING_ASYNC = 0x8000 | COR_PRF_MONITOR_REMOTING,
COR_PRF_MONITOR_SUSPENDS = 0x10000,
COR_PRF_MONITOR_CACHE_SEARCHES = 0x20000,
COR_PRF_MONITOR_CLR_EXCEPTIONS = 0x1000000,
COR_PRF_MONITOR_ALL = 0x107ffff,
COR_PRF_ENABLE_REJIT = 0x40000,
COR_PRF_ENABLE_INPROC_DEBUGGING = 0x80000,
COR_PRF_ENABLE_JIT_MAPS = 0x100000,
COR_PRF_DISABLE_INLINING = 0x200000,
COR_PRF_DISABLE_OPTIMIZATIONS = 0x400000,
COR_PRF_ENABLE_OBJECT_ALLOCATED = 0x800000,
COR_PRF_ENABLE_FUNCTION_ARGS = 0x2000000,
COR_PRF_ENABLE_FUNCTION_RETVAL = 0x4000000,
COR_PRF_ENABLE_FRAME_INFO = 0x8000000,
COR_PRF_ENABLE_STACK_SNAPSHOT = 0x10000000,
COR_PRF_USE_PROFILE_IMAGES = 0x20000000,
} COR_PRF_MONITOR;
자... 그럼 이 중에서 "Historical Debugging"과 같은 기능을 구현하려면 어떻게 해야 할까요? 그렇죠... ^^ "COR_PRF_MONITOR_JIT_COMPILATION" 플래그를 설정해야 합니다. 방법도 간단합니다. ICorProfilerCallback::Initialize 단계에서 ICorProfilerInfo::SetEventMask(COR_PRF_MONITOR_JIT_COMPILATION);으로 호출하면 이후로 ICorProfilerCallback::JITCompilationStarted 콜백이 불려지게 됩니다. 가장 중요한 기능인 IL 코드 변경은 바로 이 콜백 함수가 호출되는 단계에서 처리해주면 됩니다.
그런데, 실제로 해보면 결과가 원하는 대로 나오지 않는 것을 볼 수 있습니다.
예를 들어, "Windows Forms 빈 응용 프로그램"을 대상으로 아래와 같이 JITCompilationStarted 메서드를 구현해 놓으면,
HRESULT CNativeBridge::JITCompilationStarted(FunctionID functionId, BOOL fIsSafeToBlock)
{
IMetaDataImport * pIMetaDataImport = 0;
HRESULT hr = S_OK;
mdToken funcToken = 0;
hr = m_pICorProfilerInfo->GetTokenAndMetaDataFromFunction (functionId,
IID_IMetaDataImport,
(LPUNKNOWN *) &pIMetaDataImport,
&funcToken);
... [중간 생략] ...
... buf에는 JIT 컴파일될 함수의 이름 ...
LogEntry( L"\r\JITCompilationStarted - %s\r\n", buf );
pIMetaDataImport->Release();
return S_OK;
}
이때 출력되는 메서드 이름은 다음과 같습니다.
JITCompilationStarted - TestWinForm.Program.Main
JITCompilationStarted - TestWinForm.Form1..ctor
JITCompilationStarted - TestWinForm.Form1.InitializeComponent
JITCompilationStarted - TestWinForm.Form1.Dispose
척 봐도... 뭔가 많이 모자랍니다. 가만 보니, 우리가 만든 Windows Forms 응용 프로그램에만 구현된 메서드에 대해서 콜백 함수가 불려졌다는 것을 알 수 있는데요. 이렇다 보니, 프로파일링을 걸어놨지만 속도는 거의 차이가 나지 않을 정도로 빠릅니다. 만약 .NET Framework 레벨의 메서드가 아닌 사용자가 만든 코드 수준에 대해서만 IL 재작성을 할 필요가 있다면 프로파일링 API를 붙이는 정도는 부담되지 않는 선택입니다. (한 예로, 사용자 코드에 대해서만 Call-graph를 작성한다든지!)
하지만, Historical Debugging은 위와 같이 개발자가 만든 소스 코드만 JIT 컴파일링 되는 것을 잡아내는 것만으로는 구현이 안됩니다. .NET Framework 어셈블리까지도 IL 코드를 임의로 조작할 수 있어야 합니다.
생각해 보면, 여기에 약간의 어려움이 예상됩니다. 왜냐하면, .NET 프레임워크에서 제공되는 어셈블리들은 설치시에 NGen에 의해 미리 JIT 컴파일링되어 있기 때문에 IL 코드를 끼워넣을 수 있는 여지가 없기 때문입니다.
다행히, 이 부분에 대해서는 마이크로소프트 측에서 해법을 제공해 주고 있습니다.
Creating an IL-rewriting profiler
; https://learn.microsoft.com/en-us/archive/blogs/davbr/creating-an-il-rewriting-profiler
재미있지요. ^^ 우선 COR_PRF_USE_PROFILE_IMAGES 이벤트를 ICorProfilerInfo::SetEventMask에 추가합니다. 이걸 추가하게 되면, CLR은 NGen된 모듈을 로드하는 것을 거부하고 같은 버전의 IL 코드가 담긴 어셈블리를 로드해서 무조건 재컴파일하게 됩니다.
그래서, COR_PRF_USE_PROFILE_IMAGES를 적용하고 Windows Forms 빈 응용 프로그램을 실행하게 되면 다음과 같은 JIT 컴파일링 순서를 얻을 수 있습니다. (한마디로, 모든 메서드의 JIT 컴파일링 기록이 출력됩니다.)
JITCompilationStarted - System.AppDomain.SetupDomain
JITCompilationStarted - System.AppDomainSetup.get_Value
JITCompilationStarted - System.AppDomainSetup.set_DisallowBindingRedirects
JITCompilationStarted - System.AppDomain.SetupFusionStore
JITCompilationStarted - System.AppDomainSetup.get_DeveloperPath
JITCompilationStarted - System.AppDomainSetup.VerifyDirList
JITCompilationStarted - System.AppDomainSetup.set_DeveloperPath
JITCompilationStarted - System.AppDomainSetup.SetupFusionContext
JITCompilationStarted - System.String..cctor
JITCompilationStarted - System.Text.StringBuilder..ctor
JITCompilationStarted - System.String.GetStringForStringBuilder
JITCompilationStarted - System.Text.StringBuilder.Append
JITCompilationStarted - System.Text.StringBuilder.GetNewString
... 너무 많아서 생략 ...
JITCompilationStarted - System.Windows.Forms.Internal.DeviceContext.get_BackgroundMode
JITCompilationStarted - System.Windows.Forms.Internal.DeviceContext.SetBackgroundMode
JITCompilationStarted - System.Windows.Forms.Internal.WindowsGraphics.AdjustForVerticalAlignment
JITCompilationStarted - System.Drawing.Rectangle.get_Left
JITCompilationStarted - System.Drawing.Rectangle.get_Top
JITCompilationStarted - System.Windows.Forms.ButtonInternal.ButtonBaseAdapter.DrawFocus
JITCompilationStarted - System.Windows.Forms.Control.get_ShowFocusCues
JITCompilationStarted - System.Drawing.BufferedGraphics.Render
JITCompilationStarted - System.Drawing.BufferedGraphics.RenderInternal
JITCompilationStarted - System.Drawing.BufferedGraphics.Dispose
JITCompilationStarted - System.Drawing.BufferedGraphics.Dispose
그런데, 여기서 한 가지 선택을 할 여지가 하나 있습니다. 만약 "모든 메서드"에 대해서 IL 코드를 끼워넣어야 한다면 굳이 COR_PRF_USE_PROFILE_IMAGES | COR_PRF_MONITOR_JIT_COMPILATION 조합을 사용하는 것은 너무 번거롭습니다. 차라리 COR_PRF_MONITOR_ENTERLEAVE를 사용하면 모든 메서드의 호출에 대해서 시작과 끝 지점에서 콜백을 받을 수 있는 메서드를 등록할 수 있기 때문입니다.
제가 잠시 테스트를 하고 나서 결론을 내려 본다면,
COR_PRF_MONITOR_ENTERLEAVE에 실행될 코드를 지정하는 것은 너무 많은 오버헤드를 수반하기 때문에, 원하는 메서드가 호출된 경우에만 IL 코드를 삽입하는 COR_PRF_USE_PROFILE_IMAGES | COR_PRF_MONITOR_JIT_COMPILATION 조합이 더 현실적이라고 판단됩니다.
오늘은 여기까지만! ^^
[이 토픽에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]