Files
5bd861b54c gcasm: gc-capture assembly backend (amd64 + arm64), replacing internal/asmgen (#19)
Replace the hand-written own-emitter asm backend (internal/asmgen) with
gcasm: compile the generated pure-Go bodies with the standard Go
compiler, capture the `-S` listing, and mechanically transform it into a
shippable Plan9 `.s`. The transform strips gc's prologue/stack-check/
epilogue/morestack, regenerates them from the declared frame, and
marshals gc's ABIInternal calls to ABI0 forwarders. Wired into
transpile.Translate and cmd/wasm2go as THE asm backend; a `purego` build
tag still selects the pure bodies on every other GOARCH.

Why ship asm at all — the pure-Go bodies are the faster runtime. The asm
path exists to skip the consumer's golangci-lint/parse cost of the huge
generated bodies (measured ~36.5s pure vs ~3.3s asm for one package;
golangci-lint doesn't parse assembly). gcasm keeps gc's in-body register
allocation and only pays an ABI0 marshalling tax at call boundaries
(register-to-register calls are impossible for tool-emitted asm: the
assembler forbids non-runtime code from declaring ABIInternal).

gcasm vs the own-emitter asm it replaces — strictly better on every
measured axis (go-python microbenchmarks, count=5):
  - runtime arm64 native: 1.7-3.3x faster (Fib 0.30, LoopSum 0.50, Startup 0.58)
  - runtime amd64:        1.05-1.85x faster
  - binary size arm64:    14% smaller (33.87 vs 39.32 MB)
  - cold-cache build:     8.1 vs 10.0s
  - memory:               identical (555 B/op, 11 allocs)
  - lint benefit:         same (both skip pure bodies); 11x faster than pure

What's in internal/gcasm:
  - capture: `-gcflags=<pkg>=-S`, GOARCH-pinned (amd64 + arm64).
  - amd64 + arm64 transforms (peers): prologue/epilogue strip, ABI0 call
    marshalling, jump-table lowering to binary-search trees, const/float/
    type-descriptor pools, runtime-call rewrites, cross-package linkname
    forwarders.
  - dual-arch bundle: gcasm_<arch>.s + gcasm_decls_<arch>.go + keepalive,
    guarded by //go:build <arch>. Functions the transform can't express
    stay pure fallbacks (transparent to callers — same ABI0 forward /
    //go:linkname either way).

Fallback triggers: signatures that stack-assign in ABIInternal (>16 int
args), and functions gc compiles to a DUFFZERO/DUFFCOPY pseudo-op — a
textual duff op nil-derefs the assembler (go1.25 and go1.26 alike; only
compiler-generated progs get the runtime.duffzero target symbol), so the
union across arches is routed to pure.

Delete the superseded backend. gcasm being sole means internal/asmgen
(the SSA-to-plan9 own emitter) and internal/regalloc (its linear-scan
allocator, no other importer) were dead — codegen.Translate generated the
old asm bundle only for transpile.Translate to discard and replace it.
~20k lines removed:
  - delete internal/asmgen and internal/regalloc.
  - codegen.Translate now emits only pure-Go bodies (shared file +
    <pkg>_pure.go); the per-arch asm is entirely gcasm's job. Removed the
    asmgen call sites and the now-dead asm_bundle.go helpers.
  - drop asm_bundle_test.go (codegen+asmgen integration); the wasm ->
    runnable-asm path is covered by gcasm's gates + consumer e2e. The
    remaining codegen tests build/run codegen's PURE output, stripping the
    guard to activate it on the host; production output is unchanged.
Proven inert: regenerating the python AND googlesql bundles after the
deletion yields byte-identical output.

Coverage: the deleted integration test contributed broad cross-package
coverage the removed code no longer needs; a new transpile end-to-end test
(TestTranspileEndToEnd — Parse+Translate+gcasm, build+run on the host) plus
gcasm's gates and the consumer e2e cover the live path. Live-code coverage
lands at 84.7%, so the Makefile COVERAGE_THRESHOLD is recalibrated 85 -> 84.

Notable correctness fixes surfaced at consumer scale (bisected via the
GCASM_FALLBACK_RANGE knob; none visible to fixture gates):
  - linker method pruning of import ifaces -> init()-guarded call pins
  - narrow-int register extension (MOVLQSX / ArgU32 loads)
  - bare (SP) zero-offset outgoing store missed by the frame shift
  - wide indirect calls' stack-assigned args -> ADJSP wrap
  - EFLAGS live across jump-table dispatch -> replay the flag-setter
  - arm64 large-frame prologue/epilogue (SUBS+BLO guard, MOVD $c,R27 /
    ADD R27,RSP teardown, shape-agnostic stack-check sweep)
  - arm64 hardware-SP outgoing-arg shift: a >16-int-arg ABIInternal call
    spills the overflow arg to a hardware N(RSP) slot that must be
    shifted above our ABI0 scratch so the marshaller re-reads it; missing
    it read uninitialised stack (low 32 bits of the spilled module
    pointer) and faulted ~3.4GB outside linear memory (go-googlesql
    TestLifetime_AnalyzerOutputKeepAlive on arm64). Gate: TestA64Spill.

Verification (judged by exit code):
  - go1.25.0 (CI toolchain) AND go1.26.2: `go test -race -covermode=atomic
    -coverpkg=./... ./...` all ok; make lint 0 issues; CodeQL clean
    (FnN-index parses use strconv.ParseUint(s,10,32), a bounded conversion).
  - Consumers, GOARCH=amd64 AND arm64 (native), gcasm bundles via replace:
      googlesqlite  go test -race ./...  -> 35 ok each arch
      go-googlesql  go test ./...        -> ok each arch
      go-python     go test ./... (+race amd64) -> ok each arch
  - arm64 execution gates assemble+link on any host and execute only on an
    arm64 host (skip elsewhere), so CI on linux/amd64 stays green.


Claude-Session: https://claude.ai/code/session_01BJSha2EbWhruLZQTZE87oL

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-06 21:01:27 +09:00

134 lines
3.8 KiB
Go

package gcasm
import (
"os"
"path/filepath"
"strings"
"testing"
)
// TestA64Spill is the arm64 regression gate for ABIInternal calls that
// spill an outgoing argument to the stack (>16 integer args). gc places
// the spilled arg at a hardware `N(RSP)` slot in the low frame; the
// transform must shift that store above our ABI0 outgoing scratch so the
// call marshaller re-reads it at the right offset. Before the fix this
// read uninitialised stack (the low 32 bits of the spilled m pointer),
// faulting far outside linear memory.
func TestA64Spill(t *testing.T) {
dir := t.TempDir()
src := `package lib
//go:noinline
func Callee(a0, a1, a2, a3, a4, a5, a6, a7, a8, a9, a10, a11, a12, a13, a14, a15, a16 int32) int32 {
return a0 + a16*7 + a15
}
//go:noinline
func Caller(x int32) int32 {
return Callee(x, x+1, x+2, x+3, x+4, x+5, x+6, x+7, x+8, x+9, x+10, x+11, x+12, x+13, x+14, x+15, x+16)
}
`
for name, content := range map[string]string{
"go.mod": "module a64spill\n\ngo 1.25.0\n",
"lib/lib.go": src,
} {
p := filepath.Join(dir, name)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
fns, datas, err := captureArch(dir, "a64spill/lib", "arm64")
if err != nil {
t.Fatal(err)
}
dm := map[string]*DataSym{}
for _, d := range datas {
dm[d.Name] = d
}
byName := map[string]*Fn{}
for _, f := range fns {
byName[f.Name[strings.LastIndex(f.Name, ".")+1:]] = f
}
i32 := func(n int) []ArgKind {
out := make([]ArgKind, n)
for i := range out {
out[i] = ArgI32
}
return out
}
sig := map[string][]ArgKind{"Callee": i32(17), "Caller": i32(1)}
names := map[string][]string{
"Callee": {"a0", "a1", "a2", "a3", "a4", "a5", "a6", "a7", "a8", "a9", "a10", "a11", "a12", "a13", "a14", "a15", "a16"},
"Caller": {"x"},
}
calleeSig := func(sym string) ([]ArgKind, bool, ArgKind, string, bool) {
n := sym[strings.LastIndex(sym, ".")+1:]
if s, ok := sig[n]; ok && strings.Contains(sym, "a64spill/lib") {
return s, true, ArgI32, "·" + n, true
}
return nil, false, 0, "", false
}
var asm strings.Builder
asm.WriteString("#include \"textflag.h\"\n#include \"funcdata.h\"\n\n")
// Callee has 17 int args → it stack-assigns in ABIInternal, so it is
// itself a pure fallback (like the real Fn24168). Only Caller is asm;
// it calls the pure-Go Callee through the compiler's ABI0 wrapper.
for _, name := range []string{"Caller"} {
body, err := TransformARM64(byName[name], TransformOptions{
SymName: name,
CalleeSig: calleeSig,
Params: sig[name],
HasResult: true,
Result: ArgI32,
ArgNames: names[name],
Datas: dm,
})
if err != nil {
t.Fatalf("transform %s: %v", name, err)
}
asm.WriteString(body)
asm.WriteString("\n")
}
run := t.TempDir()
files := map[string]string{
"go.mod": "module a64spillrun\n\ngo 1.25.0\n",
"decl.go": "//go:build arm64\n\npackage a64spillrun\n\n" +
"func Caller(x int32) int32\n",
"callee.go": `package a64spillrun
//go:noinline
func Callee(a0, a1, a2, a3, a4, a5, a6, a7, a8, a9, a10, a11, a12, a13, a14, a15, a16 int32) int32 {
return a0 + a16*7 + a15
}
`,
"body_arm64.s": asm.String(),
"run_test.go": `package a64spillrun
import "testing"
func ref(x int32) int32 {
a := []int32{x, x + 1, x + 2, x + 3, x + 4, x + 5, x + 6, x + 7, x + 8, x + 9, x + 10, x + 11, x + 12, x + 13, x + 14, x + 15, x + 16}
return a[0] + a[16]*7 + a[15]
}
func TestRun(t *testing.T) {
for _, n := range []int32{0, 1, 7, 100, -3, 1 << 20} {
if got, want := Caller(n), ref(n); got != want {
t.Fatalf("Caller(%d)=%d want %d", n, got, want)
}
}
}
`,
}
for name, content := range files {
if err := os.WriteFile(filepath.Join(run, name), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
runArm64Gate(t, run, ".", "TestRun", asm.String())
}