From f1d3fba09a3989808efa47b70be6e44a96656a16 Mon Sep 17 00:00:00 2001 From: Ayke van Laethem Date: Mon, 16 Dec 2024 12:08:10 +0100 Subject: [PATCH 1/2] builder: write HTML size report This is not a big change over the existing size report with -size=full, but it is a bit more readable. More information will be added in subsequent commits. --- builder/build.go | 14 ++++++-- builder/size-report.go | 56 +++++++++++++++++++++++++++++ builder/size-report.html | 72 +++++++++++++++++++++++++++++++++++++ builder/sizes.go | 52 +++++++++++++++------------ compileopts/options.go | 2 +- compileopts/options_test.go | 2 +- main.go | 2 +- 7 files changed, 171 insertions(+), 29 deletions(-) create mode 100644 builder/size-report.go create mode 100644 builder/size-report.html diff --git a/builder/build.go b/builder/build.go index 57b67ed455..96184717e7 100644 --- a/builder/build.go +++ b/builder/build.go @@ -925,15 +925,16 @@ func Build(pkgName, outpath, tmpdir string, config *compileopts.Config) (BuildRe } // Print code size if requested. - if config.Options.PrintSizes == "short" || config.Options.PrintSizes == "full" { + if config.Options.PrintSizes != "" { sizes, err := loadProgramSize(result.Executable, result.PackagePathMap) if err != nil { return err } - if config.Options.PrintSizes == "short" { + switch config.Options.PrintSizes { + case "short": fmt.Printf(" code data bss | flash ram\n") fmt.Printf("%7d %7d %7d | %7d %7d\n", sizes.Code+sizes.ROData, sizes.Data, sizes.BSS, sizes.Flash(), sizes.RAM()) - } else { + case "full": if !config.Debug() { fmt.Println("warning: data incomplete, remove the -no-debug flag for more detail") } @@ -945,6 +946,13 @@ func Build(pkgName, outpath, tmpdir string, config *compileopts.Config) (BuildRe } fmt.Printf("------------------------------- | --------------- | -------\n") fmt.Printf("%7d %7d %7d %7d | %7d %7d | total\n", sizes.Code, sizes.ROData, sizes.Data, sizes.BSS, sizes.Code+sizes.ROData+sizes.Data, sizes.Data+sizes.BSS) + case "html": + const filename = "size-report.html" + err := writeSizeReport(sizes, filename, pkgName) + if err != nil { + return err + } + fmt.Println("Wrote size report to", filename) } } diff --git a/builder/size-report.go b/builder/size-report.go new file mode 100644 index 0000000000..d826f30274 --- /dev/null +++ b/builder/size-report.go @@ -0,0 +1,56 @@ +package builder + +import ( + _ "embed" + "fmt" + "html/template" + "os" +) + +//go:embed size-report.html +var sizeReportBase string + +func writeSizeReport(sizes *programSize, filename, pkgName string) error { + tmpl, err := template.New("report").Parse(sizeReportBase) + if err != nil { + return err + } + + f, err := os.Create(filename) + if err != nil { + return fmt.Errorf("could not open report file: %w", err) + } + defer f.Close() + + // Prepare data for the report. + type sizeLine struct { + Name string + Size *packageSize + } + programData := []sizeLine{} + for _, name := range sizes.sortedPackageNames() { + pkgSize := sizes.Packages[name] + programData = append(programData, sizeLine{ + Name: name, + Size: pkgSize, + }) + } + sizeTotal := map[string]uint64{ + "code": sizes.Code, + "rodata": sizes.ROData, + "data": sizes.Data, + "bss": sizes.BSS, + "flash": sizes.Flash(), + } + + // Write the report. + err = tmpl.Execute(f, map[string]any{ + "pkgName": pkgName, + "sizes": programData, + "sizeTotal": sizeTotal, + }) + if err != nil { + return fmt.Errorf("could not create report file: %w", err) + } + return nil +} diff --git a/builder/size-report.html b/builder/size-report.html new file mode 100644 index 0000000000..d9c4822b97 --- /dev/null +++ b/builder/size-report.html @@ -0,0 +1,72 @@ + + + + Size Report for {{.pkgName}} + + + + + + +
+

Size Report for {{.pkgName}}

+ +

How much space is used by Go packages, C libraries, and other bits to set up the program environment.

+ + + +

The binary size consists of code, read-only data, and data. On microcontrollers, this is exactly the size of the firmware image. On other systems, there is some extra overhead: binary metadata (headers of the ELF/MachO/COFF file), debug information, exception tables, symbol names, etc. Using -no-debug strips most of those.

+ +

Program breakdown

+
+ + + + + + + + + + + + + {{range .sizes}} + + + + + + + + + {{end}} + + + + + + + + + + + +
PackageCodeRead-only dataDataBSSBinary size
{{.Name}}{{.Size.Code}}{{.Size.ROData}}{{.Size.Data}}{{.Size.BSS}} + {{.Size.Flash}} +
Total{{.sizeTotal.code}}{{.sizeTotal.rodata}}{{.sizeTotal.data}}{{.sizeTotal.bss}}{{.sizeTotal.flash}}
+
+
+ + diff --git a/builder/sizes.go b/builder/sizes.go index 3f6cc4518c..7e6eefb3c5 100644 --- a/builder/sizes.go +++ b/builder/sizes.go @@ -25,7 +25,7 @@ const sizesDebug = false // programSize contains size statistics per package of a compiled program. type programSize struct { - Packages map[string]packageSize + Packages map[string]*packageSize Code uint64 ROData uint64 Data uint64 @@ -56,10 +56,11 @@ func (ps *programSize) RAM() uint64 { // packageSize contains the size of a package, calculated from the linked object // file. type packageSize struct { - Code uint64 - ROData uint64 - Data uint64 - BSS uint64 + Program *programSize + Code uint64 + ROData uint64 + Data uint64 + BSS uint64 } // Flash usage in regular microcontrollers. @@ -72,6 +73,12 @@ func (ps *packageSize) RAM() uint64 { return ps.Data + ps.BSS } +// Flash usage in regular microcontrollers, as a percentage of the total flash +// usage of the program. +func (ps *packageSize) FlashPercent() float64 { + return float64(ps.Flash()) / float64(ps.Program.Flash()) * 100 +} + // A mapping of a single chunk of code or data to a file path. type addressLine struct { Address uint64 @@ -785,49 +792,48 @@ func loadProgramSize(path string, packagePathMap map[string]string) (*programSiz // Now finally determine the binary/RAM size usage per package by going // through each allocated section. - sizes := make(map[string]packageSize) + sizes := make(map[string]*packageSize) + program := &programSize{ + Packages: sizes, + } + getSize := func(path string) *packageSize { + if field, ok := sizes[path]; ok { + return field + } + field := &packageSize{Program: program} + sizes[path] = field + return field + } for _, section := range sections { switch section.Type { case memoryCode: readSection(section, addresses, func(path string, size uint64, isVariable bool) { - field := sizes[path] + field := getSize(path) if isVariable { field.ROData += size } else { field.Code += size } - sizes[path] = field }, packagePathMap) case memoryROData: readSection(section, addresses, func(path string, size uint64, isVariable bool) { - field := sizes[path] - field.ROData += size - sizes[path] = field + getSize(path).ROData += size }, packagePathMap) case memoryData: readSection(section, addresses, func(path string, size uint64, isVariable bool) { - field := sizes[path] - field.Data += size - sizes[path] = field + getSize(path).Data += size }, packagePathMap) case memoryBSS: readSection(section, addresses, func(path string, size uint64, isVariable bool) { - field := sizes[path] - field.BSS += size - sizes[path] = field + getSize(path).BSS += size }, packagePathMap) case memoryStack: // We store the C stack as a pseudo-package. - sizes["C stack"] = packageSize{ - BSS: section.Size, - } + getSize("C stack").BSS += section.Size } } // ...and summarize the results. - program := &programSize{ - Packages: sizes, - } for _, pkg := range sizes { program.Code += pkg.Code program.ROData += pkg.ROData diff --git a/compileopts/options.go b/compileopts/options.go index bc462b29bd..30e0e4dbed 100644 --- a/compileopts/options.go +++ b/compileopts/options.go @@ -12,7 +12,7 @@ var ( validGCOptions = []string{"none", "leaking", "conservative", "custom", "precise"} validSchedulerOptions = []string{"none", "tasks", "asyncify"} validSerialOptions = []string{"none", "uart", "usb", "rtt"} - validPrintSizeOptions = []string{"none", "short", "full"} + validPrintSizeOptions = []string{"none", "short", "full", "html"} validPanicStrategyOptions = []string{"print", "trap"} validOptOptions = []string{"none", "0", "1", "2", "s", "z"} ) diff --git a/compileopts/options_test.go b/compileopts/options_test.go index 23ffec465f..ee63c4c46d 100644 --- a/compileopts/options_test.go +++ b/compileopts/options_test.go @@ -11,7 +11,7 @@ func TestVerifyOptions(t *testing.T) { expectedGCError := errors.New(`invalid gc option 'incorrect': valid values are none, leaking, conservative, custom, precise`) expectedSchedulerError := errors.New(`invalid scheduler option 'incorrect': valid values are none, tasks, asyncify`) - expectedPrintSizeError := errors.New(`invalid size option 'incorrect': valid values are none, short, full`) + expectedPrintSizeError := errors.New(`invalid size option 'incorrect': valid values are none, short, full, html`) expectedPanicStrategyError := errors.New(`invalid panic option 'incorrect': valid values are print, trap`) testCases := []struct { diff --git a/main.go b/main.go index 8ae5ce316a..9f0cc631a9 100644 --- a/main.go +++ b/main.go @@ -1509,7 +1509,7 @@ func main() { stackSize = uint64(size) return err }) - printSize := flag.String("size", "", "print sizes (none, short, full)") + printSize := flag.String("size", "", "print sizes (none, short, full, html)") printStacks := flag.Bool("print-stacks", false, "print stack sizes of goroutines") printAllocsString := flag.String("print-allocs", "", "regular expression of functions for which heap allocations should be printed") printCommands := flag.Bool("x", false, "Print commands") From e09240e29f6c3dbca19705b42641bfb3b48461f2 Mon Sep 17 00:00:00 2001 From: Ayke van Laethem Date: Tue, 17 Dec 2024 11:30:22 +0100 Subject: [PATCH 2/2] builder: show files in size report table Show which files cause a binary size increase. This makes it easier to see where the size is going: for example, this makes it easy to see how much the GC contributes to code size compared to other runtime parts. --- builder/size-report.html | 41 ++++++++++++++- builder/sizes.go | 109 ++++++++++++++++++++++++++------------- 2 files changed, 112 insertions(+), 38 deletions(-) diff --git a/builder/size-report.html b/builder/size-report.html index d9c4822b97..2afb5c43d1 100644 --- a/builder/size-report.html +++ b/builder/size-report.html @@ -11,6 +11,12 @@ border-left: calc(var(--bs-border-width) * 2) solid currentcolor; } +/* Hover on only the rows that are clickable. */ +.row-package:hover > * { + --bs-table-color-state: var(--bs-table-hover-color); + --bs-table-bg-state: var(--bs-table-hover-bg); +} + @@ -29,6 +35,9 @@

Size Report for {{.pkgName}}

The binary size consists of code, read-only data, and data. On microcontrollers, this is exactly the size of the firmware image. On other systems, there is some extra overhead: binary metadata (headers of the ELF/MachO/COFF file), debug information, exception tables, symbol names, etc. Using -no-debug strips most of those.

Program breakdown

+ +

You can click on the rows below to see which files contribute to the binary size.

+
@@ -42,8 +51,8 @@

Program breakdown

- {{range .sizes}} - + {{range $i, $pkg := .sizes}} + @@ -53,6 +62,24 @@

Program breakdown

{{.Size.Flash}} + {{range $filename, $sizes := .Size.Sub}} + + + + + + + + + {{end}} {{end}} @@ -68,5 +95,15 @@

Program breakdown

{{.Name}} {{.Size.Code}} {{.Size.ROData}}
+ {{if eq $filename ""}} + (unknown file) + {{else}} + {{$filename}} + {{end}} + {{$sizes.Code}}{{$sizes.ROData}}{{$sizes.Data}}{{$sizes.BSS}} + {{$sizes.Flash}} +
+ diff --git a/builder/sizes.go b/builder/sizes.go index 7e6eefb3c5..485a652d97 100644 --- a/builder/sizes.go +++ b/builder/sizes.go @@ -53,6 +53,20 @@ func (ps *programSize) RAM() uint64 { return ps.Data + ps.BSS } +// Return the package size information for a given package path, creating it if +// it doesn't exist yet. +func (ps *programSize) getPackage(path string) *packageSize { + if field, ok := ps.Packages[path]; ok { + return field + } + field := &packageSize{ + Program: ps, + Sub: map[string]*packageSize{}, + } + ps.Packages[path] = field + return field +} + // packageSize contains the size of a package, calculated from the linked object // file. type packageSize struct { @@ -61,6 +75,7 @@ type packageSize struct { ROData uint64 Data uint64 BSS uint64 + Sub map[string]*packageSize } // Flash usage in regular microcontrollers. @@ -79,6 +94,25 @@ func (ps *packageSize) FlashPercent() float64 { return float64(ps.Flash()) / float64(ps.Program.Flash()) * 100 } +// Add a single size data point to this package. +// This must only be called while calculating package size, not afterwards. +func (ps *packageSize) addSize(getField func(*packageSize, bool) *uint64, filename string, size uint64, isVariable bool) { + if size == 0 { + return + } + + // Add size for the package. + *getField(ps, isVariable) += size + + // Add size for file inside package. + sub, ok := ps.Sub[filename] + if !ok { + sub = &packageSize{Program: ps.Program} + ps.Sub[filename] = sub + } + *getField(sub, isVariable) += size +} + // A mapping of a single chunk of code or data to a file path. type addressLine struct { Address uint64 @@ -796,40 +830,32 @@ func loadProgramSize(path string, packagePathMap map[string]string) (*programSiz program := &programSize{ Packages: sizes, } - getSize := func(path string) *packageSize { - if field, ok := sizes[path]; ok { - return field - } - field := &packageSize{Program: program} - sizes[path] = field - return field - } for _, section := range sections { switch section.Type { case memoryCode: - readSection(section, addresses, func(path string, size uint64, isVariable bool) { - field := getSize(path) + readSection(section, addresses, program, func(ps *packageSize, isVariable bool) *uint64 { if isVariable { - field.ROData += size - } else { - field.Code += size + return &ps.ROData } + return &ps.Code }, packagePathMap) case memoryROData: - readSection(section, addresses, func(path string, size uint64, isVariable bool) { - getSize(path).ROData += size + readSection(section, addresses, program, func(ps *packageSize, isVariable bool) *uint64 { + return &ps.ROData }, packagePathMap) case memoryData: - readSection(section, addresses, func(path string, size uint64, isVariable bool) { - getSize(path).Data += size + readSection(section, addresses, program, func(ps *packageSize, isVariable bool) *uint64 { + return &ps.Data }, packagePathMap) case memoryBSS: - readSection(section, addresses, func(path string, size uint64, isVariable bool) { - getSize(path).BSS += size + readSection(section, addresses, program, func(ps *packageSize, isVariable bool) *uint64 { + return &ps.BSS }, packagePathMap) case memoryStack: // We store the C stack as a pseudo-package. - getSize("C stack").BSS += section.Size + program.getPackage("C stack").addSize(func(ps *packageSize, isVariable bool) *uint64 { + return &ps.BSS + }, "", section.Size, false) } } @@ -844,8 +870,8 @@ func loadProgramSize(path string, packagePathMap map[string]string) (*programSiz } // readSection determines for each byte in this section to which package it -// belongs. It reports this usage through the addSize callback. -func readSection(section memorySection, addresses []addressLine, addSize func(string, uint64, bool), packagePathMap map[string]string) { +// belongs. +func readSection(section memorySection, addresses []addressLine, program *programSize, getField func(*packageSize, bool) *uint64, packagePathMap map[string]string) { // The addr variable tracks at which address we are while going through this // section. We start at the beginning. addr := section.Address @@ -867,9 +893,9 @@ func readSection(section memorySection, addresses []addressLine, addSize func(st addrAligned := (addr + line.Align - 1) &^ (line.Align - 1) if line.Align > 1 && addrAligned >= line.Address { // It is, assume that's what causes the gap. - addSize("(padding)", line.Address-addr, true) + program.getPackage("(padding)").addSize(getField, "", line.Address-addr, true) } else { - addSize("(unknown)", line.Address-addr, false) + program.getPackage("(unknown)").addSize(getField, "", line.Address-addr, false) if sizesDebug { fmt.Printf("%08x..%08x %5d: unknown (gap), alignment=%d\n", addr, line.Address, line.Address-addr, line.Align) } @@ -891,7 +917,8 @@ func readSection(section memorySection, addresses []addressLine, addSize func(st length = line.Length - (addr - line.Address) } // Finally, mark this chunk of memory as used by the given package. - addSize(findPackagePath(line.File, packagePathMap), length, line.IsVariable) + packagePath, filename := findPackagePath(line.File, packagePathMap) + program.getPackage(packagePath).addSize(getField, filename, length, line.IsVariable) addr = line.Address + line.Length } if addr < sectionEnd { @@ -900,9 +927,9 @@ func readSection(section memorySection, addresses []addressLine, addSize func(st if section.Align > 1 && addrAligned >= sectionEnd { // The gap is caused by the section alignment. // For example, if a .rodata section ends with a non-aligned string. - addSize("(padding)", sectionEnd-addr, true) + program.getPackage("(padding)").addSize(getField, "", sectionEnd-addr, true) } else { - addSize("(unknown)", sectionEnd-addr, false) + program.getPackage("(unknown)").addSize(getField, "", sectionEnd-addr, false) if sizesDebug { fmt.Printf("%08x..%08x %5d: unknown (end), alignment=%d\n", addr, sectionEnd, sectionEnd-addr, section.Align) } @@ -912,17 +939,25 @@ func readSection(section memorySection, addresses []addressLine, addSize func(st // findPackagePath returns the Go package (or a pseudo package) for the given // path. It uses some heuristics, for example for some C libraries. -func findPackagePath(path string, packagePathMap map[string]string) string { +func findPackagePath(path string, packagePathMap map[string]string) (packagePath, filename string) { // Check whether this path is part of one of the compiled packages. packagePath, ok := packagePathMap[filepath.Dir(path)] - if !ok { + if ok { + // Directory is known as a Go package. + // Add the file itself as well. + filename = filepath.Base(path) + } else { if strings.HasPrefix(path, filepath.Join(goenv.Get("TINYGOROOT"), "lib")) { // Emit C libraries (in the lib subdirectory of TinyGo) as a single - // package, with a "C" prefix. For example: "C compiler-rt" for the - // compiler runtime library from LLVM. - packagePath = "C " + strings.Split(strings.TrimPrefix(path, filepath.Join(goenv.Get("TINYGOROOT"), "lib")), string(os.PathSeparator))[1] - } else if strings.HasPrefix(path, filepath.Join(goenv.Get("TINYGOROOT"), "llvm-project")) { + // package, with a "C" prefix. For example: "C picolibc" for the + // baremetal libc. + libPath := strings.TrimPrefix(path, filepath.Join(goenv.Get("TINYGOROOT"), "lib")+string(os.PathSeparator)) + parts := strings.SplitN(libPath, string(os.PathSeparator), 2) + packagePath = "C " + parts[0] + filename = parts[1] + } else if prefix := filepath.Join(goenv.Get("TINYGOROOT"), "llvm-project", "compiler-rt"); strings.HasPrefix(path, prefix) { packagePath = "C compiler-rt" + filename = strings.TrimPrefix(path, prefix+string(os.PathSeparator)) } else if packageSymbolRegexp.MatchString(path) { // Parse symbol names like main$alloc or runtime$string. packagePath = path[:strings.LastIndex(path, "$")] @@ -945,9 +980,11 @@ func findPackagePath(path string, packagePathMap map[string]string) string { // fixed in the compiler. packagePath = "-" } else { - // This is some other path. Not sure what it is, so just emit its directory. - packagePath = filepath.Dir(path) // fallback + // This is some other path. Not sure what it is, so just emit its + // directory as a fallback. + packagePath = filepath.Dir(path) + filename = filepath.Base(path) } } - return packagePath + return }