1. CPU & Memory Profiling with net/http/pprof
Importing net/http/pprof for its side effects registers a set of
profiling endpoints on the default mux — genuinely one import line to add real-time
profiling to a running service.
import (
"net/http"
_ "net/http/pprof" // registers /debug/pprof/* on the default mux
)
func main() {
go func() {
log.Println(http.ListenAndServe("localhost:6060", nil)) // separate port, not exposed publicly
}()
// ... the real application server, as before
}
# a 30-second CPU profile, while generating real load against the app
go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30
# a snapshot of current heap allocations
go tool pprof http://localhost:6060/debug/pprof/heap
pprof's debug endpoints should never be exposed on the same port as
public traffic in production — running them on a separate, internal-only port (as
above) is standard practice.
2. Reading a Flame Graph
go tool pprof -http=:8081 http://localhost:6060/debug/pprof/profile?seconds=30
This opens an interactive web UI. A flame graph stacks function calls vertically — each frame's width represents how much total time was spent in that function (including everything it called), not how long any single call took. A wide frame near the top of the stack is exactly where to look first: it means real time is genuinely being spent there, not just passed through to something below it.
A narrow frame, even one that looks alarming by name, isn't worth chasing — the
graph's width is the actual signal; everything else (color, position) is
incidental. This is the direct, visual answer to what Week 9's ns/op
and allocs/op numbers could only hint at in aggregate.
3. Reducing Allocations
A heap profile commonly surfaces the same handful of causes, each traceable back to Week 5's escape analysis.
// before — allocates a new slice on every call
func FormatTasks(tasks []Task) []string {
var result []string // nil slice, grows via repeated reallocation
for _, t := range tasks {
result = append(result, t.Title)
}
return result
}
// after — one allocation, sized up front
func FormatTasks(tasks []Task) []string {
result := make([]string, 0, len(tasks)) // capacity known in advance
for _, t := range tasks {
result = append(result, t.Title)
}
return result
}
Pre-sizing with make([]T, 0, n) when the final length is known (or
closely estimable) avoids the repeated reallocate-and-copy cycle from Week 4's
append behavior — a small, mechanical change, but one that shows up
directly and measurably in a benchmark's allocs/op.
4. Avoiding Premature Optimization
Every technique above is only worth applying to a bottleneck a profile actually identified — optimizing code the profile shows spending 0.1% of total time is wasted effort that also makes the code harder to read, for no measurable benefit.
- Profile first, always — intuition about where time goes is wrong more often than not, even for experienced engineers.
- Optimize the widest frame in the flame graph, not the function that merely looks inefficient by inspection.
- Re-profile after each change — confirm the fix actually moved the bottleneck rather than assuming it did.
- Stop once the bottleneck that actually mattered for real usage is gone — chasing diminishing returns past that point is its own form of waste.
5. Hands-on Exercise
Find and fix a real bottleneck
Profile the task API under load and fix whatever the profile actually points to.
Requirements:
- Enable
net/http/pprofon a separate internal port in the task API. - Generate real load against a data-heavy endpoint (a simple load-testing loop with many concurrent requests, or a tool like
hey/wrk, is enough) while capturing a 30-second CPU profile. - Open the flame graph and identify the widest frame that's part of your own code (not the standard library or a driver) — this is your actual bottleneck.
- Fix it, following one of this week's techniques, and re-run the exact same load test and profile capture.
- Report before/after numbers — either from the flame graph directly, or from a Week 9-style benchmark targeting the specific function you changed.
If the flame graph's widest frames are all inside the standard library or a database driver with nothing of yours visible near the top, that's a real, valid finding too — it means the bottleneck (for this particular load pattern) isn't in your application code at all, and chasing an optimization in your own code wouldn't move the needle; the honest conclusion is to say so rather than optimizing something the profile didn't actually implicate.
6. Knowledge Check
Four quick questions. Expand each to check your answer.
Q1
Why is it standard practice to run pprof's debug endpoints on a separate, internal-only port rather than the same port serving public traffic?
Why is it standard practice to run pprof's debug endpoints on a separate, internal-only port rather than the same port serving public traffic?
The /debug/pprof/* endpoints can reveal internal implementation details and, if left open, let anyone trigger an expensive profiling operation (a CPU profile, for instance, actively affects the process while it runs) — keeping them on an internal-only port prevents public exposure of both the information and that capability.
Q2
In a flame graph, what does a frame's width actually represent, and why is that the number worth paying attention to?
In a flame graph, what does a frame's width actually represent, and why is that the number worth paying attention to?
Width represents the total time spent in that function, including everything it called beneath it — it's a direct, visual measure of where real time is going, rather than a guess based on how a function looks or how many lines it has. A wide frame is genuinely worth optimizing; a narrow one, no matter how inefficient it looks by inspection, isn't contributing meaningfully to overall time.
Q3
Why does pre-sizing a slice with make([]T, 0, n) reduce allocations compared to starting from a nil slice and appending repeatedly?
Why does pre-sizing a slice with make([]T, 0, n) reduce allocations compared to starting from a nil slice and appending repeatedly?
A nil slice has zero capacity, so early append calls repeatedly hit Week 4's capacity-exceeded case — allocating a new, larger backing array and copying existing elements over, again and again as the slice grows. Pre-sizing with the final length known in advance means the backing array is allocated once, up front, and every subsequent append just writes into existing capacity with no further reallocation.
Q4
Why is optimizing a function the profile shows spending 0.1% of total time considered wasted effort, even if the optimization itself is technically correct?
Why is optimizing a function the profile shows spending 0.1% of total time considered wasted effort, even if the optimization itself is technically correct?
Even a large relative improvement to something contributing only 0.1% of total time produces an immeasurably small absolute improvement to the program's real performance, while still costing real engineering time and often making the code harder to read for future maintainers. That effort would produce a measurable improvement if redirected at whatever frame the profile actually shows as wide — which is the entire reason to profile before optimizing, not after guessing.