Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

symcost

Tools for measuring and attributing Go-binary size cost, with a particular focus on the cost of Go generics. Useful for any project where binary size matters — embedded systems, mobile platforms, container images, lambda deployments — and especially for diagnosing the impact of generic-heavy packages where one new type can pull in surprising amounts of compiled code.

This repository contains three pieces:

  • cmd/symcost — A command-line tool that opens a Go binary and reports which functions and types contribute the most bytes. Supports a global top-N view, a per-receiver-type aggregation, and a per-function deep dive.
  • symcost — The Go library powering the tool. Exposes a *Binary that parses the symbol table, runtime type descriptors, interface tables, and the pclntab; plus a Cost aggregator that attributes bytes across .text, .rodata, .gopclntab, and (on arm64) function-body machine-code references.
  • sizetest — A test-time helper for measuring how much a code change contributes to compiled binary size. Builds two variants of a tiny program (baseline and treatment), diffs the resulting binary sizes, and optionally runs symcost on the treatment to show where the bytes went.

What problem does this solve

Go's symbol table tells you the size of every named function and rodata symbol, but that's only a fraction of what a feature actually costs in the final binary:

Section What's there Visible to go tool nm?
.text Function bodies yes
.gopclntab Per-function metadata (line tables, stack maps, function names) no
.rodata (named) Generic dictionaries, itabs, type-equality functions yes
.rodata (unnamed) Runtime type descriptors, name strings, GC bitmaps no
.typelink, .itablink Indices into the above no

For generic-heavy code, the unnamed-.rodata and .gopclntab overhead can be the majority of the cost. symcost opens the binary directly (not via nm), decodes .typelink and .itablink, and on arm64 disassembles function bodies to attribute static-data references back to the function that uses them. The result is a per-receiver or per-function cost number that is much closer to ground truth than a symbol-table view alone.

The companion sizetest package turns that into a regression-detection workflow: build a small "baseline" program and a small "treatment" program that differs only in the dimension you want to measure, diff the resulting binary sizes, and watch for changes over time.

Quick start

Use cmd/symcost to inspect a binary

go install github.com/raggi/symcost/cmd/symcost@latest

# Build your binary WITHOUT -ldflags="-s -w" (symcost needs the symbol table).
# -trimpath is recommended to keep symbol names compact and reproducible.
go build -trimpath -o myprogram ./cmd/myprogram

# Top-30 grouped symbols across the whole binary:
symcost myprogram

# What does eventbus.Publisher cost?
symcost -receiver=example.com/pkg/eventbus.Publisher myprogram

# What does one specific generic function cost across all instantiations?
symcost -func='example.com/pkg/eventbus.(*SubscriberFunc[…]).dispatch' myprogram

The default mode groups all instantiations of one generic template into a single row, with count, total bytes, and per-instantiation min/avg/max.

Receiver mode aggregates everything attributable to a type: methods on the receiver across all instantiations, generic dictionaries naming the receiver, type-equality and type-hash functions, itab entries whose concrete type is the receiver, and the runtime type descriptors for every instantiation. The output is a per-section breakdown plus the contributing items sorted by descending cost.

Function mode does the same for a single function (or one generic function template across all of its instantiations).

Use sizetest to gate regressions

package mypkg_test

import (
    "testing"

    "github.com/raggi/symcost/sizetest"
)

func TestNoBinarySizeRegression(t *testing.T) {
    if testing.Short() {
        t.Skip("invokes go build twice")
    }
    baseline := sizetest.Variant{
        Name:   "baseline",
        Source: programWith(10), // 10 of the thing we're measuring
    }
    treatment := sizetest.Variant{
        Name:   "treatment",
        Source: programWith(100), // 100 of it
    }
    base, treat, delta := sizetest.Diff(t, baseline, treatment)
    perItem := float64(delta) / 90.0
    t.Logf("per-item binary cost: %.0f bytes", perItem)
    if perItem > 500 {
        t.Errorf("per-item cost %.0f exceeds gate of 500 B", perItem)
    }
}

The variant source must declare package main and func main(). Variants are built in their own synthesized temporary module that uses a Go replace directive pointing at the host module's source tree, so variant programs can freely import packages from the host module by their usual import path.

How it works

symcost (the library and the tool)

symcost.Open(path) returns a *Binary that parses:

  1. ELF sections via debug/elf — addresses and contents of .text, .rodata, .gopclntab, .typelink, .itablink, etc.
  2. Symbol table via debug/elf — addresses and sizes of every named symbol. Same data go tool nm -size reports.
  3. pclntab via debug/gosym — the function table that maps PCs to function names and ranges. Used both to enumerate functions the symbol table doesn't name (e.g. anonymous closures) and to attribute pclntab bytes back to each function.
  4. .typelink index — points at each runtime._type (the runtime representation of every Go type in the binary). symcost decodes the internal/abi.Type header layout to recover the type's name and the total bytes it occupies (including name strings, struct field tables, method tables, GC bitmaps, etc.).
  5. .itablink index — points at every interface table. symcost decodes each itab to recover the (concrete type, interface type) pair it represents.

For arm64 binaries, symcost also disassembles each function body and recognizes the ADRP+ADD / ADRP+LDR instruction pairs that Go emits for static-data addressing. The result is a per-function list of static-data addresses that the function references, which are then looked up against the type and itab tables to attribute additional rodata back to the referencing function.

The Cost aggregator wires this together: for a query target (a receiver type or a function template), it walks the symbol table, the typelink index, the itab table, and (on arm64) the per-function reference list, and sums up bytes across all four sources. The output is a section breakdown plus sorted lists of contributing items.

sizetest

Variants are built in temporary modules synthesized at test time:

  1. The variant's source is written to main.go in a fresh temp dir.
  2. A go.mod is written that declares a new module name and replaces the host module path with the host module's filesystem root, so any imports the variant code makes resolve against the host's source tree.
  3. The host module's go.sum is copied over so resolution works offline.
  4. go build -trimpath -ldflags="-s -w" produces the binary; the file size is the result.

Build options are configurable: stripping can be disabled (the default -s -w is for low-noise comparisons; disable when you want to follow up with symcost, which needs the symbol table), and GOOS/GOARCH can be set for cross-architecture measurements.

Diff builds two variants and reports the byte delta; DiffWithOptions takes a custom BuildOptions.

Practical notes

  • Stripped vs unstripped binaries. Use stripped (-s -w) builds for size measurements — the symbol table and DWARF info add noise that scales with the number of compiled functions, which is often the dimension you're trying to isolate. Use unstripped builds when you want to run symcost afterward; the symbol table is what symcost keys most of its attribution on.

  • Page quantization. Linkers align sections to page boundaries (typically 4 KB). A change that saves 1 KB may not appear in the stripped binary's file size at all — until it crosses a page boundary, at which point a single byte's worth of source can save 4 KB. For reliable regression detection, prefer either (a) per-receiver symcost attribution numbers, which aren't page-quantized, or (b) very large delta sizes between baseline and treatment in sizetest (multiple pages' worth of difference), so the per-item average is stable.

  • Cross-version stability. Absolute byte counts vary with the Go toolchain version. Both the symbol table format and the runtime type descriptor layout have changed across Go releases. A regression gate baked against a specific number should be revisited when bumping the toolchain.

  • arm64 vs others. Function-body rodata-reference attribution is currently implemented only for arm64, because that's the architecture where binary size matters most for the original author's use cases (iOS, Android, Apple Silicon). The rest of the tool works on amd64 and other architectures; arm64 just gets the extra attribution. amd64 support would be a welcome contribution.

License

BSD-3-Clause. See LICENSE.

About

Go symbol cost tool

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages