From 1e77e582506aff364cfe108ffce422bc2709e638 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Balv=C3=ADn?= Date: Sat, 19 Sep 2026 19:27:30 +0200 Subject: [PATCH] feat(gasm): audit a .s corpus with audit-instructions --corpus Assisted-by: GLM 5.3 Flash --- cmd/gasm/audit.go | 207 +++++++++++++++++++++++++++++++++++++++++- cmd/gasm/main_test.go | 55 +++++++++++ docs/CLI.md | 26 +++++- 3 files changed, 286 insertions(+), 2 deletions(-) diff --git a/cmd/gasm/audit.go b/cmd/gasm/audit.go index d78b0bb..6c25e55 100644 --- a/cmd/gasm/audit.go +++ b/cmd/gasm/audit.go @@ -37,17 +37,29 @@ import ( // construction and are excluded from the diff; the other architectures list // their conditional branches outright. func cmdAuditInstructions(args []string) error { - fs := newCommand("audit-instructions", "gasm audit-instructions [amd64|arm64|riscv64|loong64]", ` + fs := newCommand("audit-instructions", "gasm audit-instructions [--corpus [dir]] [amd64|arm64|riscv64|loong64]", ` Compare the gasm encoder for the given architecture (default amd64) against go tool asm and print the diff: superset encodings (gasm-only, shippable via gasm asm --format goobj), known-but-unencodable names (the backlog) and go- only names (feature gaps). The Go side is probed black-box with a battery of bare mnemonics, so the audit tracks whatever toolchain `+"`go env GOROOT`"+` provides. + +With --corpus the audit changes shape: it assembles every .s file under the +given directory (default GOROOT/src) with the gasm encoder only, no +toolchain probing. A file whose name carries a recognisable _arch suffix is +attempted for that architecture; a file without one is attempted for all +four, exactly as a GOARCH build would compile it. The report gives the +per-architecture pass rates and the most common failure reasons, which drive +the encodability backlog by frequency rather than by table order. `) + corpus := fs.Bool("corpus", false, "assemble a corpus of .s files and report pass rates and failure reasons") if err := fs.Parse(args); err != nil { return err } + if *corpus { + return cmdAuditCorpus(fs.Args()) + } archName := "amd64" switch n := len(fs.Args()); { case n > 1: @@ -305,3 +317,196 @@ func gasmAssembles(a arch.Arch, name, shape string) bool { func sanitize(name string) string { return strings.NewReplacer(".", "_", "$", "_").Replace(name) } + +// --- corpus audit ----------------------------------------------------------- + +// corpusTarget is one architecture row of the corpus report. +type corpusTarget struct { + a arch.Arch + name string +} + +// corpusTally accumulates one architecture's attempts over the corpus. +type corpusTally struct { + attempted int + assembled int + reasons map[string]int // failure reason → count + example map[string]string // failure reason → one representative file +} + +func (t *corpusTally) fail(path, reason string) { + t.reasons[reason]++ + if t.example[reason] == "" { + t.example[reason] = path + } +} + +// cmdAuditCorpus implements audit-instructions --corpus. +func cmdAuditCorpus(args []string) error { + if len(args) > 1 { + return fmt.Errorf("audit-instructions --corpus takes at most one directory argument") + } + root := "" + if len(args) == 1 { + root = args[0] + } else { + out, err := exec.Command("go", "env", "GOROOT").Output() + if err != nil { + return fmt.Errorf("locate GOROOT: %w", err) + } + root = filepath.Join(strings.TrimSpace(string(out)), "src") + } + stats, err := runCorpusAudit(root) + if err != nil { + return err + } + printCorpusStats(stats) + return nil +} + +// corpusStats is the outcome of one corpus audit run. +type corpusStats struct { + root string + files int + generic int // files attempted for all four architectures + full int // files that assembled for every target architecture + targets []corpusTarget + tallies []*corpusTally +} + +// runCorpusAudit assembles every .s file under root and returns the stats. +func runCorpusAudit(root string) (*corpusStats, error) { + files, err := asmFiles(root) + if err != nil { + return nil, err + } + + targets := []corpusTarget{ + {arch.AMD64, "amd64"}, + {arch.ARM64, "arm64"}, + {arch.RISCV, "riscv64"}, + {arch.LOONG64, "loong64"}, + } + tallies := make([]*corpusTally, len(targets)) + for i := range tallies { + tallies[i] = &corpusTally{reasons: map[string]int{}, example: map[string]string{}} + } + // full is the north-star number: a file counts when every architecture + // its name allows assembles it. + full, generic := 0, 0 + + for _, path := range files { + src, err := readSource(path) + if err != nil { + return nil, err + } + f, errs := parser.Parse(path, src) + + var wanted []int // indexes into targets + if a := arch.FromFilename(path); a != arch.Unknown { + for i, tg := range targets { + if tg.a == a { + wanted = append(wanted, i) + } + } + } else { + generic++ + for i := range targets { + wanted = append(wanted, i) + } + } + + ok := true + for _, i := range wanted { + tg, t := targets[i], tallies[i] + t.attempted++ + var err error + if len(errs) > 0 { + err = errs[0] // a parse failure is a failure for every target + } else { + _, err = assembleFile(tg.a, f) + } + if err != nil { + ok = false + t.fail(path, corpusReason(err)) + continue + } + t.assembled++ + } + if ok && len(wanted) > 0 { + full++ + } + } + + return &corpusStats{ + root: root, + files: len(files), + generic: generic, + full: full, + targets: targets, + tallies: tallies, + }, nil +} + +// printCorpusStats renders the corpus audit report. +func printCorpusStats(s *corpusStats) { + fmt.Printf("corpus %s: %d files (%d generic, attempted for all architectures)\n", s.root, s.files, s.generic) + fmt.Printf(" assemble for every target architecture: %d (%.1f%%)\n", s.full, 100*float64(s.full)/float64(max(s.files, 1))) + for i, tg := range s.targets { + t := s.tallies[i] + fmt.Printf(" %s: %d/%d attempted\n", tg.name, t.assembled, t.attempted) + for _, r := range topReasons(t) { + fmt.Printf(" %4d %s\n", t.reasons[r], r) + fmt.Printf(" e.g. %s\n", t.example[r]) + } + } +} + +// corpusReason buckets an assembly or parse failure for the histogram. +func corpusReason(err error) string { + msg := err.Error() + switch { + case strings.Contains(msg, "unsupported"), strings.Contains(msg, "cannot encode"): + return "instruction not encodable" + case strings.Contains(msg, "undefined label"): + return "undefined label" + case strings.Contains(msg, "undefined symbol"), strings.Contains(msg, "external symbol"), strings.Contains(msg, "file-level assembly"): + return "undefined symbol or external" + case strings.Contains(msg, "operand"), strings.Contains(msg, "operand form"): + return "unsupported operand form" + default: + return "other: " + firstLine(msg) + } +} + +// topReasons returns at most five reasons, most frequent first. +func topReasons(t *corpusTally) []string { + type kv struct { + k string + n int + } + var kvs []kv + for k, n := range t.reasons { + kvs = append(kvs, kv{k, n}) + } + slices.SortFunc(kvs, func(a, b kv) int { return b.n - a.n }) + if len(kvs) > 5 { + kvs = kvs[:5] + } + out := make([]string, len(kvs)) + for i, kv := range kvs { + out[i] = kv.k + } + return out +} + +// firstLine returns the first line of an error message, truncated. +func firstLine(msg string) string { + if i := strings.IndexByte(msg, '\n'); i >= 0 { + msg = msg[:i] + } + if len(msg) > 80 { + msg = msg[:80] + } + return msg +} diff --git a/cmd/gasm/main_test.go b/cmd/gasm/main_test.go index 83f6ffd..f806f8e 100644 --- a/cmd/gasm/main_test.go +++ b/cmd/gasm/main_test.go @@ -293,3 +293,58 @@ func TestSweepCheckLines(t *testing.T) { t.Errorf("sweepCheckLines = %q, want %q", got, want) } } + +// TestRunCorpusAudit drives the corpus audit over a small fixture tree: one +// suffixed amd64 file, one suffixed arm64 file whose body is not arm64, one +// generic file, and one file that does not parse. +func TestRunCorpusAudit(t *testing.T) { + dir := t.TempDir() + write := func(name, src string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, name), []byte(src), 0o644); err != nil { + t.Fatal(err) + } + } + write("good_amd64.s", "#include \"textflag.h\"\nTEXT ·add(SB), NOSPLIT, $0-0\n\tMOVQ AX, BX\n\tRET\n") + write("bad_arm64.s", "#include \"textflag.h\"\nTEXT ·f(SB), NOSPLIT, $0-0\n\tMOVQ AX, BX\n\tRET\n") + write("generic.s", "#include \"textflag.h\"\nTEXT ·g(SB), NOSPLIT, $0-0\n\tRET\n") + write("broken.s", "#include \"textflag.h\"\nTEXT ·b(SB), NOSPLIT, $0-0\n\tJMP nowhere\n\tRET\n") + + stats, err := runCorpusAudit(dir) + if err != nil { + t.Fatalf("runCorpusAudit: %v", err) + } + if stats.files != 4 { + t.Errorf("files = %d, want 4", stats.files) + } + if stats.generic != 2 { + t.Errorf("generic = %d, want 2 (generic.s and broken.s)", stats.generic) + } + // good_amd64 and generic.s assemble everywhere they are attempted. + if stats.full != 2 { + t.Errorf("full = %d, want 2", stats.full) + } + get := func(name string) *corpusTally { + for i, tg := range stats.targets { + if tg.name == name { + return stats.tallies[i] + } + } + t.Fatalf("no tally for %s", name) + return nil + } + // amd64: good_amd64 + generic.s + broken.s; the broken file fails to parse. + if a := get("amd64"); a.attempted != 3 || a.assembled != 2 { + t.Errorf("amd64 = %d/%d, want 2/3", a.assembled, a.attempted) + } + // arm64: bad_arm64 (MOVQ is not arm64) + generic.s + broken.s. + if a := get("arm64"); a.attempted != 3 || a.assembled != 1 { + t.Errorf("arm64 = %d/%d, want 1/3", a.assembled, a.attempted) + } + if r := get("amd64").reasons["instruction not encodable"]; r != 0 { + t.Errorf("amd64 unexpected unencodable reason: %d", r) + } + if r := get("arm64").reasons["instruction not encodable"]; r != 1 { + t.Errorf("arm64 unencodable reasons = %d, want 1", r) + } +} diff --git a/docs/CLI.md b/docs/CLI.md index ec72e51..9d7b2f5 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -338,7 +338,7 @@ add: 16 bytes, args=24, frame=0 NOSPLIT ## audit-instructions ```text -Usage: gasm audit-instructions [amd64|arm64|riscv64|loong64] +Usage: gasm audit-instructions [--corpus [dir]] [amd64|arm64|riscv64|loong64] ``` Compare the gasm encoder for the given architecture (default amd64) against the @@ -361,6 +361,30 @@ gasm encodable: 580 go tool asm recognized: 1542 shared: 580 ``` +With `--corpus` the audit changes shape: it assembles every `.s` file under +DIR (default `GOROOT/src`) with the gasm encoder only, no toolchain probing. +A file whose name carries a recognisable `_arch` suffix is attempted for that +architecture; a file without one is attempted for all four, exactly as a +`GOARCH` build would compile it. The report gives the headline number (files +that assemble for every target architecture), the per-architecture pass rates +and the most common failure reasons with one representative file each, which +drive the encodability backlog by frequency rather than by table order. A run +over GOROOT takes under a second. + +```sh +gasm audit-instructions --corpus +gasm audit-instructions --corpus "$(go env GOROOT)/src/crypto" +``` + +```text +corpus /usr/local/go/src: 627 files (365 generic, attempted for all architectures) + assemble for every target architecture: 108 (17.2%) + amd64: 77/464 attempted + 148 instruction not encodable + e.g. /usr/local/go/src/cmd/asm/internal/asm/testdata/386enc.s +... +``` + ## scaffold ```text