{"slug": "creating-a-net-clr-profiler-using-c-and-nativeaot-with-silhouette", "title": "Creating a .NET CLR profiler using C# and NativeAOT with Silhouette", "summary": "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.", "body_md": "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.\n\nKevin 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.\n\nI'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!\n\nSo before we get started, we should first recap: what *are* the .NET profiling APIs?\n\n[What are the .NET profiling APIs?](#what-are-the-net-profiling-apis-)\n\nFor 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.\n\n.NET Core documents three main categories of unmanaged APIs:\n\n[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.\n\nIn this post we're primarily looking at the final category, the profiling APIs, and will use the metadata APIs in a supporting role.\n\nWhen 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\n\n[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\n\n[Datadog .NET client library](https://github.com/DataDog/dd-trace-dotnet)to\n\n*rewrite*methods to include our instrumentation.\n\nThe 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.\n\n[Who needs C when you have NativeAOT?](#who-needs-c-when-you-have-nativeaot-)\n\nMicrosoft 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!\n\nThe 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\n\nseparate.NET runtime from the application being profiled. Yes, that technically means there's two .NET runtimes loaded in the process!\n\nOf 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/):\n\nIn a nutshell, we need to expose a\n\n`DllGetClassObject`\n\nmethod that will return an instance of`IClassFactory`\n\n. The .NET runtime will call the`CreateInstance`\n\nmethod on the class factory, which will return an instance of`ICorProfilerCallback`\n\n(or`ICorProfilerCallback2`\n\n,`ICorProfilerCallback3`\n\n, …, depending on which version of the profiling API we want to support). Last but not least, the runtime will call the`Initialize`\n\nmethod on that instance with an`IUnknown`\n\nparameter that we can use to fetch an instance of`ICorProfilerInfo`\n\n(or`ICorProfilerInfo2`\n\n,`ICorProfilerInfo3`\n\n, …) that we will need to query the profiling API.\n\nIf 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).\n\nOf 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++.\n\n[Writing a .NET profiler in C#](#writing-a-net-profiler-in-c-)\n\nAs 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.\n\n[Creating the profiler and test projects](#creating-the-profiler-and-test-projects)\n\nWe'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:\n\n```\n# Create the two projects\ndotnet new classlib -o SilhouetteProf\ndotnet new console -o TestApp\n\n# Add the projects to a sln file\ndotnet new sln\ndotnet sln add .\\SilhouetteProf\\\ndotnet sln add .\\TestApp\\\n```\n\nThis gives us our basic project structure. Now we'll add the [Silhouette](https://github.com/kevingosse/Silhouette) library to our profiler project:\n\n```\ndotnet add package Silhouette --project SilhouetteProf\n```\n\nNext we need to ensure we publish our application using NativeAOT and allow `unsafe`\n\ncode. We're not actually going to write any unsafe code ourselves in this test, but Silhouette includes a source generator which *does* use `unsafe`\n\n.\n\nOpen up *SilhoutteProj.csproj*, and add the two properties\n\n```\n<Project Sdk=\"Microsoft.NET.Sdk\">\n\n  <PropertyGroup>\n    <TargetFramework>net10.0</TargetFramework>\n    <RootNamespace>SilhouetteProf</RootNamespace>\n    <ImplicitUsings>enable</ImplicitUsings>\n    <Nullable>enable</Nullable>\n\n    <!-- 👇 Add these two  -->\n    <PublishAot>true</PublishAot>\n    <AllowUnsafeBlocks>true</AllowUnsafeBlocks>\n  </PropertyGroup>\n\n  <ItemGroup>\n    <PackageReference Include=\"Silhouette\" Version=\"3.2.0\" />\n  </ItemGroup>\n\n</Project>\n```\n\nNow we have our prerequisites, we can start creating our profiler.\n\n[Creating the basic profiler](#creating-the-basic-profiler)\n\nTo create a .NET profiler with Silhouette, you create a class that derives from the Silhouette-provided `CorProfilerCallbackBase`\n\n(or `CorProfilerCallback2Base`\n\n, `CorProfilerCallback3Base`\n\netc, depending on which functionality you need). You then decorate this class with a `[Profiler]`\n\nattribute and provide a **unique** `Guid`\n\n:\n\n```\nusing Silhouette;\n\nnamespace SilhouetteProf;\n\n// 👇 Use a new random Guid, don't just use this one! \n[Profiler(\"9FD62131-BF21-47C1-A4D4-3AEF5D7C75C6\")]\ninternal partial class MyCorProfilerCallback : CorProfilerCallback5Base\n{\n}\n```\n\nIn the example above I chose a random `Guid`\n\nfor my profiler, and derived from `CorProfilerCallback5Base`\n\n. Nothing in this post actually needs the \"5\" from `ICorProfilerInfo5`\n\n, I'm just including it here to demonstrate the pattern.\n\nThe `[Profiler]`\n\nattribute above drives a source generator included with Silhouette which generates the boilerplate necessary required by the .NET runtime for creating an `IClassFactory`\n\n. You don't *have* to use this generated code (if, for example, you need additional logic in your `DllGetClassObject`\n\nmethod); if you don't want this code, just omit the `[Profiler]`\n\nattribute. The generated code looks like this:\n\n```\nnamespace Silhouette._Generated\n{\n    using System;\n    using System.Runtime.InteropServices;\n\n    file static class DllMain\n    {\n        [UnmanagedCallersOnly(EntryPoint = \"DllGetClassObject\")]\n        public static unsafe HResult DllGetClassObject(Guid* rclsid, Guid* riid, nint* ppv)\n        {\n            if (*rclsid != new Guid(\"9fd62131-bf21-47c1-a4d4-3aef5d7c75c6\"))\n            {\n                return HResult.CORPROF_E_PROFILER_CANCEL_ACTIVATION;\n            }\n\n            *ppv = ClassFactory.For(new global::SilhouetteProf.MyCorProfilerCallback());\n            return HResult.S_OK;\n        }\n    }\n}\n```\n\nWe have the skeleton of our profiler, but before we can compile it, we need to implement the `Initialize`\n\nmethod:\n\n```\nusing Silhouette;\n\nnamespace SilhouetteProf;\n\n[Profiler(\"9FD62131-BF21-47C1-A4D4-3AEF5D7C75C6\")]\ninternal partial class MyCorProfilerCallback : CorProfilerCallback5Base\n{\n    protected override HResult Initialize(int iCorProfilerInfoVersion)\n    {\n        Console.WriteLine(\"[SilhouetteProf] Initialize\");\n        if (iCorProfilerInfoVersion < 5)\n        {\n            // we need at least ICorProfilerInfo5 and we got < 5\n            return HResult.E_FAIL;\n        }\n\n        // Call SetEventMask to tell the .NET runtime which events we're interested in\n        return ICorProfilerInfo5.SetEventMask(COR_PRF_MONITOR.COR_PRF_MONITOR_ALL);\n    }\n}\n```\n\nThe above code is about the most simple version `Initialize`\n\nmethod we can write. Silhouette takes care of working out which version of `ICorProfilerInfo`\n\nis available, and passes this as an `int`\n\nto the method. In the above code, we're making sure that we have *at least* `ICorProfilerInfo5`\n\navailable, so we can call any methods exposed by `ICorProfilerInfo5`\n\n, `ICorProfilerInfo4`\n\n, `ICorProfilerInfo3`\n\netc.\n\nYou'll notice a lot of\n\n`HResult`\n\nvalues used as return values. Returning error codes is the predominant error handling approach with the native APIs, so you'll be dealing with these a lot. Luckily, Silhouette exposes this as a handy enum for you to use.\n\nOnce we've confirmed that the current .NET runtime supports the features we need, we need to [tell the runtime which events we're interested in using the COR_PRF_MONITOR enum](https://learn.microsoft.com/en-us/dotnet/framework/unmanaged-api/profiling/cor-prf-monitor-enumeration) and the\n\n[or](https://learn.microsoft.com/en-us/dotnet/framework/unmanaged-api/profiling/icorprofilerinfo-seteventmask-method)\n\n`SetEventMask()`\n\n[methods. For simplicity I used](https://learn.microsoft.com/en-us/dotnet/framework/unmanaged-api/profiling/icorprofilerinfo5-seteventmask2-method)\n\n`SetEventMask2()`\n\n`ICorProfilerInfo5.SetEventMask()`\n\nand just enabled all the features.The\n\n`ICorProfilerInfo5`\n\nfield is initialized prior to`Initialize`\n\nbeing called, based on the available interface version. For example, if`iCorProfilerInfoVersion`\n\nis`7`\n\n, then all the`ICorProfilerInfo*`\n\nfields up to`ICorProfilerInfo7`\n\nwill be initialized. It'sveryimportant you only call interface versions that have been initialized. So if`iCorProfilerInfoVersion`\n\nis`7`\n\n,don'tcall`ICorProfilerInfo8`\n\nor higher!\n\nAt this point we could test our profiler, but I'm going to continue with the implementation a bit before we come to testing.\n\n[Adding functionality to our profiler](#adding-functionality-to-our-profiler)\n\nResponding to events with a Silhouette profiler is as easy as overriding a method in the base class. For example we could override the `Shutdown`\n\nmethod, called when the runtime is shutting down:\n\n```\nprotected override HResult Shutdown()\n{\n    Console.WriteLine(\"[SilhouetteProf] Shutdown\");\n    return HResult.S_OK;\n}\n```\n\nTo add a bit of interest, we're going to override the `AssemblyLoadFinished`\n\nmethod which is called when an assembly has finished loading (shocking, I know):\n\n```\nprotected override HResult AssemblyLoadFinished(AssemblyId assemblyId, HResult hrStatus)\n{\n    // ...\n}\n```\n\nThe `AssemblyLoadFinished`\n\nmethod provides an `AssemblyId`\n\nwhich we can use to retrieve the name of the assembly by calling *another* method on `ICorProfilerInfo5`\n\n, `GetAssemblyInfo(AssemblyId)`\n\n.\n\nThe\n\n`AssemblyId`\n\ntype is a very thin wrapper around an`IntPtr`\n\n, acting as strongly-typed wrappers around the otherwise-ubiquitous`IntPtr`\n\ns used in the profiling APIs. I'm a[big fan of this approach]as it eliminates a whole class of mistakes that you could otherwise make of passing a \"Class ID\"`IntPtr`\n\nto a method expecting an \"Assembly ID\"`IntPtr`\n\n(for example).\n\nWe can call `GetAssemblyInfo()`\n\neasily enough using the `ICorProfilerInfo5`\n\nfield, but this is a good opportunity to look at a common pattern in the Silhouette library, the use of `HResult<T>`\n\n:\n\n```\nprotected override HResult AssemblyLoadFinished(AssemblyId assemblyId, HResult hrStatus)\n{\n     HResult<AssemblyInfoWithName> assemblyInfo = ICorProfilerInfo5.GetAssemblyInfo(assemblyId)\n    // ...\n}\n```\n\n`HResult<T>`\n\nis effectively a simple [result pattern](https://andrewlock.net/series/working-with-the-result-pattern/) discriminated union. It contains both an `HResult`\n\nand, if the `HResult`\n\nrepresents success, an object `T`\n\n. This approach is a way of avoiding the common pattern in the profiling APIs of having multiple \"`out`\n\n\" parameters, and an `HRESULT`\n\nto indicate whether it's valid to *use* those values, e.g.\n\n```\nHRESULT GetAssemblyInfo(  \n    [in]  AssemblyID  assemblyId,  \n    [in]  ULONG       cchName,  \n    [out] ULONG       *pcchName,  \n    [out, size_is(cchName), length_is(*pcchName)]  \n          WCHAR       szName[] ,  \n    [out] AppDomainID *pAppDomainId,  \n    [out] ModuleID    *pModuleId);\n```\n\nThe typical pattern when working with the profiling APIs directly is to make the call, check the return value, and then decide whether to continue or not. `HResult<T>`\n\nallows that pattern too, but you can also go YOLO mode. Instead of doing all the checks yourself, you can instead call `HResult<T>.ThrowIfFailed()`\n\nwhich returns the `T`\n\nif the call was successful, and throws a `Win32Exception`\n\notherwise. This can make for some dramatically simpler code to read and write, so it's a real win.\n\nOf course, whether you would want to do this with a\n\nproductiongrade profiler is a whole other thing. But then, should you really be using anything from this post for production? Probably not 😉\n\nUsing the `ThrowIfFailed()`\n\napproach gives us the code below. We try to get the assembly name, and if it's available, print it:\n\n```\nprotected override HResult AssemblyLoadFinished(AssemblyId assemblyId, HResult hrStatus)\n{\n    try\n    {\n        // Try to get the AssemblyInfoWithName, and if the HResult returns non-success, throw\n        AssemblyInfoWithName assemblyInfo = ICorProfilerInfo5.GetAssemblyInfo(assemblyId).ThrowIfFailed();\n\n        Console.WriteLine($\"[SilhouetteProf] AssemblyLoadFinished: {assemblyInfo.AssemblyName}\");\n        return HResult.S_OK;\n    }\n    catch (Win32Exception ex)\n    {\n        // GetAssemblyInfo() failed for some reason, weird.\n        Console.WriteLine($\"[SilhouetteProf] AssemblyLoadFinished failed: {ex}\");\n        return ex.NativeErrorCode;\n    }\n}\n```\n\nThe gains of `ThrowIfFailed()`\n\naren't particularly obvious if you just have a single call. Where it really shines is when you want to chain multiple calls. For example, if we wanted to implement `ClassLoadStarted`\n\n, we would need to chain multiple calls, and that's where `ThrowIfFailed()`\n\ncomes into its own:\n\n```\nprotected override HResult ClassLoadStarted(ClassId classId)\n{\n    try\n    {\n        ClassIdInfo classIdInfo = ICorProfilerInfo.GetClassIdInfo(classId).ThrowIfFailed();\n\n        using ComPtr<IMetaDataImport>? metaDataImport = ICorProfilerInfo2\n                                                            .GetModuleMetaDataImport(classIdInfo.ModuleId, CorOpenFlags.ofRead)\n                                                            .ThrowIfFailed()\n                                                            .Wrap();\n        TypeDefPropsWithName classProps = metaDataImport.Value.GetTypeDefProps(classIdInfo.TypeDef).ThrowIfFailed();\n\n        Console.WriteLine($\"[SilhouetteProf] ClassLoadStarted: {classProps.TypeName}\");\n        return HResult.S_OK;\n    }\n    catch (Win32Exception ex)\n    {\n        Console.WriteLine($\"[SilhouetteProf] ClassLoadStarted failed: {ex}\");\n        return ex.NativeErrorCode;\n    }\n}\n```\n\nWe have three `ThrowIfFailed()`\n\ncalls in the above code, which keeps the nice, procedural, flow. We could instead have added three additional `if (result != HResult.S_OK)`\n\nin the code, but that's harder to follow, particularly if you're writing something similar or just prototyping.\n\nOK, we now have enough functionality to take our profiler for a spin!\n\n[Testing our new profiler](#testing-our-new-profiler)\n\nTo test our profiler, we need to do three things\n\n- Publish our test app.\n- Publish our profiler.\n- Set the required profiling environment variables.\n\n[Publish the test app](#publish-the-test-app)\n\nWe'll startup by publishing the test app. This isn't *technically* required, we *could* just run the app using `dotnet run`\n\nfor example. The difficulty is that this invokes the .NET SDK, which [is itself a .NET app](/exploring-the-dotnet-boot-process-via-host-tracing/#finding-the-sdk), which means we'd end up profiling that too. Which is fine, it's just not what we're\n\n*trying*to do.\n\nWe can publish our hello world app using a simple `dotnet publish`\n\n:\n\n```\n❯ dotnet publish .\\TestApp\\ -c Release \nRestore complete (0.6s)\n  TestApp net10.0 succeeded (0.9s) → TestApp\\bin\\Release\\net10.0\\publish\\\n```\n\n[Publish our profiler](#publish-our-profiler)\n\nPublishing our profiler is similar, but as we're using NativeAOT, we also need to provide a runtime ID. In .NET 10, you can also use the `--use-current-runtime`\n\noption to publish for \"whatever runtime you're currently using\". As you can see below, the SDK used `win-x64`\n\nas I'm running on Windows:\n\n```\n❯ dotnet publish .\\SilhouetteProf\\ -c Release --use-current-runtime\nRestore complete (0.6s)\n  SilhouetteProf net10.0 win-x64 succeeded (4.2s) → SilhouetteProf\\bin\\Release\\net10.0\\win-x64\\publish\\\n\nBuild succeeded in 5.5s\n```\n\nAs we're using NativeAOT, the result is a single, self contained dll (plus separate debug symbols). This is our .NET app, compiled as a NativeAOT .NET profiler!\n\n[Setting the profiling environment variables](#setting-the-profiling-environment-variables)\n\nTo attach a profiler to the .NET runtime, you need to set some environment variables. These are different depending on whether you're profiling a .NET Framework or .NET Core app. There are three different variables to set:\n\nFor profiling a .NET Framework app:\n\n`COR_ENABLE_PROFILING=1`\n\n—Enable profiling`COR_PROFILER={9FD62131-BF21-47C1-A4D4-3AEF5D7C75C6}`\n\n—Set to the value of the GUID from the`[Profiler]`\n\nattribute.`COR_PROFILER_PATH=c:\\path\\to\\profiler`\n\n—Path to the profiler dll\n\nFor profiling a .NET Core/.NET 5+ app:\n\n`CORECLR_ENABLE_PROFILING=1`\n\n—Enable profiling`CORECLR_PROFILER={9FD62131-BF21-47C1-A4D4-3AEF5D7C75C6}`\n\n—Set to the value of the GUID from the`[Profiler]`\n\nattribute.`CORECLR_PROFILER_PATH=c:\\path\\to\\profiler`\n\n—Path to the profiler dll\n\nThere are\n\n[additional platform-specific versions]of the path variable you can set if you need to support multiple platforms.\n\nAfter publishing the profiler and the app, I copied the absolute path to the profiler dll, and set the required environment variables using powershell.\n\nYou technically don't\n\nhaveto use an absolute path for the dll, you can use a relative path, but is that relative to the target app? To the working directory? I prefer to use absolute paths as they're are unambiguous!\n\n```\n$env:CORECLR_ENABLE_PROFILING=1\n$env:CORECLR_PROFILER=\"{9FD62131-BF21-47C1-A4D4-3AEF5D7C75C6}\"\n$env:CORECLR_PROFILER_PATH=\"D:\\repos\\temp\\silouette-prof\\SilhouetteProf\\bin\\Release\\net10.0\\win-x64\\publish\\SilhouetteProf.dll\"\n```\n\nNote that the GUID variable *does* include the `{}`\n\nsurrounding braces. Once the variables are set, we can take our profiler for a spin!\n\n[Testing our app with our NativeAOT profiler](#testing-our-app-with-our-nativeaot-profiler)\n\nWhen we run our app, the .NET runtime checks the `CORECLR_`\n\nvariables, and loads our NativeAOT profiler, emitting events as the application executes. As each event is raised, we write to the console, and we can see all the assemblies being loaded as the \"Hello World!\" application runs!\n\n```\n❯ .\\TestApp.exe\n[SilhouetteProf] Initialize\n[SilhouetteProf] AssemblyLoadFinished: System.Private.CoreLib\n[SilhouetteProf] AssemblyLoadFinished: TestApp\n[SilhouetteProf] AssemblyLoadFinished: System.Runtime\n[SilhouetteProf] AssemblyLoadFinished: System.Console\n[SilhouetteProf] AssemblyLoadFinished: System.Threading\n[SilhouetteProf] AssemblyLoadFinished: System.Text.Encoding.Extensions\n[SilhouetteProf] AssemblyLoadFinished: System.Runtime.InteropServices\nHello, World!\n[SilhouetteProf] Shutdown\n```\n\nAnd there we have it, our .NET profiler, written in .NET, works as expected!🎉 Now, this was obviously a very simple implementation, but it showed me how easy it is to use the Silhouette library to get something up and running vastly quicker than if I had to mess with C++.\n\nOne thing to bear in mind is that while Silhouette helps with the mechanics of listening to events and interoperating with the C++ interfaces, you still need to know *how* to use the native APIs. Silhouette helps with the learning curve there, but you'll likely still need to do research for how to achieve what you want.\n\nFrom my point of view, Silhouette is clearly a handy tool for fulfilling a specific need. You won't necessarily want to use it to produce a production-grade profiler, but for proof of concept or development work, it seems invaluable. Especially if Kevin continues [posting practical examples](https://minidump.net/measuring-ui-responsiveness/) of using Silhouette himself!\n\n[Summary](#summary)\n\nIn this post I gave a brief introduction to the unmanaged .NET profiling APIs, and how you would typically interact with these APIs using C++. I then described how you can use .NET to produce a binary that can interact with these APIs instead, giving all the benefits of working in .NET, while still being able to call native APIs.\n\nI then introduced [Kevin Gosse's](https://minidump.net/) [Silhouette library](https://github.com/kevingosse/Silhouette), and showed how this library makes producing a profiler with NativeAOT simple, by deriving from a base class, and overriding the methods you're interested in. I produced a simple profiler, published it, and used it to show all the assemblies loaded by a hello world console application. Overall I was impressed with how simple it was to Silhouette and will likely explore it much more in the future too!", "url": "https://wpnews.pro/news/creating-a-net-clr-profiler-using-c-and-nativeaot-with-silhouette", "canonical_source": "https://andrewlock.net/creating-a-dotnet-profiler-using-csharp-with-silhouette/", "published_at": "2025-12-16 10:00:00+00:00", "updated_at": "2026-05-23 21:40:26.382813+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["Kevin Gosse", "Silhouette", "Resharper", "Christophe Nasarre", ".NET CLR profiler", "NativeAOT"], "alternates": {"html": "https://wpnews.pro/news/creating-a-net-clr-profiler-using-c-and-nativeaot-with-silhouette", "markdown": "https://wpnews.pro/news/creating-a-net-clr-profiler-using-c-and-nativeaot-with-silhouette.md", "text": "https://wpnews.pro/news/creating-a-net-clr-profiler-using-c-and-nativeaot-with-silhouette.txt", "jsonld": "https://wpnews.pro/news/creating-a-net-clr-profiler-using-c-and-nativeaot-with-silhouette.jsonld"}}