protocol: quote_big_ints is unguarded and costs ~40% of the apply frame #69

Closed
opened 2026-08-20 15:58:30 +00:00 by yuris · 1 comment
Owner

Found while re-measuring #30 for #66. This is the cheapest real optimisation the
measurement turned up, and it needs no protocol change, no API change, and no
time-slicing.

The finding

protocol.quote_big_ints (stdb/protocol.lua:323) has no guard. Its sibling
quote_wide_ints (:807-810) has a necessary-condition guard and returns the
input unchanged after three C-level finds. So on any table without a 128-bit
column, the hand-written schema path pays a full gsub scan over the whole frame
text and another over every row, to rewrite nothing at all.

Measured on a 7-column bulk table with no wide-int column, native, medians over
44 samples at 10k rows:

frame 31.0ms = pre 0.6 + json.decode 14.8 + quote_big_ints 12.6 + build 3.6 + post 0.1

At 50k: decode 76.6ms, quoting 77.5ms — quoting costs more than the JSON
parser. At 100k: 183.6 vs 177.7ms. In wasm at 10k: decode 23ms, quoting 20ms. It
is consistently 40–44% of the native frame.

Independently reproduced on the #66 branch: dec=39% quo=42% bld=15%.

Why the asymmetry is the whole story

The typed path is 13–20% slower overall, and the reason is visible once the
buckets are split: going typed at 10k rows makes the quote bucket fall from
12.2ms to 6.4ms — because per-row quote_big_ints is replaced by the guarded
quote_wide_ints, which short-circuits — while build rises from 3.2 to
12.5ms for decode_value per column. The guarded function is already
demonstrating the win.

What to do

Give quote_big_ints the same shape of necessary-condition guard: a cheap plain
find for the __ marker before committing to the pattern gsub. If the frame
carries no wrapped integer, return it untouched.

Expected saving on a table with no 128-bit column: most of 40% of the apply frame.
On a table that does have one, the guard costs one plain find.

Why this matters for #30 and #9

  • For #30: it roughly halves the cost time-slicing has to spread. Worth
    landing first, since it changes the thresholds time-slicing would be tuned
    against — the 60fps cliff currently sits at ~6,100 rows.
  • For #9: it removes a large part of what BSATN was going to be credited
    with. quote_big_ints exists only because JSON cannot carry a u128 exactly
    (stdb/protocol.lua:299-320, :711-714), so BSATN deletes it — but so does a
    guard, for a fraction of the effort and with no protocol surface re-opened.

Acceptance

  • A table with no 128-bit column shows the quote bucket at or near zero in PHASE6.
  • A table with one still round-trips exactly; the existing wide-int coverage
    must keep passing.
  • The exactness guarantee is unchanged: this is a fast path, not a semantic
    change. u128 values must still survive as text.

Note on why this was invisible until now

The measurement apparatus needed to see it at all. #66 measures five buckets that
sum to the frame, and an earlier version anchored the apply on the first
json.decode — which structurally excluded the frame-level quoting scan,
because decode_server quotes the whole frame text before it decodes anything.
Anchoring on whichever instrumented call fires first is what made this cost
visible.

Found while re-measuring #30 for #66. This is the cheapest real optimisation the measurement turned up, and it needs no protocol change, no API change, and no time-slicing. ## The finding `protocol.quote_big_ints` (`stdb/protocol.lua:323`) has **no guard**. Its sibling `quote_wide_ints` (`:807-810`) has a necessary-condition guard and returns the input unchanged after three C-level finds. So on any table without a 128-bit column, the hand-written schema path pays a full `gsub` scan over the whole frame text *and* another over every row, to rewrite nothing at all. Measured on a 7-column bulk table with no wide-int column, native, medians over 44 samples at 10k rows: ``` frame 31.0ms = pre 0.6 + json.decode 14.8 + quote_big_ints 12.6 + build 3.6 + post 0.1 ``` At 50k: decode 76.6ms, quoting **77.5ms** — quoting costs more than the JSON parser. At 100k: 183.6 vs 177.7ms. In wasm at 10k: decode 23ms, quoting 20ms. It is consistently 40–44% of the native frame. Independently reproduced on the #66 branch: `dec=39% quo=42% bld=15%`. ## Why the asymmetry is the whole story The typed path is 13–20% slower overall, and the reason is visible once the buckets are split: going typed at 10k rows makes the quote bucket **fall** from 12.2ms to 6.4ms — because per-row `quote_big_ints` is replaced by the guarded `quote_wide_ints`, which short-circuits — while `build` **rises** from 3.2 to 12.5ms for `decode_value` per column. The guarded function is already demonstrating the win. ## What to do Give `quote_big_ints` the same shape of necessary-condition guard: a cheap plain `find` for the `__` marker before committing to the pattern `gsub`. If the frame carries no wrapped integer, return it untouched. Expected saving on a table with no 128-bit column: most of 40% of the apply frame. On a table that does have one, the guard costs one plain find. ## Why this matters for #30 and #9 - For **#30**: it roughly halves the cost time-slicing has to spread. Worth landing first, since it changes the thresholds time-slicing would be tuned against — the 60fps cliff currently sits at ~6,100 rows. - For **#9**: it removes a large part of what BSATN was going to be credited with. `quote_big_ints` exists only because JSON cannot carry a u128 exactly (`stdb/protocol.lua:299-320`, `:711-714`), so BSATN deletes it — but so does a guard, for a fraction of the effort and with no protocol surface re-opened. ## Acceptance - A table with no 128-bit column shows the quote bucket at or near zero in PHASE6. - A table *with* one still round-trips exactly; the existing wide-int coverage must keep passing. - The exactness guarantee is unchanged: this is a fast path, not a semantic change. u128 values must still survive as text. ## Note on why this was invisible until now The measurement apparatus needed to see it at all. #66 measures five buckets that sum to the frame, and an earlier version anchored the apply on the first `json.decode` — which structurally *excluded* the frame-level quoting scan, because `decode_server` quotes the whole frame text before it decodes anything. Anchoring on whichever instrumented call fires first is what made this cost visible.
yuris closed this issue 2026-08-20 17:29:34 +00:00
Author
Owner

Closed by #71. Post-guard thresholds on the new main (a6c2c66), for #67 to tune against.

Native, pooled medians across the two engine processes each invocation runs, STDB_BULK_RUNS=5:

rows frame µs/row
8000 15.8ms 1.98
8500 16.9ms 1.99
9000 17.7ms 1.97
10000 20.9ms 2.09
45000 107.7ms 2.40

Crossings: 16.7ms at ~8500 rows, 100ms at ~42000. Previously 6100 and 31000 — the guard moved both out by ~40%. Wasm crosses 100ms at ~10700 rows (was 9800).

Bucket split at 10000 rows, paired against main in one session:

before  frame med=30.1ms  decode=13.8  quote=12.0  build=3.9  pre=0.5  apparatus=1.49
after   frame med=20.2ms  decode=12.4  quote=0.0   build=7.1  pre=0.5  apparatus=0.71

Two cautions for #67, since both affect where a frame budget should sit:

  1. build is a residual, not a measurement — frame - pre - post - decode - quote. Post-guard it absorbs the guard's own find (2.2ms at 10k) plus the harness's wrapper around it (another 1.0ms). Real construction is still ~3.9ms. So the decode/store split that #67 slices along is roughly 12.4ms decode against 3.9ms store at 10k rows, i.e. decode is ~75% of the controllable work — not the ~85% estimated pre-#69, but still the dominant term and still the right place to yield.
  2. Run-to-run spread is 15-25% within one process, with single points at 35%, and the two engine processes of one invocation disagreed by up to 18% on the same row count. Any threshold #67 asserts against needs slack wider than that, or it will flake.

README's performance section was updated in the same PR to these numbers, including the two claims that went stale unfavourably: the codec-swap bound drops from a quoted 6–8x to ~2.4x native (the guard already collected the deletable part), and the generated-schema penalty rises from "about a tenth" to ~50%, since decode_typed never paid the per-row big-int scan and so gained nothing here.

Closed by #71. Post-guard thresholds on the new `main` (a6c2c66), for #67 to tune against. Native, pooled medians across the two engine processes each invocation runs, `STDB_BULK_RUNS=5`: | rows | frame | µs/row | |---|---|---| | 8000 | 15.8ms | 1.98 | | 8500 | 16.9ms | 1.99 | | 9000 | 17.7ms | 1.97 | | 10000 | 20.9ms | 2.09 | | 45000 | 107.7ms | 2.40 | **Crossings: 16.7ms at ~8500 rows, 100ms at ~42000.** Previously 6100 and 31000 — the guard moved both out by ~40%. Wasm crosses 100ms at ~10700 rows (was 9800). Bucket split at 10000 rows, paired against `main` in one session: ``` before frame med=30.1ms decode=13.8 quote=12.0 build=3.9 pre=0.5 apparatus=1.49 after frame med=20.2ms decode=12.4 quote=0.0 build=7.1 pre=0.5 apparatus=0.71 ``` Two cautions for #67, since both affect where a frame budget should sit: 1. **`build` is a residual**, not a measurement — `frame - pre - post - decode - quote`. Post-guard it absorbs the guard's own `find` (2.2ms at 10k) plus the harness's wrapper around it (another 1.0ms). Real construction is still ~3.9ms. So the decode/store split that #67 slices along is roughly **12.4ms decode against 3.9ms store at 10k rows**, i.e. decode is ~75% of the controllable work — not the ~85% estimated pre-#69, but still the dominant term and still the right place to yield. 2. **Run-to-run spread is 15-25%** within one process, with single points at 35%, and the two engine processes of one invocation disagreed by up to 18% on the same row count. Any threshold #67 asserts against needs slack wider than that, or it will flake. README's performance section was updated in the same PR to these numbers, including the two claims that went stale unfavourably: the codec-swap bound drops from a quoted 6–8x to ~2.4x native (the guard already collected the deletable part), and the generated-schema penalty rises from "about a tenth" to ~50%, since `decode_typed` never paid the per-row big-int scan and so gained nothing here.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
yuris/spacetimedb-defold#69
No description provided.