Skip to content

ssa/wasm: use selectors for Rund and Reth continuations - #2208

Merged
cpunion merged 9 commits into
xgo-dev:mainfrom
cpunion:codex/wasm-static-defer-dispatch
Aug 3, 2026
Merged

ssa/wasm: use selectors for Rund and Reth continuations#2208
cpunion merged 9 commits into
xgo-dev:mainfrom
cpunion:codex/wasm-static-defer-dispatch

Conversation

@cpunion

@cpunion cpunion commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Implements #2276.

Problem

LLGo's defer lowering preserves function-local continuations in runtime.Defer.Rund and runtime.Defer.Reth. Native targets encode them as LLVM blockaddress values and resume with indirectbr.

That representation is valid LLVM IR, but WebAssembly has structured control flow rather than arbitrary computed gotos. Its SelectionDAG rejects any residual BlockAddress or BRIND operation.

LLVM schedules IndirectBrExpandPass for WebAssembly to convert blockaddress/indirectbr into integer selectors and a switch. That late fallback is not sufficient as LLGo's correctness boundary: with LLVM 19, the affected runtime IR deterministically reaches WebAssembly instruction selection with the computed-goto form still present and crashes while compiling runtime.EnsureLocalInitializer after #2079. This is a compiler crash before any WebAssembly module is produced, not a crash in the generated program and not a flaky build.

LLVM 22.1.8 does not remove the limitation. It still rejects computed gotos and still relies on the materially unchanged IndirectBrExpandPass. A version-specific crash may change, but LLGo still needs to guarantee that WebAssembly defer IR contains no computed-goto form.

The previous implementation also needed to cover both continuations: converting Rund alone leaves Reth, including panic during loop-defer draining, exposed to the same failure.

Design

  • On WebAssembly, store dense pointer-shaped selectors in the existing Rund and Reth fields and dispatch both through switch with an unreachable invalid default.
  • Rund selector 0 is terminal rethrow; later RunDefers continuations use 1, 2, and so on.
  • Reth selector 0 is procBlk, selector 1 is terminal rethrow, and later selectors identify intermediate continuations.
  • Preserve the current Reth selector while draining loop and range-function defer nodes so a nested panic resumes at the correct drainer continuation.
  • Keep runtime.Defer field types, order, size, offsets, and ABI unchanged.

Why Native keeps blockaddress

Native targets retain the existing blockaddress and indirectbr path.

Their LLVM backends can lower this form directly to a label address and register-indirect branch. Replacing it with selectors everywhere would add a different CFG representation, pointer/integer conversion, an invalid/default edge, and a switch that may become a compare chain or jump table.

Keeping the Native path:

  • preserves existing Native code generation and performance behavior;
  • avoids adding dispatch work where computed goto is already supported;
  • limits the portability workaround to the target that needs it;
  • reduces regression risk and keeps this change independent of any future Native selector benchmark.

This is a target-capability decision, not an operating-system decision. Another architecture should use selectors only if its backend cannot reliably lower computed gotos.

Supporting changes

  • Let the shared llgen flags parser accept GOOS/GOARCH and -target; litgen uses the same path through llgen.GenFrom rather than parsing flags separately.
  • Within that flags-file path, derive GOOS/GOARCH from -target, so flags.txt containing only -target=wasm selects js/wasm generation without changing general crosscompile target selection.
  • Stop ModeGen after producing its in-memory LLVM module, so target-specific llgen/litgen checks do not invoke a target C compiler or linker.

Benefits

  • Removes all defer-generated blockaddress and indirectbr operations from WebAssembly, covering normal RunDefers and panic/rethrow continuation.
  • Does not depend on LLVM 19, LLVM 22, or another backend version successfully applying a late rescue transform.
  • Preserves Native code generation without adding Native dispatch overhead.
  • Avoids a runtime ABI or layout migration.
  • Generates target-specific LLVM golden files without requiring emcc or another target linker.
  • Replaces the broader all-target selector experiment in ssa: use defer selectors on all targets (superseded by #2208) #2270 with a target-specific design.

Validation

  • Litgen-generated contiguous IR checks cover complete defer setup, both dispatches, and invalid defaults for fixed linux/amd64 Native, direct wasip1/wasm, and named -target=wasm cases.
  • The two WebAssembly goldens are identical: both contain two switch i32 dispatches and no blockaddress or indirectbr. The Native golden retains block addresses and two indirectbr operations.
  • linux/amd64 and darwin/arm64 SSA tests verify the Native path; wasip1/wasm verifies the WebAssembly path and unchanged runtime.Defer field types.
  • Native llgo test and the WebAssembly runtime fixture cover a panic raised midway through loop-defer draining.
  • js/wasm and wasip1/wasm runtime fixtures build successfully and pass wasm-tools validate.
  • Target-only litgen generation passes with emcc absent from PATH.
  • Full local SSA, compiler, and internal/build suites pass.
  • The complete GitHub Actions matrix passes on Ubuntu amd64, Linux arm64, macOS amd64, and macOS arm64; Codecov patch coverage is 100%.

@codecov

codecov Bot commented Jul 29, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@fennoai fennoai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: wasm static defer continuation dispatch

The change is well-scoped and correct in shape: wasm-specific dispatch is cleanly isolated behind storeRunDefersTarget / jumpRunDefersTarget, and the non-wasm path is unchanged (still IndirectJump on a block address). Switching from indirectbr to an index-based switch is the right choice for wasm, which has no computed-goto. I confirmed the panic path is consistent: index 0 routes to nexts[0] == rethrowBlk. Performance: no concerns (dispatch build is O(n), same as before, and runs once per endDefer). Security: no regression — the unreachable default matches the prior indirectbr semantics on an out-of-range value, and the index round-trips at a consistent uintptr width, so no truncation/wrong-case is possible.

Findings are inline. The most important is that the test does not actually exercise the wasm pointer width.

No blocking issues; event is COMMENT (non-approving).

Comment thread ssa/eh_defer_test.go Outdated
Comment thread ssa/eh.go Outdated
Comment thread ssa/eh.go Outdated
Comment thread ssa/eh.go Outdated
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown

LLGo baseline benchmarks

dcff207d2e16 | workflow run | long-term charts

Program measurements

Platform Workload File size vs base Build vs base Run vs base
Linux cprintf 18344 B +0.0% 304.916 ms -0.1% (better) 1.248 ms -3.4% (better)
Linux fmtprintf 1831176 B +0.0% 3.086 s +0.1% (worse) 3.053 ms -4.0% (better)
Linux println 67720 B +0.0% 295.381 ms -2.2% (better) 1.618 ms -0.9% (better)
macOS cprintf 84672 B +0.0% 437.527 ms -41.9% (better) 3.483 ms -52.2% (better)
macOS fmtprintf 1869328 B +0.0% 4.005 s +21.9% (worse) 17.457 ms +25.7% (worse)
macOS println 121200 B +0.0% 341.359 ms -55.5% (better) 4.247 ms -24.2% (better)
Core language and compiler benchmarks
Platform Benchmark ns/op vs base
Linux BenchmarkLookupPCRandom 13.410 ns/op +1.4% (worse)
Linux BenchmarkMergeCompilerFlags 152.100 ns/op +0.6% (worse)
Linux BenchmarkMergeLinkerFlags 95.810 ns/op +0.9% (worse)
Linux BenchmarkChannelBuffered 33.910 ns/op +0.7% (worse)
Linux BenchmarkChannelHandoff 26335 ns/op -4.3% (better)
Linux BenchmarkDefer 51.500 ns/op +1.1% (worse)
Linux BenchmarkDirectCall 1.557 ns/op -0.1% (better)
Linux BenchmarkGlobalRead 1.556 ns/op +0.0%
Linux BenchmarkGlobalWrite 2.484 ns/op +0.1% (worse)
Linux BenchmarkGoroutine 42003 ns/op +31.1% (worse)
Linux BenchmarkInterfaceCall 8.102 ns/op -0.0% (better)
Linux BenchmarkRuntimeGetG 2.180 ns/op -0.1% (better)
macOS BenchmarkLookupPCRandom 12.310 ns/op -4.1% (better)
macOS BenchmarkMergeCompilerFlags 117.900 ns/op -28.2% (better)
macOS BenchmarkMergeLinkerFlags 91.280 ns/op +7.9% (worse)
macOS BenchmarkChannelBuffered 23.310 ns/op -18.4% (better)
macOS BenchmarkChannelHandoff 7485 ns/op -8.8% (better)
macOS BenchmarkDefer 53.650 ns/op +12.6% (worse)
macOS BenchmarkDirectCall 1.077 ns/op -63.7% (better)
macOS BenchmarkGlobalRead 1.034 ns/op -23.5% (better)
macOS BenchmarkGlobalWrite 1.127 ns/op -41.8% (better)
macOS BenchmarkGoroutine 56260 ns/op +41.7% (worse)
macOS BenchmarkInterfaceCall 4.647 ns/op -27.7% (better)
macOS BenchmarkRuntimeGetG 2.149 ns/op -25.8% (better)

Compared with d39ed76b102e measured in the same runner job.

@cpunion
cpunion marked this pull request as draft August 3, 2026 11:43
@cpunion
cpunion force-pushed the codex/wasm-static-defer-dispatch branch from 62e773d to 96b82b9 Compare August 3, 2026 12:56
@cpunion cpunion changed the title ssa/wasm: use static defer continuation dispatch ssa/wasm: use selectors for Rund and Reth continuations Aug 3, 2026
@cpunion
cpunion marked this pull request as ready for review August 3, 2026 12:58

@fennoai fennoai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review summary

This PR cleanly splits defer/panic continuation dispatch into two encodings: native keeps blockaddress/indirectbr, while wasm uses a dense integer selector lowered to an LLVM switch (wasm has no blockaddress). I verified the selector index bookkeeping is correct and dense in both endDefer (Reth: 0→procBlk, 1→rethrow, 2..n→intermediates) and the incremental RunDefers/initDeferState path (Rund grows monotonically). The n==0 edge case is safe, the unreachable default is exhaustive-by-construction, and applyFlagsFile is transactional (commits next only on success) and well tested. Comments and the new littest CHECK directives accurately match the code.

Findings below are minor (maintainability, a small test gap, and two low-severity codegen smells). No correctness, security, or performance regressions.

Test gap (not inline): TestDeferContinuationDispatch only exercises DeferAlways. The loopDeferDrainer resume path (ssa/eh.go:470) stores a resume selector to rethPtr and relies on the same wasm dense-index bookkeeping, but no test combines DeferInLoop with a wasm target to assert the switch cases for the loop-drain resume. Since the index bookkeeping is the trickiest part of this change, a wasm loop-defer dispatch assertion would meaningfully reduce risk. (test/defer_test.go and the wasm-runtime littest exercise loop-defer behavior at runtime, but not the wasm IR shape.)

Codegen smells (low, not inline): On wasm the selector is a pure integer forced through the pointer-typed Defer.Reth/Rund fields, so every store emits inttoptr and every dispatch emits load+ptrtoint+switch (ssa/eh.go:585-611). LLVM typically folds the constant cast pair, so this is a smell rather than a hot-path cost. Separately, jumpDeferTarget allocates a fresh single-instruction unreachable default block on every call (twice per deferring function on wasm); a shared per-function unreachable block would avoid the minor IR bloat. Both optional.

Comment thread ssa/eh.go
target := b.Load(ptr)
if b.Prog.target.GOARCH != "wasm" {
blocks := make([]BasicBlock, len(targets))
for i, target := range targets {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confusing shadowing (maintainability). The outer target (line 593) is the loaded Expr (selector / block address), but this loop and the one at line 606 re-bind target to a deferTarget. The code is correct today — IndirectJump(target, blocks) uses the outer Expr because the loop var is loop-scoped — but this is exactly the shadowing that invites a bug if a future edit moves a use of target into or out of the loop. Consider renaming the outer variable (e.g. loaded/addr) or the loop variable (e.g. t).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 756a51c: the loaded Expr is now named loaded, so the deferTarget loop variables no longer shadow it.

Comment thread ssa/eh.go
nexts := self.rundsNext
if len(nexts) == 0 {
rundTargets := self.rundTargets
if len(rundTargets) == 0 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dead guard. rundTargets is always seeded with one element in initDeferState ({index: 0, block: rethrowBlk}) and only ever appended to, and endDefer already returned early when self == nil. So len(rundTargets) can never be 0 here. Harmless, but a reader tracing the index bookkeeping may waste time reasoning about an impossible empty case — consider dropping it or replacing it with a comment documenting the invariant.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kept the guard and clarified its invariant in 756a51c. Removing it makes the existing ssa.TestEndDefer panic because endDefer deliberately tolerates a partially constructed aDefer before initDeferState seeds target 0. go test ./ssa -count=1 passes with the documented guard.

@cpunion

cpunion commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator Author

@fennoai The latest revision is ready for another review. All CI checks are green, including the Native/Wasm target matrix, and Codecov patch coverage is 100%. Please take another look when available.

@fennoai

fennoai Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Re-review complete — LGTM

I re-reviewed the latest revision over the locked range d39ed76…dcff207 and posted the full conclusion as a PR review. No blocking issues.

Verified statically:

  • ssa/eh.go — wasm selectors are dense (rund 0,1,…; reth {0,…,n}) with unreachable defaults; the native blockaddress/indirectbr path is provably behavior-preserving (the refactored resume target equals the old drainEntry).
  • runtime/internal/runtime/z_rt.go — comment-only; Defer layout/ABI unchanged (tests assert fields 3/4 stay unsafe.Pointer).
  • internal/llgen/llgenf.go — flags parsing resolves -target (all spellings; -target=wasmjs/wasm) with atomic error handling.
  • internal/build/build.goModeGen early-return placed after full module processing; only skips cgo/asm/link so litgen runs without emcc.
  • Golden IR matches every claim: two wasm goldens identical (2 switch i32, 2 unreachable, no block addresses); native retains block addresses + 2 indirectbr.
  • Tests are targeted and strong, incl. the panic-mid-loop-defer-drain path.

Not done: couldn't compile/run the suite here — the LLVM C headers for github.com/xgo-dev/llvm aren't present in this sandbox, so the cgo binding fails to build. Review is static; relying on your reported green CI + 100% patch coverage for dynamic confirmation.

View job run

@fennoai fennoai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review: ssa/wasm defer continuation selectors

Conclusion: LGTM. The latest revision is a clean, correct, well-tested change. I found no blocking issues. Reviewed statically over the locked range d39ed76…dcff207.

What I verified

Core dispatch logic (ssa/eh.go)

  • Selector density & defaults. rundTargets indices are 0,1,2,… (seed 0 = rethrowBlk, one per RunDefers); rethTargets covers {0,1,…,n} (0procBlk, 1→terminal rethrow, 2..n→intermediate continuations). Both wasm switches are dense and carry an unreachable invalid default via jumpDeferTarget. The committed goldens confirm this: reth switch cases 1,2,0, rund switch cases 0,1.
  • Native path is behavior-preserving. deferTargetValue returns block.Addr() on native, so storeDeferTarget/jumpDeferTarget emit exactly the prior blockaddress + indirectbr IR. The refactored loop-defer resume argument is provably the same block as the old drainEntry (both are rethTargets[i+1]/rethsNext[i+1], the block endDefer opens before invoking stmts[i]), so the native panic-during-drain resume target is unchanged.
  • Reth-selector preservation while draining is threaded correctly through appendDeferStmt/appendLoopDeferDrainerloopDeferDrainer(self, resume), ensuring a nested panic resumes at the right continuation.
  • initDeferState: native reth init deferTargetValue({0, procBlk}) == old procBlk.Addr(); the new wasm-only rund seed (selector 0 = rethrowBlk) is consistent with rundTargets[0].

Runtime ABI (runtime/internal/runtime/z_rt.go) — comment-only; field types, order, size, and offsets unchanged. TestDeferContinuationDispatch additionally asserts fields 3/4 stay unsafe.Pointer across all targets.

Flags path (internal/llgen/llgenf.go)applyFlagsFile parses GOOS=/GOARCH=/-target (all spellings), resolves -target to GOOS/GOARCH (with -target=wasmjs/wasm), and mutates a copy next, committing only on success. Error handling is atomic: on a bad/unknown/valueless -target the original config is left untouched — locked in by TestApplyFlagsFileErrorIncludesPath.

ModeGen short-circuit (internal/build/build.go) — the early return nil is placed after LLVM transforms, opt passes, and emitFuncInfoEntrySites, so the in-memory module is fully formed; it only skips cgo/asm/archive steps. This is what lets litgen emit wasm goldens with emcc absent.

Test coverage — strong and targeted:

  • TestDeferContinuationDispatch: native (blockaddress + 2 indirectbr, no switch) vs wasm (no blockaddress/indirectbr, 2 switch i32, ≥2 unreachable), plus the empty-RunDefers-before-any-defer edge case.
  • TestPanicWhileDrainingLoopDefers and the wasm-runtime fixture exercise a panic raised mid-drain (order [2,1,0], recovered value checked) — the exact path this refactor touches.
  • Golden files match every claim: the two wasm goldens have identical CHECK lines; native retains block addresses.

Notes (non-blocking)

  • I could not compile or run the suite in this sandbox — the LLVM C headers (llvm-c/Core.h) for github.com/xgo-dev/llvm aren't present, so go build ./ssa/... fails at the cgo binding. My review is therefore static: source reasoning plus the committed golden IR. I'm relying on your report of green CI + 100% patch coverage for the dynamic confirmation.
  • Precedence when a flags.txt sets both GOOS=/GOARCH= and -target: -target wins (overwrites GOOS/GOARCH). Reasonable and matches the PR description; just worth a one-line comment if that combination is ever expected in practice.

Nice work on keeping the Native ABI and codegen untouched while making the Wasm path lowerable.

View job run

@cpunion
cpunion merged commit ca0d419 into xgo-dev:main Aug 3, 2026
42 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant