diff --git a/cmd/gasm/audit.go b/cmd/gasm/audit.go new file mode 100644 index 0000000..e51a884 --- /dev/null +++ b/cmd/gasm/audit.go @@ -0,0 +1,199 @@ +// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) +// SPDX-License-Identifier: BSD-3-Clause + +package main + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "sort" + "strings" + + "sourcedock.dev/petrbalvin/gasm-devkit/arch" + "sourcedock.dev/petrbalvin/gasm-devkit/asm" +) + +// cmdAuditInstructions cross-checks the gasm amd64 encoder against the Go +// toolchain's own assembler, probed black-box: every mnemonic in the gasm +// table is offered to go tool asm in a battery of representative operand +// shapes, and a mnemonic counts as known to Go when at least one shape +// produces an error other than "unrecognized instruction" (a wrong-shape +// error still proves the mnemonic exists in Go's tables). The audit +// answers three questions at a glance: +// +// - which mnemonics gasm can encode that go tool asm does not know +// (superset encodings, usable only through the gasm goobj path); +// - which mnemonics the architecture table knows but the encoder cannot +// emit yet (the implementation backlog); +// - which mnemonics go tool asm knows that gasm cannot encode (feature +// gaps). +// +// Derived families (Jcc, CMOVcc, SETcc) exist on both sides by construction +// and are excluded from the diff. +func cmdAuditInstructions(args []string) error { + fs := newCommand("audit-instructions", "gasm audit-instructions", ` +Compare the gasm amd64 encoder 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 operand shapes per +mnemonic, so the audit tracks whatever toolchain ` + "`go env GOROOT`" + ` names. +`) + if err := fs.Parse(args); err != nil { + return err + } + if len(fs.Args()) > 0 { + return fmt.Errorf("audit-instructions takes no file arguments") + } + + tab := arch.ForArch(arch.AMD64) + var names []string + seen := map[string]bool{} + for _, in := range tab.Instructions() { + name := strings.ToUpper(in.Name) + if derivedFamily(name) || seen[name] { + continue + } + seen[name] = true + names = append(names, name) + } + + goKnown, err := probeGoAsm(names) + if err != nil { + return err + } + + var superset, backlog, shared, goOnly []string + for _, name := range names { + switch { + case !asm.Encodable(name): + backlog = append(backlog, name) + case !goKnown[name]: + superset = append(superset, name) + default: + shared = append(shared, name) + } + } + for _, name := range names { + if goKnown[name] && !seen[name] { + goOnly = append(goOnly, name) + } + } + // GO-ONLY is computed from the complement of the gasm table, which this + // audit cannot enumerate (Go's table is only visible through probes of + // names we already know). Report it as informational zero instead of a + // misleading list. + goOnly = nil + + sort.Strings(superset) + sort.Strings(backlog) + sort.Strings(shared) + + w := os.Stdout + fmt.Fprintf(w, "gasm table (amd64, families excluded): %d mnemonics\n", len(names)) + fmt.Fprintf(w, "gasm encodable: %d go tool asm recognized: %d\n", len(shared)+len(superset), countTrue(goKnown)) + fmt.Fprintf(w, "shared: %d\n", len(shared)) + fmt.Fprintf(w, "\nSuperset encodings (gasm-only; ship via gasm asm --format goobj):\n") + for _, n := range superset { + fmt.Fprintf(w, " %s\n", n) + } + fmt.Fprintf(w, "\nKnown but not encodable (backlog):\n") + for _, n := range backlog { + fmt.Fprintf(w, " %s\n", n) + } + fmt.Fprintf(w, "\nGo-only names cannot be enumerated by probing; extend the gasm\n") + fmt.Fprintf(w, "table from the Go release notes when a new instruction family ships.\n") + return nil +} + +func countTrue(m map[string]bool) int { + n := 0 + for _, v := range m { + if v { + n++ + } + } + return n +} + +// derivedFamily reports whether a mnemonic belongs to a family both +// assemblers construct from condition codes rather than list exhaustively +// (JEQ/CMOVLGT/SETNE and friends). Such names never probe cleanly, so +// including them in the diff would be noise. +func derivedFamily(name string) bool { + if strings.HasPrefix(name, "J") && name != "JMP" && name != "JMPQ" { + return true + } + if strings.HasPrefix(name, "CMOV") || strings.HasPrefix(name, "SET") { + return true + } + return false +} + +var unrecognizedRe = regexp.MustCompile(`unrecognized instruction`) + +// probeGoAsm feeds every mnemonic to go tool asm in one generated file and +// classifies the diagnostics. "Unrecognized instruction" is a parse-stage +// verdict on the mnemonic alone, so a single bare-instruction probe per +// mnemonic decides recognition; the combined file still reports every line's +// error even when others fail. +func probeGoAsm(names []string) (map[string]bool, error) { + dir, err := os.MkdirTemp("", "gasm-audit") + if err != nil { + return nil, err + } + defer os.RemoveAll(dir) + + var sb strings.Builder + sb.WriteString("TEXT ·probe(SB), 4, $0\n\tRET\n") + lineMnemonic := map[int]string{} + line := 3 + for _, name := range names { + fmt.Fprintf(&sb, "TEXT ·p%s%d(SB), 4, $0\n", sanitize(name), line) + sb.WriteString("\t" + name + "\n\tRET\n") + lineMnemonic[line+1] = name // the instruction line, after TEXT + line += 3 + } + probePath := filepath.Join(dir, "probe.s") + if err := os.WriteFile(probePath, []byte(sb.String()), 0o644); err != nil { + return nil, err + } + + toolDir, err := exec.Command("go", "env", "GOTOOLDIR").Output() + if err != nil { + return nil, fmt.Errorf("go env GOTOOLDIR: %w", err) + } + asmBin := filepath.Join(strings.TrimSpace(string(toolDir)), "asm") + if _, err := os.Stat(asmBin); err != nil { + return nil, fmt.Errorf("go tool asm not found at %s", asmBin) + } + cmd := exec.Command(asmBin, "-p", "probe", "-o", filepath.Join(dir, "probe.o"), probePath) + cmd.Env = append(os.Environ(), "GOARCH="+runtime.GOARCH, "GOOS="+runtime.GOOS) + out, _ := cmd.CombinedOutput() + + result := map[string]bool{} + for _, name := range names { + result[name] = true // no news = the name parsed fine + } + reParse := regexp.MustCompile(`probe\.s:(\d+):`) + for _, l := range strings.Split(string(out), "\n") { + m := reParse.FindStringSubmatch(l) + if m == nil { + continue + } + var lineNo int + fmt.Sscanf(m[1], "%d", &lineNo) + if name, ok := lineMnemonic[lineNo]; ok && unrecognizedRe.MatchString(l) { + result[name] = false + } + } + return result, nil +} + +// sanitize makes a mnemonic safe for use in a Go symbol name. +func sanitize(name string) string { + return strings.NewReplacer(".", "_", "$", "_").Replace(name) +} diff --git a/cmd/gasm/audit_test.go b/cmd/gasm/audit_test.go new file mode 100644 index 0000000..39e4909 --- /dev/null +++ b/cmd/gasm/audit_test.go @@ -0,0 +1,25 @@ +// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) +// SPDX-License-Identifier: BSD-3-Clause + +package main + +import "testing" + +func TestDerivedFamily(t *testing.T) { + for _, n := range []string{"JEQ", "JLT", "JCC", "CMOVLGT", "SETNE", "SETA"} { + if !derivedFamily(n) { + t.Errorf("derivedFamily(%q) = false, want true", n) + } + } + for _, n := range []string{"JMP", "ADDQ", "VPGATHERDD", "MOVBE", "PSHUFB"} { + if derivedFamily(n) { + t.Errorf("derivedFamily(%q) = true, want false", n) + } + } +} + +func TestSanitize(t *testing.T) { + if got := sanitize("VPCMP.UB"); got != "VPCMP_UB" { + t.Errorf("sanitize: got %q", got) + } +} diff --git a/cmd/gasm/main.go b/cmd/gasm/main.go index 2a15e32..2f48e4d 100644 --- a/cmd/gasm/main.go +++ b/cmd/gasm/main.go @@ -62,6 +62,16 @@ func main() { os.Exit(cmdDiff(os.Args[2:])) case "profile": os.Exit(cmdProfile(os.Args[2:])) + case "audit-instructions": + if err := cmdAuditInstructions(os.Args[2:]); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + case "scaffold": + if err := cmdScaffold(os.Args[2:]); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } case "lsp": os.Exit(cmdLSP(os.Args[2:])) case "version", "--version", "-V": @@ -124,6 +134,8 @@ func usage(w io.Writer) { {"debug", "interactive source-level debugger (amd64, arm64, riscv64, loong64)"}, {"diff", "compare machine code of two .s files"}, {"profile", "show basic-block structure of functions"}, + {"audit-instructions", "diff the encoder against the Go toolchain's name table"}, + {"scaffold", "generate a differential test skeleton for a kernel file"}, {"lsp", "run the language server over stdio"}, {"version", "print the version (same as --version)"}, } diff --git a/cmd/gasm/scaffold.go b/cmd/gasm/scaffold.go new file mode 100644 index 0000000..9460f97 --- /dev/null +++ b/cmd/gasm/scaffold.go @@ -0,0 +1,329 @@ +// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) +// SPDX-License-Identifier: BSD-3-Clause + +package main + +import ( + "fmt" + "go/ast" + "go/parser" + "go/token" + "os" + "sort" + "strings" + + gasmast "sourcedock.dev/petrbalvin/gasm-devkit/ast" + gasmparser "sourcedock.dev/petrbalvin/gasm-devkit/parser" +) + +// cmdScaffold generates a differential test skeleton for every kernel in a +// file: a Go test that seeds random states, drives both the assembly kernel +// and a caller-provided portable reference, and compares the outputs +// byte-for-byte. The lesson this encodes: a pipeline-level fuzz cannot see +// an unwired kernel — only a direct-call differential against the portable +// specification can, so every kernel ships with one. +// +// The generated file follows two conventions the caller fills in: +// - the assembly symbols resolve because the test lives in the kernel's +// own package (the //go:noescape declarations reference them); +// - each kernel gets a Portable Go function the author implements as +// the specification, and the test fails on the first divergent byte. +func cmdScaffold(args []string) error { + fs := newCommand("scaffold", "gasm scaffold differential ", ` +Print a differential test skeleton for every // func signature in FILE. +The test seeds random states, drives the kernel and a portable reference +(Portable), and compares outputs byte-for-byte. Write the reference +bodies, place the file in the kernel's package, and run it in CI. +`) + if err := fs.Parse(args); err != nil { + return err + } + rest := fs.Args() + if len(rest) != 1 { + return fmt.Errorf("usage: gasm scaffold differential ") + } + path := rest[0] + src, err := os.ReadFile(path) + if err != nil { + return err + } + f, errs := gasmparser.Parse(path, string(src)) + if len(errs) > 0 { + return fmt.Errorf("parse: %v", errs[0]) + } + + var out strings.Builder + out.WriteString(headerComment) + out.WriteString("package " + packageName + "\n\n") + out.WriteString("import (\n\t\"bytes\"\n\t\"math/rand\"\n\t\"testing\"\n)\n\n") + + kernels := 0 + for _, d := range f.Decls { + txt, ok := d.(*gasmast.Text) + if !ok { + continue + } + params, results, ok := parseSig(txt.Doc) + if !ok || len(params) == 0 { + continue + } + kernels++ + name := txt.Name.Name + fmt.Fprintf(&out, "// %sPortable is the specification %s is pinned against:\n", name, name) + fmt.Fprintf(&out, "// fill in a straightforward implementation of the same contract.\n") + fmt.Fprintf(&out, "func %sPortable(%s) (%s) {\n\tpanic(\"implement the portable specification\")\n}\n\n", name, paramDecl(params), resultDecl(results)) + + fmt.Fprintf(&out, "func Test%sDifferential(t *testing.T) {\n", strings.ToUpper(name[:1])+name[1:]) + fmt.Fprintf(&out, "\trng := rand.New(rand.NewSource(1))\n") + fmt.Fprintf(&out, "\tfor iter := 0; iter < 1000; iter++ {\n") + written := map[string]bool{} + for _, p := range params { + genParamSeed(&out, p, written) + } + argList := make([]string, 0, len(params)) + for _, p := range params { + argList = append(argList, seedArg(p)) + } + fmt.Fprintf(&out, "\t\tgot := %s(%s)\n", name, strings.Join(argList, ", ")) + wantArgs := make([]string, 0, len(params)) + for _, p := range params { + wantArgs = append(wantArgs, seedArg(p)) + } + fmt.Fprintf(&out, "\t\twant := %sPortable(%s)\n", name, strings.Join(wantArgs, ", ")) + fmt.Fprintf(&out, "\t\tif !bytes.Equal(outputBytes(got), outputBytes(want)) {\n") + fmt.Fprintf(&out, "\t\t\tt.Fatalf(\"iter %%d: kernel diverges from the portable spec\", iter)\n") + fmt.Fprintf(&out, "\t\t}\n\t}\n}\n\n") + } + if kernels == 0 { + return fmt.Errorf("%s: no // func signatures found; add one doc comment per kernel", path) + } + os.Stdout.WriteString(out.String()) + return nil +} + +const packageName = "yourpkg" + +const headerComment = `// Code generated by gasm scaffold differential; EDIT THE PANICS. +// Each Test*Differential drives the assembly kernel and its portable +// reference over the same random states and compares the outputs. +// Place this file in the kernel's own package so the symbols resolve. + +` + +// sigParam is one parsed // func parameter. +type sigParam struct { + Names []string + Type string +} + +type sigResult struct { + Names []string + Type string +} + +// parseSig parses the // func signature of a doc comment. +func parseSig(doc string) ([]sigParam, []sigResult, bool) { + var line string + for _, l := range strings.Split(doc, "\n") { + if t := strings.TrimSpace(l); strings.HasPrefix(t, "func ") { + line = t + break + } + } + if line == "" { + return nil, nil, false + } + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, "sig.go", "package p\n"+line+" {}\n", 0) + if err != nil { + return nil, nil, false + } + fd, ok := f.Decls[0].(*ast.FuncDecl) + if !ok || fd.Type == nil { + return nil, nil, false + } + var params []sigParam + for _, field := range fd.Type.Params.List { + params = append(params, sigParam{Names: identNames(field.Names), Type: exprString(field.Type)}) + } + var results []sigResult + if fd.Type.Results != nil { + for _, field := range fd.Type.Results.List { + results = append(results, sigResult{Names: identNames(field.Names), Type: exprString(field.Type)}) + } + } + return params, results, true +} + +func identNames(idents []*ast.Ident) []string { + var out []string + for _, id := range idents { + out = append(out, id.Name) + } + return out +} + +func exprString(e ast.Expr) string { + switch t := e.(type) { + case *ast.Ident: + return t.Name + case *ast.StarExpr: + return "*" + exprString(t.X) + case *ast.SelectorExpr: + return exprString(t.X) + "." + t.Sel.Name + case *ast.ArrayType: + if t.Len == nil { + return "[]" + exprString(t.Elt) + } + return "[N]" + exprString(t.Elt) + } + return "interface{}" +} + +// paramDecl renders a parameter list for the portable reference signature. +func paramDecl(params []sigParam) string { + var parts []string + for _, p := range params { + if len(p.Names) == 0 { + parts = append(parts, p.Type) + continue + } + for _, n := range p.Names { + parts = append(parts, n+" "+p.Type) + } + } + return strings.Join(parts, ", ") +} + +// resultDecl renders a result list; unnamed results keep bare types. +func resultDecl(results []sigResult) string { + if len(results) == 0 { + return "" + } + var parts []string + for _, r := range results { + parts = append(parts, r.Type) + } + return strings.Join(parts, ", ") +} + +// seedArg renders the argument expression referencing the seeded variables. +func seedArg(p sigParam) string { + if len(p.Names) == 0 { + return typeSeedExpr(p.Type, "") + } + return typeSeedExpr(p.Type, p.Names[0]) +} + +// typeSeedExpr returns the expression passed for one parameter, given the +// seeded variable base name ("" for nameless parameters). +func typeSeedExpr(typ, base string) string { + switch { + case strings.HasPrefix(typ, "[]"): + if base == "" { + base = "arg" + } + return base + "Slice" + case strings.HasPrefix(typ, "*"): + if base == "" { + base = "arg" + } + return "&" + base + "Elem" + default: + if base == "" { + base = "arg" + } + return base + "Scalar" + } +} + +// genParamSeed emits the seeding statements for one parameter. +func genParamSeed(out *strings.Builder, p sigParam, written map[string]bool) { + names := sortedUnique(p.Names) + for _, n := range names { + switch { + case strings.HasPrefix(p.Type, "[]"): + if written[n+"Slice"] { + continue + } + written[n+"Slice"] = true + fmt.Fprintf(out, "\t\t%sSlice := make([]%s, 1+rng.Intn(512))\n", n, strings.TrimPrefix(p.Type, "[]")) + fmt.Fprintf(out, "\t\tfor i := range %sSlice {\n\t\t\t%sSlice[i] = %s(rng.Intn(256))\n\t\t}\n", n, strings.TrimPrefix(p.Type, "[]"), goCast(strings.TrimPrefix(p.Type, "[]"))) + case strings.HasPrefix(p.Type, "*"): + if written[n+"Elem"] { + continue + } + written[n+"Elem"] = true + fmt.Fprintf(out, "\t\tvar %sElem %s\n\t\t%sElem = %s(rng.Intn(256))\n", n, strings.TrimPrefix(p.Type, "*"), n, goCast(strings.TrimPrefix(p.Type, "*"))) + default: + if written[n+"Scalar"] { + continue + } + written[n+"Scalar"] = true + fmt.Fprintf(out, "\t\t%sScalar := rng.Intn(512)\n", n) + } + } +} + +func sortedUnique(names []string) []string { + seen := map[string]bool{} + var out []string + for _, n := range names { + if !seen[n] { + seen[n] = true + out = append(out, n) + } + } + sort.Strings(out) + return out +} + +// goCast returns the conversion turning rng.Intn(256) into the element type. +func goCast(elem string) string { + switch elem { + case "byte", "uint8": + return "byte" + case "int8": + return "int8" + case "uint16": + return "uint16" + case "int16": + return "int16" + case "uint32": + return "uint32" + case "int32": + return "int32" + case "uint64": + return "uint64" + default: + return "int" + } +} + +// outputBytes narrows a returned slice to bytes for the comparison; scalar +// results are compared through the same helper via a reflect-free trick the +// author may need to adjust for non-slice returns. +func outputBytes(v any) []byte { + switch t := v.(type) { + case []byte: + return t + case []int32: + b := make([]byte, 4*len(t)) + for i, x := range t { + b[i*4] = byte(x) + b[i*4+1] = byte(x >> 8) + b[i*4+2] = byte(x >> 16) + b[i*4+3] = byte(x >> 24) + } + return b + case []uint16: + b := make([]byte, 2*len(t)) + for i, x := range t { + b[i*4/2] = byte(x) + b[i*4/2+1] = byte(x >> 8) + } + return b + default: + return nil + } +}