Skip to content

perf(qwp): egress result decoding performance improvements - #66

Open
glasstiger wants to merge 11 commits into
mainfrom
ia_egress_pref_improvements
Open

glasstiger wants to merge 11 commits into
mainfrom
ia_egress_pref_improvements

Conversation

@glasstiger

@glasstiger glasstiger commented Sep 25, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Egress (query) decoding performance improvements in packages/client-core/src/_qwp/_core/result-batch.ts. No public API change, and decoded values are unchanged.

  • Indexed fill instead of Array.from. Replace Array.from({ length }, mapper) in eager decode() and in QwpResultBatchView.materialize() with a fillArray helper (indexed loop), preserving read order. The gain comes from skipping Array.from's generic array-like path.
  • Number-valued varints. readCount() and readQwpVarintNumber() built every count, length, and symbol ID as a BigInt only to convert it back. The new internal readQwpVarintSmall() (_core/varint-number.ts, deliberately not re-exported from the _core barrel) accumulates encodings of up to 7 bytes in a plain number and falls back to BigInt for 8-10-byte encodings, with identical validation, errors, and reader advancement. The exported readQwpVarint() is unchanged.
  • No null expansion for null-free columns. When a column's null flag is 0, readNullLayout() no longer allocates a per-row boolean array, and expandNulls() hands over the fresh dense array instead of copying it.
  • Unboxed numeric arrays. V8 tracks elements kinds per allocation site, and a single shared fillArray site is pretransitioned to generic elements, which boxes every double. Numeric arrays handed to users get their own sites: readFloat64Values for DOUBLE_ARRAY elements, fillIntegers/fillFloats for null-free numeric columns in materialize(), and a per-type array in readFixedWidthValues for fixed-width columns in decode().
  • rows() loop. QwpResultBatch.rows() resolves each column's values array once per batch and fills each row with a plain loop, instead of a columns.map() closure call per cell. It still yields a fresh array per row.
  • Fixed-width columns from a DataView. BYTE, SHORT, CHAR, INT, IPV4, FLOAT, DOUBLE, LONG, and raw DATE/TIMESTAMP columns check bounds once per column and read the DataView directly, instead of a closure plus a bounds-checked reader call per value. Truncation throws the same truncated QWP payload while reading <label> error, before the first value. Each type allocates its own array, so FLOAT/DOUBLE stay unboxed.
  • Tests: multi-row materialization with nulls, bit-packed booleans, raw timestamps, nested/empty arrays, and local symbols; row order of every fixed-width type through decode(), rows(), and materialize(); the truncation label of each fixed-width type; readQwpVarintSmall against readQwpVarint over boundary, zero-padded, truncated, overlong, every-10th-byte, and 5,000 random encodings.

Performance

Node 24, diagnostic, not CI gates. Before is main.

benchmarks/egress.bench.ts, 10k rows (INT, DOUBLE, VARCHAR, Gorilla TIMESTAMP):

Benchmark main this PR
decode() materialized ~481 batches/s ~845 batches/s (1.76x)

Steady-state probes on a 10k-row batch of 2 SYMBOL, 10 LONG, 10 DOUBLE, and a TIMESTAMP column (medians of 5 separate processes), measured for the varint/null-expansion commit against the preceding commit:

Mode before after
query() batches 2.33M rows/s 5.93M rows/s (2.5x)
query() batches, 10% nulls 2.49M 2.90M (1.16x)
batch.rows() 1.93M 2.55M (1.3x)
batch.rows(), 10% nulls 1.99M 2.20M (1.10x)
queryViews() 16.1M 56.5M (3.5x)
queryViews(), 10% nulls 10.7M 19.8M (1.85x)

The rows() and fixed-width commits, against the commit before them (same probe, interleaved, medians of 5):

Mode before rows() loop + fixed-width
query() batches 5.98M rows/s 5.95M 11.70M (1.96x)
query() batches, 10% nulls 2.93M 2.95M 3.62M (1.24x)
batch.rows() 2.57M 3.25M 4.43M (1.72x)
batch.rows(), 10% nulls 2.25M 2.76M 2.73M (1.22x)

Retained heap for user-visible doubles (steady state, Node 20/22/24):

Values main this PR
query() null-free DOUBLE/FLOAT columns ~24 B/element 8 B/element
materialize() null-free DOUBLE/FLOAT columns 8 B/element 8 B/element
DOUBLE_ARRAY values 8 B/element 8 B/element

Validation

  • pnpm exec vitest run (1,106 tests), pnpm test:dist, pnpm test:qwp-browser
  • pnpm typecheck, pnpm typecheck:test, pnpm typecheck:qwp-browser
  • pnpm eslint, pnpm format:check, pnpm check:packages
  • Differential fuzzing against the pre-change decoder: 160,000 valid, mutated, and truncated RESULT_BATCH frames across all column types (identical values and errors), and ~600,000 varint encodings (identical value, error, and reader position)

@glasstiger glasstiger changed the title perf(qwp): preallocate arrays during result batch decoding perf(qwp): replace Array.from with indexed fill in result batch decoding Sep 25, 2026
fillArray's single `new Array` allocation site is shared with schema,
bigint, and object callers, so V8 pretransitions it to generic elements.
DOUBLE_ARRAY values and materialize()d FLOAT/DOUBLE columns then stored
every double as a boxed HeapNumber: ~24 instead of 8 bytes per element
retained, and ~0.7x speed for consumer loops, versus Array.from on main.

Give arrays handed to users that hold only numbers their own allocation
sites: readFloat64Values for DOUBLE_ARRAY elements, and fillIntegers /
fillFloats for null-free numeric columns in materialize().
readCount() and readQwpVarintNumber() built every count, length, and
symbol ID as a BigInt only to convert it back to a number. The new
internal readQwpVarintSmall() accumulates encodings of up to 7 bytes
(49 bits, always exact) in a plain number and falls back to BigInt for
8-10-byte encodings, with the same validation, errors, and reader
advancement as readQwpVarint(). It lives outside the _core barrel, so
the public surface is unchanged; readQwpVarint() still returns bigint.

For a column whose null flag is 0, readNullLayout() no longer allocates
a rowCount-long boolean array and expandNulls() hands the fresh dense
array over instead of copying it. Those arrays now reach users as-is, so
null-free numeric columns are built through the number-only allocation
sites (fillIntegers/fillFloats), which keeps FLOAT/DOUBLE query() values
unboxed: 8 instead of ~24 bytes per element.

Measured on a 10k-row batch of 2 SYMBOL, 10 LONG, 10 DOUBLE, and a
TIMESTAMP column (medians of 5 processes): query() 2.33M -> 5.93M rows/s,
batch.rows() 1.93M -> 2.55M, queryViews() 16.1M -> 56.5M; with 10% nulls
+16%, +10%, and +85%.
@glasstiger glasstiger changed the title perf(qwp): replace Array.from with indexed fill in result batch decoding perf(qwp): egress result decoding performance improvements Sep 25, 2026
- Decode and materialize a 3-row batch of distinct null-free BYTE, SHORT,
  INT, IPV4, FLOAT, and DOUBLE values plus a nullable DOUBLE, so reordering
  in fillIntegers/fillFloats or the materialize() numeric branches fails.
  Every existing multi-row numeric test had a null, so it took the generic
  path.
- Compare readQwpVarintSmall with readQwpVarint on 10-byte encodings whose
  last byte is 0x00..0x7f, pinning the uint64 overflow check.
- Move @internal after the readQwpVarintSmall summary, per TSDoc.
QwpResultBatch.rows() yielded `this.columns.map((column) =>
column.values[row])` for every row: a closure call and a `column.values`
lookup per cell. Resolve each column's values array once per batch and
fill each row with a plain loop. It is still a generator yielding a fresh
array per row, since callers may retain rows.
BYTE, SHORT, CHAR, INT, IPV4, FLOAT, DOUBLE, LONG, and raw (non-Gorilla)
DATE/TIMESTAMP values decoded through a closure per value that called a
bounds-checked reader method. Check bounds once per column with
readBytes() and read the DataView directly. Truncation throws the same
"truncated QWP payload while reading <label>" error, now before the
first value rather than at the first missing one.

Each type allocates its own array, keeping one V8 elements kind per
value type: SMI for integers, unboxed doubles for FLOAT/DOUBLE, so
null-free DOUBLE query() values stay at 8 bytes per element.

Tests cover row order for every fixed-width type through decode(),
rows(), and materialize(), and the truncation label for each.
@glasstiger

Copy link
Copy Markdown
Collaborator Author

Review of this PR (level 3, full pass)

Verdict: approve. No Critical or Moderate findings. One Minor note below.

Minor

  • The PR description no longer matches the code on one point. It says fillIntegers/fillFloats are used "for null-free numeric columns in decode() and materialize()". At head they are only called from materialize() (result-batch.ts:925 and :928). Since 887f94a, decode() builds these columns through readFixedWidthValues. The body of commit 886e8ca says the same outdated thing, so only the description needs editing.

Test coverage

  • Test gate: passes, with no coverage gaps that need fixing.
  • Tests at head: core.test.ts and egress.test.ts pass (107 tests), and the wider test/qwp suite passes (882 tests).
  • Mutation testing: 30 of 35 deliberate code mutations were caught by these tests. Three of the misses change nothing observable.
  • Remaining misses, both low risk:
    • readCount's BigInt branch is never exercised. Only a server that sends counts in an unusual, over-long 8–10-byte form reaches it, and that code is unchanged from main.
    • Gorilla-flagged timestamps with raw encoding 0 are only tested with a single row. The code it calls, readInt64Values, is tested with multiple rows through LONG and plain TIMESTAMP.

Summary

  • Correctness: results matched main in every differential run. That covers:

    • about 60,000 fuzzed batches across all 23 column types, plus malformed input;
    • about 213,000 varint inputs, including the public readQwpVarintNumber and its ingress caller;
    • wire-format edge cases: very large row counts, byte arrays at unusual offsets, shared memory, pooled Node buffers and zstd-compressed frames.

    Values, error types and messages, and decoder state after an error were identical to main.

  • Performance: the claims hold up, measured on Node 24 with the machine heavily loaded.

    • egress.bench.ts decode is about 1.75× faster (roughly 470 vs 830 batches/s).
    • Every other decode path, rows() and materialize() workload measured was the same speed or faster, including very wide batches.
  • One consumer-side cost, not raised as a finding: a user's reduce over a DOUBLE column is about 1.1× slower, because the values are now stored unboxed. Decoding that column is about 37× faster, so decode plus reduce is still well ahead. Only V8 was measured, not the browser engines and not Node 20.

  • Other checks:

    • Public API: the new internal readQwpVarintSmall can't be reached through either package's public exports.
    • Browser build: no Node built-ins were added.
    • Checks: typecheck, eslint and Prettier are clean. test:dist was not re-run; the description says it passes.
  • Submodules: none.

  • Confirmed findings: 0 in the diff and 0 in unchanged callers. By severity: 0 Critical, 0 Moderate, 1 Minor.

readCount() reads varints of up to 7 bytes as a number and longer ones
as a bigint, and range-checks each branch separately. Only the number
branch was tested: the bigint check could be deleted with test/qwp still
green, which would let a zero-padded count bypass the row, column, and
table-name limits.

Send over-limit table name length, row count, and column count as 8-,
9-, and 10-byte varints through decode() and decodeView(), and expect
the "out of range" QwpProtocolError.

Rename the wire-order test to say decode(): it never calls materialize().
readQwpVarintNumber() returns encodings of up to 7 bytes directly as a
number and converts longer ones from a bigint after a safe-integer
check. No test reached the longer branch: returning the raw bigint or
deleting the check left test/qwp green.

Read zero-padded 8-, 9-, and 10-byte encodings and MAX_SAFE_INTEGER as
numbers with the reader fully advanced, and expect the safe-integer
QwpProtocolError for 2^53 and 2^64 - 1.

Share the RESULT_BATCH header between egress tests through
resultBatchPrefix() and resultBatchHeader() instead of building it by
hand, and drop "nested" from a test name whose arrays have one
dimension.
@glasstiger

Copy link
Copy Markdown
Collaborator Author

Review of this PR (level 3, full pass)

Verdict: approve. No Critical, Moderate or Minor findings. Reviewed commit c2d79e4 against main at 68dfe19.

Test coverage

  • Test gate: passes, with no coverage gaps that need fixing.
  • Every changed path has a test that fails if it breaks. That covers:
    • each case in readFixedWidthValues;
    • the fillIntegers/fillFloats branches of materialize(), and rows();
    • expandNulls with and without nulls;
    • raw and raw-fallback Gorilla timestamps;
    • the error message when a fixed-width column is truncated;
    • readQwpVarintSmall compared against readQwpVarint;
    • the 8–10-byte branches of readCount and of readQwpVarintNumber.
  • Mutation testing: 36 of 43 deliberate mutations were caught. Of the 7 that survived:
    • 5 cannot change any result. Examples: getInt16 instead of getUint16 for CHAR, which gives the same string, and using the fast fill helpers for columns with nulls, which only changes how V8 stores the array.
    • 2 only matter when a server pads a successful count to 8–10 bytes. No known server does that, and that branch has the same logic as on main.
  • Checks on the PR commit:
    • core.test.ts and egress.test.ts pass (110 tests).
    • package-boundaries.e2e.ts and qwp/dist.e2e.ts pass (34 tests).
    • pnpm typecheck, pnpm typecheck:test and pnpm eslint are clean, and Prettier finds nothing in the changed files.
    • All CI checks passed.

Summary

  • Correctness: decoding was compared between main and this PR in several independent runs, well over 700,000 decodes in total. Values, -0, NaN, null placement, error class and message all matched in every run. They covered:

    • all 23 column types;
    • raw, Gorilla-flagged and delta-dictionary frames;
    • zstd-compressed frames;
    • frames stored at an offset inside a larger buffer, in a SharedArrayBuffer, or in a pooled Node Buffer;
    • truncated, corrupted and hostile frames.

    A separate comparison of about 300,000 varint inputs matched the value, error and reader position every time.

  • No values are shared by mistake: returning the decoded array directly for columns with no nulls is safe, because every function that builds those arrays allocates a new one.

  • Limits still apply first: batch-size limits are still checked before any array is allocated. The largest fixed-width read, count*8, is at most about 8.4M bytes.

  • Performance: the claims hold up on Node 24. Every decode path measured was faster:

    • decode() was 1.7× faster on a single row and 16.8× faster on 10,000 rows;
    • materialize() was 1.3–2.9× faster;
    • rows() was up to 2.1× faster;
    • readQwpVarintNumber was 1.3–1.8× faster;
    • no slowdown was measured for code that uses the decoded values.

    One tradeoff, not a finding: rows() on an empty, 200-column batch takes about 280 ns longer per call. Decoding that same batch got faster by far more than that.

  • Public API: readQwpVarintSmall doesn't appear in either package's type declarations or export list. The browser build still has no Node imports. The PR description matches the code.

  • Submodules: none.

  • Confirmed findings: 0 in the diff and 0 in unchanged callers. By severity: 0 Critical, 0 Moderate, 0 Minor.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant