Files
tinygo/builder/sizes_test.go
T
2026-08-26 13:00:31 +02:00

227 lines
6.4 KiB
Go

package builder
import (
"flag"
"fmt"
"os"
"regexp"
"runtime"
"strconv"
"strings"
"testing"
"time"
"github.com/tinygo-org/tinygo/compileopts"
)
var sema = make(chan struct{}, runtime.NumCPU())
var flagUpdate = flag.Bool("update", false, "update builder package tests")
type sizeTest struct {
target string
path string
}
// Test whether code and data size is as expected for the given targets.
// This tests both the logic of loadProgramSize and checks that code size
// doesn't change unintentionally.
//
// If you find that code or data size is reduced, then great! You can update the
// golden file by passing -update to the test.
// If you find that the code or data size is increased, take a look as to why
// this is. It could be due to an update (LLVM version, Go version, etc) which
// is fine, but it could also mean that a recent change introduced this size
// increase. If so, please consider whether this new feature is indeed worth the
// size increase for all users.
func TestBinarySize(t *testing.T) {
if runtime.GOOS == "linux" && !hasBuiltinTools {
// Debian LLVM packages are modified a bit and tend to produce
// different machine code. Ideally we'd fix this (with some attributes
// or something?), but for now skip it.
t.Skip("Skip: using external LLVM version so binary size might differ")
}
// This is a small number of very diverse targets that we want to test.
tests := []sizeTest{
// microcontrollers
{"hifive1b", "examples/echo"},
{"microbit", "examples/serial"},
{"wioterminal", "examples/pininterrupt"},
// TODO: also check wasm. Right now this is difficult, because
// wasm binaries are run through wasm-opt and therefore the
// output varies by binaryen version.
}
sizes := measureBinarySizes(t, tests)
output := formatSizeTable(tests, sizes)
checkGolden(t, "testdata/binary-size.txt", output)
}
func checkGolden(t *testing.T, path, actual string) {
t.Helper()
if *flagUpdate {
if err := os.WriteFile(path, []byte(actual), 0o666); err != nil {
t.Fatal("failed to write updated golden file:", err)
}
return
}
expected, err := os.ReadFile(path)
if err != nil {
t.Fatal("failed to read golden file:", err)
}
if actual != string(expected) {
t.Errorf("%s does not match expected output (re-run with -update to regenerate):\nexpected:\n%sactual:\n%s", path, expected, actual)
}
}
func measureBinarySizes(t *testing.T, tests []sizeTest) []*programSize {
t.Helper()
type result struct {
index int
size *programSize
err error
}
results := make(chan result, len(tests))
for i, tc := range tests {
tmpdir := t.TempDir()
go func() {
size, err := measureBinarySize(tc, tmpdir)
results <- result{i, size, err}
}()
}
sizes := make([]*programSize, len(tests))
failed := false
for range tests {
result := <-results
if result.err != nil {
tc := tests[result.index]
t.Errorf("%s/%s: %v", tc.target, tc.path, result.err)
failed = true
}
sizes[result.index] = result.size
}
if failed {
t.FailNow()
}
return sizes
}
func measureBinarySize(tc sizeTest, tmpdir string) (*programSize, error) {
result, err := buildBinaryInDir(tc.target, tc.path, tmpdir)
if err != nil {
return nil, err
}
size, err := loadProgramSize(result.Executable, nil)
if err != nil {
return nil, fmt.Errorf("could not read program size: %w", err)
}
return size, nil
}
func formatSizeTable(tests []sizeTest, sizes []*programSize) string {
targetWidth := len("target")
packageWidth := len("package")
codeWidth := len("code")
rodataWidth := len("rodata")
dataWidth := len("data")
bssWidth := len("bss")
for i, tc := range tests {
targetWidth = max(targetWidth, len(tc.target))
packageWidth = max(packageWidth, len(tc.path))
codeWidth = max(codeWidth, len(strconv.FormatUint(sizes[i].Code, 10)))
rodataWidth = max(rodataWidth, len(strconv.FormatUint(sizes[i].ROData, 10)))
dataWidth = max(dataWidth, len(strconv.FormatUint(sizes[i].Data, 10)))
bssWidth = max(bssWidth, len(strconv.FormatUint(sizes[i].BSS, 10)))
}
var output strings.Builder
fmt.Fprintf(&output, "%-*s %-*s %*s %*s %*s %*s\n",
targetWidth, "target", packageWidth, "package",
codeWidth, "code", rodataWidth, "rodata", dataWidth, "data", bssWidth, "bss")
for i, tc := range tests {
size := sizes[i]
fmt.Fprintf(&output, "%-*s %-*s %*d %*d %*d %*d\n",
targetWidth, tc.target, packageWidth, tc.path,
codeWidth, size.Code, rodataWidth, size.ROData,
dataWidth, size.Data, bssWidth, size.BSS)
}
return output.String()
}
// Check that the -size=full flag attributes binary size to the correct package
// without filesystem paths and things like that.
func TestSizeFull(t *testing.T) {
tests := []string{
"microbit",
"wasip1",
}
libMatch := regexp.MustCompile(`^C [a-z -]+$`) // example: "C interrupt vector"
pkgMatch := regexp.MustCompile(`^[a-z/]+$`) // example: "internal/task"
for _, target := range tests {
t.Run(target, func(t *testing.T) {
t.Parallel()
// Build the binary.
result := buildBinary(t, target, "examples/serial")
// Check whether the binary doesn't contain any unexpected package
// names.
sizes, err := loadProgramSize(result.Executable, result.PackagePathMap)
if err != nil {
t.Fatal("could not read program size:", err)
}
for _, pkg := range sizes.sortedPackageNames() {
if pkg == "(padding)" || pkg == "(unknown)" || pkg == "Go types" {
// TODO: correctly attribute all unknown binary size.
continue
}
if libMatch.MatchString(pkg) {
continue
}
if pkgMatch.MatchString(pkg) {
continue
}
t.Error("unexpected package name in size output:", pkg)
}
})
}
}
func buildBinary(t *testing.T, targetString, pkgName string) BuildResult {
t.Helper()
result, err := buildBinaryInDir(targetString, pkgName, t.TempDir())
if err != nil {
t.Fatal(err)
}
return result
}
func buildBinaryInDir(targetString, pkgName, tmpdir string) (BuildResult, error) {
options := compileopts.Options{
Target: targetString,
Opt: "z",
Semaphore: sema,
InterpTimeout: 60 * time.Second,
Debug: true,
VerifyIR: true,
}
target, err := compileopts.LoadTarget(&options)
if err != nil {
return BuildResult{}, fmt.Errorf("could not load target: %w", err)
}
config := &compileopts.Config{
Options: &options,
Target: target,
}
result, err := Build(pkgName, "", tmpdir, config)
if err != nil {
return BuildResult{}, fmt.Errorf("could not build: %w", err)
}
return result, nil
}