August 16, 2021·6 min read·engineeringgocompilermodules

Go 1.17 changes function calls and dependency loading

Register-based calls reduce execution overhead on selected amd64 platforms. Pruned module graphs trade a longer go.mod file for less work loading irrelevant dependency metadata.

Go 1.17 makes a dependency file longer so the go command can do less work. A module declaring the new version records explicit requirements for modules providing transitively imported packages. That information lets the command avoid loading parts of dependency graphs that the requested operation does not need. The additional declarations have an operational purpose, even though their size may initially look like a regression.

The August 16 release announcement pairs this module change with a new register-based calling convention. Both improve work that programmers perform repeatedly. One changes how the toolchain loads dependency information. The other changes how compiled functions exchange arguments and results. Their compatibility boundaries differ, so they deserve separate explanations.

I like improvements that make ordinary Go programs cheaper without requiring new layers of application abstraction. These two changes have that property, although module authors need to understand the new metadata. The value is in the work removed and the conditions under which that removal is valid.

More explicit requirements enable pruning

The Go 1.17 release notes tie module graph pruning to the go directive. For a module specifying Go 1.17 or higher, the graph includes immediate dependencies of other Go 1.17 modules without automatically retaining their full transitive dependency graphs. The version in go.mod therefore affects dependency loading, not just the language syntax a package can use.

Pruning needs enough information to preserve the dependencies relevant to imported packages. A Go 1.17 module consequently records an explicit require directive for every module supplying a transitively imported package. Previously, go.mod typically concentrated on requirements for directly imported packages. The expanded file supplies information that would otherwise require exploring further dependency metadata.

This distinction is easier to understand by separating packages from modules. A program imports packages, while module requirements describe the versioned units that provide them. A transitive package import can make another module relevant even when the main module has no source file importing that module's package directly. The new file records those relevant requirements explicitly.

The release notes say the command can then avoid reading, or downloading, dependency go.mod files unless the requested operation needs them. This is lazy loading. Pruning reduces what belongs in the graph, while lazy loading reduces what the command must inspect to complete a particular request. The expanded main module file makes the latter possible without dropping necessary version information.

Indirect requirements receive their own require block. This preserves a visible distinction between modules directly imported by the main module and requirements recorded to support its transitive imports. A larger file is therefore expected after adopting the new module version. Removing those lines merely because they are indirect would discard information the new loading behavior uses. The indirect marker describes why a requirement is present. It does not mean the requirement has no effect on dependency resolution.

The migration preserves selected versions

The release provides an explicit conversion command. It updates the module's go version and the corresponding requirement information without changing the selected dependency versions, according to the release notes.

go mod tidy -go=1.17

This is a metadata migration with a defined version-selection goal. It is useful to distinguish it from deliberately upgrading dependencies. A review can then examine the additional indirect requirements as part of adopting the loading model instead of interpreting every new line as a newly selected dependency version.

Compatibility also determines which checksums tidy retains. By default, tidy verifies that dependency versions relevant to the main module match what the previous Go release would use. For a module declaring Go 1.17, that means Go 1.16. It also preserves checksum entries the previous release needs, including entries other commands may no longer require.

The compat flag can change that behavior. Tidying with compatibility set to Go 1.17 stops retaining the extra checksums and checking consistency for Go 1.16. This is a distinct decision from changing the go directive. A module can adopt the new graph representation while retaining metadata that supports the previous toolchain's dependency view.

The notes also explain that older modules can still require such a dependency when its packages use language and library capabilities those older toolchains understand. That qualification matters. Dependency metadata compatibility does not make newer language syntax or unavailable library calls compile on an older toolchain.

I prefer a migration command with this specific contract. It gives a reviewer a reason for the changed file and separates that reason from application behavior. The version directive now selects dependency-loading semantics, so its change deserves the same attention as the requirement lines around it.

Registers replace stack traffic at call boundaries

The compiler change passes function arguments and results using registers instead of the stack. This release enables it for Linux, macOS, and Windows on amd64. The announcement's broader reference to amd64 needs that platform qualification when deciding whether a particular build benefits.

The reported improvement is about 5 percent for a representative set of Go packages and programs, with typical binary-size reductions around 2 percent. These are the project's reported benchmark observations. They are not measurements of every application, and the release does not promise that each function or workload gains the same amount.

The mechanism changes the calling convention shared by compiled code. Arguments and results have to be in locations the caller and callee both understand. Using registers can reduce the work of moving those values through memory at function boundaries. An application written in safe Go keeps its source-level behavior while the compiler changes the machinery implementing those calls.

Assembly introduces a compatibility problem because existing functions may expect the previous convention. The compiler generates adapters between the register-based convention and the older stack-based one. These adapters let existing assembly participate without requiring every assembly routine to change at the same time as the compiler.

The release notes identify two cases with a small possible adapter overhead, indirect calls to assembly functions through a Go function value and calls from assembly into Go. The presence of adapters therefore belongs in a precise explanation of the performance change. A convention that improves ordinary compiled calls still needs a translation at boundaries using the old convention.

Undocumented code pointers can expose the adapters

Most code does not observe the adapter directly. The notes name an exception when code takes function addresses through assembly, reflect, or unsafe operations. Such code may receive the adapter's address. A program relying on a particular code pointer can therefore behave differently even though ordinary function calls retain their intended meaning.

This is connected to another compiler improvement in the release. Functions containing closures can now be inlined. The same source function can consequently produce distinct closure code pointers at different inlining sites. Go function values are not directly comparable, and using lower-level machinery to compare their code pointers does not establish a supported identity rule.

Stack traces also change their presentation. Arguments appear separately, and aggregate arguments have delimiters describing their grouping. A register-only argument may have an inaccurate printed value if it was not stored to memory. The improved formatting does not turn every displayed argument into reliable captured state.

These details locate the compatibility boundary more usefully than a blanket claim that nothing changes. Safe Go code keeps its behavior. Code depending on undocumented address properties or unsafe assumptions has additional constraints. The release notes identify the mechanisms that make those assumptions observable.

Other version boundaries remain explicit

Go 1.17 also adds native Windows ARM64 support, including cgo. It is separate from the amd64 calling-convention rollout. A new supported port does not mean it participates in every architecture-specific compiler change in the same release.

The new slice-to-array-pointer conversion is another distinct boundary. It can panic when the slice is shorter than the target array. The notes call this the first type conversion that can panic at runtime and tell analysis tools to account for it. The new unsafe.Add and unsafe.Slice helpers simplify some pointer operations while retaining the existing unsafe.Pointer rules.

Together, these changes illustrate why a release needs more than a list of headline features. Module graph behavior depends on a declaration in go.mod. Register calls depend on the target platform, and assembly compatibility uses generated adapters. The practical benefit comes from understanding those conditions well enough to attribute a changed dependency file or code pointer to the mechanism that produced it.