docs java javascript python c / c++ c# reading reports
How to profile C# and .NET
The .NET runtime has a sampling profiler built into it — EventPipe — and
a cross-platform CLI that drives it. No IDE required, no recompile, and it attaches to a
process that's already running in production. That's dotnet-trace, and it's
the path flamelens takes.
Install and collect
dotnet tool install --global dotnet-trace
# list candidate processes
dotnet-trace ps
# attach to a running app for 60 seconds
dotnet-trace collect --process-id <pid> --duration 00:00:01:00
# ...or launch the app under the profiler
dotnet-trace collect -- dotnet run -c Release
That writes trace.nettrace. It's a binary EventPipe file, so convert it to
the interchange format before uploading:
dotnet-trace convert trace.nettrace --format speedscope
# → trace.speedscope.json
Upload the .speedscope.json. flamelens reads the file's
exporter field, recognizes it came from dotnet-trace rather
than py-spy, and analyzes it as .NET — allocation pressure, async state machines,
ThreadPool starvation, boxing, and the rest of the .NET performance vocabulary.
Profile a Release build. A Debug build disables JIT optimizations
and inlining, so a Debug profile measures a program you don't ship: it will show
time in methods that vanish entirely under -c Release. Also set
DOTNET_TieredPMStubs aside and just warm the path up first
— tiered compilation means the first few hundred calls run cold Tier-0 code, and a
profile of startup is mostly JIT.
Choosing what to collect
The default profile is CPU sampling, which is what you want for "why is this slow". Two other presets pay off often:
# allocation-heavy investigation (GC events + allocation ticks)
dotnet-trace collect --process-id <pid> --profile gc-verbose --duration 00:00:00:30
# minimal overhead, just GC collections and pauses
dotnet-trace collect --process-id <pid> --profile gc-collect --duration 00:00:01:00
--profile cpu-sampling is the default and the right first move.
gc-verbose is genuinely expensive on an allocation-heavy service — use it
for a short window when you already suspect allocation.
In JetBrains Rider
- Rider bundles dotTrace: Run → Profile '<config>', or the
profile button next to Run. Choose Timeline or
Sampling — Sampling is the closer analogue to what
dotnet-tracecollects. - Exercise the slow path, then stop. Rider opens its own flame view.
- dotTrace saves proprietary
.dtp/.dttsnapshots, which aren't a format flamelens can read. For a file to upload, run thedotnet-tracepair above from Rider's built-in terminal against the same process — Rider's Run window shows the pid on the first line.
In Visual Studio
- Debug → Performance Profiler (Alt+F2), tick CPU Usage, and start. This is the fastest way to look at a hotspot interactively.
- Visual Studio writes
.diagsession, which is again a proprietary container. For a portable profile, use the Developer PowerShell:dotnet-trace collect --process-id <pid> --duration 00:00:01:00 dotnet-trace convert trace.nettrace --format speedscope - Everything works identically on Windows, Linux, and macOS — EventPipe is part of the runtime, not the OS tooling.
In VS Code
- With the C# Dev Kit installed, run your app as usual (F5 or
dotnet run -c Releasein the terminal). - Wire the collection as a task in
.vscode/tasks.json:{ "version": "2.0.0", "tasks": [{ "label": "profile with dotnet-trace", "type": "shell", "command": "dotnet-trace collect --name ${input:procName} --duration 00:00:01:00 && dotnet-trace convert trace.nettrace --format speedscope" }] } - Upload the resulting
trace.speedscope.json.
Now analyze it. Take trace.speedscope.json to the
flamelens terminal — auth <email> →
verify <code> → scan init. F# and VB.NET profile
identically; it's the same runtime and the same EventPipe.
Why there's no “scan live” for .NET — yet
Java exposes JMX and Node exposes the inspector, so flamelens can attach to those over
the network. .NET's diagnostic channel is a local IPC transport — a
Unix domain socket on Linux/macOS, a named pipe on Windows — which
dotnet-trace connects to by process id. It doesn't listen on a TCP port,
so there's nothing for us to dial from outside the machine.
There is a real path to changing that:
dotnet-monitor is
a Microsoft-supported sidecar that exposes exactly these diagnostics over HTTP, with
authentication. If you're already running it, tell us — a live-scan integration against
dotnet-monitor is the natural next step, and it's the one .NET path that
wouldn't require weakening anything. Until then, use an agent.
Continuous sampling with an agent ENTERPRISE
The agent runs on the host, connects out over HTTPS, and samples on a cadence — a profile every N minutes, each diffed against the last:
agent new prod-api-1
# → prints a token, ONCE. store it like a password.
schedule new api 1234 dotnet 30 60
# → sample pid 1234 for 60s, every 30 minutes
The target is a pid (or whatever your script resolves to one), since
that's what dotnet-trace takes. The recorder step is two lines:
dotnet-trace collect --process-id "$TARGET" --duration 00:00:00:"$secs" \
--output /tmp/trace.nettrace
dotnet-trace convert /tmp/trace.nettrace --format speedscope --output "$out"
The agent needs to reach the diagnostic socket, which lives in
/tmp/dotnet-diagnostic-* on Linux and is owned by the app's user — so run
the agent as that user, or share /tmp and match the uid. In Kubernetes a
sidecar needs shareProcessNamespace: true plus an emptyDir mounted at
/tmp in both containers. The full polling loop is on the
JavaScript page; only the recorder line
differs per runtime.
What makes a good profile
- Release build, warmed up. Tiered compilation means the first calls run unoptimized — hit the path before you record it, or you'll profile the JIT.
- Server GC changes everything. If you're diagnosing GC pauses, note
whether
ServerGarbageCollectionis on; the workstation and server collectors have completely different pause profiles, and advice for one can be wrong for the other. - 60 seconds under real load. Long enough to catch a gen-2 collection, short enough to stay readable.
- Async-heavy code needs the whole window. A request that spends its life awaiting shows almost no CPU samples — if the profile looks empty but the service is slow, that's the finding, and it points at I/O or contention rather than CPU.