Usually we don't care too much about how large our compiled code is. Most of the time there's plenty of bandwidth and disk space available. But recently while working on sigstore-go I thought that 26 MB seemed large for what the example binary was doing, so I spent some time seeing if we could make it smaller.
I'm far from a Go expert, so my first approach was pretty naive. I knew I could cache the dependencies locally in a vendor directory with go mod vendor, with the idea that I'd run something like du -d 3 -h vendor to see how much space on disk those dependencies used and see if anything stuck out. But of course, this tells you the size of the source code repository, not the compiled code. That includes tests, documentation, logos, and all sorts of other unrelated things.
What I really needed was information from the compiler (and linker) about what was making it into the binary. And hey, since Go is a statically typed, compiled language, shouldn't it know if I'm only using one function in a dependency, and throw away the rest? My co-worker phillmv suggested that maybe there's some anti-pattern we're accidentally using that interferes with that?
One question at a time! So yes, the Go linker does have [dead code detection](https://github.com/golang/go/blob/master/src/cmd/link/internal/ld/deadcode.go) and yes, using reflections can prevent dead code detection (since we won't know what method gets picked until run-time). How can we get an idea of if dead code detection is working for us or not?
The linker looks like it has pretty good instrumentation, so let's pass some flags to it and find out:
sigstore/sigstore-go$ go build -ldflags="-v=2" cmd/sigstore-go/main.go ... 521600 symbols, 250287 reachable ...
Okay, so it seems like generally the dead code detection is working, at least. Is there anything else the linker can tell us? Oh, there's a call graph! Note that linker additional linker output goes to stderr, not stdout:
$ go build -ldflags="-c" cmd/sigstore-go/main.go 2> callgraph.txt
Now we at least know how dependencies are getting pulled in, but we're still missing the crucial information about the size of those objects. You can ask the linker for that information, but you can also get it directly from the binary:
$ go tool nm -size main > nm.txt
Now we finally have the information we need - how big the compiled objects are, and how they are getting included (via the call graph). I wasn't able to find tooling for tying these two together, so I wrote a basic Python script to do so:
$ cat account.py from collections import defaultdict import sys import re def main(nm, callgraph): account = defaultdict(int) symbol_size = defaultdict(int) with open(nm, "r") as fd: while True: line = fd.readline() if not line: break parts = re.split(r'\s+', line.strip())[0:4] if len(parts) != 4: continue _, size, _, symbol = parts symbol_size[symbol] = int(size) with open(callgraph, "r") as fd: while True: line = fd.readline() if not line: break if line.startswith("#") or len(line.split(" ")) > 3: continue caller, _, callee = line.split(" ")[0:3] account[caller] += symbol_size[callee.strip()] items = list(account.items()) items.sort(key=lambda each: each[1], reverse=True) for each in items: print(each[0], each[1]) if __name__ == "__main__": if len(sys.argv) > 3: print("Usage: %s nm.txt callgraph.txt".format(sys.argv[0])) else: main(sys.argv[1], sys.argv[2])
Let's give it a spin:
$ python3 account.py nm.txt callgraph.txt crypto/internal/nistec/fiat.(*P521Element).Invert 258592 github.com/go-playground/validator/v10.(*validate).traverseField 181568 unicode.map.init.1 177792 github.com/go-playground/validator/v10.map.init.8 174528 fmt.(*pp).printValue 149440 crypto/internal/nistec.(*P521Point).Add 144384 crypto/internal/nistec.(*P521Point).Double 127264 reflect.StructOf 124416 crypto/internal/nistec/fiat.(*P384Element).Invert 122112 crypto/internal/nistec.p384SqrtCandidate 115264 gopkg.in/yaml%2ev3.yaml_parser_scan_flow_scalar 107840 github.com/go-logr/logr/funcr.Formatter.prettyWithFlags 107264 gopkg.in/yaml%2ev2.yaml_parser_scan_flow_scalar 105920 mime.FormatMediaType 104544 debug/dwarf.init 101216 ...
So how do we use this list? Note that we're only going one link down the call graph, so if we look at this list top-to-bottom we're getting a sense of where a symbol has a lot of "fan out" to large direct dependencies. An item near the top that we're getting lots of use from is fine, we're looking for things that are large but not crucial.
github.com/go-playground/validator jumped out at me; that wasn't something we used in sigstore-go and it appears twice in this list. Let's check the call graph:
$ grep 'go-playground\/validator' callgraph.txt ... github.com/sigstore/rekor/pkg/pki/pgp.PublicKey.EmailAddresses calls github.com/go-playground/validator/v10.New ...
After doing some research, it turns out that project also uses the similar github.com/asaskevich/govalidator, so I made a pull request to use the smaller dependency. We just saved 1.5 MB! Now we can regenerate our call graph and symbol table and start looking for our next target.
When I started this project, I knew very little about Go's compiler or linker. But there's lots of resources at your disposal - the source code, the tooling's built-in instrumentation, and of course brainstorming with your coworkers! Hopefully this helps you next time you have a Go binary that you'd like to be smaller, or when you have an engineering challenge in an area you aren't familiar with.