Creating a .NET CLR profiler using C# and NativeAOT with Silhouette This article summarizes an exploration of Kevin Gosse's Silhouette library, which enables developers to build .NET CLR profilers using C# and NativeAOT instead of traditional C/C++ code. The author creates a basic profiler that simply logs when an assembly is loaded, focusing on the ease of getting started with the library rather than delving into the complexities of the underlying unmanaged profiling APIs. The post also explains that NativeAOT allows the profiler to run as a self-contained native binary with its own separate .NET runtime within the same process as the application being profiled. In this post I take Kevin Gosse's https://minidump.net/ Silhouette library https://github.com/kevingosse/Silhouette for a spin, to see how easy it is to build a basic .NET CLR profiler. And when I say basic, I mean basic —in this post we'll simply log when an assembly is loaded and that's it. I was mostly interested in seeing how easy it was to get up and running. Kevin already has a 5 part series https://minidump.net/writing-a-net-profiler-in-c-part-5/ on writing a .NET profiler in C in which he describes his Silhouette https://github.com/kevingosse/Silhouette library, as well as follow up posts using the library to do real work, like measuring UI responsiveness in Resharper https://minidump.net/measuring-ui-responsiveness/ and using profiler function hooks https://minidump.net/using-function-hooks-with-silhouette/ . This post is going to be much more basic than that, just a demonstration of the library in action. I'm also not going to go into great details about the profiling APIs themselves. Kevin covers some of this in his above posts, or alternatively you can see Christophe Nasarre https://chnasarre.medium.com/ 's series of posts on the profiling APIs https://chnasarre.medium.com/start-a-journey-into-the-net-profiling-apis-40c76e2e36cc . In this post we're really not diving in deep like that, we're just going to dip a toe in and see what's possible So before we get started, we should first recap: what are the .NET profiling APIs? What are the .NET profiling APIs? what-are-the-net-profiling-apis- For 99% of people, working with .NET means staying in a nice managed runtime and never having to worry about native code, other than perhaps the occasional P/Invoke https://learn.microsoft.com/en-us/dotnet/standard/native-interop/pinvoke . However, behind the scenes, the .NET base class libraries often interact with native libraries, and the .NET runtime itself is a native application /exploring-the-dotnet-boot-process-via-host-tracing/ . What's more, both .NET Core and .NET Framework expose a whole suite of unmanaged APIs https://learn.microsoft.com/en-us/dotnet/core/unmanaged-api/ that you can invoke from native code. .NET Core documents three main categories of unmanaged APIs: Debugging APIs https://learn.microsoft.com/en-us/dotnet/core/unmanaged-api/debugging/ for debugging code that runs in the common language runtime CLR environment. Metadata APIs https://learn.microsoft.com/en-us/dotnet/core/unmanaged-api/debugging/ for reading or generating details about modules and types without loading them in the CLR. Profiling APIs https://learn.microsoft.com/en-us/dotnet/core/unmanaged-api/profiling/ for monitoring a program's execution by the CLR. In this post we're primarily looking at the final category, the profiling APIs, and will use the metadata APIs in a supporting role. When you think of "profiling", you probably think about the profiling tools built into Visual Studio https://learn.microsoft.com/en-us/dotnet/core/unmanaged-api/profiling/ , JetBrain's dotTrace https://www.jetbrains.com/profiler/ , or viewing dotnet-trace traces https://learn.microsoft.com/en-us/dotnet/core/diagnostics/dotnet-trace in PerfView https://github.com/microsoft/perfview . These "traditional" profilers can use the profiling APIs for their functionality though there are other approaches too , but the profiling APIs are far more general than that. For example, we use them in the Datadog .NET client library https://github.com/DataDog/dd-trace-dotnet to rewrite methods to include our instrumentation. The profiling APIs are very powerful, but the real difficulty is that they're unmanaged APIs, which typically means writing C/C++ code to use them. Yuk. Well, that's where Silhouette https://github.com/kevingosse/Silhouette comes in. Who needs C when you have NativeAOT? who-needs-c-when-you-have-nativeaot- Microsoft have been working on NativeAOT for many releases, and with each new version of .NET it gets a little bit better. With NativeAOT, you can compile your .NET application to a native , standalone binary. And a native standalone binary is all you need to write a .NET profiler The key thing with a NativeAOT binary is that it's fully self-contained. That means that when your profiling binary is loaded, it's running a completely separate.NET runtime from the application being profiled. Yes, that technically means there's two .NET runtimes loaded in the process Of course, just compiling .NET as a native binary isn't the only requirement. You also need to make sure your native binary exposes all the correct entrypoints and interfaces such that the .NET runtime can load your library as though it was built using C++. To quote from Kevin's article on Silhouette https://minidump.net/writing-a-net-profiler-in-c-part-1-d3978aae9b12/ : In a nutshell, we need to expose a DllGetClassObject method that will return an instance of IClassFactory . The .NET runtime will call the CreateInstance method on the class factory, which will return an instance of ICorProfilerCallback or ICorProfilerCallback2 , ICorProfilerCallback3 , …, depending on which version of the profiling API we want to support . Last but not least, the runtime will call the Initialize method on that instance with an IUnknown parameter that we can use to fetch an instance of ICorProfilerInfo or ICorProfilerInfo2 , ICorProfilerInfo3 , … that we will need to query the profiling API. If that went right over your head, that's normal😅 These are APIs that very few .NET engineers ever need to work with, and seeing as they're C++ APIs, that's even worse But that's the point; with Native AOT and the Silhouette library, you don't need to understand all these interactions. The Silhouette library handles the messy work of setting up your entrypoint and exposing .NET types as C++ interfaces https://minidump.net/writing-a-net-profiler-in-c-part-1-d3978aae9b12/ exposing-a-c-interface-kind-of . Of course, Silhouette doesn't let you completely off the hook—you still need to know what the unmanaged APIs are for , how to use them, and how to chain them together. But Silhouette makes it easier to get started, and means you can write your logic in C , the language you know best, instead of having to wrestle with C++. Writing a .NET profiler in C writing-a-net-profiler-in-c- As an example of how easy Silhouette makes getting started, for the rest of the post we're going to write a simple profiler using .NET that simply prints out the assemblies that are loaded to the console. Creating the profiler and test projects creating-the-profiler-and-test-projects We'll start by creating a simple solution. This will consist of two projects: a class library which is our profiler, and a "hello world" test app, which we will profile: Create the two projects dotnet new classlib -o SilhouetteProf dotnet new console -o TestApp Add the projects to a sln file dotnet new sln dotnet sln add .\SilhouetteProf\ dotnet sln add .\TestApp\ This gives us our basic project structure. Now we'll add the Silhouette https://github.com/kevingosse/Silhouette library to our profiler project: dotnet add package Silhouette --project SilhouetteProf Next we need to ensure we publish our application using NativeAOT and allow unsafe code. We're not actually going to write any unsafe code ourselves in this test, but Silhouette includes a source generator which does use unsafe . Open up SilhoutteProj.csproj , and add the two properties