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

48 lines
3.5 KiB
WebAssembly Text Format

;; spinguard.wat — loop shapes for the bare-atomic-spin preemption guard
;; (spinguard.go). `bare_spin` is ggml_barrier's wait: an inline atomic
;; load compared against an argument, back-branching with no call — the
;; shape that must receive a spinRelax guard. `spin_with_call` is the
;; same wait but with a direct call in the body (already preemptible —
;; no guard), and `plain_loop` spins on a non-atomic load (not a
;; cross-thread wait — no guard). `big_spin` is a bare spin whose body
;; carries dozens of data-dependent values: it must still be guarded,
;; at a shorter derived interval (the guard budget is time, not
;; iterations).
(module
(memory 1 1 shared)
;; $tick recurses on its own (compile-time-unknown) argument, so no
;; amount of inlining or constant folding removes the recursive call —
;; the spin_with_call loop keeps a genuine OpCallDirect, which its
;; no-guard expectation needs. (A constant or straight-line callee
;; gets inlined away, correctly turning the caller into a bare spin —
;; the guard analysis runs on the final, post-inline code.) At runtime
;; the argument is a zeroed memory word, so the recursion never fires.
(func $tick (param i32) (result i32)
(if (result i32) (local.get 0)
(then (call $tick (local.get 0)))
(else (i32.const 1))))
(func (export "bare_spin") (param i32) (result i32)
(loop $w
(br_if $w (i32.eq (i32.atomic.load (i32.const 16)) (local.get 0))))
(i32.atomic.load (i32.const 16)))
(func (export "bare_spin64") (param i64) (result i64)
(loop $w
(br_if $w (i64.eq (i64.atomic.load (i32.const 24)) (local.get 0))))
(i64.atomic.load (i32.const 24)))
(func (export "spin_with_call") (param i32) (result i32)
(loop $w
(drop (call $tick (i32.atomic.load (i32.const 48))))
(br_if $w (i32.eq (i32.atomic.load (i32.const 16)) (local.get 0))))
(i32.atomic.load (i32.const 16)))
(func (export "big_spin") (param i32) (result i32)
(local i32)
(loop $w
(local.set 1 (i32.mul (i32.add (i32.xor (i32.mul (i32.add (i32.xor (i32.mul (i32.add (i32.xor (i32.mul (i32.add (i32.xor (i32.mul (i32.add (i32.xor (i32.mul (i32.add (i32.xor (i32.mul (i32.add (i32.xor (i32.mul (i32.add (i32.xor (i32.mul (i32.add (i32.xor (i32.mul (i32.add (i32.xor (i32.mul (i32.add (i32.xor (i32.mul (i32.add (i32.xor (i32.mul (i32.add (i32.xor (i32.mul (local.get 1) (i32.const 654435747)) (i32.const 147483637)) (i32.const 723777223)) (i32.const 297842322)) (i32.const 874135908)) (i32.const 300713404)) (i32.const 877006734)) (i32.const 451071065)) (i32.const 18975748)) (i32.const 445557479)) (i32.const 954737977)) (i32.const 528803076)) (i32.const 105096655)) (i32.const 531678126)) (i32.const 107970553)) (i32.const 682031787)) (i32.const 248887926)) (i32.const 677566809)) (i32.const 178021163)) (i32.const 687070589)) (i32.const 261265992)) (i32.const 837493786)) (i32.const 264075474)) (i32.const 831980196)) (i32.const 406041327)) (i32.const 982334913)) (i32.const 408916381)) (i32.const 985208943)) (i32.const 492165306)) (i32.const 68454501)) (i32.const 495036228)) (i32.const 61892367)) (i32.const 638054881)) (i32.const 212250156)) (i32.const 640924687)) (i32.const 215121114)) (i32.const 717541523)) (i32.const 224629086)) (i32.const 792398768)) (i32.const 219111304)))
(br_if $w (i32.eq (i32.atomic.load (i32.const 16))
(i32.add (local.get 0) (local.get 1)))))
(local.get 1))
(func (export "plain_loop") (param i32) (result i32)
(loop $w
(br_if $w (i32.eq (i32.load (i32.const 16)) (local.get 0))))
(i32.load (i32.const 16))))