Skip to content

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.

Terminal window
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.

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:

internal/page/page.go
// 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) }
go tool linkweight -top 4 -allow linkweight-allow.txt
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.

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.

With -allow, every capability and every function that turns elimination off has to be explained by a line of the list, the reason after #:

linkweight-allow.txt
# What the shop's binaries may reach, each with the reason.
cmd/api capability dial # net/http's client comes with its server
cmd/api capability http-server # it is the shop's web server
cmd/api capability listen # it is the shop's web server
cmd/api capability reflect-call # text/template calls what a page names
cmd/api reflect-method text/template # the front page is a template; a renderer without one is owed
cmd/worker capability exec # the nightly job is a shell command

It 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.

  • The answer is for the GOOS, GOARCH, build tags and CGO_ENABLED it 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/unix the binary links. A raw syscall.Syscall with 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.
Taskfile.yml
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.json

CGO_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.