From f35afeff0e1f577dc2453ca888cb1ce3360284c9 Mon Sep 17 00:00:00 2001 From: Brandon Cook Date: Sun, 7 Jun 2026 09:53:10 +1000 Subject: [PATCH] =?UTF-8?q?guest=20sdk:=20large-room=20scale=20=E2=80=94?= =?UTF-8?q?=201024=20lazy=20frame=20baselines=20+=20cross-callback=20roste?= =?UTF-8?q?r=20cache?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes that take the SDK from 16-player rooms to 1024: rosterCap 16 -> 1024, with lazy per-slot baselines. The SDK silently drops Send for a roster index >= rosterCap, so the old cap was a hard invisible wall for any room beyond 16 players. Each ~45 KiB baseline slot is now allocated on first commit (guest memory tracks the active roster, not the cap), and Identical reconciles only allocated slots — a never-sent-to slot recovers via its first send opening with a keyframe, the same path as a roster change. No wire/ABI change. Cross-callback roster cache. The host re-sends the full member list in every callback payload, but rosters change only on join/leave/index- shift. decodeCtx now skims the member section's raw bytes (additive wire.(*Rd).SkipStr) and memcmps them against the previous callback's: on a match the previously decoded []Player is reused with zero allocation; only a real change re-decodes. Replaces the roster fingerprint hash (the byte compare is strictly stronger). Under -gc=leaking the old per-callback decode leaked the roster at callback rate (~100 KB/callback at 1000 players — guest OOM within seconds of large-room play); steady-state callbacks are now allocation-free in the member path. Members() slices are valid for the duration of the callback — copy retained Players (long-lived state keys by AccountID, as before). Load-benchmarked at 50..1000 players (200 ticks/size): mean input delivery halved at every size, guest heap growth halved under -gc=leaking, hibernation snapshots ~6x smaller at 1000 players. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/large-room-scale.md | 25 ++++++ internal/game/codec.go | 142 +++++++++++++++++++++------------ internal/game/diff_test.go | 100 +++++++++++++++++++---- internal/game/room.go | 13 ++- internal/game/run.go | 23 +++--- wire/wire.go | 11 +++ 6 files changed, 237 insertions(+), 77 deletions(-) create mode 100644 .changeset/large-room-scale.md diff --git a/.changeset/large-room-scale.md b/.changeset/large-room-scale.md new file mode 100644 index 0000000..74b9b3b --- /dev/null +++ b/.changeset/large-room-scale.md @@ -0,0 +1,25 @@ +--- +"kit": minor +--- + +Large-room scale: the guest SDK now supports rooms of up to 1024 players. + +- **Per-index frame baselines raised 16 → 1024, allocated lazily.** The SDK + used to silently drop `Send` for a roster index ≥ 16; the per-slot baseline + table is now sized for 1024 consumers but each ~45 KiB slot is allocated on + first commit, so guest linear memory tracks the ACTIVE roster, not the cap. + A broadcast (`Identical`) reconciles only allocated slots; a never-sent-to + slot recovers via its first send opening with a keyframe (unconditionally + accepted) — the same path as a roster change. No wire/ABI change. +- **Cross-callback roster cache.** The host re-sends the full member list in + every callback payload, but rosters change only on join/leave/index-shift. + The SDK now skims the member section's raw bytes (new additive + `wire.(*Rd).SkipStr`) and compares them to the previous callback's: on a + match the previously decoded `[]Player` is reused with zero allocation; + only a real roster change re-decodes. This replaces the roster fingerprint + hash (the byte compare is strictly stronger) and removes an O(members) + allocation from EVERY callback — which, under `-gc=leaking`, leaked the + roster at callback rate and OOM'd long-lived large rooms (~100 KB/callback + at 1000 players). Lifetime contract: the slice from `Room.Members()` is + valid for the duration of the callback; copy `Player` values you retain + (long-lived state should be keyed by `AccountID`, as before). diff --git a/internal/game/codec.go b/internal/game/codec.go index c42031d..18e9b40 100644 --- a/internal/game/codec.go +++ b/internal/game/codec.go @@ -1,6 +1,10 @@ package game -import "github.com/shellcade/kit/v2/wire" +import ( + "bytes" + + "github.com/shellcade/kit/v2/wire" +) // The guest side of the ABI codecs: thin mappings between wire types (the // canonical encodings owned by gamekit/wire) and the authoring types. @@ -13,28 +17,82 @@ type callContext struct { settled bool } +// Roster cache: the host re-sends the full member list in EVERY callback +// payload, but rosters change only on join/leave/index-shift. Decoding N +// members afresh per callback is O(N) string allocations per input/wake — +// under -gc=leaking (the recommended build profile while the TinyGo GC issue +// is open) those allocations are PERMANENT, so a long-lived large room leaks +// its roster at callback rate (load testing measured ~100KB leaked per +// callback at 1000 players, OOMing the guest within seconds of play). +// +// Instead, the raw wire bytes of the member section are compared against the +// previous callback's (a ~100B/member memcmp); on a match the previously +// decoded []Player is reused with ZERO allocation, and only a real roster +// change re-decodes. The byte compare is strictly stronger than the old +// rosterFingerprint hash (which this replaces): any join/leave/index-shift/ +// kind-change alters the bytes. +// +// Lifetime contract: the members slice handed to a callback (and returned by +// Room.Members()) is valid for the DURATION OF THAT CALLBACK; a later roster +// change re-decodes into the same backing array. Games that retain players +// across callbacks must copy the Player values they keep (kit games key +// long-lived state by AccountID already). +var ( + rosterCache []Player + rosterCacheBytes []byte +) + // decodeCtx decodes a CallContext and returns the reader positioned at the -// event-specific extra payload. -func decodeCtx(b []byte) (callContext, *wire.Rd) { +// event-specific extra payload, plus whether the roster changed since the +// previous callback (true on the first callback). +func decodeCtx(b []byte) (callContext, *wire.Rd, bool) { r := &wire.Rd{B: b} - wc := wire.DecodeCtx(r) - c := callContext{ - nowUnixNanos: wc.NowUnixNanos, - cfg: RoomConfig{ - Mode: Mode(wc.Mode), - Capacity: int(wc.Capacity), - MinPlayers: int(wc.MinPlayers), - Seed: wc.Seed, - SeedSet: wc.SeedSet, - }, - settled: wc.Settled, + c := callContext{nowUnixNanos: r.I64()} + c.cfg.Seed = r.I64() + c.cfg.SeedSet = r.Bool() + c.cfg.Mode = Mode(r.U8()) + c.cfg.Capacity = int(r.U16()) + c.cfg.MinPlayers = int(r.U16()) + + // Skim the member section (count + per-member strings) without decoding, + // to find its extent for the cache compare. + start := r.Off + n := int(r.U16()) + for i := 0; i < n && !r.Bad; i++ { + r.SkipStr() // handle + r.SkipStr() // account id + r.SkipStr() // conn + r.U8() // kind } - for _, p := range wc.Members { - c.members = append(c.members, Player{ - AccountID: p.AccountID, Handle: p.Handle, Conn: p.Conn, Kind: Kind(p.Kind), - }) + region := r.B[start:r.Off] + + changed := rosterCacheBytes == nil || !bytes.Equal(region, rosterCacheBytes) + if changed { + rr := &wire.Rd{B: region} + cnt := int(rr.U16()) + rosterCache = rosterCache[:0] + for i := 0; i < cnt && !rr.Bad; i++ { + var p Player + p.Handle = rr.Str() + p.AccountID = rr.Str() + p.Conn = rr.Str() + p.Kind = Kind(rr.U8()) + rosterCache = append(rosterCache, p) + } + if r.Bad { + // Malformed member section: don't prime the cache — every + // malformed callback stays "changed" and decodes what it can + // (the pre-cache behavior). A well-formed region is ≥2 bytes + // (the count), so a primed cache is never nil. + rosterCacheBytes = nil + } else { + rosterCacheBytes = append(rosterCacheBytes[:0], region...) + } } - return c, r + c.members = rosterCache + + c.settled = r.Bool() + return c, r, changed } // encodeMeta packs GameMeta for the meta export. @@ -104,9 +162,12 @@ func encodeFrame(f *Frame) []byte { // ---- frame diffing state (ABI v2) ------------------------------------------- // rosterCap is the fixed compile-time roster ceiling for per-index baselines. -// At 24-byte cells the baseline table is (rosterCap+broadcast)*FrameBytes ≈ -// 0.78 MB of guest linear memory, lazily grown, far under the 32 MiB cap. -const rosterCap = 16 +// 1024 supports large-room games (the SDK SILENTLY DROPS Send for an index +// >= rosterCap, so the cap must comfortably exceed any real roster). +// Guest linear memory stays proportional to the ACTIVE roster, not the cap: +// per-slot baselines are lazily allocated on first commit — ~45 KiB per +// actively-sent-to consumer instead of a ~47 MiB static table. +const rosterCap = 1024 // Per-consumer SDK baseline state, allocated once and reused forever (leaking-GC // safe; one room per instance + serial callbacks ⇒ no locking). Each slot holds @@ -114,8 +175,9 @@ const rosterCap = 16 // host-returned epoch it was stamped with, and a present flag. Index rosterCap // is the broadcast (Identical) slot. deltaScratch is the pre-sized worst-case // (keyframe-sized) delta buffer written by index — never wire.Buf, which grows. +// Baseline slots are nil until first committed (lazy; see rosterCap note). var ( - baselines [rosterCap + 1][wire.FrameBytes]byte + baselines [rosterCap + 1][]byte // wire.FrameBytes each once allocated baselineEpoch [rosterCap + 1]uint32 baselinePresent [rosterCap + 1]bool deltaScratch [wire.MaxDeltaBytes]byte @@ -145,7 +207,7 @@ func buildSendPayload(slot int, packed []byte) []byte { n := wire.BuildKeyframe(packed, deltaScratch[:], epoch) return deltaScratch[:n] } - n := wire.BuildFrameDelta(baselines[slot][:], packed, deltaScratch[:], epoch) + n := wire.BuildFrameDelta(baselines[slot], packed, deltaScratch[:], epoch) if n >= wire.KeyframeBytes { n = wire.BuildKeyframe(packed, deltaScratch[:], epoch) } @@ -154,37 +216,17 @@ func buildSendPayload(slot int, packed []byte) []byte { // commitBaseline records `packed` as slot `slot`'s baseline and stamps it with // the host-returned epoch, marking it present. Called on a successful send. +// The slot buffer is allocated on first commit (lazy; leaking-GC safe because +// a slot is allocated at most once and reused forever). func commitBaseline(slot int, packed []byte, returnedEpoch uint32) { - copy(baselines[slot][:], packed) + if baselines[slot] == nil { + baselines[slot] = make([]byte, wire.FrameBytes) + } + copy(baselines[slot], packed) baselineEpoch[slot] = returnedEpoch baselinePresent[slot] = true } -// rosterFingerprint is a cheap fixed-scratch identity of the current roster used -// to detect a roster change between callbacks (join/leave/index-shift): the -// member count plus a rolling hash of each member's account id + kind. It needs -// no allocation and survives across callbacks as a package global. -var ( - lastRosterPrint uint64 - lastRosterSet bool -) - -func rosterFingerprint(members []Player) uint64 { - h := uint64(1469598103934665603) // FNV-1a offset - mix := func(b byte) { h ^= uint64(b); h *= 1099511628211 } - mix(byte(len(members))) - mix(byte(len(members) >> 8)) - for _, p := range members { - for i := 0; i < len(p.AccountID); i++ { - mix(p.AccountID[i]) - } - mix(0) - mix(byte(p.Kind)) - mix('|') - } - return h -} - // encodeResult packs a Result against the current roster (player -> index). func encodeResult(res Result, roster []Player) []byte { var wr wire.Result diff --git a/internal/game/diff_test.go b/internal/game/diff_test.go index a2700e9..812abde 100644 --- a/internal/game/diff_test.go +++ b/internal/game/diff_test.go @@ -88,12 +88,10 @@ func resetDiffState() { for i := range baselinePresent { baselinePresent[i] = false baselineEpoch[i] = 0 - for j := range baselines[i] { - baselines[i][j] = 0 - } + baselines[i] = nil // lazy slots: drop, re-allocated on next commit } - lastRosterSet = false - lastRosterPrint = 0 + rosterCache = nil + rosterCacheBytes = nil } func frameWith(text string) *Frame { @@ -189,18 +187,58 @@ func TestIdenticalReconcilesAllSlotsThenSend(t *testing.T) { } } -func TestRosterChangeInvalidates(t *testing.T) { +// ctxPayload wire-encodes a CallContext for decodeCtx tests. +func ctxPayload(members ...wire.Player) []byte { + var w wire.Buf + wire.EncodeCtx(&w, wire.Ctx{ + NowUnixNanos: 1, Seed: 7, SeedSet: true, + Mode: 0, Capacity: 1000, MinPlayers: 1, + Members: members, + }) + return w.B +} + +func TestRosterCache(t *testing.T) { resetDiffState() - p1 := rosterFingerprint([]Player{{AccountID: "a", Kind: KindMember}}) - p2 := rosterFingerprint([]Player{{AccountID: "a", Kind: KindMember}, {AccountID: "b", Kind: KindMember}}) - if p1 == p2 { - t.Fatal("roster fingerprint collision on join") + ada := wire.Player{Handle: "ada", AccountID: "a", Conn: "c1", Kind: 1} + bob := wire.Player{Handle: "bob", AccountID: "b", Conn: "c2", Kind: 1} + + // First callback: changed=true, members decoded. + c1, _, changed := decodeCtx(ctxPayload(ada)) + if !changed { + t.Fatal("first callback must report a roster change") + } + if len(c1.members) != 1 || c1.members[0].AccountID != "a" { + t.Fatalf("decoded members = %+v", c1.members) + } + + // Same roster bytes: changed=false and the SAME backing slice is reused + // (zero member allocations — the -gc=leaking lifeline). + c2, _, changed := decodeCtx(ctxPayload(ada)) + if changed { + t.Fatal("identical roster must not report a change") + } + if len(c2.members) != 1 || &c1.members[0] != &c2.members[0] { + t.Fatal("unchanged roster did not reuse the cached members slice") + } + + // A join: changed=true, members re-decoded. + c3, _, changed := decodeCtx(ctxPayload(ada, bob)) + if !changed { + t.Fatal("join must report a roster change") + } + if len(c3.members) != 2 || c3.members[1].AccountID != "b" { + t.Fatalf("post-join members = %+v", c3.members) } - // Same roster -> same print. - if p1 != rosterFingerprint([]Player{{AccountID: "a", Kind: KindMember}}) { - t.Fatal("fingerprint not stable for identical roster") + + // A leave back to the original shape: changed again, and correct. + c4, _, changed := decodeCtx(ctxPayload(ada)) + if !changed || len(c4.members) != 1 { + t.Fatalf("leave: changed=%v members=%+v", changed, c4.members) } - // invalidateBaselines clears present. + + // invalidateBaselines clears present (the decodeCall side effect of a + // roster change). baselinePresent[0] = true baselinePresent[broadcastSlot] = true invalidateBaselines() @@ -208,3 +246,37 @@ func TestRosterChangeInvalidates(t *testing.T) { t.Fatal("invalidateBaselines did not clear present") } } + +// TestLazyBaselineHighSlot: slots beyond the old 16-slot table work and are +// allocated lazily (the 1024-player patch's regression guard — the SDK used +// to silently drop sends for index >= 16). +func TestLazyBaselineHighSlot(t *testing.T) { + resetDiffState() + const slot = 900 + if baselines[slot] != nil { + t.Fatal("slot 900 allocated before first commit") + } + f := frameWith("deep") + packed := append([]byte(nil), encodeFrame(f)...) + + // First send to the slot: keyframe form (not present), then commit. + payload := buildSendPayload(slot, packed) + if !wire.IsKeyframe(payload) { + t.Fatal("first send to a fresh high slot must be a keyframe") + } + commitBaseline(slot, packed, 5) + if baselines[slot] == nil || !baselinePresent[slot] { + t.Fatal("commit did not lazily allocate + mark the high slot") + } + if !bytes.Equal(baselines[slot], packed) { + t.Fatal("high-slot baseline does not match the committed frame") + } + + // Second send: a delta against the lazily-allocated baseline. + f2 := frameWith("deeq") + packed2 := encodeFrame(f2) + payload2 := buildSendPayload(slot, packed2) + if wire.IsKeyframe(payload2) { + t.Fatal("second send to the slot should be a delta, not a keyframe") + } +} diff --git a/internal/game/room.go b/internal/game/room.go index d9309b0..98d3665 100644 --- a/internal/game/room.go +++ b/internal/game/room.go @@ -102,10 +102,19 @@ func (r *room) Identical(f *Frame) { returned = uint32(hostIdentical(m.Offset())) m.Free() } - // Reconcile the broadcast slot and EVERY per-index baseline. + // Reconcile the broadcast slot and every ALLOCATED per-index baseline. + // Unallocated (never sent-to) slots are NOT allocated here — committing + // to all rosterCap slots would materialize the whole lazy table on the + // first broadcast. An unallocated slot is instead left not-present, so a + // later per-player Send to it opens with a keyframe (unconditionally + // accepted) — same recovery path as a roster change. commitBaseline(broadcastSlot, packed, returned) for i := 0; i < rosterCap; i++ { - commitBaseline(i, packed, returned) + if baselines[i] != nil { + commitBaseline(i, packed, returned) + } else { + baselinePresent[i] = false + } } } diff --git a/internal/game/run.go b/internal/game/run.go index 3ed7b6c..b036bba 100644 --- a/internal/game/run.go +++ b/internal/game/run.go @@ -41,22 +41,23 @@ func ExportMeta() int32 { } // decodeCall decodes the input payload into a Room for this callback. It also -// runs the roster-change backstop (D7): a cheap fixed-scratch fingerprint of the -// roster is compared to the previous callback's; on any change (join/leave/ -// index-shift) every per-index baseline is invalidated so the next send to each -// slot is a keyframe. This is the host-authority backstop, not the primary -// resync (the host's epoch bump is) — but it keeps the guest's baselines from -// diffing across a roster renumber. +// runs the roster-change backstop (D7): decodeCtx's roster cache compares the +// member section's raw wire bytes against the previous callback's; on any +// change (join/leave/index-shift) every per-index baseline is invalidated so +// the next send to each slot is a keyframe. This is the host-authority +// backstop, not the primary resync (the host's epoch bump is) — but it keeps +// the guest's baselines from diffing across a roster renumber. The byte +// compare is strictly stronger than the fingerprint hash it replaced, and on +// an unchanged roster the callback decodes ZERO member strings (allocation- +// free — load-bearing under -gc=leaking, where per-callback roster decodes +// leak at callback rate and OOM long-lived large rooms). func decodeCall() (*room, *wire.Rd) { - ctx, r := decodeCtx(inputBytes()) + ctx, r, rosterChanged := decodeCtx(inputBytes()) if rng == nil { rng = rand.New(rand.NewSource(ctx.cfg.Seed)) } - print := rosterFingerprint(ctx.members) - if !lastRosterSet || print != lastRosterPrint { + if rosterChanged { invalidateBaselines() - lastRosterPrint = print - lastRosterSet = true } return &room{ctx: ctx, rng: rng}, r } diff --git a/wire/wire.go b/wire/wire.go index f39759b..be95376 100644 --- a/wire/wire.go +++ b/wire/wire.go @@ -257,6 +257,17 @@ func (r *Rd) Str() string { return s } +// SkipStr advances past one length-prefixed string without materializing it — +// the allocation-free skim used by the SDK's roster cache to find the member +// section's extent before deciding whether to decode it. +func (r *Rd) SkipStr() { + n := int(r.U16()) + if !r.ok(n) { + return + } + r.Off += n +} + // Err returns the decode error state. func (r *Rd) Err() error { if r.Bad {