perf(qwp): egress result decoding performance improvements - #66
glasstiger wants to merge 11 commits into
Conversation
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%.
- 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.
Review of this PR (level 3, full pass)Verdict: approve. No Critical or Moderate findings. One Minor note below. Minor
Test coverage
Summary
|
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.
Review of this PR (level 3, full pass)Verdict: approve. No Critical, Moderate or Minor findings. Reviewed commit Test coverage
Summary
|
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.Array.from. ReplaceArray.from({ length }, mapper)in eagerdecode()and inQwpResultBatchView.materialize()with afillArrayhelper (indexed loop), preserving read order. The gain comes from skippingArray.from's generic array-like path.readCount()andreadQwpVarintNumber()built every count, length, and symbol ID as a BigInt only to convert it back. The new internalreadQwpVarintSmall()(_core/varint-number.ts, deliberately not re-exported from the_corebarrel) 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 exportedreadQwpVarint()is unchanged.readNullLayout()no longer allocates a per-row boolean array, andexpandNulls()hands over the fresh dense array instead of copying it.fillArraysite is pretransitioned to generic elements, which boxes every double. Numeric arrays handed to users get their own sites:readFloat64ValuesforDOUBLE_ARRAYelements,fillIntegers/fillFloatsfor null-free numeric columns inmaterialize(), and a per-type array inreadFixedWidthValuesfor fixed-width columns indecode().rows()loop.QwpResultBatch.rows()resolves each column's values array once per batch and fills each row with a plain loop, instead of acolumns.map()closure call per cell. It still yields a fresh array per row.truncated QWP payload while reading <label>error, before the first value. Each type allocates its own array, so FLOAT/DOUBLE stay unboxed.decode(),rows(), andmaterialize(); the truncation label of each fixed-width type;readQwpVarintSmallagainstreadQwpVarintover 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):decode()materializedSteady-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:
query()batchesquery()batches, 10% nullsbatch.rows()batch.rows(), 10% nullsqueryViews()queryViews(), 10% nullsThe
rows()and fixed-width commits, against the commit before them (same probe, interleaved, medians of 5):rows()loopquery()batchesquery()batches, 10% nullsbatch.rows()batch.rows(), 10% nullsRetained heap for user-visible doubles (steady state, Node 20/22/24):
query()null-free DOUBLE/FLOAT columnsmaterialize()null-free DOUBLE/FLOAT columnsDOUBLE_ARRAYvaluesValidation
pnpm exec vitest run(1,106 tests),pnpm test:dist,pnpm test:qwp-browserpnpm typecheck,pnpm typecheck:test,pnpm typecheck:qwp-browserpnpm eslint,pnpm format:check,pnpm check:packages