linkweight
A binary is larger than its code and can do more than its code says: every dependency brings its
own dependencies, and a single call into a library can keep thousands of functions alive. linkweight
builds each binary of the repository and reads the answer from the linker itself.
go tool linkweight -roots ./cmd/...For each main package it says four things:
- Weight. The bytes each module puts in the binary, its code and its share of the runtime’s
per-function table (
.gopclntab, a third of a large Go binary and in no symbol). Memory reserved at run time (.bss) is not counted: it is not in the file. - Method elimination. Whether the linker could drop the methods nothing calls, and if not, what stopped it and through which imports.
- Cuts. For each import a package of the repository makes, how much the binary would stop linking without it, the largest first: where a refactor pays.
- Capabilities. What the binary can reach -
exec,exec-replace,listen,dial,http-server,mount,setuid,namespaces,ptrace,reboot,kernel-module,plugin,reflect-call,cgo- each with the imports that bring it in.
An example
Section titled “An example”A shop has two binaries: cmd/api serves a front page rendered by internal/page, and
cmd/worker runs a nightly shell command. The page is a template:
// Package page renders the shop's pages.package page
import ( "io" "text/template")
var front = template.Must(template.New("front").Parse("<h1>{{.}}</h1>\n"))
// Front writes the front page.func Front(w io.Writer, title string) error { return front.Execute(w, title) }cmd/api: 7.4 MB shipped, 2 modules, 160 packages (0.5 MB in no package: strings, funcdata) method elimination: OFF - every exported method of every reachable type is linked, because of: text/template cmd/api > internal/page > text/template capabilities: dial cmd/api > net/http > net http-server cmd/api > net/http listen cmd/api > net/http > net (through a kept method: may go when elimination is back on) reflect-call cmd/api > internal/page > text/template > reflect heaviest modules: 6.3 MB std 0.0 MB example.com/shop imports whose cut drops the most (package-level estimate): 3.8 MB cmd/api > net/http 0.2 MB cmd/api > internal/page 0.2 MB internal/page > text/template
cmd/worker: 1.6 MB shipped, 2 modules, 43 packages (0.1 MB in no package: strings, funcdata) method elimination: on capabilities: exec cmd/worker > os/exec > os heaviest modules: 1.4 MB std 0.0 MB example.com/shop imports whose cut drops the most (package-level estimate): 0.1 MB cmd/worker > os/exec
OK: every capability and every method-elimination breaker has a reason, and every reason still applies.cmd/api has method elimination off, and the chain says why: cmd/api imports internal/page,
which imports text/template. cmd/worker keeps it on, and its one capability is exec.
Method elimination
Section titled “Method elimination”The linker drops a method nothing calls - unless a reachable function calls
reflect.Value.Method or MethodByName with a name it cannot see. Then any exported method could
be called by name, so the linker keeps every exported method of every reachable type. One such
call anywhere in the binary is enough, and text/template makes it for every template it runs.
The cost is the size of the libraries with large types. An AWS SDK client is a type with a method
per operation: service/ec2’s has 2,433 of them. In spin’s control plane, which calls a handful,
elimination being off kept all of them. Measured on spin’s binaries, with the three templates
replaced and grpc built with -tags grpcnotrace (its golang.org/x/net/trace imports html/template):
| binary | before | after |
|---|---|---|
| controlplane | 102.7 MB | 51.6 MB |
| runner | 84.2 MB | 34.6 MB |
| boot | 74.0 MB | 24.8 MB |
| cli | 43.0 MB | 28.0 MB |
| supervisor | 24.6 MB | 20.0 MB |
A function listed under linked as a method its type keeps is marked by the linker too, but it
is in the binary only as a method of a type the linker kept. It may be there only because
elimination is already off, and it goes with the others: encoding/json/v2 and godbus/dbus
did, in spin. Remove the first group first.
A cut is one edge of the import graph: internal/installation/provider/aws > github.com/aws/aws-sdk-go-v2/service/ec2.
Its size is the bytes of every package the main package no longer reaches without that edge, and
(-N modules) the modules it no longer links at all. A chain of cuts with the same size is one
path with no other way in - the first cut of the chain is the cheapest refactor that removes it.
The size is an estimate at the package level, in both directions: a smaller import graph can let the linker drop more than the packages it no longer reaches, and a refactor that moves the code a binary needs elsewhere drops less. Cuts under 64 KiB are not listed.
The gate
Section titled “The gate”With -allow, every capability and every function that turns elimination off has to be explained
by a line of the list, the reason after #:
# What the shop's binaries may reach, each with the reason.cmd/api capability dial # net/http's client comes with its servercmd/api capability http-server # it is the shop's web servercmd/api capability listen # it is the shop's web servercmd/api capability reflect-call # text/template calls what a page namescmd/api reflect-method text/template # the front page is a template; a renderer without one is owedcmd/worker capability exec # the nightly job is a shell commandIt fails on a finding no line explains - surface nobody decided the binary has - and on a line no finding matches: a binary that lost a capability or got elimination back has its list shortened in the same commit, so the list stays the binary’s description and not its history. The weights and the cuts are a report, not a gate: a pinned size would let each deletion pay for the next addition.
What it cannot see
Section titled “What it cannot see”- The answer is for the GOOS, GOARCH, build tags and
CGO_ENABLEDit runs with: run it as the release builds (CGO_ENABLED=0 go tool linkweight -tags ...). It reads ELF, so Linux binaries. - A capability is a function of the standard library or
golang.org/x/sys/unixthe binary links. A rawsyscall.Syscallwith a number of its own is not one of them. - A chain is the shortest path the linker took. When the compiler inlined a call, the imports in between are filled in from the package graph.
In a Taskfile
Section titled “In a Taskfile”tasks: linkweight: desc: What each binary links and why; fails on a capability nobody wrote down. cmds: - CGO_ENABLED=0 go tool linkweight -roots ./cmd/... -allow hack/linkweight-allow.txt -json _output/linkweight.jsonCGO_ENABLED=0 on the command, not in the task’s env:: a variable the caller’s environment
already sets wins over a task’s env:, and a CI job that sets CGO_ENABLED=1 for its race
detector would have the gate answer for a binary no release ships - every one of them reported
with cgo.

