Files
b2e0579065 Guard bare atomic spin loops with a bounded preemption point (#55)
* Guard bare atomic spin loops with a periodic preemption call

A loop that waits on an inline atomic load and makes no call — the
shape ggml_barrier compiles to — is legal, preemptible Go after
emission, but the gcasm bundler captures the compiled function into a
.s TEXT and the Go runtime refuses to async-preempt assembly. A
goroutine spinning inside the captured loop blocks every
stop-the-world; when the store it waits for comes from a goroutine the
GC already parked, the process livelocks with all cores burning.
Observed in production against llama.cpp's ggml_barrier as
intermittent multi-minute stalls. Until now every wasm guest had to
patch its own spin sites (llama-wasm's sched-yield patch); this makes
the generated code correct by construction.

A new emission-time analysis (spinguard.go) finds natural loops whose
body performs an inline atomic load and contains no surviving call,
and both block emitters prepend one statement to such a loop's header:

    spinRelax(&__spinGuard)

where spinRelax is a //go:noinline helper that increments the counter
and calls runtime.Gosched on every 1024th iteration. The call opcode
survives to machine code, so the spinning goroutine periodically
executes ordinary preemptible Go regardless of how the function is
lowered; the yield hands the core to the thread the barrier waits for.
Yielding every iteration measures ~3.4x decode latency on ggml's
barrier (n_threads=8) — every 1024th is parity with no yield at all
while still reaching the scheduler every few microseconds of spinning.
The policy and its constant live in the single helper; generated code
carries only the call.

The analysis runs on the final post-inline SSA, so a callee the
translator inlines away does not mask a spin (the fixture pins this),
and helper calls do not count as preemption points (small pure helpers
routinely inline). Fixtures cover both emitters' loop forms plus the
two no-guard shapes: a spin whose body keeps a real call, and a loop
over a plain non-atomic load.

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

* Add a unit test for the spinRelax preemption valve

Also keeps the linter's unused check satisfied — like the other
helpers, spinRelax is referenced only by generated code.

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

* Move the spin-guard counter inline; keep only the cold yield in the helper

An unconditional spinRelax call per spin iteration measured ~40%
decode overhead at n_threads=8 on the ggml barrier: eight workers
reaching runtime.Gosched at spin rate serialize on sched.lock
(runtime.usleep 38% + cond_signal/wait 23% of the profile), and the
call round-trip alone showed ~15%. The stop-the-world fix never needed
a yield per iteration — the call's stack-check prologue is the
preemption point — so emit the counter in the generated loop and keep
the call cold:

    __spinGuard++
    if __spinGuard&16383 == 0 { spinRelax() }

The hot path is an increment and a not-taken branch; a stop-the-world
now waits at most ~16k spin iterations (tens of microseconds), and
runtime.Gosched still donates the core on genuinely long waits.

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

* Rate-limit the spin-guard Gosched across workers

Even with the counter inline and the call cold (one call per 16384
spin iterations), yielding on every cold call measured ~10% decode
overhead at n_threads=8: on barrier-heavy graphs waiting is most of a
worker's time, so eight workers still reached runtime.Gosched often
enough to spend 30% of the profile in pthread_cond_signal (wakep).
The stop-the-world preemption point is the cold call's prologue, not
the Gosched — so keep the call and gate the yield behind a global
counter: one Gosched per 64 cold calls, i.e. proportional to
aggregate spin time. Rare on an uncontended box, automatically more
frequent when oversubscription makes spins long.

The counter is package-level state the function-only helper
extraction cannot carry, so emitHelpers appends its declaration
whenever spinRelax is included; helpers/spinrelax_state.go mirrors it
(un-embedded) purely so the helpers package still compiles for its
own tests.

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

* Derive the spin-guard interval from loop body size: a time budget, not an iteration count

A fixed 16384-iteration interval means the time between preemption
points stretches with the guarded loop's body: fine for a
handful-of-values barrier spin (~tens of µs), but an interpreter-style
bare loop carrying hundreds of values per iteration would push the
stop-the-world arrival bound toward milliseconds. Make the single
tunable a latency budget instead: interval × body size ≈ 2^18
value-iterations, derived per loop at emission (power of two, clamped
to [2^8, 2^14]), so the bound lands in the tens-to-low-hundreds of
microseconds of spinning regardless of what the guest's spin bodies
look like. Minimal spins keep the 16384 interval unchanged — the
measured llama parity is unaffected — and the derivation is pinned by
a big-body fixture that must come out at a shorter interval.

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

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-08-26 16:51:06 +09:00

165 lines
5.3 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package codegen
import (
"github.com/goccy/wasm2go/internal/ssa"
)
// Bare atomic spin loops
//
// A loop that waits on an inline atomic load and makes no call — the
// shape ggml's barrier emits, `while (atomic_load(p) == x) {}` — is
// legal Go after emission and preemptible as such, but the gcasm
// bundler captures the compiled function into a .s TEXT, and the Go
// runtime refuses to async-preempt assembly functions. A goroutine
// spinning inside the captured loop then blocks every stop-the-world:
// if the store it waits for comes from a goroutine the GC already
// parked, the process livelocks with all cores burning. Observed in
// production against llama.cpp's ggml_barrier (intermittent multi-
// minute stalls, GC-timing dependent).
//
// spinGuardHeaders finds the headers of such loops so the emitters can
// prepend one spinRelax(&__spinGuard) call per iteration (see the
// helper's comment for the policy and its measured cost — parity with
// the unguarded loop). The call is the fix: a loop whose body calls a
// //go:noinline function periodically executes ordinary Go code, which
// the runtime can preempt, no matter how the surrounding function is
// lowered.
//
// A loop qualifies when its body
// - performs an inline atomic load (the atomicInlineSpecs load
// entries — sync/atomic intrinsics, a bare LDAR/MOV in machine
// code), and
// - contains no call that survives to machine code: direct, indirect,
// and import calls, and the helper-form atomic ops (their //go:noinline
// helper chain) all count as calls. OpHelperCall does NOT count —
// small pure helpers routinely inline away, and a guard next to a
// call that then disappears would leave the loop bare again.
//
// Over-approximation is deliberately cheap: a guarded loop that never
// spins long pays one counter increment and a predictable branch per
// iteration.
//
// The returned value per header is the guard MASK: the cold call runs
// when __spinGuard&mask == 0. The interval is derived from the loop
// body's size so the stop-the-world arrival bound is a TIME budget,
// not an iteration count that would stretch with the body: a minimal
// barrier spin (a handful of values) checks every 16384th iteration,
// while an interpreter-style loop carrying hundreds of values per
// iteration checks every few hundred — either way the budget lands in
// the tens-to-low-hundreds of microseconds of spinning.
func spinGuardHeaders(f *ssa.Func) map[ssa.BlockID]int {
if f == nil || f.Entry == nil {
return nil
}
idom := ssa.Dominators(f)
dominates := func(a, b *ssa.Block) bool {
for cur := b; cur != nil; cur = idom[cur.ID] {
if cur == a {
return true
}
if cur == f.Entry {
break
}
}
return a == f.Entry
}
// Natural loop bodies, merged per header: every back-edge b→h
// (h dominating b) contributes the blocks that reach b without
// passing through h.
bodies := map[*ssa.Block]map[*ssa.Block]bool{}
for _, b := range f.Blocks {
for _, e := range b.Succs {
h := e.Block
if !dominates(h, b) {
continue
}
body := bodies[h]
if body == nil {
body = map[*ssa.Block]bool{h: true}
bodies[h] = body
}
stack := []*ssa.Block{b}
for len(stack) > 0 {
n := stack[len(stack)-1]
stack = stack[:len(stack)-1]
if body[n] {
continue
}
body[n] = true
for _, pe := range n.Preds {
stack = append(stack, pe.Block)
}
}
}
}
if len(bodies) == 0 {
return nil
}
var headers map[ssa.BlockID]int
for h, body := range bodies {
sawAtomicLoad := false
sawCall := false
bodyValues := 0
scan:
for blk := range body {
bodyValues += len(blk.Values)
for _, v := range blk.Values {
switch v.Op {
case ssa.OpCallDirect, ssa.OpCallIndirect, ssa.OpCallImport:
sawCall = true
break scan
case ssa.OpAtomicCall:
name, _ := v.Aux.(string)
if spec, ok := atomicInlineSpecs[name]; ok {
if !spec.isStore {
sawAtomicLoad = true
}
} else {
// Helper-form atomic (RMW, wait/notify,
// sub-word): a real //go:noinline call.
sawCall = true
break scan
}
}
}
}
if sawAtomicLoad && !sawCall {
if headers == nil {
headers = map[ssa.BlockID]int{}
}
headers[h.ID] = spinGuardMask(bodyValues)
}
}
return headers
}
// Spin-guard interval policy. The single tunable is a stop-the-world
// latency budget expressed in value-iterations: interval × body size
// ≈ spinGuardBudget, i.e. roughly constant TIME between preemption
// points across guests, assuming ~ns-scale SSA values. The clamps keep
// the derivation honest at the extremes: intervals above 2^14 buy
// nothing (the cold call is already <0.1% there), and intervals below
// 2^8 would let the guard itself become a per-iteration cost on very
// large bodies (which already reach the budget in few iterations).
const (
spinGuardBudget = 1 << 18
spinGuardMinInterval = 1 << 8
spinGuardMaxInterval = 1 << 14
)
// spinGuardMask derives a power-of-two guard interval from the loop
// body size and returns interval-1, the literal the emitters test the
// counter against.
func spinGuardMask(bodyValues int) int {
if bodyValues < 1 {
bodyValues = 1
}
interval := spinGuardMaxInterval
for interval > spinGuardMinInterval && interval*bodyValues > spinGuardBudget {
interval >>= 1
}
return interval - 1
}