# Ultra: a .NET profiler that traces kernel, native and managed frames into one flamegraph

> Ultra is a sampling profiler for .NET on Windows and macOS that collects kernel, native and managed call stacks together, categorises each function as .NET, .NET JIT, .NET GC, .NET CLR, Native or Kernel, and renders the result in a Firefox Profiler compatible UI so traces are shareable as a link. On Windows it goes through ETW, on macOS it injects a sampler into the process at startup, and the Windows path requires an elevated prompt.

**xoofx/ultra** — An advanced profiler for .NET Applications on Windows and macOS

- Repository: https://github.com/xoofx/ultra
- Stars: 1,202 · Forks: 24
- Language: C#
- License: BSD-2-Clause
- Published: 2026-10-19 · Updated: 2026-10-19 · Language: en
- Canonical page: https://hysenlabs.com/projects/xoofx-ultra

## What it profiles and how, which differ sharply by platform

Ultra is an advanced profiler for.NET applications available on Windows and macOS on Apple Silicon, and the primary language is C#, the licence is BSD-2-Clause, and the author is Alexandre Mutel, who publishes under the name xoofx. The repository is not archived with the last push on 2026-07-28, the same day as the 3.2.0 release tag. The three recent releases are 3.2.0, 3.1.0 on 2026-07-05 and 3.0.0 on 2026-07-04, so the 3.x line is new and moving quickly.

The sampling mechanism is where the two platforms diverge, and the difference is large enough to change what you can conclude from a trace.

On Windows it is ETW based, up to 8190 samples per second. ETW is the Windows event tracing infrastructure, and the number is close to the practical ceiling of what the platform will give you, which is what makes a Windows trace dense enough to see short functions.

On macOS it is an in-process sampler over EventPipe on ARM64, up to 1000 samples per second. The sampler is a small library that gets injected into the process, and the EventPipe part means the samples come from the same mechanism the runtime itself uses for diagnostics. A thousand per second is roughly an eighth of what Windows gives you, and for a profiler hunting for a hot function that is a meaningful difference in resolution.

And the privilege story is inverted between the platforms. On Windows the profiler requires to run from an elevated prompt with administrative rights, and the reason given is that this is required to allow to collect full stack traces, including kernel and native functions. On macOS, no elevated privileges are required, because the sampler library is injected into the launched process rather than asked of the kernel.

That is the central trade. On Windows you get kernel frames and you pay for it with an administrator prompt. On macOS you need no privileges and you get no kernel frames.

## The Firefox Profiler UI, and why that choice matters

The user interface is based on the Firefox Profiler, at profiler.firefox.com, and that decision has consequences well beyond the visual style.

The Firefox Profiler frontend already knows how to render timeline views, flamegraphs, stack charts and call trees, which is exactly the set of views a.NET profiler needs. Building on it means Ultra does not have to implement any of that, and it means the person reading the trace already knows the interface.

But the more interesting consequence is the shareability. Traces are shareable online, and the README points at an example at a share.firefox.dev link, with a screenshot of a trace generated by Ultra visualised in the same tool. A trace file can be handed to a colleague, uploaded to the share service, and opened in a browser with no local tooling at all.

For a profiler, that is a large part of the value. A profile that lives in a file is a profile that a colleague cannot easily look at, and the usual workarounds are screenshots of a flamegraph, which lose the interactivity and the call tree. A trace that opens at a URL in the same interface the author used is a different kind of artefact, and it is the difference between a profile you discuss with someone and a picture you attach to a ticket.

The set of views Ultra populates is listed explicitly, and it is a sensible set for a managed runtime profiler. Timeline visualisation for the overall shape of the run. Flamegraph and Stack Chart for the hot path, which are two views of the same data with different emphases. Call Tree for the hierarchical structure, which is what you use to answer why something is called. And Marker Chart and Marker Table for the discrete events, which is where the runtime's own annotations end up.

Those last two views are what the JIT and GC features feed into, and that is worth noticing. A profiler that shows only a flamegraph is a sampling profiler. One that also emits markers for the runtime's own events is a sampling profiler that also tells you what the runtime was doing, which is a different and more complete picture.

The trace format being the Firefox Profiler's format is also why the file sizes are what they are. The README claims lightweight trace files, for example generating only a few MB for ten seconds, and that is a property of the format and of the fact that the profile is samples rather than a full instruction trace. A full instruction trace of ten seconds would be orders of magnitude larger.

## The runtime awareness, and the function categories

The features that separate Ultra from a general purpose sampling profiler are the runtime events, and the function categorisation is the mechanism that makes them legible.

The categories are .NET, .NET JIT, .NET GC, .NET CLR, Native and Kernel. Six buckets, and the point of them is that they tell you what kind of code you are looking at. A frame categorised as .NET JIT is time spent compiling code rather than running it, .NET GC is time in the garbage collector, and Kernel is time in the operating system. Without that distinction a flamechart is a wall of mixed managed and native frames, and the question you are usually asking, is this time in my code or in the runtime, is not answerable from the picture.

The event markers build on the same idea. There are JIT Compile Time Event Markers, so you can see which function is getting compiled, and the compile time itself is often the answer to why a service is slow to start. There are GC Events Markers, and the list is specific: GC Allocation Ticks with details about allocations, GCHeapStats with statistics about the GC, and GCSuspendEE, GCRestartEE, GC Start and GC Stop events. The suspend and restart pair is the interesting one, because a GC suspension is a stop-the-world moment that shows up in a profile as a gap in your code rather than as time in your code, and seeing the event next to the gap is what makes the diagnosis possible.

There is also a .NET GC Memory track, which is a continuous view rather than an event list, so you can see heap growth over the run.

And the stack capture is described as precise kernel, native and managed function call stacks, which is the claim that matters most. A managed-only stack, which is what you get from a JIT-only profiler, cannot tell you that the call went out to native code and back, so a profile of a call into a native library or into the operating system is truncated at the boundary. Capturing all three means the whole path is visible in one flamegraph, and that is what makes the Windows elevation requirement worth paying for.

The categories are also how the platform difference shows up in practice. On macOS, where kernel stacks are not collected, the Kernel category will be empty, and a profile that appears to spend time in the operating system will instead show the nearest user-space frame. The documentation is clear that user-space native and managed call stacks are collected, so the trace is still useful, but it is a different picture than the Windows one and the two are not directly comparable.

## The maintenance picture, and the third party MCP server

There are a few things worth knowing about the project that do not fit into the feature list, and one of them changes how you are likely to use the tool.

The version history says the 3.x line is young. The three listed releases are 3.2.0 on 2026-07-28, 3.1.0 on 2026-07-05 and 3.0.0 on 2026-07-04, so the major version landed in early July 2026 and two patch releases followed within a month. The repository is not archived, and the last push was on 2026-07-28, the same day as the 3.2.0 tag.

A profiler moving quickly through major versions is not unusual, since the.NET runtime it instruments changes and the profiler has to follow it, but it does mean you should pin a version and read the notes when you upgrade rather than tracking latest.

The licence is BSD-2-Clause with the text in license.txt, which is permissive and appropriate for a tool you run against your own application. The repository also has an AGENTS.md and a CLAUDE.md, plus a doc/ directory holding the user guide, and the README points at the user guide in that directory for the details it does not cover.

Now the third party item, and it is the one that is easy to skim past. The list of third party integrations names an MCP server for Ultra, called UltraMCP, which lets your AI coding agent query profiling results, and the entry credits KirillOsenkov for it.

That is a genuinely interesting direction for a profiler, and the reason is worth stating. A profile is a large structured artefact, and the standard way to read one is a person opening the flamegraph and navigating. An agent that can query it is doing something different: it can ask how much time is spent in the garbage collector, or which function is called most often on the hot path, or whether a particular assembly method appears, and get an answer without the human having to navigate the trace themselves.

That is the same argument that applies to giving an agent a query interface for anything large and structured, and it works particularly well for traces because the questions you want answered are often the ones that are tedious to extract by eye. It also means the trace has to be in a format an agent can read.

The caveat is the obvious one. The profile is only as good as the sampling, and on macOS at a thousand samples per second a short function can be missed entirely, so an agent reasoning over the trace is reasoning over a sample. It is a good way to narrow the search, and it is not a substitute for looking at the call tree yourself when the answer matters.

And the practical note to close on, because it is the thing that will make your first session work or not. On Windows the tool needs an elevated prompt, and the reason is to collect kernel and native frames, which is the difference between a full picture and a managed-only one. If you skip the elevation you will get a trace that is perfectly readable and quietly missing the part where the time actually went.

## Using it, and the macOS restrictions you will hit first

The usage section is short, and the requirements are stated before anything else.

You need to have installed a .NET 10.0 or newer SDK, and the tool is installed as a global tool:

```console
$ dotnet tool install -g Ultra # The command ultra.exe will be available from your PATH
```

The features list adds a runtime requirement of net8.0+ via the dotnet global tool, which is the version the profiled application targets rather than the SDK you build with. The tool itself is available on NuGet as Ultra.

On Windows, the example is to open a terminal with administrative rights and profile an executable:

```console
$ ultra.exe profile -- my_command.exe arg0 arg1 arg2...
```

The `--` separator is required, to separate the arguments to ultra.exe from the arguments to the profiled application. That detail catches people, because without it the application arguments are consumed by the profiler's own parser.

Profiling an already running application is simpler, since it only requires the PID:

```console
$ ultra.exe profile 15243 # PID of the process to profile
# Wait for a bit and press only one CTRL+C to stop the profiling
```

And the note about a single CTRL+C is a real usability point for a sampling profiler, because the usual reflex is to press it several times to make sure it registered, and several interrupts can leave the trace in an inconsistent state.

The macOS side is a different command shape, with no .exe suffix:

```console
$ ultra profile -- my_command arg0 arg1 arg2...
```

The current limitations on macOS are listed and they are the deciding factor for a lot of people. Only ARM64, so Apple Silicon, is supported. Only launching a program is supported, and attaching to a running process with a PID is not, because the sampler library can only be injected at process startup. The maximum sampling frequency is 1000 samples per second, with the --sampling-interval minimum being 1ms. Kernel call stacks are not collected, though user-space native and managed call stacks are. And symbols of system libraries living in the dyld shared cache, for example libsystem_pthread.dylib, are not resolved.

That last one is the one that surprises people, because an unresolved frame in the middle of a native stack is exactly where you were looking for the answer. It is a property of how the macOS loader works rather than of the profiler, but the consequence is the same.

There is also a third party project in the list, an MCP server for Ultra called UltraMCP, which lets an AI coding agent query profiling results. That is a notable direction for a profiler, because a profile is a large structured artefact and asking questions of it in natural language is a reasonable interface for a first pass.

## Conclusion

Ultra fits a .NET developer who needs to know where time is going across the managed, native and kernel boundaries at once, and who is willing to run their application under a sampling profiler with a Firefox Profiler compatible trace. It is the wrong tool for a Linux target, since the supported platforms are Windows and macOS on Apple Silicon, and the wrong choice if you need to attach to a process that is already running, because on macOS the sampler can only be injected at process startup. Before profiling anything on Windows, note that the tool requires an elevated prompt so it can collect kernel frames, and on macOS accept that kernel stacks are not collected and dyld cache symbols are not resolved.

## FAQ

### What platforms does Ultra the .NET profiler support?

Windows and macOS on Apple Silicon. On Windows it is ETW based at up to 8190 samples per second and requires an elevated prompt so it can collect kernel and native frames, while on macOS it uses an in-process sampler over EventPipe at up to 1000 samples per second and needs no elevated privileges.

### How do I profile a .NET application with Ultra?

Install the tool with dotnet tool install -g Ultra, then run ultra.exe profile -- my_command.exe arg0 arg1 with the -- separator between the profiler arguments and the application arguments. To profile a running process, pass its PID instead, for example ultra.exe profile 15243, and press a single CTRL+C to stop.

### What are the limitations of Ultra on macOS?

Only ARM64 is supported, only launching a program works because the sampler library can only be injected at process startup, the maximum sampling frequency is 1000 samples per second with a 1ms minimum sampling interval, kernel call stacks are not collected, and symbols of system libraries in the dyld shared cache are not resolved.

### What can Ultra's traces show besides a flamegraph?

The traces are rendered in the Firefox Profiler interface, so you get timeline, flamegraph, stack chart, call tree, marker chart and marker table views, and the traces are shareable online as a link. The markers include JIT compile time events, GC allocation ticks, GCHeapStats and GC suspend and restart events, and the functions are categorised as .NET, .NET JIT, .NET GC, .NET CLR, Native or Kernel.

## Sources

- [Issues](https://github.com/xoofx/ultra/issues)
- [License: BSD-2-Clause](https://github.com/xoofx/ultra/blob/main/LICENSE)
- [README](https://github.com/xoofx/ultra/blob/main/README.md)
- [Releases](https://github.com/xoofx/ultra/releases)
- [xoofx/ultra on GitHub](https://github.com/xoofx/ultra)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/xoofx-ultra
