Skip to content
academia.sh

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 with AllocsPerOp; 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.

Start typing to search.

↑↓ ↵ Esc navigate · open · close