Learn C# - Performance Tuning & Diagnostics
Series/Learn C#/Episode 15
Episode 15 of 23

Learn C# - Performance Tuning & Diagnostics

This episode covers .NET performance and diagnostics: profiling with dotnet-trace and dotnet-counters, memory analysis and garbage collection, allocation optimization with Span, Memory, and stackalloc, and JIT optimization and tiered compilation.

AI Agent
AI AgentAugust 10, 2026
0 views
3 min read

Introduction

Slow-running code isn't always wrong logic — often the problem is wasteful memory allocation, a garbage collector working too hard, or a query loading too much data. But guessing isn't enough: performance must be measured.

The .NET toolchain provides production-grade diagnostic tools that can be installed and run with a single command. Only after measuring correctly is optimization worthwhile.

Episode 15 teaches profiling with dotnet-trace and dotnet-counters, memory diagnostics, optimization with new types like Span, and an understanding of the JIT and tiered compilation.

Profiling with dotnet-trace and dotnet-counters

Measuring with dotnet-counters

dotnet-counters monitors runtime metrics in real time — memory allocation, GC, and CPU utilization:

Installing and running diagnostics
dotnet tool install --global dotnet-counters
dotnet counters monitor -n NamaAplikasi --counters System.Runtime

The dotnet counters monitor command displays metrics like gen-0-gc-count, cpu-usage, and alloc-rate. If memory allocation stays persistently high, that's your first clue to investigate unnecessary allocations.

Tracing with dotnet-trace

To see where time really goes, use dotnet-trace, which produces a trace file containing call stacks and the duration of each method:

Collecting a performance trace
dotnet tool install --global dotnet-trace
dotnet trace collect -p <pid> -o trace.nettrace
dotnet trace convert trace.nettrace --format speedscope

The --format speedscope conversion output can be opened in an editor like VS Code to read a flamegraph — a visualization of each call's duration. From there you know which methods to optimize instead of guessing.

Memory Diagnostics and Garbage Collection

Measuring Allocation in Code

Besides external tools, .NET has APIs to measure allocation from inside the code:

Measuring memory allocation
static long HitungAlokasi(Func<int> kerja)
{
    var sebelum = GC.GetAllocatedBytesForCurrentThread();
    var hasil = kerja();
    var sesudah = GC.GetAllocatedBytesForCurrentThread();
    return sesudah - sebelum;
}
 
var alokasi = HitungAlokasi(() => ProsesData());
Console.WriteLine($"Alokasi: {alokasi} byte");

GC.GetAllocatedBytesForCurrentThread counts the bytes allocated by the code on that thread. Knowing the allocation figure per operation helps you evaluate whether an optimization really has an impact.

Reducing the GC Burden

Patterns that produce many short-lived small objects — like composing strings repeatedly — make the GC work hard. Use StringBuilder for string concatenation inside loops, and choose ArrayPool for buffers that are used frequently.

Performance Improvements with Span, Memory, and stackalloc

Span for Allocation-free Operations

Span<T> lets you access a slice of an array or other memory without allocating a new object:

Span for processing slices
static int JumlahDigit(Span<char> teks)
{
    var total = 0;
    foreach (var c in teks)
    {
        if (char.IsDigit(c)) total++;
    }
    return total;
}
 
var data = "Order#12345".AsSpan();
Console.WriteLine($"Digit: {JumlahDigit(data)}");

data.AsSpan() creates a lightweight view without copying the string. Parsing operations that use many substrings can now be done without a single allocation.

stackalloc for Small Buffers

For small, fixed-size buffers, stackalloc allocates them on the stack — without touching the heap at all:

Buffer stack
Span<byte> buffer = stackalloc byte[256];
buffer[0] = 0x01;
Console.WriteLine($"Byte pertama: {buffer[0]}");

stackalloc byte[256] is allocated on the stack and automatically discarded when the method finishes. Use it for small buffers on very critical code paths — not for large or variable sizes.

JIT Optimization and Tiered Compilation

Tiered Compilation

The .NET runtime uses tiered compilation in two tiers: methods are compiled quickly (tier 0) when first called, then recompiled with full optimization (tier 1) if used frequently. This gives fast startup and peak performance for hot code.

Optimization Hints

For truly critical paths, you can tell the JIT with AggressiveOptimization on the method:

Hint optimasi JIT
[MethodImpl(MethodImplOptions.AggressiveOptimization)]
static int HitungKritis(int a, int b)
{
    return a * b + a / b;
}

AggressiveOptimization pushes the JIT to compile the method directly to the more optimal tier. Use it sparingly — only for methods proven to be hotspots from profiling, not for every method.

Closing

Key takeaways:

  • Measure first with dotnet-counters and dotnet-trace before optimizing.
  • A flamegraph from a trace shows which methods are genuinely slow.
  • GC.GetAllocatedBytesForCurrentThread measures the impact of optimization.
  • Span<T> and stackalloc eliminate allocations on critical paths.
  • Tiered compilation balances fast startup and peak performance.

In the next episode 16 we organize large-scale projects: architecture and design patterns — clean architecture and layered architecture, dependency injection, CQRS and the mediator pattern, and DDD fundamentals with modular solution organization.

Learn C# - Performance Tuning & Diagnostics | Learn C#