Skip to main content

Performance and Profiling

For developers with .NET (C#) experience, this guide explains pprof, trace, benchmarks, and reducing allocations. In C# you pick among external tools such as BenchmarkDotNet, PerfView, dotnet-trace, dotnet-counters, and dotMemory. In Go, the profiler (pprof), the execution tracer (go tool trace), benchmarks (testing.B), and runtime metrics (runtime/metrics) are included in the toolchain and the standard library from the start. This guide shows how to practice "measure before you optimize" with Go's tools.

1. What This Document Covers​

  • Enabling and reading net/http/pprof
  • Heap and allocation analysis
  • sync.Pool and preallocation
  • GOMAXPROCS in containers

Enabling and reading net/http/pprof​

Blank-importing net/http/pprof registers endpoints under /debug/pprof/ on http.DefaultServeMux. The main profiles you can obtain are:

EndpointContent
/debug/pprof/profile?seconds=30CPU profile (sampled for the given number of seconds)
/debug/pprof/heapHeap (memory usage and allocations)
/debug/pprof/allocsAll past allocations
/debug/pprof/goroutineStacks of all goroutines (?debug=2 for details)
/debug/pprof/blockTime blocked on synchronization (must be enabled)
/debug/pprof/mutexMutex contention (must be enabled)
/debug/pprof/trace?seconds=5Execution trace (open with go tool trace)

You read the collected profiles with go tool pprof.

# CPU: sample for 30 seconds and open in the browser
go tool pprof -http=:8081 "http://localhost:6060/debug/pprof/profile?seconds=30"

# Heap: the default is memory in use (inuse_space)
go tool pprof -http=:8081 http://localhost:6060/debug/pprof/heap

In the web UI you can switch among Top (flat and cum per function), Graph, Flame Graph, Source, and other views.

  • flat: the amount consumed by the function itself.
  • cum: the cumulative amount including the functions it calls.

First look for "wide" (high-consumption) spots in the Flame Graph or Top, then inspect them in Source.

Heap and allocation analysis​

Go's memory performance depends heavily on the number of allocations and on the size of the heap the GC has to scan. Look at it from these angles:

  • Heap profile sample types: inuse_space / inuse_objects (what is in use now) are for investigating leaks and resident memory, and alloc_space / alloc_objects (cumulative so far) are for investigating GC load and the volume of temporary objects. Switch with go tool pprof -sample_index=alloc_space ....
  • Diffing two profiles: -base (or -diff_base) shows the difference between heap profiles taken at two points in time.
  • Allocations in benchmarks: go test -bench . -benchmem reports bytes per operation (B/op) and allocations per operation (allocs/op).
  • Escape analysis: go build -gcflags=-m makes the compiler report which variables escape to the heap. Whether a value fits on the stack or is allocated on the heap is decided by compiler analysis, not by the type being a class or a struct as in C#.
  • Tuning the GC: GOGC (default 100) is the percentage of heap growth that triggers a GC. GOMEMLIMIT (Go 1.19 and later) gives a soft memory limit. When a container memory limit is fixed, setting GOMEMLIMIT slightly below it makes OOM kills easier to avoid.

sync.Pool and preallocation​

The three main ways to reduce allocations are:

  • Preallocation: when you know the capacity, allocate up front with make([]T, 0, n) for slices, make(map[K]V, n) for maps, and Grow(n) for strings.Builder. This avoids the reallocation and copying that happen as elements are added.
  • sync.Pool: pools short-lived, reusable objects (such as large buffers). It is close to .NET's ArrayPool<T> and ObjectPool<T>. However, objects in the pool can be released when the GC runs, so reuse is never guaranteed.
  • Avoiding needless copies and conversions: converting between string and []byte copies data. fmt.Sprintf and reflection are convenient, but strconv and strings.Builder allocate less in hot paths.

GOMAXPROCS in containers​

GOMAXPROCS is the upper bound on the number of OS threads (Ps) that can execute Go code at once, and defaults to the number of logical CPUs. Where the .NET thread pool automatically takes Environment.ProcessorCount and container CPU limits into account, Go differs as follows:

  • Go 1.24 and earlier: the default ignores the container CPU limit (cgroup CPU quota) and uses the host's logical CPU count. For example, even with a CPU limit of 2, on a 64-core host GOMAXPROCS becomes 64, which can cause worse latency through CPU throttling. As countermeasures, use Uber's go.uber.org/automaxprocs or set the GOMAXPROCS environment variable explicitly.
  • Go 1.25 and later: on Linux, the default GOMAXPROCS takes the cgroup CPU bandwidth limit into account and follows changes to the limit. If you set GOMAXPROCS explicitly through the environment variable or runtime.GOMAXPROCS, that value takes precedence.

Check the release notes for the behavior of the version you use.

2. .NET Mapping​

.NET (C#)Go
dotnet-trace / dotnet-countersgo tool trace / runtime/metrics
PerfView / dotMemorypprof (CPU / heap / goroutine)
BenchmarkDotNettesting.B / benchstat

Additional correspondences that complement the table above:

.NET (C#)Go
[Benchmark] / [MemoryDiagnoser]func BenchmarkXxx(b *testing.B) / go test -bench . -benchmem
BenchmarkDotNet statistics (mean, error, etc.)Run several times with -count=10 and compare with benchstat
ArrayPool<T> / ObjectPool<T>sync.Pool
Avoiding allocations with Span<T> / stackallocSlices (subslicing does not allocate), escape analysis
GC.GetTotalAllocatedBytes() / GC.CollectionCount()runtime/metrics (/gc/heap/allocs:bytes, etc.), runtime.ReadMemStats
DOTNET_gcServer / GCHeapHardLimitGOGC / GOMEMLIMIT
Environment.ProcessorCount / thread poolGOMAXPROCS / the goroutine scheduler
dotnet-dump dumpheap / thread listing/debug/pprof/heap / /debug/pprof/goroutine?debug=2
PGO (Dynamic PGO)PGO (put default.pgo in the main package, Go 1.21 and later)

3. Common Pitfalls for C# Developers​

Profile; do not guess​

Guesses about where the time goes are often wrong, in C# as well. First check the top CPU and allocation consumers with pprof, then change the code. Compare before and after with benchmarks taken under the same conditions.

A single benchmark run is not reliable​

go test -bench results fluctuate with machine load and CPU frequency. It does not process statistics automatically like BenchmarkDotNet, so run it several times with something like -count=10 and compare before and after with benchstat. benchstat also tells you whether a difference is statistically significant.

go test -bench . -benchmem -count=10 > old.txt
# change the code
go test -bench . -benchmem -count=10 > new.txt
benchstat old.txt new.txt

benchstat is not included by default; install it with go install golang.org/x/perf/cmd/benchstat@latest.

The benchmark target can be optimized away​

A loop whose result is unused may be removed entirely by compiler optimization, or folded into a constant. From Go 1.24, for b.Loop() { ... } keeps calls in the loop alive, which mitigates this problem and the hand-written b.N loop. On older versions, use for i := 0; i < b.N; i++ and assign the result to a package-level variable so it is not eliminated.

Do not expose the pprof endpoints​

net/http/pprof exposes information close to memory contents and internal structure, so never publish it on the internet. Serve it on a separate port bound only to localhost, apart from the application's main port, and reach it with something like kubectl port-forward when needed. The blank import registers on http.DefaultServeMux, so if the application itself also uses DefaultServeMux, the endpoints are exposed unintentionally.

CPU profiles are samples and do not show waiting​

A CPU profile samples time spent using the CPU, so time stopped on lock waits or I/O waits does not show up. When it is slow but the CPU is idle, use block / mutex profiles or go tool trace. Block and mutex profiles are disabled by default; enable them with runtime.SetBlockProfileRate and runtime.SetMutexProfileFraction.

Do not decide between values and pointers by size alone​

In C#, the type decides: struct is passed by value and class by reference. In Go you choose either one yourself, so making everything a pointer on the assumption that "pointers must be faster" can increase heap allocations as a result of escape analysis and raise GC load. Small structs are often faster kept as values, so decide by measuring.

A subslice keeps a huge array alive​

Taking a subslice such as small := big[:10] keeps the entire underlying array from being garbage collected. If you pull out a small part of a large buffer and hold it for a long time, copy it into a new slice with slices.Clone or copy.

sync.Pool is an optimization tool, not a prerequisite for correctness​

An object taken from the pool still holds the contents of its previous use, so always initialize it, for example with Reset, before using it. After Put, do not keep using references to that object. The pool can also become empty at any time. Always confirm the effect with a benchmark.

The GC is not generational​

The .NET GC is generational and compacts. Go's GC is a concurrent mark-and-sweep collector that is neither generational nor compacting. As a result, the assumption that "short-lived objects are cheap" holds less well, and the number of allocations and the size of the live heap translate directly into GC load.

Do not worry too much about small-function overhead​

The cost of things like defer and calls through interfaces is small with the current compiler and is often not a problem. Touch only the spots you have confirmed to be hot paths.

4. Code Examples​

Example 1: Enabling pprof​

In C#, you attach dotnet-trace collect or dotnet-counters monitor from the outside. In Go, you add a few lines to the application and fetch profiles over HTTP.

package main

import (
"log"
"net/http"
_ "net/http/pprof" // registers /debug/pprof/ on DefaultServeMux
"time"
)

func main() {
// start a separate server for pprof only, bound to localhost
go func() {
log.Println(http.ListenAndServe("localhost:6060", nil)) // nil = DefaultServeMux
}()

// the application itself (uses its own mux, not DefaultServeMux)
mux := http.NewServeMux()
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("hello"))
})
srv := &http.Server{
Addr: ":8080",
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
}
log.Fatal(srv.ListenAndServe())
}

After starting it, collect and analyze from another terminal as follows:

go tool pprof -http=:8081 "http://localhost:6060/debug/pprof/profile?seconds=30"
go tool pprof -http=:8081 http://localhost:6060/debug/pprof/heap

# execution trace
curl -o trace.out "http://localhost:6060/debug/pprof/trace?seconds=5"
go tool trace trace.out

go tool trace shows goroutine scheduling, GC, blocking, network waits, and more on a timeline, which suits investigating why something is slow even though the CPU is idle. It serves a purpose similar to the combination of dotnet-trace and PerfView in .NET.

To use block and mutex profiles, enable them at startup.

runtime.SetBlockProfileRate(1) // record every blocking event (mind the overhead)
runtime.SetMutexProfileFraction(5) // record 1 in 5 contention events

Example 2: Benchmarks and reducing allocations​

C# (BenchmarkDotNet):

[MemoryDiagnoser]
public class BuildBenchmarks
{
[Benchmark(Baseline = true)]
public List<string> Naive()
{
var list = new List<string>();
for (var i = 0; i < 1000; i++) list.Add(i.ToString());
return list;
}

[Benchmark]
public List<string> Prealloc()
{
var list = new List<string>(1000);
for (var i = 0; i < 1000; i++) list.Add(i.ToString());
return list;
}
}

Go (build.go):

package perf

import "strconv"

func buildNaive(n int) []string {
var out []string
for i := 0; i < n; i++ {
out = append(out, strconv.Itoa(i))
}
return out
}

func buildPrealloc(n int) []string {
out := make([]string, 0, n) // allocate capacity up front to avoid reallocation and copying
for i := 0; i < n; i++ {
out = append(out, strconv.Itoa(i))
}
return out
}

Go (build_test.go, using b.Loop from Go 1.24):

package perf

import "testing"

func BenchmarkNaive(b *testing.B) {
for b.Loop() {
buildNaive(1000)
}
}

func BenchmarkPrealloc(b *testing.B) {
for b.Loop() {
buildPrealloc(1000)
}
}

Run and compare as follows:

go test -bench . -benchmem -count=10 | tee bench.txt
benchstat bench.txt

The output includes the time per operation (ns/op), bytes (B/op), and allocations (allocs/op). On Go 1.23 and earlier, replace for b.Loop() with for i := 0; i < b.N; i++. To collect CPU and memory profiles, add -cpuprofile cpu.out -memprofile mem.out and open them with go tool pprof cpu.out.

Example 3: Reusing temporary buffers with sync.Pool​

The usage is close to ArrayPool<byte>.Shared.Rent / Return in .NET.

package main

import (
"bytes"
"fmt"
"sync"
)

var bufPool = sync.Pool{
New: func() any { return new(bytes.Buffer) },
}

func render(name string) string {
buf := bufPool.Get().(*bytes.Buffer)
buf.Reset() // the previous contents remain, so always initialize
defer bufPool.Put(buf)

buf.WriteString("hello, ")
buf.WriteString(name)
return buf.String() // String() returns a copy, so it is safe after Put
}

func main() {
fmt.Println(render("gopher"))
}

If you use a reference into a buffer you returned to the pool (such as the result of buf.Bytes()) after Put, it collides with reuse by another goroutine. Either return a copy as buf.String() does, or call Put after the caller has finished using it.

Example 4: Reading runtime state with runtime/metrics​

You can read, from the program, values corresponding to the GC and thread information you watched with dotnet-counters.

package main

import (
"fmt"
"runtime/metrics"
)

func main() {
samples := []metrics.Sample{
{Name: "/gc/heap/allocs:bytes"},
{Name: "/sched/goroutines:goroutines"},
}
metrics.Read(samples)

for _, s := range samples {
switch s.Value.Kind() {
case metrics.KindUint64:
fmt.Printf("%s = %d\n", s.Name, s.Value.Uint64())
case metrics.KindFloat64:
fmt.Printf("%s = %f\n", s.Name, s.Value.Float64())
default:
fmt.Printf("%s: (unsupported kind)\n", s.Name)
}
}

// the list of available metrics is available from metrics.All()
fmt.Println("available metrics:", len(metrics.All()))
}

When you use Prometheus or OpenTelemetry, collectors and packages that expose runtime metrics are available. First check the available names and descriptions with metrics.All().

Example 5: GOMAXPROCS and the memory limit​

C# (runtimeconfig.json example):

{
"configProperties": {
"System.GC.HeapHardLimit": 1073741824
}
}

In Go, you tune with environment variables.

# a guideline for the memory limit (soft limit); set slightly below the container limit
export GOMEMLIMIT=900MiB

# in containers on Go 1.24 and earlier, set it explicitly to match the CPU limit
export GOMAXPROCS=2

To check from the program:

package main

import (
"fmt"
"runtime"
)

func main() {
fmt.Println("NumCPU:", runtime.NumCPU())
fmt.Println("GOMAXPROCS:", runtime.GOMAXPROCS(0)) // 0 only returns the current value
}

On Go 1.24 and earlier, if you want the CPU limit reflected automatically without setting the environment variable, you can load go.uber.org/automaxprocs with import _ "go.uber.org/automaxprocs". From Go 1.25, the runtime itself takes the cgroup CPU bandwidth limit into account.

Example 6: Checking escape analysis​

package main

import "fmt"

type point struct{ x, y int }

func newPoint() *point {
p := point{1, 2}
return &p // returns a pointer to the caller, so it escapes to the heap
}

func sum() int {
p := point{1, 2} // stays within the function, so it can live on the stack
return p.x + p.y
}

func main() {
fmt.Println(newPoint().x, sum())
}
go build -gcflags=-m

Messages such as moved to heap: p show which variables are allocated on the heap. The details of the output vary by Go version.

5. Summary​

  • Go provides a profiler (pprof), an execution tracer (go tool trace), benchmarks (testing.B), and runtime metrics (runtime/metrics) in the toolchain and the standard library.
  • Enable net/http/pprof on a separate, localhost-only port, and read CPU, heap, and goroutine profiles with go tool pprof. Never expose it to the internet.
  • CPU profiles do not show waiting time, so combine them with block / mutex profiles and go tool trace.
  • For benchmarks, look at allocations with -benchmem, run several times with -count, and compare before and after with benchstat.
  • Reducing allocations centers on preallocation, sync.Pool, and avoiding needless copies and conversions. Check escape analysis with -gcflags=-m.
  • Go's GC is not generational, so the number of allocations and the size of the live heap become the load. Tune with GOGC and GOMEMLIMIT.
  • In containers, on Go 1.24 and earlier you must set GOMAXPROCS to match the CPU limit. From Go 1.25 the runtime takes the cgroup limit into account.
  • Start from measurement, not guesses, and optimize only the spots confirmed to be hot paths.

References​