February 1, 2023·6 min read·engineeringgopgoerrorsgenerics

Go 1.20 gives the compiler workload-specific evidence

Go 1.20 previews profile-guided inlining using CPU profiles. Its library and language changes also make existing abstractions carry information that previously required custom conventions.

A compiler can see that a function is expensive to inline without knowing how often a particular caller reaches it. Go 1.20 gives the compiler another source of evidence. A CPU profile describes where an application actually spends time, so the compiler can spend more of its inlining budget at important call sites. The February 1 release announcement introduces this as a preview of profile-guided optimization, or PGO.

The initial implementation is deliberately specific. Go 1.20 uses profile information to inline functions more aggressively at hot call sites. It does not promise an arbitrary collection of workload-specific transformations. The release notes report improvements of about 3 to 4 percent on a representative set of Go programs with profile-guided inlining enabled. Those results describe the project's benchmarks, rather than a guaranteed improvement for every application.

I like this direction because it can improve a program while leaving its source readable. A developer does not need to duplicate a function's body at selected call sites merely to communicate that those calls matter. The build can use information about execution to make that decision within the compiler's existing responsibilities.

Profiles become build inputs

The compiler accepts CPU profiles in the pprof format. Existing collection mechanisms include runtime/pprof and net/http/pprof, as the release notes explain. The new -pgo build flag accepts the path to a profile, which connects the observation of an executing program to compilation of a subsequent binary.

That connection changes what constitutes the build's input. Source code and dependency versions describe the program, while the profile describes the distribution of work used to guide optimization. Two builds of the same source can therefore make different inlining decisions if they use different profiles. This follows from the purpose of PGO, rather than indicating a change to the language's meaning.

The distinction between correctness and optimization remains essential. A profile says which paths consumed CPU during the observed execution. It does not redefine the program's valid inputs or permit the compiler to ignore a less frequent case. The same application still needs to behave correctly when its workload differs from the profile. The profile gives the optimizer evidence about relative importance.

Go 1.20 also supports -pgo=auto. In this mode, the command looks for default.pgo in the main package's directory and uses it when present. The release notes specify that this preview mode requires a single main package on the command line. Explicit -pgo=off disables profile-guided optimization. These controls make the selected profile an identifiable part of the build configuration.

That explicit input is useful for explaining a binary. A profile should have an understandable relationship to the application it guides. A service dominated by one request family presents a different optimization problem from the same service dominated by another family. The preview does not remove that distinction. It provides a way for the build to express it without embedding workload assumptions in application code.

The release announcement warns that rough edges may preclude production use of the preview. That describes its status on release day. The useful contribution is already concrete, though. Go now has a supported route for supplying runtime CPU evidence to an ordinary build, and its first consumer is the compiler's inliner.

Coverage reaches the executable

Go 1.20's coverage change addresses a related separation between source-level tooling and whole applications. Earlier coverage workflows centered on unit tests. The new go build -cover option instruments a program itself, so integration tests can exercise the executable through its normal inputs and still produce coverage information.

The instrumented program writes coverage profiles to the directory named by GOCOVERDIR. That separates the build step from the execution that supplies the observations. An integration test driver can invoke the program as an external process without becoming a Go unit test in the package being measured. The release notes also introduce runtime/coverage for programs that need to write coverage data while they remain running.

Coverage and CPU profiles answer different questions. Coverage identifies code exercised by the observed executions. A CPU profile helps identify where execution time accumulates. Neither observation alone explains whether an assertion was adequate or whether a request produced the right result. Their value comes from connecting a specific execution to information the developer can use about the program.

For a command-line application, executable coverage makes the actual command invocation a useful unit of observation. Arguments and process behavior can belong to the integration test while coverage still describes the application packages. For a long-running program, the runtime API gives explicit control over when to emit data. The toolchain can support these forms of testing without requiring the application to imitate the unit-test runner.

Errors can retain more than one cause

The library's error changes make a different kind of information easier to preserve. Previously, a single error-wrapping chain was a natural fit for one operation adding context to one cause. Some operations have several failures to report. Discarding all but one loses information, while concatenating messages loses the programmatic relationship to the original errors.

Go 1.20 permits an error to wrap multiple errors through an Unwrap() []error method. The errors.Is and errors.As functions now inspect these multiply wrapped errors. The new errors.Join function constructs an error that wraps a list, and fmt.Errorf accepts multiple %w operands.

This changes how callers can ask about a combined result. A caller that understands one of the original errors can still find it through the standard inspection functions. The code producing the combined error does not have to choose a single cause merely to retain compatibility with error inspection. The representation can preserve multiple causes while exposing one returned error value.

I prefer this addition to a convention where every library invents its own multi-error inspection API. Go's error values are useful because callers can make policy explicit. Extending the common inspection functions allows that policy to remain visible even when the operation has more than one failure to describe.

The release also adds context.WithCancelCause and context.Cause. Cancellation can now carry an error explaining why it happened. That explanation is distinct from the fact that the context has been canceled. Code coordinating concurrent work can propagate a reason without requiring every participant to invent a second communication mechanism for the same event.

Comparable becomes usable with interface keys

The language change to comparable resolves a mismatch that became visible in generic code. Ordinary interface types can be used as map keys, subject to runtime restrictions on the dynamic values they contain. A generic container constrained by comparable previously could not accept every type that ordinary map syntax allowed in that role.

Go 1.20 permits comparable types, including ordinary interfaces, to satisfy a comparable constraint even when they are not strictly comparable. The release notes explicitly retain the possibility of a panic during comparison. Passing an interface type as the type argument does not make every value that can inhabit that interface safe to compare.

The distinction matters for API design. The constraint now permits a useful instantiation, but it does not establish a universal runtime guarantee about interface contents. A generic container using interface keys inherits the relevant comparison obligations of those keys. Removing the earlier restriction makes the generic abstraction more consistent with ordinary Go without changing the underlying behavior of interface comparisons.

I judge these changes by how much special explanation they remove from everyday code. Profile-guided inlining lets the compiler use workload evidence. Multiple-error wrapping lets standard inspection retain several causes. The comparable change lets generic APIs express a case already familiar from maps, while preserving the runtime qualification that makes the case precise.

The same release also repairs the cost of building the language's newer abstractions. Its notes report build-speed improvements of up to 10 percent after regressions associated with generics work in Go 1.18 and Go 1.19. Standard library archives now use the build cache instead of arriving precompiled in the distribution. These are changes to the work performed by the toolchain, and they matter even to programs whose source adopts none of the new APIs.

The practical effect is a broader definition of what Go tooling can understand about an application. Execution data can inform compilation and integration coverage can describe a complete process. Both additions keep the connection to ordinary Go builds explicit, which makes their inputs and outputs available for inspection alongside the source.