Files
wasm2go/internal/codegen/simd_fuse_f16gather.go
f57f53361e Replace the engine-specific body retargets with project-supplied assembly overrides (#67)
* codegen: keep fused-tree operands ahead of their consumers after the f16 gather rewrite

rewriteF16GatherGroup appends the collapsed v128_load16x4_u at the end
of the node list and points the chain's tail (now f16x4_cvt) at it, a
forward reference. The asm splicers walk the tree recursively and never
noticed, but the pure twin body emits nodes in list order, so any tree
that reached it through this rewrite declared the load after its use:
"undefined: n6 / declared and not used: n6" from the gcasm capture
build. It surfaced once ggml's wasm f16 vector loads started producing
the gather idiom everywhere (vec_dot_f16, vec_mad_f16, flash attention)
instead of only in the repack kernels.

compactDeadNodes, which every rewrite already runs, now places a node's
operands before the node while otherwise keeping list order — the
collapsed load lands where the chain's last lane load used to be.

* helpers: detect FEAT_I8MM on linux/arm64 (HWCAP2 bit 13)

The kernel-override dispatch and the SMMLA splices gate on it; darwin
already reported it through hw.optional.arm.FEAT_I8MM.

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

* Replace the engine-specific body retargets with project-supplied kernel overrides

wasm2go's job is to read a wasm module and emit Go and, where it can do
better than gc, assembly derived from that wasm. Two features had grown
past that line: the repack GEMM retarget swapped the body of an export
named dbg_gemm_q8_0_4x4 for a hand-written kernel that knows one
engine's quantized block layout, and the vec_dot row/column pairing
recognized one engine's trait-table layout and handed the paired
companion to another hand-written kernel of the same kind. Neither body
came from the wasm, and both tied the transpiler to that engine. On the
current engine build the pairing also no longer measures (prompt and
decode throughput within noise with it off), because the repack path
carries the hot matmuls.

Both are removed: the retarget and its -no-repack-gemm switch, the
pairing and its -vec-dot-pair-entry / -vec-dot-rows switches, the
kernels and the trait-table recognizer. The passive-segment placement
scan the f16-table detection shares moves to its own file.

In their place, a generic mechanism: -kernel-overrides <manifest>. The
project that produced the wasm may supply the assembly body of an
exported leaf function, per architecture and CPU feature level.
wasm2go validates the manifest against the module (export present,
signature and pointer width as declared), wraps each body in a fixed
ABI — the linear-memory base and size in named registers, arguments by
the ABI0 layout rule, a kov_oob label that reaches the module's
out-of-bounds trap — emits a feature-dispatch stub (i8mm > dotprod >
neon; avx512vnni > avx2 > sse4) with the lowered body as the portable
fallback, and pins the exports out of line. It never inspects what a
body computes; the body must stay inside the wasm execution model
(linear memory through the given base and size, bounds-checked, no
calls), and the manifest loader rejects TEXT, calls, system
instructions and foreign symbol references. docs/kernel-overrides.md
states the contract and the verification the project owes.

Tests: manifest validation, ABI text and dispatch shape, and an
end-to-end transpile of a module with and without an override body on
the host architecture — same sums, and the override traps past the end
of memory.

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

* helpers: detect FEAT_I8MM on darwin/arm64 too

The linux and fallback twins gained CPUI8MM; a darwin build of a bundle
whose kernel overrides declare an i8mm body needs the same symbol in
the base package.

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

* Rename kernel overrides to assembly overrides

"Kernel" is numeric-computing vocabulary; the mechanism itself knows
nothing about what the function computes — it replaces the body of an
exported function with project-supplied assembly. The name now says
that: -asm-overrides / Options.AsmOverrides, docs/asm-overrides.md,
the manifest's "functions" array, the ovr_oob label and ovr_ data
prefix, and Fn<idx>ovr<feature> body symbols.

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

---------

Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-09-04 00:10:00 +09:00

617 lines
18 KiB
Go

package codegen
// f16 gather normalization inside fused windows.
//
// The guest converts f16 values through ggml's f32 lookup table one
// lane at a time: four scalar load16_u reads feed shifted indices
// into a v128_load32_zero + 3x v128_load32_lane chain over the
// table. The SSA-level RecognizeF16Gather pass cannot claim the
// shape whenever the shift amount rides a local (constant only
// through definition chasing) — exactly the form the window fusion's
// chase machinery resolves. So the normalization runs HERE, on the
// built tree: chase each lane's index expression down to a
// scalar_i32_load16_u at consecutive addresses, verify the table is
// the IEEE conversion, and rewrite the chain into
// f16x4_cvt(v128_load16x4_u(base)) — which the splicers emit as one
// D-register load plus FCVTL. Proven by direct measurement (hand
// patch first, per the asm-first rule) before being wired here.
import (
"fmt"
"go/ast"
"go/token"
"github.com/goccy/wasm2go/internal/simdfuse"
)
// fuseF16Gather normalizes every recognizable lane-gather group in
// the built tree and returns the (possibly remapped) roots. Any
// mismatch leaves that group untouched; the rewrite is fully
// transactional per group via the chase snapshot.
func (fb *fusedTreeBuilder) fuseF16Gather(roots []int) []int {
if fb.sc == nil || fb.sc.em == nil || fb.sc.em.t == nil {
return roots
}
tableOK := fb.sc.em.t.f16TableOK
changed := false
// Uses of each node by other nodes (chain-interior nodes must be
// exclusively chain-consumed).
uses := func() []int {
u := make([]int, len(fb.nodes))
for _, n := range fb.nodes {
for _, a := range n.Args {
if a.Kind == simdfuse.ArgNode {
u[a.Index]++
}
}
}
return u
}
for tailIdx := range fb.nodes {
if fb.nodes[tailIdx].Op != "v128_load32_lane" {
continue
}
fn := ""
if fb.sc != nil && fb.sc.em != nil && fb.sc.em.curFn != nil {
fn = fb.sc.em.curFn.Name
}
_ = fn
u := uses()
chain, ok := fb.f16GatherChain(tailIdx, u)
if !ok {
continue
}
snap := fb.snapshotChase()
fb.wideChase = true
fb.capRelax = true
if fb.rewriteF16GatherGroup(tailIdx, chain, tableOK) {
changed = true
} else {
fb.restoreChase(snap)
}
fb.wideChase = false
fb.capRelax = false
}
if !changed {
return roots
}
return fb.compactDeadNodes(roots)
}
// f16GatherChain follows a lane-3 tail down to its load32_zero,
// returning the four node indices in lane order 0..3. Interior nodes
// must be single-use (the chain itself).
func (fb *fusedTreeBuilder) f16GatherChain(tailIdx int, uses []int) ([4]int, bool) {
var chain [4]int
n := &fb.nodes[tailIdx]
if len(n.Args) != 4 || n.Args[2].Kind != simdfuse.ArgConst || n.Args[2].Const != 3 ||
n.Args[3].Kind != simdfuse.ArgNode {
return chain, false
}
chain[3] = tailIdx
cur := n.Args[3].Index
for lane := int32(2); lane >= 1; lane-- {
c := &fb.nodes[cur]
if c.Op != "v128_load32_lane" || len(c.Args) != 4 ||
c.Args[2].Kind != simdfuse.ArgConst || c.Args[2].Const != lane ||
c.Args[3].Kind != simdfuse.ArgNode || uses[cur] != 1 {
return chain, false
}
chain[lane] = cur
cur = c.Args[3].Index
}
z := &fb.nodes[cur]
if z.Op != "v128_load32_zero" || len(z.Args) != 2 || uses[cur] != 1 {
return chain, false
}
chain[0] = cur
return chain, true
}
// f16LaneIndex chases one lane's address argument down to a
// scalar_i32_load16_u node, returning the load's address argument and
// the total table addend accumulated along the way.
func (fb *fusedTreeBuilder) f16LaneIndex(a simdfuse.Arg) (base simdfuse.Arg, addend int64, ok bool) {
switch a.Kind {
case simdfuse.ArgScalar:
narg, cok := fb.chaseI32(fb.scalars[a.Index])
if !cok {
return base, 0, false
}
a = narg
case simdfuse.ArgSum:
narg, cok := fb.chaseI32(fb.scalars[a.Index])
if !cok {
return base, 0, false
}
addend += int64(a.Const)
a = narg
case simdfuse.ArgConst:
return base, 0, false
}
if a.Kind != simdfuse.ArgNode {
return base, 0, false
}
n := &fb.nodes[a.Index]
if n.Op == "scalar_i32_add" && len(n.Args) == 2 {
x, c := n.Args[0], n.Args[1]
if x.Kind == simdfuse.ArgConst {
x, c = c, x
}
if c.Kind != simdfuse.ArgConst || x.Kind != simdfuse.ArgNode {
return base, 0, false
}
addend += int64(c.Const)
n = &fb.nodes[x.Index]
}
if n.Op != "scalar_i32_shl" || len(n.Args) != 2 ||
n.Args[1].Kind != simdfuse.ArgConst || n.Args[1].Const != 2 ||
n.Args[0].Kind != simdfuse.ArgNode {
return base, 0, false
}
ld := &fb.nodes[n.Args[0].Index]
if ld.Op != "scalar_i32_load16_u" || len(ld.Args) != 1 {
return base, 0, false
}
// Any address form is acceptable here — the group verifier proves
// lane adjacency structurally (argOffsetBy), and the fused mem
// emitters take chain-computed ArgNode addresses.
return ld.Args[0], addend, true
}
// rewriteF16GatherGroup verifies one chain group end to end and
// rewrites it in place. False leaves the tree unchanged (the caller
// restores the chase snapshot).
func (fb *fusedTreeBuilder) rewriteF16GatherGroup(tailIdx int, chain [4]int, tableOK func(uint32) bool) bool {
ldBase, total, ok := fb.f16LaneGather(chain)
if !ok {
ldBase, total, ok = fb.f16WordGather(chain)
}
if !ok {
return false
}
if total <= 0 || total > 1<<31-1 || !tableOK(uint32(total)) {
return false
}
// Capacity: the rewrite adds two nodes (the chain nodes die in
// compaction afterwards).
if len(fb.nodes)+2 > fusedMaxNodes {
return false
}
// The rewrite removes the original per-lane call sites, so the
// synthetic fallback body's helpers must be registered here.
fb.sc.em.useHelper("simd_f16x4_cvt")
if fb.addr64 {
fb.sc.em.useHelper("simd_m64_v128_load16x4_u")
} else {
fb.sc.em.useHelper("simd_v128_load16x4_u")
}
ldArgs := []simdfuse.Arg{ldBase, {Kind: simdfuse.ArgConst, Const: 0}}
fb.nodes = append(fb.nodes, simdfuse.Node{Op: "v128_load16x4_u", Args: ldArgs})
fb.nodeVals = append(fb.nodeVals, nil)
nl := len(fb.nodes) - 1
fb.nodes[tailIdx] = simdfuse.Node{Op: "f16x4_cvt", Args: []simdfuse.Arg{{Kind: simdfuse.ArgNode, Index: nl}}}
return true
}
// f16LaneGather proves the four-scalar-loads gather form: each lane's
// index chases to a scalar_i32_load16_u, the four loads sit at one
// base +0,+2,+4,+6 in lane order, and every lane accumulates the same
// table addend. Returns the base address argument for the collapsed
// 8-byte load and the shared table addend.
func (fb *fusedTreeBuilder) f16LaneGather(chain [4]int) (simdfuse.Arg, int64, bool) {
var bases [4]simdfuse.Arg
var totals [4]int64
for k := 0; k < 4; k++ {
n := &fb.nodes[chain[k]]
if n.Args[1].Kind != simdfuse.ArgConst {
return simdfuse.Arg{}, 0, false
}
b, addend, ok := fb.f16LaneIndex(n.Args[0])
if !ok {
return simdfuse.Arg{}, 0, false
}
bases[k] = b
totals[k] = addend + int64(n.Args[1].Const)
}
for k := 1; k < 4; k++ {
if totals[k] != totals[0] {
return simdfuse.Arg{}, 0, false
}
}
// The four f16 sources must be one base value at +0,+2,+4,+6 in
// lane order: proven structurally, with the lane delta carried by
// exactly one constant along otherwise-identical chase trees.
for k := 1; k < 4; k++ {
if !fb.argOffsetBy(bases[0], bases[k], int32(2*k)) {
// Diagnosis: report which deltas DO hold to reveal the
// lane-order variant.
pat := ""
for j := 1; j < 4; j++ {
for _, d := range []int32{-6, -4, -2, 0, 2, 4, 6} {
if fb.argOffsetBy(bases[0], bases[j], d) {
pat += fmt.Sprintf(" l%d=%+d", j, d)
break
}
}
}
return simdfuse.Arg{}, 0, false
}
}
return bases[0], totals[0], true
}
// f16WordGather proves the word-unpack gather form the guest emits
// when it loads four f16 values as one 64-bit word and unpacks the
// table indices by shift and mask:
//
// lane 0: ( w & 0xffff ) << 2
// lane k: ( w >>u (16k-2) & 0x3fffc ) + table (k = 1..3)
//
// The mask 0x3fffc after the pre-scaled shift selects exactly
// half-word k times 4, so each lane's index equals the classic
// per-load form; signedness of the shift is irrelevant because the
// mask discards every bit above 17. The collapsed v128_load16x4_u
// reads the same eight bytes the word load does.
func (fb *fusedTreeBuilder) f16WordGather(chain [4]int) (simdfuse.Arg, int64, bool) {
wName := ""
var totals [4]int64
for k := 0; k < 4; k++ {
n := &fb.nodes[chain[k]]
if n.Args[1].Kind != simdfuse.ArgConst {
return simdfuse.Arg{}, 0, false
}
a := n.Args[0]
add := int64(n.Args[1].Const)
var e ast.Expr
switch a.Kind {
case simdfuse.ArgScalar:
e = fb.scalars[a.Index]
case simdfuse.ArgSum:
e = fb.scalars[a.Index]
add += int64(a.Const)
default:
return simdfuse.Arg{}, 0, false
}
name, tadd, ok := fb.f16WordLane(e, k)
if !ok {
return simdfuse.Arg{}, 0, false
}
if k == 0 {
wName = name
} else if name != wName {
return simdfuse.Arg{}, 0, false
}
totals[k] = add + tadd
}
for k := 1; k < 4; k++ {
if totals[k] != totals[0] {
return simdfuse.Arg{}, 0, false
}
}
// Resolve the shared word ident to its 64-bit linear-memory load
// and chase that load's address as the gather base.
if fb.sc == nil || fb.sc.defBind == nil {
return simdfuse.Arg{}, 0, false
}
def, isDef := fb.sc.defBind[wName]
if !isDef || len(def.Rhs) != 1 {
return simdfuse.Arg{}, 0, false
}
star, ok := def.Rhs[0].(*ast.StarExpr)
if !ok {
return simdfuse.Arg{}, 0, false
}
// The uint64 address wrapper on the 64-bit load carries no
// memory64 information (every 8-byte deref widens its offset), so
// addr64 is deliberately not derived from it here.
addr, _, mok := matchMemDeref(star, "int64")
if !mok {
return simdfuse.Arg{}, 0, false
}
aarg, aok := fb.chaseAddr(addr)
if !aok {
return simdfuse.Arg{}, 0, false
}
return aarg, totals[0], true
}
// f16WordLane matches one lane of the word-unpack form, returning the
// word local's name and the table addend folded into the expression.
func (fb *fusedTreeBuilder) f16WordLane(e ast.Expr, lane int) (wName string, tadd int64, ok bool) {
e = stripIntConv(e)
if lane == 0 {
// (w & mask16) << 2
shl, sok := e.(*ast.BinaryExpr)
if !sok || shl.Op != token.SHL {
return "", 0, false
}
if s, cok := matchShiftConst(shl.Y, fb); !cok || s != 2 {
return "", 0, false
}
and, aok := stripIntConv(shl.X).(*ast.BinaryExpr)
if !aok || and.Op != token.AND {
return "", 0, false
}
w, m := and.X, and.Y
if _, isID := wordOperandIdent(w); !isID {
w, m = m, w
}
name, isID := wordOperandIdent(w)
if !isID {
return "", 0, false
}
mc, mok := fb.chaseI32(m)
if !mok || mc.Kind != simdfuse.ArgConst || uint32(mc.Const)&0xffff != 0xffff {
return "", 0, false
}
return name, 0, true
}
// ((w >> (16*lane-2)) & 0x3fffc) + table
addE, aok := e.(*ast.BinaryExpr)
if !aok || addE.Op != token.ADD {
return "", 0, false
}
andSide, constSide := addE.X, addE.Y
if _, isAnd := stripIntConv(andSide).(*ast.BinaryExpr); !isAnd {
andSide, constSide = constSide, andSide
}
tc, tok := fb.chaseI32(constSide)
if !tok || tc.Kind != simdfuse.ArgConst {
return "", 0, false
}
and, andok := stripIntConv(andSide).(*ast.BinaryExpr)
if !andok || and.Op != token.AND {
return "", 0, false
}
shrSide, maskSide := and.X, and.Y
if _, isShr := stripIntConv(shrSide).(*ast.BinaryExpr); !isShr {
shrSide, maskSide = maskSide, shrSide
}
mc, mok := fb.chaseI32(maskSide)
if !mok || mc.Kind != simdfuse.ArgConst || mc.Const != 0x3fffc {
return "", 0, false
}
shr, shrok := stripIntConv(shrSide).(*ast.BinaryExpr)
if !shrok || shr.Op != token.SHR {
return "", 0, false
}
if s, cok := matchShiftConst(shr.Y, fb); !cok || s != int32(16*lane-2) {
return "", 0, false
}
name, isID := wordOperandIdent(shr.X)
if !isID {
return "", 0, false
}
return name, int64(tc.Const), true
}
// stripIntConv unwraps integer-width conversion calls around an
// expression (identities for the chase, which runs at pointer width).
func stripIntConv(e ast.Expr) ast.Expr {
for {
c, ok := e.(*ast.CallExpr)
if !ok || len(c.Args) != 1 {
return e
}
id, ok := c.Fun.(*ast.Ident)
if !ok {
return e
}
switch id.Name {
case "int32", "uint32", "int64", "uint64", "uint":
e = c.Args[0]
default:
return e
}
}
}
// wordOperandIdent unwraps conversions and the unsigned-view helper
// (base.Ui64) around the packed-word operand and returns its ident.
func wordOperandIdent(e ast.Expr) (string, bool) {
e = stripIntConv(e)
if c, ok := e.(*ast.CallExpr); ok && len(c.Args) == 1 && helperName(c.Fun) == "Ui64" {
e = stripIntConv(c.Args[0])
}
id, ok := e.(*ast.Ident)
if !ok {
return "", false
}
return id.Name, true
}
// argOffsetBy reports that b evaluates to a + delta, structurally:
// matching scalar/sum indices with the constant difference, or equal
// node trees in which exactly one constant arm carries the whole
// delta. Two separate loads of one address compare equal — the same
// no-concurrent-writer contract the chase already relies on.
func (fb *fusedTreeBuilder) argOffsetBy(a, b simdfuse.Arg, delta int32) bool {
switch {
case a.Kind == simdfuse.ArgScalar && b.Kind == simdfuse.ArgScalar:
return a.Index == b.Index && delta == 0
case a.Kind == simdfuse.ArgScalar && b.Kind == simdfuse.ArgSum:
return a.Index == b.Index && b.Const == delta
case a.Kind == simdfuse.ArgSum && b.Kind == simdfuse.ArgScalar:
return a.Index == b.Index && -a.Const == delta
case a.Kind == simdfuse.ArgSum && b.Kind == simdfuse.ArgSum:
return a.Index == b.Index && b.Const-a.Const == delta
case a.Kind == simdfuse.ArgConst && b.Kind == simdfuse.ArgConst:
return b.Const-a.Const == delta
case a.Kind == simdfuse.ArgNode && b.Kind == simdfuse.ArgNode:
na, nb := &fb.nodes[a.Index], &fb.nodes[b.Index]
if na.Op == nb.Op && len(na.Args) == len(nb.Args) {
if len(na.Args) == 0 {
return delta == 0
}
for carry := range na.Args {
ok := true
for i := range na.Args {
d := int32(0)
if i == carry {
d = delta
}
if !fb.argOffsetBy(na.Args[i], nb.Args[i], d) {
ok = false
break
}
}
if ok {
return true
}
}
}
// Fall back to const-add peeling: one side may carry the lane
// delta as an explicit `base + const` node the other side lacks
// (lane 0 is the bare base). Each peel removes one add node, so
// the recursion terminates.
if x, c, ok := fb.addConstArm(nb); ok && fb.argOffsetBy(a, x, delta-c) {
return true
}
if x, c, ok := fb.addConstArm(na); ok && fb.argOffsetBy(x, b, delta+c) {
return true
}
return false
case b.Kind == simdfuse.ArgNode:
if x, c, ok := fb.addConstArm(&fb.nodes[b.Index]); ok {
return fb.argOffsetBy(a, x, delta-c)
}
return false
case a.Kind == simdfuse.ArgNode:
if x, c, ok := fb.addConstArm(&fb.nodes[a.Index]); ok {
return fb.argOffsetBy(x, b, delta+c)
}
return false
}
return false
}
// addConstArm decomposes a `scalar_i32_add(x, const)` node into its
// non-const arm and the constant.
func (fb *fusedTreeBuilder) addConstArm(n *simdfuse.Node) (x simdfuse.Arg, c int32, ok bool) {
if n.Op != "scalar_i32_add" || len(n.Args) != 2 {
return x, 0, false
}
x, ca := n.Args[0], n.Args[1]
if x.Kind == simdfuse.ArgConst {
x, ca = ca, x
}
if ca.Kind != simdfuse.ArgConst || x.Kind == simdfuse.ArgConst {
return x, 0, false
}
return x, ca.Const, true
}
// compactDeadNodes drops nodes no live node reaches (stores and roots
// are live; everything else must be referenced), remaps every
// ArgNode index and the roots, and compacts the scalar parameter
// arrays the dropped nodes exclusively used.
func (fb *fusedTreeBuilder) compactDeadNodes(roots []int) []int {
n := len(fb.nodes)
live := make([]bool, n)
var mark func(i int)
mark = func(i int) {
if live[i] {
return
}
live[i] = true
for _, a := range fb.nodes[i].Args {
if a.Kind == simdfuse.ArgNode {
mark(a.Index)
}
}
}
for _, r := range roots {
mark(r)
}
for i, nd := range fb.nodes {
if simdfuse.IsStore(nd.Op) {
mark(i)
}
}
// Rebuild in index order, except that a node's operands are placed
// before the node: a rewrite that appends a fresh operand node at
// the end of the list (the gather's collapsed 8-byte load) would
// otherwise leave a forward reference, and the pure twin body
// emits nodes in list order. Hoisting a dependency to just before
// its first consumer keeps every other relative order intact — the
// load lands where the chain's last lane load used to be.
remap := make([]int, n)
for i := range remap {
remap[i] = -1
}
var newNodes []simdfuse.Node
newNodeVals := fb.nodeVals[:0:0]
var place func(i int)
place = func(i int) {
if remap[i] >= 0 || !live[i] {
return
}
for _, a := range fb.nodes[i].Args {
if a.Kind == simdfuse.ArgNode && remap[a.Index] < 0 {
place(a.Index)
}
}
remap[i] = len(newNodes)
newNodes = append(newNodes, fb.nodes[i])
newNodeVals = append(newNodeVals, fb.nodeVals[i])
}
for i := 0; i < n; i++ {
place(i)
}
for i := range newNodes {
for ai := range newNodes[i].Args {
if newNodes[i].Args[ai].Kind == simdfuse.ArgNode {
newNodes[i].Args[ai].Index = remap[newNodes[i].Args[ai].Index]
}
}
}
fb.nodes = newNodes
fb.nodeVals = newNodeVals
newRoots := make([]int, len(roots))
for i, r := range roots {
newRoots[i] = remap[r]
}
// Scalar parameter compaction: keep only indices some live node
// still references.
usedScalar := map[int]bool{}
for _, nd := range fb.nodes {
for _, a := range nd.Args {
if a.Kind == simdfuse.ArgScalar || a.Kind == simdfuse.ArgSum {
usedScalar[a.Index] = true
}
}
}
sremap := make([]int, len(fb.scalars))
newScalars := fb.scalars[:0:0]
newOwner := fb.scalarOwner[:0:0]
newSrc := fb.scalarSrc[:0:0]
for i := range fb.scalars {
if !usedScalar[i] {
sremap[i] = -1
continue
}
sremap[i] = len(newScalars)
newScalars = append(newScalars, fb.scalars[i])
newOwner = append(newOwner, fb.scalarOwner[i])
newSrc = append(newSrc, fb.scalarSrc[i])
}
for i := range fb.nodes {
for ai := range fb.nodes[i].Args {
a := &fb.nodes[i].Args[ai]
if a.Kind == simdfuse.ArgScalar || a.Kind == simdfuse.ArgSum {
a.Index = sremap[a.Index]
}
}
}
fb.scalars = newScalars
fb.scalarOwner = newOwner
fb.scalarSrc = newSrc
// The dedup maps hold stale indices now; the rewrite runs after
// every walk, so nothing consults them again.
fb.scalarDedup = nil
return newRoots
}