Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 63 additions & 22 deletions doc/size-report.md
Original file line number Diff line number Diff line change
@@ -1,21 +1,57 @@
# Size Report Options

The `llgo build -size` flag emits a TinyGo-style table showing how much code,
rodata, data, and BSS each component contributes to the final binary. This
document captures the parsing strategy and new aggregation controls.
The `llgo build -size` flag measures the final linked artifact after Wasm
optimization, PCLN/debug packaging, stripping and firmware format conversion.
It reports file bytes separately from code/data payload and lists the final
build artifacts. A requested report that cannot be produced fails the build;
it is not silently replaced by a warning.

## Parsing Strategy

- We invoke `llvm-readelf --elf-output-style=LLVM --all <binary>` and parse the textual output with an
indentation-sensitive state machine (no JSON). Only the `Sections` and
`Symbols` blocks are inspected.
- Section metadata records the index, address, size, name, and segment. Each
section is classified into text/rodata/data/bss buckets.
- Symbols are attached to their containing sections with start addresses. By
sorting symbols and walking their ranges, we compute byte spans that can be
attributed to packages/modules.
- Sections with no symbols fall back to `(unknown <section>)`, and gaps become
`(padding <section>)` entries so totals still add up.
- ELF is read directly using section flags/types and symbol sizes. All
`SHF_ALLOC` sections are included, including unwind and initialization arrays.
Zero-size labels do not split functions, and aliases/overlapping symbols are
counted once per section. Overlaps belonging to different owners appear as
`(shared <section>)`; bytes without a sized owner appear as
`(unknown <section>)`, which may include padding. Stripped files still have
accurate section totals even when source attribution is unavailable.
- Wasm is read directly, without invoking `llvm-readelf`. Code is the encoded
function bodies (including local declarations); data is the stored segment
content, including passive segments. Names are taken from the final `name`
section when available, otherwise code/data stay in explicit unknown buckets.
Custom-section bytes and the remaining encoding bytes are listed separately:
`code + data + custom_bytes + structure_bytes == file_size`.
- Browser output resolves the sibling `.wasm` instead of reading JavaScript or
HTML as an object file. The artifact list includes the glue, owned host
resources, runtime symbols and external DWARF described by the build output.
Alternate firmware formats are listed individually, not summed as if every
format must be deployed together.
- PE/COFF is read directly using section characteristics. Section payload excludes
raw-file alignment padding; virtual zero-fill is counted as BSS. COFF symbol
ownership uses address-range estimates because symbols do not carry ELF-style
sizes. Stripped Windows executables retain section totals.
- Mach-O retains the `llvm-readelf` reader and address-range estimates.
Parentheses within Go method names are preserved.

Native `flash` remains the sum of allocated code/rodata/data section payload;
`ram` is data plus zero-fill (ELF NOBITS or PE virtual zero-fill). These are not the ELF container size,
load-image padding, physical RAM after target address-alias resolution, or
dynamic runtime usage. Linker reservations may be included in NOBITS.

Wasm reports imported/defined linear-memory initial and maximum sizes, shared
status and memory64 separately. It does not infer BSS from unused linear memory
or claim that data bytes equal RAM usage; `flash` and `ram` fields are omitted
for Wasm. Stack/heap reservations and worker/runtime peaks need additional
target/runtime measurements.

Symbol attribution describes the final physical owner. Inlining and LTO may
move work across package boundaries. LLVM instruction counts and archive sizes
are not substituted for final bytes; this reader also works with cached build
inputs and after LLVM modules have been released.

The parsers, aggregation and output live in `internal/sizereport`, with only
standard-library dependencies. The build layer selects the final module, converts
package/artifact metadata and invokes the Mach-O tool fallback.

## Aggregation Levels

Expand Down Expand Up @@ -49,16 +85,21 @@ llgo build -size -size-format=json . # JSON output (works with all levels)

## Validation

1. Unit tests: `go test ./internal/build -run TestParseReadelfOutput -count=1`.
JSON reports have `version: 1`, `stage: "final"`, `format`, `file_size`,
`binary`, `modules`, `total` and `artifacts`. Wasm additionally has a `wasm`
object containing sections, memory limits and encoding overhead. Optional
diagnostic warnings do not change the measured totals. Native per-module fields
remain compatible with the previous report.

1. Unit tests:
```sh
CGO_ENABLED=0 go test ./internal/sizereport -count=1
go test ./internal/build -run 'Test(SizeReport|FinalSize|ReportBuildOutputs)' -count=1
```
2. Real binary test:
```sh
cd cl/_testgo/rewrite
../../../dev/llgo.sh build .
LLGO_SIZE_REPORT_BIN=$(pwd)/rewrite \
go test ./internal/build -run TestParseReadelfRealBinary -count=1
LLGO_SIZE_REPORT_BIN=/absolute/path/to/app.wasm \
go test ./test/sizereport -run '^TestCollectFinalSizeRealBinary$' -count=1
```
3. Manual smoke test: `../../../dev/llgo.sh build -size -size-level=module .` (or
3. Manual smoke test: `llgo build -size -size-level=module .` (or
`package`/`full` as desired).

The parser works across Mach-O and ELF targets as long as `llvm-readelf` is in
`PATH`.
29 changes: 25 additions & 4 deletions internal/build/artifact_report.go
Original file line number Diff line number Diff line change
Expand Up @@ -173,17 +173,38 @@ func primaryArtifactFormat(conf *Config, path string) string {
}
}

func reportBuildArtifacts(conf *Config, out *OutFmtDetails, w io.Writer) error {
if conf == nil || (!conf.DebugArtifactModeSet && conf.Target == "") {
// reportBuildOutputs shares one artifact snapshot between the size report and
// the artifact listing after all output transformations have completed.
func reportBuildOutputs(conf *Config, out *OutFmtDetails, pkgs []Package, sizeOutput, artifactOutput io.Writer) error {
if conf == nil {
return nil
}
wantSize := conf.Mode == ModeBuild && conf.SizeReport
wantArtifacts := conf.DebugArtifactModeSet || conf.Target != ""
if !wantSize && !wantArtifacts {
return nil
}
artifacts, err := CollectArtifacts(conf, out)
if err != nil {
return err
}
if wantSize {
if err := reportFinalSize(conf, out, pkgs, artifacts, sizeOutput); err != nil {
return err
}
}
if wantArtifacts {
return reportBuildArtifacts(artifacts, artifactOutput)
}
return nil
}

func reportBuildArtifacts(artifacts []Artifact, w io.Writer) error {
for _, artifact := range artifacts {
fmt.Fprintf(w, "llgo: artifact role=%s format=%s size=%d path=%q\n",
artifact.Role, artifact.Format, artifact.Size, artifact.Path)
if _, err := fmt.Fprintf(w, "llgo: artifact role=%s format=%s size=%d path=%q\n",
artifact.Role, artifact.Format, artifact.Size, artifact.Path); err != nil {
return err
}
}
return nil
}
75 changes: 71 additions & 4 deletions internal/build/artifact_report_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ package build

import (
"bytes"
"encoding/json"
"io"
"os"
"path/filepath"
"reflect"
Expand Down Expand Up @@ -240,7 +242,7 @@ func TestReportBuildArtifacts(t *testing.T) {
}
var report bytes.Buffer
conf := &Config{DebugArtifactMode: DebugArtifactNone, DebugArtifactModeSet: true}
if err := reportBuildArtifacts(conf, &OutFmtDetails{Out: path}, &report); err != nil {
if err := reportBuildOutputs(conf, &OutFmtDetails{Out: path}, nil, io.Discard, &report); err != nil {
t.Fatal(err)
}
want := "llgo: artifact role=deployment format=executable size=4 path=" + strconv.Quote(path) + "\n"
Expand All @@ -250,20 +252,85 @@ func TestReportBuildArtifacts(t *testing.T) {

report.Reset()
conf.DebugArtifactModeSet = false
if err := reportBuildArtifacts(conf, &OutFmtDetails{Out: path}, &report); err != nil || report.Len() != 0 {
if err := reportBuildOutputs(conf, &OutFmtDetails{Out: path}, nil, io.Discard, &report); err != nil || report.Len() != 0 {
t.Fatalf("implicit artifact report = %q, %v", report.String(), err)
}
conf.DebugArtifactModeSet = true
if err := reportBuildArtifacts(conf, &OutFmtDetails{Out: path + ".missing"}, &report); err == nil {
if err := reportBuildOutputs(conf, &OutFmtDetails{Out: path + ".missing"}, nil, io.Discard, &report); err == nil {
t.Fatal("reportBuildArtifacts() succeeded with a missing artifact")
}

conf.Target = "cortex-m-qemu"
conf.DebugArtifactMode = DebugArtifactHost
if err := reportBuildArtifacts(conf, &OutFmtDetails{Out: path}, &report); err != nil {
if err := reportBuildOutputs(conf, &OutFmtDetails{Out: path}, nil, io.Discard, &report); err != nil {
t.Fatal(err)
}
if got := report.String(); !strings.Contains(got, "role=debug format=elf size=4") {
t.Fatalf("target artifact report = %q", got)
}
}

type artifactRemovingWriter struct {
bytes.Buffer
path string
}

func (w *artifactRemovingWriter) Write(p []byte) (int, error) {
n, err := w.Buffer.Write(p)
if err == nil && w.path != "" {
err = os.Remove(w.path)
w.path = ""
}
return n, err
}

func TestReportBuildOutputsSharesArtifacts(t *testing.T) {
dir := t.TempDir()
out := &OutFmtDetails{Out: filepath.Join(dir, "app.wasm"), PCLN: filepath.Join(dir, "app.pclntab")}
if err := os.WriteFile(out.Out, sizeWasmFixture(), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(out.PCLN, []byte("symbols"), 0o600); err != nil {
t.Fatal(err)
}
conf := &Config{Mode: ModeBuild, SizeReport: true, SizeFormat: "json", Goarch: "wasm", DebugArtifactMode: DebugArtifactNone, DebugArtifactModeSet: true}
// Removing a sidecar after the first report is written proves that the
// second report consumes the same snapshot rather than re-statting files.
output := artifactRemovingWriter{path: out.PCLN}
var listing bytes.Buffer
if err := reportBuildOutputs(conf, out, nil, &output, &listing); err != nil {
t.Fatal(err)
}
var payload struct{ Artifacts []Artifact }
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if len(payload.Artifacts) != 2 || payload.Artifacts[1].Size != 7 {
t.Fatalf("JSON artifact snapshot = %+v", payload.Artifacts)
}
if want := "role=runtime-symbols format=pclntab size=7 path=" + strconv.Quote(out.PCLN); !strings.Contains(listing.String(), want) {
t.Fatalf("listing does not use the same snapshot: %s", listing.String())
}
if err := reportBuildOutputs(conf, out, nil, io.Discard, io.Discard); err == nil {
t.Fatal("a new build report must reject the missing sidecar")
}
}

func TestReportBuildOutputsErrors(t *testing.T) {
if err := reportBuildOutputs(nil, nil, nil, io.Discard, io.Discard); err != nil {
t.Fatal(err)
}
if err := reportBuildOutputs(&Config{}, nil, nil, io.Discard, io.Discard); err != nil {
t.Fatal(err)
}
path := filepath.Join(t.TempDir(), "app.wasm")
if err := os.WriteFile(path, sizeWasmFixture(), 0o600); err != nil {
t.Fatal(err)
}
conf := &Config{Mode: ModeBuild, SizeReport: true, SizeFormat: "json", DebugArtifactMode: DebugArtifactNone, DebugArtifactModeSet: true}
for _, outputs := range [][2]io.Writer{{sizeFailWriter{}, io.Discard}, {io.Discard, sizeFailWriter{}}} {
if err := reportBuildOutputs(conf, &OutFmtDetails{Out: path}, nil, outputs[0], outputs[1]); err == nil {
t.Fatal("report write error was ignored")
}
}
}
9 changes: 2 additions & 7 deletions internal/build/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -1242,18 +1242,13 @@ func executeInitialPackageLink(ctx *context, link *initialPackageLink, verbose,
if err != nil {
return nil, err
}
if link.conf.Mode == ModeBuild && link.conf.SizeReport {
if err := reportBinarySize(link.outFmts.Out, link.conf.SizeFormat, link.conf.SizeLevel, link.allPkgs); err != nil {
fmt.Fprintf(os.Stderr, "Warning: size report failed: %v\n", err)
}
}
if linkCtx.buildConf.BuildMode == BuildModeCArchive || linkCtx.buildConf.BuildMode == BuildModeCShared {
libname := strings.TrimSuffix(filepath.Base(link.outFmts.Out), link.conf.AppExt)
headerPath := filepath.Join(filepath.Dir(link.outFmts.Out), libname) + ".h"
if err := header.GenHeaderFile(linkCtx.prog, cHeaderPackages(link.allPkgs), libname, headerPath, verbose); err != nil {
return nil, err
}
return nil, reportBuildArtifacts(link.conf, link.outFmts, os.Stderr)
return nil, reportBuildOutputs(link.conf, link.outFmts, link.allPkgs, os.Stdout, os.Stderr)
}

envMap := link.outFmts.ToEnvMap()
Expand All @@ -1262,7 +1257,7 @@ func executeInitialPackageLink(ctx *context, link *initialPackageLink, verbose,
return nil, err
}
}
if err := reportBuildArtifacts(link.conf, link.outFmts, os.Stderr); err != nil {
if err := reportBuildOutputs(link.conf, link.outFmts, link.allPkgs, os.Stdout, os.Stderr); err != nil {
return nil, err
}
switch link.conf.Mode {
Expand Down
12 changes: 10 additions & 2 deletions internal/build/multi_build_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -243,14 +243,22 @@ func TestModeBuildSinglePackageAcceptsOutputDirectory(t *testing.T) {
}
conf := multiBuildConfig()
conf.OutFile = out
conf.SizeReport = true
conf.SizeFormat = "invalid" // A report failure is intentionally non-fatal after a successful build.
if _, err := Build(Invocation{Args: []string{"./cmd/only"}, Config: conf, Dir: root}); err != nil {
t.Fatal(err)
}
assertBuiltProgram(t, filepath.Join(out, "only"+conf.AppExt), "only")
}

func TestModeBuildRejectsInvalidSizeFormat(t *testing.T) {
conf := multiBuildConfig()
conf.SizeReport = true
conf.SizeFormat = "invalid"
if _, err := Build(Invocation{Config: conf, Dir: t.TempDir()}); err == nil ||
!strings.Contains(err.Error(), "invalid size format") {
t.Fatalf("size report configuration error = %v", err)
}
}

func multiBuildConfig() *Config {
conf := NewDefaultConf(ModeBuild)
conf.BuildParallelism = 2
Expand Down
Loading
Loading