Measure the frame that applies a first subscription (#30) #66
No reviewers
Labels
No labels
area:defold
area:docs
area:infra
area:protocol
area:testing
kind:bug
kind:chore
phase:breadth
phase:core
phase:hardening
phase:slice
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
yuris/spacetimedb-defold!66
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/30-frame-budget"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Measures #30 rather than closing it. Phase 4 of the v1.1.0 milestone (#59).
The issue is explicit: "Measure before choosing a number." This PR is only the
measurement and the instrumentation that produced it. No time-slicing code —
that decision now has data behind it and should be reviewed against the numbers
rather than bundled with them.
The answer: time-slicing is justified
Native, 60fps budget (16.7ms): ~6,400 rows. Measured, not interpolated —
6,000 rows medians 15.9/16.2ms (under), 6,400 gives 16.8/17.8ms (over), 6,800
gives 18.5/19.4ms.
Native, noticeable hitch (100ms): ~32,000 rows. 32,000 medians 100.5/97.8ms,
33,000 gives 102.1/104.1ms.
HTML5, 100ms: ~9,400 rows. 9,000 → 95ms, 10,000 → 110ms.
6,400 rows is not an exotic number. A chat backlog, a leaderboard, an inventory
across a player base, any "select the whole table" subscription on a modest game
reaches it, so this is a cliff real games hit rather than a theoretical one.
Native sweep
Defold headless, x86_64-linux, hand-written schema, 7-column rows (~83 B/row on
the wire). 6 runs per point — 3 in each of
conformance.sh's two engineprocesses. Medians.
json.decodemsCost is linear in rows, ~2.7µs/row native. No super-linear blow-up, so the
problem is purely that the whole set lands in one frame.
HTML5 sweep
Same client as a wasm bundle in headless Chromium. The wasm engine is
vsync-locked to
requestAnimationFrame, so an idle frame already measures 18ms— that is a floor, not work, and the "excess" column is the honest number.
Because of the rAF floor, "16.7ms of work" is the meaningful 60fps threshold on
web, and it arrives at ~2,600 rows — roughly 2.5× worse than native.
The typed path, which is the one the README recommends, is worse
Same rows through
protocol.decode_typedinstead ofdecode_row:Typed decoding costs 12–17% more, so the real-world thresholds are ~12%
lower than the headline numbers above. Worth stating plainly, since the README
steers people to the typed path.
The
json.decodesplit — and what it means for #9Native: 40–49% of the frame, mean ~45%. WASM: 20–25%, mean ~23%.
This is the data point #9's first checkbox asks for, and it argues against #9:
json.decodewith a native/C++ BSATN decoder is1/(1-0.45)= 1.8× on native, and only 1.3× in wasm.remove the hitch, it postpones it.
protocol.quote_big_ints' gsub, buildingthe row tables, pk indexing, event-list construction and
tables.dispatch—none of which a decoder swap touches.
So a BSATN decoder is not the fix for #30, and on the web tier — where the
budget is tightest — it is nearly irrelevant. Posted to #9 separately.
Method, and why each choice
socket.gettime(LuaSocketgettimeofday), a wall clock. Rejectedos.clock, which is processor time and under-reports any wait, so it is notwhat a player feels; rejected
chronos, which is a third-party nativeextension and absent here.
socket.gettimeresolves in both tiers —LuaSocket is compiled into the headless engine and the wasm build — at 1ms
resolution under emscripten.
attributed by timestamp rather than by counting frames, so the number does not
depend on whether the engine dispatches extension callbacks before or after
script update.
stdb.connect'sopts.json.protocol.initreceives it and every decode goes through it, sothe split needed zero changes to frozen
stdb/.decode_callsmustequal
rows + 1(one envelope plus one per row) andcache.countmust equalrows, or PHASE6 fails. Without that, a truncated apply would report aflatteringly small number.
What changed
dev/server/index.jsgains a new table and a bulk-insert reducer with the rowcount as a parameter, so the sweep is not hardcoded.
messageis untouched —it is byte-frozen and the fixtures assert its exact shape. The schema fixture was
regenerated with
gen-schema.sh, not hand-edited.smoke.scriptgains PHASE6following the existing phase pattern; PHASE1–PHASE5 still pass unchanged.
Recommendation
Implement time-slicing as #30 specifies — a per-frame deadline shared across
every callback
OnUpdatedelivers in that frame (not per callback, sinceOnUpdatedrains the whole queue in one pass), a coroutine per in-flight batch,later frames queued behind an in-progress batch so ordering holds absolutely,
and per-table callbacks firing only on batch completion so no consumer ever sees
a half-populated table.
Tracked separately so the design is reviewed against this data rather than
alongside it.
Verification
Independently reproduced by the maintainer on a rebase onto current
main:BULK rows=10000 frame=26.4ms decode=10.6ms rest=15.8ms decode_share=40%,against the sweep's 26.6ms / 40–49%.
Machine: AMD Ryzen 7 8845HS (8C/16T Zen 4 mobile), NixOS, kernel 6.18.41,
x86_64, against live pinned SpacetimeDB 2.8.2.
91054417f9fd0cca21adReview actioned. All eight findings, and the two blocking ones changed the
conclusions rather than just the prose.
F1 (BLOCKING) —
quote_big_intsis now its own bucket, and it changed everythingYou were right that the split stopped one function short, and the consequence was
larger than "a bucket is mislabelled".
quote_big_intsis not reachable through any injected table — it is a file-localand
protocol.inittakes onlyjson. Rather than edit frozenstdb/, the harnesswraps
string.gsuband keys on the exact pattern literal, which is safe becausethat pattern appears nowhere else in
stdb/and no string function is localisedthere, so
text:gsubresolves through the string table at call time. Verified inbare Lua against the real module before spending an engine build, including that
gsub's two return values are preserved.PHASE6 now reports five buckets that sum to the frame exactly —
pre,json.decode,quote,build,post— obtained with no extra clock reads bykeeping both ends of the interval instead of only its width.
Your numbers are confirmed. Native medians, 44 samples at 10k:
At 50k, quoting (77.5ms) costs more than the JSON parser (76.6ms).
Corrected ceiling. Native untouchable is 11.3–11.7% net of apparatus → 8.5×,
which is above your 2.5–6× estimate. WASM is where I have to correct you back:
your ~13% is construction only, but the rAF floor plus websocket delivery are
another 45% of the wasm frame and no decoder touches those either, so the real wasm
frame ceiling is 1.7×, not 2.5–6×. On the SDK's apply alone it is 4.1×. The
published 1.8×/1.3× was Amdahl on
json.decode's share alone; #9 now carries thecorrection and a retraction.
And the finding under the finding, now #69:
quote_big_intshas no guard,while
quote_wide_intshas a necessary-condition guard and short-circuits. On atable with no 128-bit column the hand-written path scans the whole frame and every
row to rewrite nothing. That asymmetry is also why the typed path's quote bucket
halves (12.2 → 6.4ms at 10k) while
buildrises — neither of us had spotted thecause. It is the cheapest real optimisation in the whole exercise and it takes much
of BSATN's ground for one plain
find.Your finding also indicted this PR's own apparatus. The old
bracket=figurewas
apply_end - decode_first, anddecode_serverquotes the whole frame beforeit decodes anything — so the single most expensive scan of the batch was
structurally outside the measured window. It could not have seen this cost even if
it had wrapped the function. An early corrected run surfaced this as
build = -2.8ms; the anchor is now "whichever instrumented call fires first", with apermanent guard that fails the phase if
buildgoes more negative thanmax(4 ticks, 5% of frame).F2 (BLOCKING) — wasm attribution fixed
Confirmed within a few ms on every bucket: 17 / 28 / 23 / 20 / 14 / 1. The rAF
floor is now measured rather than asserted — the 100-row wasm point is an 18.0ms
frame of which
preis 17.0ms, 94% — and the README now states the floor at all,where before it appeared only in the PR body.
~11us/rowis gone. Measured: 180 us/row at 100 rows, 23 at 1k, 11.0 at 5k, 10.2at 10k. The README now gives wasm as a floor plus a rate (
~17ms + 8.5us/row at 10k) and says the per-row part climbs, because socket delivery is inside it. Theremainder sentence is scoped to native.
F3 — re-measured, and both of us were off
Per-row drift to 100k is +33% on medians (yours +49%, the README's +25%), +45%
on worst-case samples. Now quoted as a range, +30% to +45%. Your stronger point
stands: the PR's 327.5ms sat below all eight of my samples (median 407.9,
range 364.5–445.3, spread 20%). The README now also says where one number stops
describing the behaviour — ~25,000 rows, past which per-row cost is +10% and
worst-case spread reaches 28–31%.
F4 — you were slightly high, and the growth claim does not reproduce
The naive comparison is unusable: the untyped baseline itself drifted 15% between
two passes 30s apart, the same magnitude as the effect. Six alternating passes
(U,T,U,T,U,T), 18 samples per point per schema, with
json.decode's median as acontrol (it must match on both paths — same bytes, same C parser — and it does).
Corrected for apparatus (the hand-written path carries ~2× the instrumentation,
which biases against the typed penalty): +13% at 1k, +20% at 10k, +16% at
50k — band 13–20%. So the PR's 12–17% was essentially right, your 18–25% slightly
high, and the penalty does not grow with row count: 50k is below 10k. Typed
16.7ms threshold measured directly at ~5,700 rows (yours ~5,300).
F5, F6, F7, F8 — all fixed
config()itself, which repaired the same set-but-empty trap forall six readers, not just
STDB_BULK_SWEEP.STDB_BULK_SWEEP=andSTDB_BULK_SWEEP=noneboth pass now.it moves) and printed rather than asserted: 0.001ms native, 1.000ms wasm.
AT-CLOCK-RESOLUTIONwith aquant=±sqrt(bucket*tick)uncertainty on every line. It fires on exactly thewasm 100- and 1000-row points. I kept the 100-row point rather than dropping it,
because it is the direct measurement of the rAF floor.
STDB_BULK_RUNSstays at 1 with aONE-SAMPLEmarker, and the justificationturned out empirical rather than a guess: the single-sample acceptance run read
136ms where the six-sample median is 102ms, i.e. 31% high. Raising the default
triples PHASE6 in every ordinary conformance run for a number that run does not
publish; the marker cannot be missed.
Thresholds — all three hold, all swept across the crossing
Each swept across the crossing rather than extrapolated, which is why they are
quotable at all. Your ~6,200 for the first was closer than mine.
State
Rebased onto current
main(d6ce8a2) — it was still oncf8b430, which youwould have seen as phantom deletions. Diff is 5 files,
stdb/untouched.Independently reproduced after the rebase:
dec=39% quo=42% bld=15%, 28.0ms at 10k rows.One environment note, not a code failure: one engine process in an alternating
pass could not reach the server at all (182 reconnect attempts over 900s) while
every other pass passed and the server pinged 200 afterwards. Re-run clean. Worth
recording in case it recurs.