Repository: tetratelabs/wazero
Pull request #1680 (2023-09-04 00:29:58)

wazevo(regalloc): fixes phi node handlings
This fixes the handling of phi nodes (in our terminology SSA block arguments)
in the register allocation especially by implementing the missing logic in the
liveness analysis. 

As a result, wazevo, our new optimizing compiler #1496, finally has started passing 
all the WebAssembly v1 specification tests on AArch64.



mathetake: thanks for the help! @evacchi 

Patch:
diff --git a/internal/engine/wazevo/backend/compiler_lower.go b/internal/engine/wazevo/backend/compiler_lower.go
index 54a8e8a8..c3a2582c 100644
--- a/internal/engine/wazevo/backend/compiler_lower.go
+++ b/internal/engine/wazevo/backend/compiler_lower.go
@@ -152,9 +152,8 @@ func (c *compiler) lowerBlockArguments(args []ssa.Value, succ ssa.BasicBlock) {
 			}{cInst: srcDef.Instr, dst: dstReg})
 		} else {
 			srcReg := c.VRegOf(src)
-			if srcReg != dstReg { // Self-assignment can be no-op. This happens when, for example, passing a param as-is to the loop from the body.
-				c.varEdges = append(c.varEdges, [2]regalloc.VReg{srcReg, dstReg})
-			}
+			// Even when the src=dst, insert the move so that we can keep such registers keep-alive.
+			c.varEdges = append(c.varEdges, [2]regalloc.VReg{srcReg, dstReg})
 		}
 	}
 
diff --git a/internal/engine/wazevo/backend/isa/arm64/machine_regalloc.go b/internal/engine/wazevo/backend/isa/arm64/machine_regalloc.go
index 7dea1778..ea0a5297 100644
--- a/internal/engine/wazevo/backend/isa/arm64/machine_regalloc.go
+++ b/internal/engine/wazevo/backend/isa/arm64/machine_regalloc.go
@@ -16,8 +16,6 @@ type (
 		reversePostOrderBlocks []regAllocBlockImpl
 		// labelToRegAllocBlockIndex maps label to the index of reversePostOrderBlocks.
 		labelToRegAllocBlockIndex map[label]int
-		// predsSlice is used for regalloc.Block Preds() method, defined here for reuse.
-		predsSlice []regalloc.Block
 		// vs is used for regalloc.Instr Defs() and Uses() methods, defined here for reuse.
 		vs []regalloc.VReg
 	}
@@ -55,7 +53,6 @@ func (f *regAllocFunctionImpl) addBlock(sb ssa.BasicBlock, l label, pos *labelPo
 
 func (f *regAllocFunctionImpl) reset() {
 	f.reversePostOrderBlocks = f.reversePostOrderBlocks[:0]
-	f.predsSlice = f.predsSlice[:0]
 	f.vs = f.vs[:0]
 	f.iter = 0
 }
@@ -141,15 +138,17 @@ func (r *regAllocBlockImpl) ID() int {
 }
 
 // Preds implements regalloc.Block Preds.
-func (r *regAllocBlockImpl) Preds() []regalloc.Block {
+func (r *regAllocBlockImpl) Preds() int {
+	return r.sb.Preds()
+}
+
+// Pred implements regalloc.Block Pred.
+func (r *regAllocBlockImpl) Pred(i int) regalloc.Block {
 	sb := r.sb
-	r.f.predsSlice = r.f.predsSlice[:0]
-	for pred := sb.BeginPredIterator(); pred != nil; pred = sb.NextPredIterator() {
-		l := r.f.m.ssaBlockIDToLabels[pred.ID()]
-		index := r.f.labelToRegAllocBlockIndex[l]
-		r.f.predsSlice = append(r.f.predsSlice, &r.f.reversePostOrderBlocks[index])
-	}
-	return r.f.predsSlice
+	pred := sb.Pred(i)
+	l := r.f.m.ssaBlockIDToLabels[pred.ID()]
+	index := r.f.labelToRegAllocBlockIndex[l]
+	return &r.f.reversePostOrderBlocks[index]
 }
 
 // InstrIteratorBegin implements regalloc.Block InstrIteratorBegin.
@@ -171,6 +170,17 @@ func (r *regAllocBlockImpl) InstrIteratorNext() regalloc.Instr {
 	}
 }
 
+// BlockParams implements regalloc.Block BlockParams.
+func (r *regAllocBlockImpl) BlockParams() []regalloc.VReg {
+	c := r.f.m.compiler
+	regs := r.f.vs[:0]
+	for i := 0; i < r.sb.Params(); i++ {
+		v := c.VRegOf(r.sb.Param(i))
+		regs = append(regs, v)
+	}
+	return regs
+}
+
 func (r *regAllocBlockImpl) instrIteratorNext() *regAllocInstrImpl {
 	cur := r.instrImpl.i
 	if r.pos.end == cur {
diff --git a/internal/engine/wazevo/backend/regalloc/api.go b/internal/engine/wazevo/backend/regalloc/api.go
index 076f046a..aef91065 100644
--- a/internal/engine/wazevo/backend/regalloc/api.go
+++ b/internal/engine/wazevo/backend/regalloc/api.go
@@ -41,15 +41,17 @@ type (
 	Block interface {
 		// ID returns the unique identifier of this block.
 		ID() int
+		BlockParams() []VReg
 		// InstrIteratorBegin returns the first instruction in this block. Instructions added after lowering must be skipped.
 		// Note: multiple Instr(s) will not be held at the same time, so it's safe to use the same impl for the return Instr.
 		InstrIteratorBegin() Instr
 		// InstrIteratorNext returns the next instruction in this block. Instructions added after lowering must be skipped.
 		// Note: multiple Instr(s) will not be held at the same time, so it's safe to use the same impl for the return Instr.
 		InstrIteratorNext() Instr
-		// Preds returns the predecessors of this block in the CFG.
-		// Note: multiple returned []Block will not be used at the same time, so it's safe to use the same slice for []Block.
-		Preds() []Block
+		// Preds returns the number of predecessors of this block in the CFG.
+		Preds() int
+		// Pred returns the i-th predecessor of this block in the CFG.
+		Pred(i int) Block
 		// Entry returns true if the block is for the entry block.
 		Entry() bool
 	}
diff --git a/internal/engine/wazevo/backend/regalloc/coloring.go b/internal/engine/wazevo/backend/regalloc/coloring.go
index d7530c0e..ae826938 100644
--- a/internal/engine/wazevo/backend/regalloc/coloring.go
+++ b/internal/engine/wazevo/backend/regalloc/coloring.go
@@ -24,8 +24,8 @@ func (a *Allocator) buildNeighborsByLiveNodes(lives []liveNodeInBlock) {
 	for i, src := range lives[:len(lives)-1] {
 		srcRange := &src.n.ranges[src.rangeIndex]
 		for _, dst := range lives[i+1:] {
-			if dst == src {
-				panic("BUG: dup entry in liveNodes")
+			if dst == src || dst.n == src.n {
+				panic(fmt.Sprintf("BUG: %s and %s are the same node", src.n.v, dst.n.v))
 			}
 			dstRange := &dst.n.ranges[dst.rangeIndex]
 			if src.n.v.RegType() == dst.n.v.RegType() && // Interfere only if they are the same type.
@@ -218,7 +218,7 @@ func (a *Allocator) coloringFor(allocatable []RealReg) {
 			}
 			for neighbor := range n.neighbors {
 				if n.r == neighbor.r {
-					panic("BUG color conflict")
+					panic(fmt.Sprintf("BUG color conflict: %s vs %s", n.v, neighbor.v))
 				}
 			}
 		}
diff --git a/internal/engine/wazevo/backend/regalloc/regalloc.go b/internal/engine/wazevo/backend/regalloc/regalloc.go
index 64f14824..c8f874a9 100644
--- a/internal/engine/wazevo/backend/regalloc/regalloc.go
+++ b/internal/engine/wazevo/backend/regalloc/regalloc.go
@@ -25,6 +25,7 @@ func NewAllocator(allocatableRegs *RegisterInfo) Allocator {
 		realRegSet:      make(map[RealReg]struct{}),
 		nodeSet:         make(map[*node]int),
 		allocatedRegSet: make(map[RealReg]struct{}),
+		phis:            make(map[VReg]struct{}),
 	}
 	allocatableSet := make(map[RealReg]struct{},
 		len(allocatableRegs.AllocatableRegisters[RegTypeInt])+len(allocatableRegs.AllocatableRegisters[RegTypeFloat]),
@@ -64,6 +65,8 @@ type (
 		blockInfos   [] /* blockID to */ blockInfo
 		vs           []VReg
 		spillHandler spillHandler
+		// phis keeps track of the VRegs that are defined by phi functions.
+		phis map[VReg]struct{}
 
 		// Followings are re-used during various places e.g. coloring.
 		realRegSet map[RealReg]struct{}
@@ -75,12 +78,15 @@ type (
 
 	// blockInfo is a per-block information used during the register allocation.
 	blockInfo struct {
-		// TODO: reuse!!!
 		liveOuts map[VReg]struct{}
 		liveIns  map[VReg]struct{}
 		defs     map[VReg]programCounter
 		lastUses map[VReg]programCounter
 		kills    map[VReg]programCounter
+		// phiUses are the set of VRegs that are used in a phi function.
+		phiUses map[VReg]struct{}
+		// phiDefs are the set of VRegs that are defined in a phi function.
+		phiDefs map[VReg]struct{}
 		// Pre-colored real registers can have multiple live ranges in one block.
 		realRegUses map[VReg][]programCounter
 		realRegDefs map[VReg][]programCounter
@@ -160,14 +166,22 @@ const (
 func (a *Allocator) livenessAnalysis(f Function) {
 	// First, we need to allocate blockInfos.
 	for blk := f.PostOrderBlockIteratorBegin(); blk != nil; blk = f.PostOrderBlockIteratorNext() { // Order doesn't matter.
-		a.allocateBlockInfo(blk.ID())
+		info := a.allocateBlockInfo(blk.ID())
+		if blk.Entry() {
+			continue
+		}
+		// If this is not the entry block, we should define phi nodes, which are not defined by instructions.
+		for _, p := range blk.BlockParams() {
+			info.phiDefs[p] = struct{}{} // Mark this block has the definition of this phi `p`.
+			info.defs[p] = 0             // Earliest definition is at the beginning of the block.
+			a.phis[p] = struct{}{}
+		}
 	}
 
 	// Gathers all defs, lastUses, and VRegs in use (into a.vs).
 	a.vs = a.vs[:0]
 	for blk := f.PostOrderBlockIteratorBegin(); blk != nil; blk = f.PostOrderBlockIteratorNext() {
-		blkID := blk.ID()
-		info := a.blockInfoAt(blkID)
+		info := a.blockInfoAt(blk.ID())
 
 		var pc programCounter
 		for instr := blk.InstrIteratorBegin(); instr != nil; instr = blk.InstrIteratorNext() {
@@ -198,32 +212,38 @@ func (a *Allocator) livenessAnalysis(f Function) {
 				}
 			}
 			if instr.IsCopy() {
+				if _, ok := a.phis[dstVR]; ok {
+					// This is the phi move at the end of the block, so srcVR is the phi use (input to the phi function).
+					info.phiUses[srcVR] = struct{}{}
+				}
 				a.recordCopyRelation(dstVR, srcVR)
 			}
 			pc += pcStride
 		}
 
 		if wazevoapi.RegAllocLoggingEnabled {
-			fmt.Printf("constructed block info for block[%d]:\n%s\n\n", blkID, info)
+			fmt.Printf("constructed block info for block[%d]:\n%s\n\n", blk.ID(), info)
 		}
 	}
 
 	// Run the Algorithm 9.9. in the book. This will construct blockInfo.liveIns and blockInfo.liveOuts.
-	// Note that we don't have "phi"s at this point, but rather they are lowered to special VRegs which
-	// have multiple defs.
 	for _, v := range a.vs {
 		if v.IsRealReg() {
 			// Real registers do not need to be tracked in liveOuts and liveIns because they are not allocation targets.
 			panic("BUG")
 		}
 		for blk := f.PostOrderBlockIteratorBegin(); blk != nil; blk = f.PostOrderBlockIteratorNext() {
-			if len(blk.Preds()) == 0 && !blk.Entry() {
+			if blk.Preds() == 0 && !blk.Entry() {
 				panic(fmt.Sprintf("block without predecessor must be optimized out by the compiler: %d", blk.ID()))
 			}
 			info := a.blockInfoAt(blk.ID())
 			if _, ok := info.lastUses[v]; !ok {
 				continue
 			}
+			// Phi uses are always live-outs because it is the new value coming out to the successor.
+			if _, ok := info.phiUses[v]; ok {
+				info.liveOuts[v] = struct{}{}
+			}
 			// TODO: we might want to avoid recursion here.
 			a.upAndMarkStack(blk, v, 0)
 		}
@@ -247,7 +267,6 @@ func (a *Allocator) livenessAnalysis(f Function) {
 }
 
 // upAndMarkStack is the Algorithm 9.10. in the book named Up_and_Mark_Stack(B, v).
-// The only difference is that we don't have phis; instead, we have multiple defs in predecessors for such a VReg.
 //
 // We recursively call this, so passing `depth` for debugging.
 func (a *Allocator) upAndMarkStack(b Block, v VReg, depth int) {
@@ -256,7 +275,8 @@ func (a *Allocator) upAndMarkStack(b Block, v VReg, depth int) {
 	}
 
 	info := a.blockInfoAt(b.ID())
-	if _, ok := info.defs[v]; ok {
+	_, isPhi := a.phis[v]
+	if _, ok := info.defs[v]; ok && !isPhi {
 		return // Defined in this block, so no need to go further climbing up.
 	}
 	// v must be in liveIns.
@@ -269,13 +289,20 @@ func (a *Allocator) upAndMarkStack(b Block, v VReg, depth int) {
 
 	// Now we can safely mark v as a part of live-in
 	info.liveIns[v] = struct{}{}
+
+	// Plus if this is this block has the definition of this phi, we can stop climbing up.
+	if _, ok := info.phiDefs[v]; ok {
+		return
+	}
+
 	preds := b.Preds()
-	if len(preds) == 0 {
+	if preds == 0 {
 		panic(fmt.Sprintf("BUG: block has no predecessors while requiring live-in: blk%d", b.ID()))
 	}
 
 	// and climb up the CFG.
-	for _, pred := range preds {
+	for i := 0; i < preds; i++ {
+		pred := b.Pred(i)
 		if wazevoapi.RegAllocLoggingEnabled {
 			fmt.Printf("%sadding %v live-out at block[%d]\n", strings.Repeat("\t", depth+1), v, pred.ID())
 		}
@@ -351,6 +378,12 @@ func (a *Allocator) buildLiveRangesForNonReals(blkID int, info *blockInfo) {
 		if v.IsRealReg() {
 			panic("BUG: real registers should not be in defs")
 		}
+
+		if _, ok := ins[v]; ok {
+			// This case, phi value is coming in (used somewhere) but re-defined at the end.
+			continue
+		}
+
 		var end programCounter
 		if _, ok := outs[v]; ok {
 			// v is defined here and live-out, so it is live-through.
@@ -454,7 +487,6 @@ func (a *Allocator) Reset() {
 		delete(a.allocatedRegSet, r)
 	}
 
-	a.vs = a.vs[:0]
 	a.nodes1 = a.nodes1[:0]
 	for n := range a.nodeSet {
 		a.nodes1 = append(a.nodes1, n)
@@ -465,14 +497,17 @@ func (a *Allocator) Reset() {
 	a.nodes1 = a.nodes1[:0]
 	a.nodes2 = a.nodes2[:0]
 	a.realRegs = rr[:0]
+	resetMap(a, a.phis)
+	a.vs = a.vs[:0]
 }
 
-func (a *Allocator) allocateBlockInfo(blockID int) {
+func (a *Allocator) allocateBlockInfo(blockID int) *blockInfo {
 	if blockID >= len(a.blockInfos) {
 		a.blockInfos = append(a.blockInfos, make([]blockInfo, blockID+1)...)
 	}
 	info := &a.blockInfos[blockID]
 	a.initBlockInfo(info)
+	return info
 }
 
 func (a *Allocator) blockInfoAt(blockID int) (info *blockInfo) {
@@ -557,6 +592,16 @@ func (a *Allocator) initBlockInfo(i *blockInfo) {
 	} else {
 		resetMap(a, i.realRegDefs)
 	}
+	if i.phiDefs == nil {
+		i.phiDefs = make(map[VReg]struct{})
+	} else {
+		resetMap(a, i.phiDefs)
+	}
+	if i.phiUses == nil {
+		i.phiUses = make(map[VReg]struct{})
+	} else {
+		resetMap(a, i.phiUses)
+	}
 }
 
 func (i *blockInfo) addRealRegUsage(v VReg, pc programCounter) {
diff --git a/internal/engine/wazevo/ssa/basic_block.go b/internal/engine/wazevo/ssa/basic_block.go
index dcc1969a..13ba0963 100644
--- a/internal/engine/wazevo/ssa/basic_block.go
+++ b/internal/engine/wazevo/ssa/basic_block.go
@@ -58,6 +58,8 @@ type BasicBlock interface {
 	NextPredIterator() BasicBlock
 	// Preds returns the number of predecessors of this block.
 	Preds() int
+	// Pred returns the i-th predecessor of this block.
+	Pred(i int) BasicBlock
 }
 
 type (
@@ -227,6 +229,11 @@ func (bb *basicBlock) Preds() int {
 	return len(bb.preds)
 }
 
+// Pred implements BasicBlock.Pred.
+func (bb *basicBlock) Pred(i int) BasicBlock {
+	return bb.preds[i].blk
+}
+
 // Root implements BasicBlock.Root.
 func (bb *basicBlock) Root() *Instruction {
 	return bb.rootInstr


Test patch:
diff --git a/internal/engine/wazevo/backend/backend_test.go b/internal/engine/wazevo/backend/backend_test.go
index 67c90b65..643246e4 100644
--- a/internal/engine/wazevo/backend/backend_test.go
+++ b/internal/engine/wazevo/backend/backend_test.go
@@ -452,13 +452,13 @@ L4 (SSA Block: blk3):
 			afterFinalizeARM64: `
 L1 (SSA Block: blk0):
 	str x30, [sp, #-0x10]!
-	cbnz w2, #0x8 (L2)
+	cbnz w2, #0xc (L2)
 L3 (SSA Block: blk2):
+	mov x0, x3
 	b #0x8 (L4)
 L2 (SSA Block: blk1):
-	mov x3, x2
+	mov x0, x2
 L4 (SSA Block: blk3):
-	mov x0, x3
 	ldr x30, [sp], #0x10
 	ret
 `,
diff --git a/internal/engine/wazevo/backend/compiler_lower_test.go b/internal/engine/wazevo/backend/compiler_lower_test.go
index 4640dbad..0eb79d45 100644
--- a/internal/engine/wazevo/backend/compiler_lower_test.go
+++ b/internal/engine/wazevo/backend/compiler_lower_test.go
@@ -67,22 +67,6 @@ func TestCompiler_lowerBlockArguments(t *testing.T) {
 				}
 			},
 		},
-		{
-			name: "all src=dst",
-			setup: func(builder ssa.Builder) (*compiler, []ssa.Value, ssa.BasicBlock, func(t *testing.T)) {
-				blk := builder.AllocateBasicBlock()
-				i32 := blk.AddParam(builder, ssa.TypeI32)
-				i64 := blk.AddParam(builder, ssa.TypeI64)
-				f32 := blk.AddParam(builder, ssa.TypeF32)
-				f64 := blk.AddParam(builder, ssa.TypeF64)
-
-				m := &mockMachine{} // This ensures that InsertMove isn't called (otherwise, nil-panic happens).
-				c := newCompiler(m, builder)
-				c.ssaValueDefinitions = []SSAValueDefinition{{}, {}, {}, {}}
-				c.ssaValueToVRegs = []regalloc.VReg{0, 1, 2, 3}
-				return c, []ssa.Value{i32, i64, f32, f64}, blk, func(t *testing.T) {}
-			},
-		},
 		{
 			name: "overlap",
 			setup: func(builder ssa.Builder) (*compiler, []ssa.Value, ssa.BasicBlock, func(t *testing.T)) {
@@ -100,18 +84,22 @@ func TestCompiler_lowerBlockArguments(t *testing.T) {
 				c.ssaValueToVRegs = []regalloc.VReg{0, 1, 2, 3}
 				c.nextVRegID = 100 // Temporary reg should start with 100.
 				return c, []ssa.Value{v2, v1, v3 /* Swaps v1, v2 and pass v3 as-is. */}, blk, func(t *testing.T) {
-					require.Equal(t, 4, len(insertMoves))
-					mov1, mov2, mov3, mov4 := insertMoves[0], insertMoves[1], insertMoves[2], insertMoves[3]
+					require.Equal(t, 6, len(insertMoves)) // Three values are overlapped.
+					mov1, mov2, mov3, mov4, mov5, mov6 := insertMoves[0], insertMoves[1], insertMoves[2], insertMoves[3], insertMoves[4], insertMoves[5]
 					// Save the values to the temporary registers.
 					require.Equal(t, regalloc.VRegID(1), mov1.src.ID())
 					require.Equal(t, regalloc.VRegID(100), mov1.dst.ID())
 					require.Equal(t, regalloc.VRegID(0), mov2.src.ID())
 					require.Equal(t, regalloc.VRegID(101), mov2.dst.ID())
+					require.Equal(t, regalloc.VRegID(2), mov3.src.ID())
+					require.Equal(t, regalloc.VRegID(102), mov3.dst.ID())
 					// Then move back to the original place.
-					require.Equal(t, regalloc.VRegID(100), mov3.src.ID())
-					require.Equal(t, regalloc.VRegID(0), mov3.dst.ID())
-					require.Equal(t, regalloc.VRegID(101), mov4.src.ID())
-					require.Equal(t, regalloc.VRegID(1), mov4.dst.ID())
+					require.Equal(t, regalloc.VRegID(100), mov4.src.ID())
+					require.Equal(t, regalloc.VRegID(0), mov4.dst.ID())
+					require.Equal(t, regalloc.VRegID(101), mov5.src.ID())
+					require.Equal(t, regalloc.VRegID(1), mov5.dst.ID())
+					require.Equal(t, regalloc.VRegID(102), mov6.src.ID())
+					require.Equal(t, regalloc.VRegID(2), mov6.dst.ID())
 				}
 			},
 		},
diff --git a/internal/engine/wazevo/backend/regalloc/api_test.go b/internal/engine/wazevo/backend/regalloc/api_test.go
index c9fbd78d..43709ed3 100644
--- a/internal/engine/wazevo/backend/regalloc/api_test.go
+++ b/internal/engine/wazevo/backend/regalloc/api_test.go
@@ -194,11 +194,17 @@ func (m *mockBlock) InstrIteratorNext() Instr {
 	return ret
 }
 
-// Preds implements Instr.
-func (m *mockBlock) Preds() []Block {
-	return m._preds
+// Preds implements Block.
+func (m *mockBlock) Preds() int {
+	return len(m._preds)
 }
 
+// BlockParams implements Block.
+func (m *mockBlock) BlockParams() []VReg { return nil }
+
+// Pred implements Instr.
+func (m *mockBlock) Pred(i int) Block { return m._preds[i] }
+
 // Defs implements Instr.
 func (m *mockInstr) Defs() []VReg {
 	return m.defs
diff --git a/internal/engine/wazevo/backend/regalloc/regalloc_test.go b/internal/engine/wazevo/backend/regalloc/regalloc_test.go
index 77c4a8e4..4cefa489 100644
--- a/internal/engine/wazevo/backend/regalloc/regalloc_test.go
+++ b/internal/engine/wazevo/backend/regalloc/regalloc_test.go
@@ -705,6 +705,12 @@ func initMapInInfo(info *blockInfo) {
 	if info.realRegDefs == nil {
 		info.realRegDefs = make(map[VReg][]programCounter)
 	}
+	if info.phiDefs == nil {
+		info.phiDefs = make(map[VReg]struct{})
+	}
+	if info.phiUses == nil {
+		info.phiUses = make(map[VReg]struct{})
+	}
 }
 
 func TestNode_assignedRealReg(t *testing.T) {
diff --git a/internal/engine/wazevo/e2e_test.go b/internal/engine/wazevo/e2e_test.go
index 39336765..6fdd2712 100644
--- a/internal/engine/wazevo/e2e_test.go
+++ b/internal/engine/wazevo/e2e_test.go
@@ -32,8 +32,7 @@ func TestSpectestV1(t *testing.T) {
 	// Configure the new optimizing backend!
 	configureWazevo(config)
 
-	// TODO: adds incrementally one by one as we support more test cases. And eventually remove this
-	// and migrate to integration_test/spectest/v1/spec_test.go by the time when closing https://github.com/tetratelabs/wazero/issues/1496
+	// TODO: migrate to integration_test/spectest/v1/spec_test.go by the time when closing https://github.com/tetratelabs/wazero/issues/1496
 	for _, tc := range []struct {
 		name string
 	}{
@@ -82,7 +81,7 @@ func TestSpectestV1(t *testing.T) {
 		{name: "left-to-right"},
 		{name: "linking"},
 		{name: "load"},
-		//{name: "loop"},
+		{name: "loop"},
 		{name: "local_get"},
 		{name: "local_set"},
 		{name: "local_tee"},
@@ -317,6 +316,15 @@ func TestE2E(t *testing.T) {
 				{params: []uint64{1000, 300}, expResults: []uint64{11 + 300}},
 			},
 		},
+		{
+			name: "multi_predecessor_local_ref",
+			m:    testcases.MultiPredecessorLocalRef.Module,
+			calls: []callCase{
+				{params: []uint64{0, 100}, expResults: []uint64{100}},
+				{params: []uint64{1, 100}, expResults: []uint64{1}},
+				{params: []uint64{1, 200}, expResults: []uint64{1}},
+			},
+		},
 	} {
 		tc := tc
 		t.Run(tc.name, func(t *testing.T) {