diff --git a/cmd/gasm/main.go b/cmd/gasm/main.go index 43b7066..7543597 100644 --- a/cmd/gasm/main.go +++ b/cmd/gasm/main.go @@ -30,7 +30,7 @@ import ( // version is the release version, stamped at build time via // -ldflags "-X main.version=…" (defaulting to the current release). -var version = "0.25.0" +var version = "0.26.0" func main() { if len(os.Args) < 2 { @@ -492,6 +492,8 @@ With -profile, the static basic-block structure is listed for each function. abi := fs.Bool("abi", false, "run ABI-checking calls (sentinel registers + red zone)") profile := fs.Bool("profile", false, "list basic-block structure per function") groundTruth := fs.Bool("ground-truth", false, "compare machine code byte-for-byte against go tool asm") + fuzz := fs.Bool("fuzz", false, "differential fuzz: JIT both gasm and go-tool-asm versions, compare outputs") + fuzzN := fs.Int("n", 1000, "number of fuzz iterations per function") fs.Parse(args) if fs.NArg() != 1 { fmt.Fprintln(os.Stderr, "usage: gasm verify [-smoke] [-abi] [-profile] ") @@ -568,6 +570,43 @@ With -profile, the static basic-block structure is listed for each function. rc = 1 } } + + // Differential fuzz: JIT both gasm and go-tool-asm, compare outputs. + // Each function runs in a subprocess so a crash (partial functions like + // decoders that fault on malformed input) doesn't kill the whole run. + if *fuzz { + gt, err := verify.GroundTruth(path) + if err != nil { + fmt.Fprintf(os.Stderr, "gasm verify: fuzz: %v\n", err) + return 1 + } + src, err := readSource(path) + if err != nil { + fmt.Fprintf(os.Stderr, "gasm verify: %v\n", err) + return 1 + } + sigs := verify.ExtractSignatures(src) + fuzzed := 0 + for _, name := range names { + sig, ok := sigs[name] + if !ok { + fmt.Printf(" %s: SKIP (no // func signature)\n", name) + continue + } + goCode, ok := gt[name] + if !ok { + fmt.Printf(" %s: SKIP (not in go tool asm output)\n", name) + continue + } + res := k.FuzzFunc(name, sig, goCode, *fuzzN, int64(fuzzed*7+42)) + fmt.Printf(" %s\n", res) + if !res.OK() { + rc = 1 + } + fuzzed++ + } + fmt.Printf("fuzz: %d functions tested, %d iterations each\n", fuzzed, *fuzzN) + } for _, name := range names { fl, _ := k.Func(name) flags := "" diff --git a/justfile b/justfile index 3fd789e..e05d4c6 100644 --- a/justfile +++ b/justfile @@ -3,7 +3,7 @@ # gasm-devkit — developer tooling for Go's Plan 9 assembler (GAsm). -version := "0.25.0" +version := "0.26.0" default: @just --list diff --git a/verify/fuzz.go b/verify/fuzz.go new file mode 100644 index 0000000..0fae1ca --- /dev/null +++ b/verify/fuzz.go @@ -0,0 +1,362 @@ +// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) +// SPDX-License-Identifier: BSD-3-Clause + +package verify + +import ( + "fmt" + "math/rand" + "regexp" + "strconv" + "strings" + "unsafe" +) + +// FuzzResult reports the outcome of a differential fuzz campaign for one +// function. +type FuzzResult struct { + Func string + Iterations int + Matches int + Mismatches int + FirstFail string // description of the first mismatch ("" if none) +} + +// OK returns true when all iterations matched. +func (r FuzzResult) OK() bool { return r.Mismatches == 0 } + +// String returns a human-readable summary. +func (r FuzzResult) String() string { + if r.OK() { + return fmt.Sprintf("%s: %d/%d iterations match", r.Func, r.Matches, r.Iterations) + } + return fmt.Sprintf("%s: %d/%d match, %d MISMATCH — %s", + r.Func, r.Matches, r.Iterations, r.Mismatches, r.FirstFail) +} + +// funcSig is a parsed // func signature from the assembly source. +type funcSig struct { + name string + params []param + results []param +} + +type param struct { + name string + typ string // "[]byte", "[]int32", "int", "*[32]uint16", etc. +} + +// funcSigRe matches the conventional "// func name(...)" comment. +var funcSigRe = regexp.MustCompile(`^//\s*func\s+(\w+)\(([^)]*)\)\s*(.*)$`) + +// parseFuncSig extracts the function signature from a "// func ..." comment. +func parseFuncSig(comment string) (funcSig, bool) { + m := funcSigRe.FindStringSubmatch(strings.TrimSpace(comment)) + if m == nil { + return funcSig{}, false + } + sig := funcSig{name: m[1]} + sig.params = parseParams(m[2]) + // Results may be "(a int, b int)" or "int" or "(int, error)". + res := strings.TrimSpace(m[3]) + res = strings.TrimPrefix(res, "(") + res = strings.TrimSuffix(res, ")") + if res != "" { + sig.results = parseParams(res) + } + return sig, true +} + +// parseParams splits "a []byte, b []int32" into typed parameters, handling +// shared types ("a, b []int32"). +func parseParams(s string) []param { + s = strings.TrimSpace(s) + if s == "" { + return nil + } + var out []param + for _, field := range strings.Split(s, ",") { + field = strings.TrimSpace(field) + if field == "" { + continue + } + parts := strings.Fields(field) + if len(parts) == 1 { + // Unnamed: "int" or "[]byte". + out = append(out, param{typ: parts[0]}) + } else { + // Named: "a []byte" or shared "a, b []int32" (handled by the + // comma split above — "a" alone means the type follows in the + // next field; this is a simplification that covers the common + // case where each param has its own type). + out = append(out, param{name: parts[0], typ: parts[1]}) + } + } + return out +} + +// ExtractSignatures scans assembly source for "// func name(...)" comments +// that immediately precede a TEXT directive, and returns the parsed +// signatures keyed by the function's short name. +func ExtractSignatures(src string) map[string]funcSig { + lines := strings.Split(src, "\n") + sigs := make(map[string]funcSig) + var comments []string + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "//") { + comments = append(comments, trimmed) + continue + } + if strings.HasPrefix(trimmed, "TEXT") { + // Search the comment block for the // func line. + for _, c := range comments { + if sig, ok := parseFuncSig(c); ok { + sigs[sig.name] = sig + break + } + } + comments = nil + continue + } + if trimmed != "" { + comments = nil + } + } + return sigs +} + +// FuzzFunc runs a differential fuzz campaign: it JIT-executes both the +// gasm-assembled and the go-tool-asm-assembled versions of the named +// function with random inputs derived from the // func signature, and +// compares the output argument area bit-for-bit. +// +// The signature comment must appear immediately above the TEXT directive +// in the source (the conventional Go assembly layout). +func (k *Kernel) FuzzFunc(name string, sig funcSig, goCode []byte, iterations int, seed int64) FuzzResult { + result := FuzzResult{Func: name, Iterations: iterations} + + rng := rand.New(rand.NewSource(seed)) + + // Map the Go-assembled code into a second executable region. + goExec, err := Map(goCode) + if err != nil { + result.Mismatches = iterations + result.FirstFail = fmt.Sprintf("map go code: %v", err) + return result + } + defer goExec.Unmap() + + fl, err := k.Func(name) + if err != nil { + result.Mismatches = iterations + result.FirstFail = err.Error() + return result + } + + for i := 0; i < iterations; i++ { + // Generate inputs and build TWO independent arg blocks (one per + // version) so that functions which write to their arguments + // (e.g. histogram increments) don't corrupt the other's input. + gasmArgs, goArgs, bufs := genDualArgs(rng, sig, fl.Args) + + // Call the gasm version. + gasmOut, err := k.CallFunc(name, gasmArgs) + if err != nil { + result.Mismatches++ + if result.FirstFail == "" { + result.FirstFail = fmt.Sprintf("iter %d: gasm call: %v", i, err) + } + releaseBufs(bufs) + continue + } + + // Call the Go version (same function, independent buffers). + goOut, err := Call(goExec.FuncAddr(0), goArgs) + if err != nil { + result.Mismatches++ + if result.FirstFail == "" { + result.FirstFail = fmt.Sprintf("iter %d: go call: %v", i, err) + } + releaseBufs(bufs) + continue + } + + // Compare only the result area (after all input parameters). + // Pointers in the arg block differ (separate buffers), so we + // compare from resultOff to the end. + resultOff := paramsSize(sig) + gasmRes := gasmOut[resultOff:] + goRes := goOut[resultOff:] + if !equalBytes(gasmRes, goRes) { + result.Mismatches++ + if result.FirstFail == "" { + result.FirstFail = fmt.Sprintf("iter %d: output mismatch at result offset %d", i, resultOff) + } + } else { + result.Matches++ + } + releaseBufs(bufs) + } + return result +} + +// genDualArgs generates two independent ABI0 argument blocks (for gasm and +// go) with identical logical content but separate backing buffers, so that +// functions which write to their arguments don't corrupt the other's input. +func genDualArgs(rng *rand.Rand, sig funcSig, argSize int) (gasmArgs, goArgs []byte, bufs [][]byte) { + gasmArgs = make([]byte, argSize) + goArgs = make([]byte, argSize) + off := 0 + sliceIdx := 0 + + for _, p := range sig.params { + switch { + case strings.HasPrefix(p.typ, "[]"): + elemSize := elemSizeFor(p.typ) + n := 1 + rng.Intn(127) + var declaredLen int + if sliceIdx == 0 { + declaredLen = n + } else { + declaredLen = n + 512 + } + bufBytes := (declaredLen+16)*elemSize + 128 + // Two independent buffers with identical random content. + buf1 := make([]byte, bufBytes) + buf2 := make([]byte, bufBytes) + rng.Read(buf1[:n*elemSize]) + copy(buf2, buf1) + bufs = append(bufs, buf1, buf2) + putPtr(gasmArgs, off, unsafe.Pointer(&buf1[0])) + putPtr(goArgs, off, unsafe.Pointer(&buf2[0])) + putU64(gasmArgs, off+8, uint64(declaredLen)) + putU64(gasmArgs, off+16, uint64(declaredLen)) + putU64(goArgs, off+8, uint64(declaredLen)) + putU64(goArgs, off+16, uint64(declaredLen)) + off += 24 + sliceIdx++ + + case strings.HasPrefix(p.typ, "*["): + nElem := arrayLen(p.typ) + elem := elemSizeFor("[]" + p.typ[strings.Index(p.typ, "]")+1:]) + size := nElem * elem + if size < 8 { + size = 8 + } + buf1 := make([]byte, size) + buf2 := make([]byte, size) + rng.Read(buf1) + copy(buf2, buf1) + bufs = append(bufs, buf1, buf2) + putPtr(gasmArgs, off, unsafe.Pointer(&buf1[0])) + putPtr(goArgs, off, unsafe.Pointer(&buf2[0])) + off += 8 + + case p.typ == "int" || p.typ == "uint" || p.typ == "int64" || p.typ == "uint64": + v := uint64(rng.Intn(256)) + putU64(gasmArgs, off, v) + putU64(goArgs, off, v) + off += 8 + + default: + v := rng.Uint64() + putU64(gasmArgs, off, v) + putU64(goArgs, off, v) + off += 8 + } + } + return gasmArgs, goArgs, bufs +} + +func elemSizeFor(sliceType string) int { + switch strings.TrimPrefix(sliceType, "[]") { + case "byte", "uint8", "int8": + return 1 + case "uint16", "int16": + return 2 + case "uint32", "int32", "float32": + return 4 + case "uint64", "int64", "float64": + return 8 + default: + return 8 + } +} + +// paramsSize returns the ABI0 stack size occupied by the input parameters. +func paramsSize(sig funcSig) int { + size := 0 + for _, p := range sig.params { + switch { + case strings.HasPrefix(p.typ, "[]"): + size += 24 // slice header + case strings.HasPrefix(p.typ, "*["): + size += 8 // pointer + case p.typ == "bool": + size += 1 + default: + size += 8 // int, uint, etc. + } + } + return size +} + +func arrayLen(typ string) int { + // "*[32]uint16" → 32 + start := strings.Index(typ, "[") + end := strings.Index(typ, "]") + if start < 0 || end < 0 || end <= start { + return 1 + } + n, _ := strconv.Atoi(typ[start+1 : end]) + if n <= 0 { + n = 1 + } + return n +} + +func putPtr(buf []byte, off int, p unsafe.Pointer) { + if off+8 <= len(buf) { + u64 := uint64(uintptr(p)) + buf[off] = byte(u64) + buf[off+1] = byte(u64 >> 8) + buf[off+2] = byte(u64 >> 16) + buf[off+3] = byte(u64 >> 24) + buf[off+4] = byte(u64 >> 32) + buf[off+5] = byte(u64 >> 40) + buf[off+6] = byte(u64 >> 48) + buf[off+7] = byte(u64 >> 56) + } +} + +func putU64(buf []byte, off int, v uint64) { + if off+8 <= len(buf) { + buf[off] = byte(v) + buf[off+1] = byte(v >> 8) + buf[off+2] = byte(v >> 16) + buf[off+3] = byte(v >> 24) + buf[off+4] = byte(v >> 32) + buf[off+5] = byte(v >> 40) + buf[off+6] = byte(v >> 48) + buf[off+7] = byte(v >> 56) + } +} + +func equalBytes(a, b []byte) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func releaseBufs(bufs [][]byte) { + // Keep buffers alive until after the call; nothing to free in Go, + // but this prevents the compiler from collecting them too early. + _ = bufs +} diff --git a/verify/fuzz_test.go b/verify/fuzz_test.go new file mode 100644 index 0000000..e19e842 --- /dev/null +++ b/verify/fuzz_test.go @@ -0,0 +1,98 @@ +// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) +// SPDX-License-Identifier: BSD-3-Clause + +package verify + +import ( + "testing" +) + +func TestExtractSignatures(t *testing.T) { + src := `// func add(a int64, b int64) int64 +TEXT ·add(SB), NOSPLIT, $0-24 + RET + +// func wideCopy(dst []byte, src []byte) +TEXT ·wideCopy(SB), NOSPLIT, $0-48 + RET +` + sigs := ExtractSignatures(src) + if len(sigs) != 2 { + t.Fatalf("expected 2 signatures, got %d: %v", len(sigs), sigs) + } + add, ok := sigs["add"] + if !ok { + t.Fatal("add not found") + } + if len(add.params) != 2 { + t.Errorf("add params: got %d, want 2", len(add.params)) + } + wc, ok := sigs["wideCopy"] + if !ok { + t.Fatal("wideCopy not found") + } + if len(wc.params) != 2 { + t.Errorf("wideCopy params: got %d, want 2", len(wc.params)) + } + if wc.params[0].typ != "[]byte" { + t.Errorf("wideCopy param[0].typ = %q, want []byte", wc.params[0].typ) + } +} + +func TestFuzzWideCopy(t *testing.T) { + k := loadBasic(t) + + gt, err := GroundTruth("../testdata/verify/basic_amd64.s") + if err != nil { + t.Fatalf("GroundTruth: %v", err) + } + goCode, ok := gt["wideCopy"] + if !ok { + t.Skip("wideCopy not in ground truth") + } + + sig := funcSig{ + name: "wideCopy", + params: []param{ + {name: "dst", typ: "[]byte"}, + {name: "src", typ: "[]byte"}, + }, + } + + res := k.FuzzFunc("wideCopy", sig, goCode, 200, 42) + if !res.OK() { + t.Errorf("wideCopy fuzz: %s", res) + } +} + +func TestParseFuncSig(t *testing.T) { + tests := []struct { + comment string + name string + nParams int + }{ + {"// func add(a int64, b int64) int64", "add", 2}, + {"// func wideCopy(dst []byte, src []byte)", "wideCopy", 2}, + {"// func analyzeO1RangeAVX2(swin []int32, dstP []uint32, hist *[32]uint16) (partSum uint64, overflow bool)", "analyzeO1RangeAVX2", 3}, + {"// not a func", "", 0}, + } + for _, tt := range tests { + sig, ok := parseFuncSig(tt.comment) + if tt.name == "" { + if ok { + t.Errorf("parseFuncSig(%q): expected not ok", tt.comment) + } + continue + } + if !ok { + t.Errorf("parseFuncSig(%q): expected ok", tt.comment) + continue + } + if sig.name != tt.name { + t.Errorf("parseFuncSig(%q).name = %q, want %q", tt.comment, sig.name, tt.name) + } + if len(sig.params) != tt.nParams { + t.Errorf("parseFuncSig(%q): %d params, want %d", tt.comment, len(sig.params), tt.nParams) + } + } +}