Files
32841813d9 spin guard: yield on every cold call when agents outnumber processors (#68)
* spin guard: yield on every cold call when agents outnumber processors

A wasm thread is a goroutine, and the spin guard's cold call yielded
its processor once per 64 calls: enough to keep stop-the-world
pauses bounded and cheap when every agent has a processor of its own.
With more agents than GOMAXPROCS it is the wrong rate. A barrier the
guest spins on releases only when every agent has arrived, the
missing agents are exactly the ones waiting for a processor, and the
spinners hold those processors for milliseconds between yields. On a
4-core runner an engine with 8 threads decoded at 1/100 of its
single-thread rate; native ggml under an OpenMP barrier loses little
in the same setting.

threadLaunch now maintains a live spawned-agent gauge, and spinRelax
yields on every cold call while that gauge reaches GOMAXPROCS (the
spawner itself is the extra goroutine). Below that the 1/64 rate is
unchanged, so the uncontended path keeps its measured parity.

Qwen2.5-0.5B Q8_0 on a 10-core M-series host, decode tok/s (old ->
new): 12 threads 8.3 -> 87.7, 16 threads 3.5 -> 57.2, 32 threads
0.5 -> 25.7; prompt at 32 threads 220 -> 1312; 1 thread unchanged.

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

* spin guard: yield rarely when every agent has a processor

One yield per 64 cold calls kept donation proportional to spin time,
but on an uncontended box every yield wakes an idle scheduler thread
(wakep, pthread_cond_signal) that spins in findRunnable and parks
again. Profiled at n_threads = 4 on a 10-core host that churn was 60%
of a decode step's CPU samples, and on a 4-core host it takes cores
from the workers themselves. With every agent on its own processor
there is nobody to donate the core to: the uncontended rate is now one
yield per 1024 cold calls (tens of milliseconds of spinning, the
granularity sysmon preempts at anyway); oversubscription still yields
on every cold call.

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

---------

Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-09-05 14:26:45 +09:00

92 lines
3.3 KiB
Go

package codegen_test
import (
"bytes"
"strings"
"testing"
"github.com/goccy/wasm2go/internal/codegen"
"github.com/goccy/wasm2go/internal/testfixture"
"github.com/goccy/wasm2go/internal/wasm"
)
// translateSpinguard translates the spinguard fixture and returns the
// primary source, the concatenation of every emitted file (function
// bodies land in sidecar files), and the Translate result.
func translateSpinguard(t *testing.T) (string, string, codegen.Result) {
t.Helper()
bin := testfixture.Wasm(t, "spinguard.wasm")
mod, err := wasm.Parse(bytes.NewReader(bin))
if err != nil {
t.Fatal(err)
}
var buf bytes.Buffer
res, err := codegen.Translate(&buf, mod, codegen.Options{Package: "pkg", OutputImportPath: "gentest/pkg"})
if err != nil {
t.Fatalf("translate: %v", err)
}
all := buf.String()
for _, data := range res.Files {
all += "\n" + string(data)
}
return buf.String(), all, res
}
// TestSpinGuardInjection pins where the bare-atomic-spin preemption
// guard lands: exactly the two bare spin loops (i32 and i64 waits with
// no call in the body) get a spinRelax call and the counter local; the
// same wait with a direct call in the body, and a loop spinning on a
// plain (non-atomic) load, must stay untouched.
func TestSpinGuardInjection(t *testing.T) {
_, all, _ := translateSpinguard(t)
if got := strings.Count(all, "__spinGuard++"); got != 3 {
t.Errorf("spin guard sites = %d, want 3 (bare_spin, bare_spin64, big_spin)", got)
}
// The minimal spins get the widest interval; big_spin's body carries
// dozens of values, so its derived interval is proportionally
// shorter — the guard budget is time, not iterations.
if got := strings.Count(all, "if __spinGuard&16383 == 0 {"); got != 2 {
t.Errorf("spin guard cold branches at max interval = %d, want 2", got)
}
if got := strings.Count(all, "if __spinGuard&2047 == 0 {"); got != 1 {
t.Errorf("spin guard cold branches at derived big-body interval = %d, want 1", got)
}
if got := strings.Count(all, "var __spinGuard uint32"); got != 3 {
t.Errorf("__spinGuard declarations = %d, want 3", got)
}
if !strings.Contains(all, "func spinRelax(") {
t.Error("spinRelax helper source not included")
}
// The guard's oversubscription gauge is package state the helper
// extraction cannot carry: the runtime template must declare it.
if !strings.Contains(all, "var spinAgents int32") || !strings.Contains(all, "var spinOversubscribed uint32") {
t.Error("spinAgents gauge / spinOversubscribed flag not declared alongside spinRelax")
}
}
// TestSpinGuardRuns compiles and runs the guarded output: the wait
// condition fails on the first check (memory is zeroed, the argument is
// nonzero), so each spin executes its guard once and returns. This is
// the compile-level assertion that the injected statements are legal Go
// in both loop shapes.
func TestSpinGuardRuns(t *testing.T) {
src, _, res := translateSpinguard(t)
main := `package main
import (
"fmt"
"gentest/pkg"
)
func main() {
m := pkg.New()
m.BigSpin(1) // exits on the first check; pins that the guarded big-body loop runs
fmt.Println(m.BareSpin(1), m.BareSpin64(1), m.SpinWithCall(1), m.PlainLoop(1))
}
`
got := runGoSnippetNoRace(t, src, main, res.Sidecars, filesWithAux(res))
if strings.TrimSpace(got) != "0 0 0 0" {
t.Errorf("guarded spins: got %q, want \"0 0 0 0\"", got)
}
}