Lesson 07 / 16
Profiling
A profile is a run fact and in this lesson it is read as a number: how many values the same function's two call forms allocate. Limiting measurement: a profile sees only the path that runs — the values a branch that never executes would have allocated never appear in the profile, even if they sit written in the source.
Published
In this lesson
- Measures and compares the allocation counts of the same function's two call forms with AllocsPerOp
- Demonstrates by running that the values a branch that never executes would allocate never appear in the profile
- Distinguishes that a profile answers "where allocation happens" but not "why something is slow"
Contents
The previous lesson showed that coverage ratio counts which lines ran, not whether they were tested correctly. This lesson asks the same question of memory: where a running program allocates cannot be seen by reading the source, but once a run finishes, a profile states it as a number. A profile is a run fact just like coverage — someone reading the source cannot see how many values a given function allocated; they can only run it and ask.
This shared shape is not a coincidence: every lesson since the course opened has used the same skeleton — first classify whether a fact stands in the source, in a tool, or in a run, then measure the limit specific to that class. The profile’s place here is clear: like coverage, it is learned only by running. But unlike coverage, what the profile reads is not a true/false decision but a quantity — how that difference carries into the measurement is this lesson’s subject.
The Concurrency course (M09/K03) already established profiling and a measurement discipline: a measurement cannot be compared without stating what it counts. This lesson carries that discipline forward with Go’s own tools; that course’s axis (the overlapping step) is not used here. The question here is narrow: what does a profile tell you — where — and what does it not tell you?
Three Profile Types, One Number
The catalog defines this topic with three profile types: a CPU profile, a memory profile, and a block profile. All three can be produced with the language’s own measurement package, but the three do not carry the same kind of information. A CPU profile samples where the program spends its time by function — by its nature this measurement is duration, and duration is not written, printed, or measured in any lesson of this course. A block profile samples similarly: it measures how long a goroutine waits on a lock or a channel, again duration-based.
A memory profile answers a different question: how many values were allocated, not how much time passed. This is the only profile type this course can count — its number stays constant from one run to the next, unlike duration, which depends on the machine and the load at that moment. This lesson therefore focuses only on the memory profile, and on a single summary of it — the allocation count per operation. CPU and block profiles continue to exist; they are not measured here, because what they measure is duration.
Profile: How Many Values, Which Call
In this lesson the word “profile” is reduced to a single number: how many values a call allocates per operation. This number is read from inside the program with the language’s own measurement package — memory bytes, duration, and stack traces are never printed.
// profile.go — the two call forms of the same function, showing how many values the profile allocates
package main
import "fmt"
type Record struct {
Name string
Amount int
}
// format turns a record into a single line. When verbose is requested it
// builds an extra field slice; when it is not, that body never runs.
func format(r Record, verbose bool) string {
line := fmt.Sprintf("%s:%d", r.Name, r.Amount)
if verbose {
fields := make([]string, 0, 3)
fields = append(fields, fmt.Sprintf("name=%s", r.Name))
fields = append(fields, fmt.Sprintf("amount=%d", r.Amount))
fields = append(fields, fmt.Sprintf("length=%d", len(r.Name)))
for _, f := range fields {
line += " " + f
}
}
return line
}
PM1. format is a single function but carries two separate paths: when verbose
is false, only the first line runs; when it is true, three separate formatting calls
and a concatenation loop are added. Someone reading the source sees that both paths
exist but cannot see how much each allocates — that is information no reading can
supply, only a run can.
Measuring the Two Call Forms
format itself measures nothing — it only produces a result. Measurement stands in a
separate layer: the language’s own benchmark function (func(*testing.B))
repeats the call b.N times; the measurement package counts how many values moved to
the heap during these repetitions and yields an average number per operation. The file
below defines two separate benchmark functions — one calls
verbose=false, the other verbose=true — and runs each of them five times.
// measurement.go — two calls of the same function, allocation count across five runs
package main
import (
"fmt"
"testing"
)
var sampleRecord = Record{Name: "bolt", Amount: 12}
func profilePlain(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = format(sampleRecord, false)
}
}
func profileVerbose(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = format(sampleRecord, true)
}
}
func fiveRuns(f func(*testing.B)) (int64, int) {
values := map[int64]bool{}
var first int64
for i := 0; i < 5; i++ {
r := testing.Benchmark(f)
values[r.AllocsPerOp()] = true
if i == 0 {
first = r.AllocsPerOp()
}
}
return first, len(values)
}
func main() {
fmt.Println("-- profile: how many values were allocated --")
plain, count1 := fiveRuns(profilePlain)
verbose, count2 := fiveRuns(profileVerbose)
fmt.Printf("%-24s allocs=%-3d distinct values across five runs=%d\n", "plain call", plain, count1)
fmt.Printf("%-24s allocs=%-3d distinct values across five runs=%d\n", "verbose call", verbose, count2)
fmt.Println("difference:", verbose-plain, "allocs, belonging only to the verbose path")
}
-- profile: how many values were allocated -- plain call allocs=2 distinct values across five runs=1 verbose call allocs=9 distinct values across five runs=1 difference: 7 allocs, belonging only to the verbose path
PM2. fiveRuns applies the same pattern that holds in every performance lesson of
this course: the same measurement is repeated five times and the number of distinct
values is counted. Duration is never read here; only AllocsPerOp is taken from the
result testing.Benchmark returns. A single value appearing in all five runs shows
that this number is constant from run to run — unlike duration, it does not depend on
the environment.
values is kept as a map[int64]bool, not a single counter; the reason is to
prove, not assume, whether the five runs give the same value. If the five runs
give the same number, values carries a single key and len(values) comes out as
one; if a different number showed up, a second key would be added and this count
would rise to two — exactly like the duration measurement in the course’s shared core
coming out different in two of eight runs and being excluded from the frame. For the
allocation count this never happens; both lines in the output below giving
distinct values across five runs=1 confirms this claim.
Profiling Inside a Function, Profiling a Service
The measurement above is a scaled-down form of a profiling tool. Profiling a service running in production measures by sampling how much memory which function allocates under real traffic — the service runs for hours, the profiling tool collects allocations throughout that time, and in the end it yields a distribution. This distribution reflects real user input but does not come out the same from run to run: if a day brings more verbose records, the profile changes too.
The method used in this lesson does something different: it runs a single function with a single input five times and looks for a constant number. This does not stand in for profiling a service — it is a small, isolated, and reproducible measurement. The difference matters: a production profile answers the question “what is actually happening,” and its answer changes with traffic; the measurement here answers the question “what does this function do with this particular input,” and its answer is constant. Both carry the word profile, both are run facts, but one is a repeatable experiment and the other is a continuous observation.
This narrowing has a cost: the measurement of an isolated function does not show that
function’s share inside a real service. format might take one percent of a request’s
time, or ninety percent — this lesson never says, because the measurement severs the
function from its context. A production profile’s real strength is exactly here: it
shows which function makes up how much of the total allocation while the whole
program runs. The method built in this lesson is a model of examining a single tile
inside that larger picture with a magnifying glass — not the larger picture itself.
The Difference Between Profiling and Benchmarking
A later lesson in this course also uses the same measurement tool
(testing.Benchmark, AllocsPerOp) but asks a different question: which of two
implementations of the same work allocates less. This lesson does not ask that
question — here there is a single function, format, and its two call forms separate
its two branches. Benchmarking compares two separate implementations;
profiling shows where a single implementation allocates. The tool is the same, the
question is different: one asks “which is better,” the other asks “where.” This
distinction matters because the same number (AllocsPerOp) shows up in both contexts,
and which question it answers can only be told from how the measurement is set up.
Reading the Numbers
The plain call allocates two values: the fmt.Sprintf("%s:%d", ...) call itself
produces a string, and in doing so moves at least one intermediate value to the heap.
The verbose call allocates nine values. The seven allocations in between belong
entirely to the if verbose block: three separate fmt.Sprintf calls, one slice
allocation, and part of the concatenations inside the loop. In the source, both paths
sit side by side in the same function body; without a profile, no one can tell which
one allocates how much.
This makes clear the question a profile answers: where allocation happens. Which call form allocates more values is read directly from the measurement. But a profile does not say why something is slow — a question never measured in this lesson, because answering it requires looking at duration, and duration is not written in any lesson of this course. A high allocation count does not mean the program runs slowly; it only means that path requests more memory. The two usually move together, but the profile itself does not separate them — the person reading the allocation count does.
The plain call’s two allocations are themselves a lesson in reading. The source shows
a single fmt.Sprintf call, but this one line hides more steps than it appears to:
Sprintf produces a new string, and while doing so, it also wraps an integer like
r.Amount into an interface value as it passes it to a variadic parameter (...any).
Even though the source shows no pointer, no new, no append, exactly these two
steps — string production and wrapping into an interface — lead to two separate
allocations. Which value gets wrapped this way and when is a consequence of the
language’s own calling convention and a decision made by escape analysis. That
decision itself is the subject of the next lesson; this lesson only counts how many
values were allocated, not which decision led to it.
The course’s opening split eleven facts into source, tool, and run; this lesson uses only the run out of those three classes, because a profile’s number can only be learned by running.
Limiting Measurement: The Path That Does Not Run Is Absent From The Profile
In the table above, the plain row shows two values — none of the three
Sprintf calls, the slice allocation, or the concatenation loop enter this row. The
reason is not that these values are small or that the compiler optimizes them away:
when verbose is passed false, the if block’s body never runs at all. The
source holds code corresponding to seven allocations, but profilePlain never
triggers it; a profile cannot see anything that never triggered.
PM3. This is the limit profiling shares with coverage ratio (the previous
lesson): both measure only the path that runs. Coverage ratio did not count an
untested line; a profile does not count an untriggered branch’s allocation. Before
concluding that a branch “allocates little,” one must be certain that branch was
actually triggered — otherwise what is measured is not that branch’s behavior, but
the fact that it never ran. The parallel in form is also worth noting: coverage ratio
ignored an untested line by counting it as zero, and a profile ignores an untriggered
branch’s allocation by counting it as zero too — both write “absent” and
“unmeasured” with the same symbol (0), and it is up to the reader to tell the two
apart.
This limit works in two directions in practice. On one hand, a rarely triggered
branch (say, a code path entered only on an error) may never show up in daily
profiles and go unnoticed. On the other hand, when comparing two call forms, one must
be sure the input used actually triggers both branches — otherwise the comparison is
looking at the wrong path. In the measurement above, profilePlain calls only with
verbose=false, and profileVerbose calls only with verbose=true — this choice is
deliberate, because the comparison’s purpose is exactly to see these two branches
separately.
The same limit holds inside a single call form too. format was called with only two
cases: verbose either entirely true or entirely false. A real set of callers might
call this function with a mixed distribution — a tenth of the records verbose, the
rest not — and the average allocation count that distribution produces would equal
neither of the two extremes above. A profile describes the input it was measured
with; a different input distribution’s profile produces a different table. This is
why a profile number cannot be carried without stating which input produced it — just
as the previous lesson’s coverage ratio cannot be carried without stating which test
set produced it.
What The Number Must Not Decide
A two-line table alone does not propose an action. The verbose call allocating
seven more values does not mean the verbose branch should be removed — that branch
probably serves a real need, and formatting three separate fields individually may
genuinely be used somewhere else. The profile number only shows where allocation
happens; deciding whether that allocation is necessary requires knowing which caller
uses which branch at what frequency, something the number does not address. This is a
warning the next lesson will repeat too: a number getting smaller is not
automatically an improvement, because the number alone does not say what it got
smaller in exchange for.
This repeats a pattern that has held since the course’s first lesson: the information a tool states is limited to what the tool measures. The coverage tool only stated which line ran, not whether it was correct; the profiling tool only states which call allocated how many values, not whether that allocation is needed. In both cases, a reading that goes past the limit does not come from the tool itself, but from the reader forgetting the tool’s limit.
Summary
- A profile is a run fact: how many values a given call allocates is learned only by running it, not by reading the source.
- The same function’s two call forms (
plain,verbose) were measured withAllocsPerOp; the verbose call allocated seven more values and this difference did not change across five runs. - A profile answers where allocation happens, not why something is slow — duration is not measured in any lesson of this course.
- The values a branch that never runs would have allocated never appear in a profile; code sitting in the source does not change this, only that path actually running does.
- This limit matches the previous lesson’s coverage ratio limit in shape: both measure only the path that runs.
Next Step
A profile states where a value was allocated after the run. But the decision of whether a value will sit on the stack or the heap is made long before the run, while the source is merely being compiled — this lesson saw the result of that decision as a number, never the decision itself. The next lesson looks at that decision’s mechanism: how the compiler reports this decision, which patterns move a value to the heap, and how a small change to the same function changes the decision.
To keep your progress and take notes, Log in
My notes
Log in to take notes.