Files
c426606899 gcasm: O(1) jump tables — init-scanned address tables replace the compare tree (#36)
* gcasm: O(1) jump tables — init-scanned address tables replace the compare tree

gc's jump-table dispatch (LEAQ table + JMP (base)(idx*8)) could not be
reproduced in hand-written Plan9 asm because DATA cannot hold
address-of-label; emitJumpTree degraded it to an O(log n) compare tree,
a measured pessimization on interpreter-style guests (spidermonkey PBL,
CPython) — and the pure-fallback alternative (#34) regressed consumer
cold-build time up to +49% by handing the largest functions back to gc.

This restores gc's exact O(1) shape. The dispatch is retargeted at a
Go-declared table var — LEAQ ·fnN_jtOFF(SB), base; JMP (base)(idx*8)
(arm64: the same MOVD/MOVD/JMP triple) — clobbering only the registers
gc's own dispatch clobbered and, like gc's dispatch, writing NO flags,
so the tree's whole flag-replay machinery (and its unreplayable-flags
pure-fallback class) does not apply to the pad.

Table entries are the ABSOLUTE addresses of per-target stubs emitted
right after the dispatch, each opening with an executable no-op
signature (amd64: 0F 1F 80 sig32 + complement twin; arm64: a
MOVZ/MOVK/MOVK triple into the R17 scratch) followed by JMP pcN.
Because the consumer's assembler picks final encodings, addresses are
never baked: a generated package init() scans the function's code bytes
for the signatures (via a tiny fnN_jtpc address helper) and fills the
tables — PIE-safe, toolchain-version-safe, and a missing signature
panics at init instead of ever wild-jumping.

Plumbing follows the ConstPool/TypeTable pattern: TransformOptions.JT
accumulates per-file metadata; Build appends JT.EmitAsm() to each .s
and JT.EmitGo() (tables + init + scanner) to the decls file. JT=nil
keeps the legacy compare tree (finders take a flagTransparent flag the
pad sets to skip the moot NZCV/EFLAGS analysis).

Gates now run the pad end to end: the 64-case amd64 dispatch gate, the
flag-consuming replay gates on BOTH arches (arm64 executes natively;
its fixture previously could not transform at all on this toolchain —
flag transparency resolves it), and the cg_bigdispatch / cg_brtable64 /
cg_dispatchif fixture gates. Full go test ./... and make lint green.

Co-Authored-By: Claude Fable 5 <[email protected]>

* gcasm: inline the FNV-1a signature hash (errcheck-clean)

Co-Authored-By: Claude Fable 5 <[email protected]>

* gcasm: arm64 pad register discipline + expected-set signature filter

Two consumer-suite findings on the real spidermonkey bundle:

1. arm64 dispatch correctness. The first pad hardcoded R16/R17 —
   allocatable under gc, so live values could be clobbered — and
   re-assembled the captured (Rb)(Ri<<3) as the 64-bit X-form when
   gc's original load is ldr xt,[xb,w_i,uxtw #3]: selector high-bit
   garbage (legal under gc) poisoned the address and crashed the
   arm64 lane with SIGSEGV. The pad now clobbers exactly the captured
   table-base and target registers (what gc's own dispatch clobbered)
   and re-establishes uxtw with an explicit MOVWU (br_table selectors
   are wasm i32). Signature MOVZ/MOVK writes follow the target
   register; the scanner masks Rd out.

2. Scanner false positives. Real arm64 code materializes 48-bit
   constants with the same MOVZ/MOVK/MOVK shape; those matches
   consumed the scan budget and ended the scan before the true stubs,
   panicking init with "signature missing" (the fail-fast doing its
   job). Both scanners now count only signatures the spec expects.

go-spidermonkey matrix green on the regenerated bundle: amd64/arm64,
plain and -race, all exit 0. cpubench arm64-native interleaved A/B
(5 pairs, ranges disjoint): compare tree 60.29 ms -> pad 55.56 ms
(-7.9%). Cold build of the bundle: 25.5s vs 23.7s baseline (the
pure-fallback approach measured 35.3s).

Co-Authored-By: Claude Fable 5 <[email protected]>

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-23 12:32:03 +09:00

293 lines
8.8 KiB
Go

package gcasm
import (
"bytes"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"testing"
"github.com/goccy/wasm2go/internal/codegen"
"github.com/goccy/wasm2go/internal/testfixture"
"github.com/goccy/wasm2go/internal/wasm"
)
// a64FixtureGate mirrors fixtureGate for arm64: translate a wasm
// fixture to pure Go, compile the PURE bodies with gc for arm64,
// transform every fnN into ABI0 arm64 .s (TransformARM64), and run
// transformed vs pure over an input sweep on GOARCH=arm64 (native on
// Apple Silicon).
func a64FixtureGate(t *testing.T, fixture string) {
bin := testfixture.Wasm(t, fixture)
mod, err := wasm.Parse(bytes.NewReader(bin))
if err != nil {
t.Fatal(err)
}
var buf bytes.Buffer
res, err := codegen.Translate(&buf, mod, codegen.Options{Package: "pkg", OutputImportPath: "gentest/pkg"})
if err != nil {
t.Fatal(err)
}
pureFiles := map[string][]byte{"gen.go": buf.Bytes()}
for name, data := range res.Sidecars {
pureFiles[name] = data
}
for name, data := range res.Files {
pureFiles[name] = data
}
pureFiles = PureFilter(pureFiles)
writeTree := func(dir string, files map[string][]byte) {
t.Helper()
for name, data := range files {
p := filepath.Join(dir, name)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, data, 0o644); err != nil {
t.Fatal(err)
}
}
}
capDir := t.TempDir()
{
files := map[string][]byte{"go.mod": []byte("module gentest\n\ngo 1.25.0\n")}
for name, data := range pureFiles {
files["pkg/"+name] = data
}
writeTree(capDir, files)
}
sigs := parseSigs(pureFiles)
fnIdxRe := regexp.MustCompile(`^gentest/pkg\.fn(\d+)$`)
var unresolved []string
stdSig := gate3StdlibSigs
calleeSig := func(sym string) ([]ArgKind, bool, ArgKind, string, bool) {
if std, ok := stdSig[sym]; ok {
return std.params, std.hasRes, std.res, "·" + std.wrap, true
}
if !strings.HasPrefix(sym, "gentest/pkg.") {
return nil, false, 0, "", false
}
name := strings.TrimPrefix(sym, "gentest/pkg.")
if strings.ContainsAny(name, ".()*") {
unresolved = append(unresolved, sym)
return nil, false, 0, "", false
}
sig, found := sigs[name]
if !found || !sig.ok {
unresolved = append(unresolved, sym)
return nil, false, 0, "", false
}
return sig.params, sig.hasRes, sig.res, "·" + name, true
}
transformable := func(idx int) bool {
ft := mod.FuncTypeOf(uint32(idx) + mod.NumImportedFuncs)
return len(ft.Results) <= 1
}
fns, datas, err := captureArch(capDir, "gentest/pkg", "arm64")
if err != nil {
t.Fatal(err)
}
dm := map[string]*DataSym{}
for _, d := range datas {
dm[d.Name] = d
}
pool := &ConstPool{}
types := &TypeTable{}
jt := &JTTable{}
var asmB, declB strings.Builder
asmB.WriteString("//go:build arm64\n\n#include \"textflag.h\"\n#include \"funcdata.h\"\n\n")
declB.WriteString("//go:build arm64\n\npackage pkg\n")
declB.WriteString(stdlibWrappersArm)
declB.WriteString("\n")
var fnIdxs []int
for _, f := range fns {
m := fnIdxRe.FindStringSubmatch(f.Name)
if m == nil {
continue
}
idx, aerr := strconv.Atoi(m[1])
if aerr != nil {
t.Fatal(aerr)
}
if !transformable(idx) {
continue
}
ft := mod.FuncTypeOf(uint32(idx) + mod.NumImportedFuncs)
params := []ArgKind{ArgPtr}
names := []string{"m"}
var declArgs strings.Builder
declArgs.WriteString("m *Module")
for i, p := range ft.Params {
params = append(params, kindOfVal(p))
names = append(names, fmt.Sprintf("l%d", i))
fmt.Fprintf(&declArgs, ", l%d %s", i, goTypeOfKind(kindOfVal(p)))
}
hasRes := len(ft.Results) == 1
var resKind ArgKind
resDecl := ""
if hasRes {
resKind = kindOfVal(ft.Results[0])
resDecl = fmt.Sprintf(" (r0 %s)", goTypeOfKind(resKind))
}
body, err := TransformARM64(f, TransformOptions{
SymName: fmt.Sprintf("fn%d", idx),
CalleeSig: calleeSig,
Params: params,
HasResult: hasRes,
Result: resKind,
ArgNames: names,
Datas: dm,
Consts: pool,
Types: types,
JT: jt,
})
if err != nil {
t.Fatalf("transform %s: %v", f.Name, err)
}
asmB.WriteString(body)
asmB.WriteString("\n")
fmt.Fprintf(&declB, "func fn%d(%s)%s\n", idx, declArgs.String(), resDecl)
fnIdxs = append(fnIdxs, idx)
}
if len(fnIdxs) == 0 {
t.Skip("no transformable fnN")
}
if len(unresolved) > 0 {
sort.Strings(unresolved)
t.Fatalf("unresolved callees: %v", unresolved)
}
asmB.WriteString(pool.Emit())
asmB.WriteString(jt.EmitAsm("arm64"))
declB.WriteString("\n")
declB.WriteString(jt.EmitGo("arm64"))
if len(types.Names) > 0 {
t.Skipf("type refs present (%d) — arm64 type-desc wiring TODO", len(types.Names))
}
// Run vs pure: rename pure fnN → fnN_pure, install asm as fnN.
runDir := t.TempDir()
transformed := map[string]bool{}
for _, idx := range fnIdxs {
transformed[strconv.Itoa(idx)] = true
}
renRe := regexp.MustCompile(`\bfn(\d+)\b`)
runFiles := map[string][]byte{"go.mod": []byte("module gentest\n\ngo 1.25.0\n")}
for name, data := range pureFiles {
if strings.HasSuffix(name, ".go") {
data = renRe.ReplaceAllFunc(data, func(mm []byte) []byte {
idx := renRe.FindSubmatch(mm)
if transformed[string(idx[1])] {
return []byte(string(mm) + "_pure")
}
return mm
})
}
runFiles["pkg/"+name] = data
}
runFiles["pkg/arm64.s"] = []byte(asmB.String())
runFiles["pkg/decls_arm64.go"] = []byte(declB.String())
var driver strings.Builder
driver.WriteString("package pkg\n\nimport (\n\t\"math\"\n\t\"testing\"\n)\n\nvar _ = math.Float32bits\n\ntype outcome struct{ val int64; panicked bool }\n\nfunc run(f func() int64) (o outcome) {\n\tdefer func() { if recover() != nil { o.panicked = true } }()\n\to.val = f()\n\treturn\n}\n\nfunc TestA64FG(t *testing.T) {\n")
argSet := []string{"0", "1", "2", "3", "5", "42", "255", "1000"}
cases := 0
for _, idx := range fnIdxs {
ft := mod.FuncTypeOf(uint32(idx) + mod.NumImportedFuncs)
if len(ft.Results) != 1 {
continue
}
var tuples [][]string
switch len(ft.Params) {
case 0:
tuples = [][]string{{}}
case 1:
for _, a := range argSet {
tuples = append(tuples, []string{a})
}
default:
for _, a := range argSet {
for _, bb := range []string{"0", "1", "7"} {
tup := []string{a, bb}
for len(tup) < len(ft.Params) {
tup = append(tup, "1")
}
tuples = append(tuples, tup)
}
}
}
for _, tup := range tuples {
args := ""
for i, a := range tup {
args += fmt.Sprintf(", %s(%s)", goTypeOfKind(kindOfVal(ft.Params[i])), a)
}
fmt.Fprintf(&driver, "\t{ g := run(func() int64 { m := New(); return int64(fn%d(m%s)) }); w := run(func() int64 { m := New(); return int64(fn%d_pure(m%s)) }); if g != w { t.Errorf(\"fn%d(%s): asm=%%+v pure=%%+v\", g, w) } }\n", idx, args, idx, args, idx, strings.Join(tup, ","))
cases++
}
}
driver.WriteString("}\n")
runFiles["pkg/a64fg_test.go"] = []byte(driver.String())
writeTree(runDir, runFiles)
runArm64Gate(t, runDir, "./pkg/", "TestA64FG", asmB.String())
t.Logf("a64 %s: %d cases, asm matches pure", fixture, cases)
}
var stdlibWrappersArm = `
import (
"math"
"math/bits"
"unsafe"
)
var _ = unsafe.Pointer(nil)
func gcasmMathCeil(x float64) float64 { return math.Ceil(x) }
func gcasmMathFloor(x float64) float64 { return math.Floor(x) }
func gcasmMathTrunc(x float64) float64 { return math.Trunc(x) }
func gcasmMathRoundToEven(x float64) float64 { return math.RoundToEven(x) }
func gcasmMathSqrt(x float64) float64 { return math.Sqrt(x) }
func gcasmBitsOnesCount32(x uint32) int { return bits.OnesCount32(x) }
func gcasmBitsOnesCount64(x uint64) int { return bits.OnesCount64(x) }
func gcasmBitsLeadingZeros32(x uint32) int { return bits.LeadingZeros32(x) }
func gcasmBitsLeadingZeros64(x uint64) int { return bits.LeadingZeros64(x) }
func gcasmBitsTrailingZeros32(x uint32) int { return bits.TrailingZeros32(x) }
func gcasmBitsTrailingZeros64(x uint64) int { return bits.TrailingZeros64(x) }
//go:noinline
func gcasmTrapDivZero() { panic("wasm: integer divide by zero") }
//go:noinline
func gcasmTrapOverflow() { panic("wasm: integer overflow") }
//go:noinline
func gcasmTrapUnreachable() { panic("wasm: unreachable") }
//go:noinline
func gcasmTrapBounds() { panic("wasm: out of bounds access") }
//go:noinline
func gcasmTrapIndirectSig() { panic("wasm: indirect call type mismatch") }
var (
_ = gcasmTrapDivZero
_ = gcasmTrapOverflow
_ = gcasmTrapUnreachable
_ = gcasmTrapBounds
_ = gcasmTrapIndirectSig
)
`
// TestA64Fixtures sweeps the arm64 transform over the codegen corpus.
func TestA64Fixtures(t *testing.T) {
for _, fx := range []string{
"control", "cg_brtable64", "cg_bittest", "cg_numerics", "cg_memops",
"cg_globals", "cg_frame", "cg_nestedloop", "cg_dispatchif", "cg_traps",
"cg_deadend", "cg_largeoff", "cg_misc", "cg_unreachable", "arith", "ssa_cf",
} {
t.Run(fx, func(t *testing.T) { a64FixtureGate(t, fx) })
}
}