diff --git a/benchmarks/tables.ts b/benchmarks/tables.ts index bade37b..66b24be 100644 --- a/benchmarks/tables.ts +++ b/benchmarks/tables.ts @@ -1,4 +1,7 @@ -import { QWP_COLUMN_TYPE, QwpTableBuffer } from "../packages/client-core/src/_qwp/_core"; +import { + QWP_COLUMN_TYPE, + QwpTableBuffer, +} from "../packages/client-core/src/_qwp/_core"; import type { BenchmarkRow } from "./workloads"; export function buildBenchmarkTable( diff --git a/packages/client-core/src/_qwp/_core/result-batch.ts b/packages/client-core/src/_qwp/_core/result-batch.ts index c386962..fc5cced 100644 --- a/packages/client-core/src/_qwp/_core/result-batch.ts +++ b/packages/client-core/src/_qwp/_core/result-batch.ts @@ -13,7 +13,7 @@ import { } from "./constants"; import { QwpResultBatchMessage } from "./egress"; import { QwpProtocolError } from "./errors"; -import { readQwpVarint } from "./varint"; +import { readQwpVarintSmall } from "./varint-number"; import { decompressQwpZstdFrame } from "./zstd"; // One axis bound for both directions: a decoder that accepted an axis the @@ -98,8 +98,19 @@ export class QwpResultBatch { } *rows(): IterableIterator { + // Look each column's values up once per batch, not once per cell. + const columns = this.columns; + const width = columns.length; + const values = new Array(width); + for (let column = 0; column < width; column++) { + values[column] = columns[column].values; + } for (let row = 0; row < this.rowCount; row++) { - yield this.columns.map((column) => column.values[row]); + const out = new Array(width); + for (let column = 0; column < width; column++) { + out[column] = values[column][row]; + } + yield out; } } } @@ -908,12 +919,20 @@ export class QwpResultBatchView { this.columnViews.map((column) => ({ name: column.name, type: column.type, - values: Array.from({ length: this._rowCount }, (_, row) => { - const value = column.get(row); - // Binary values are zero-copy slices in the view API. materialize() - // promises independently owned data, so detach those slices here. - return value instanceof Uint8Array ? value.slice() : value; - }), + values: + column.nonNullCount === this._rowCount && + INTEGER_COLUMN_TYPES.has(column.type) + ? fillIntegers(this._rowCount, (row) => column.get(row) as number) + : column.nonNullCount === this._rowCount && + FLOAT_COLUMN_TYPES.has(column.type) + ? fillFloats(this._rowCount, (row) => column.get(row) as number) + : fillArray(this._rowCount, (row) => { + const value = column.get(row); + // Binary values are zero-copy slices in the view API. + // materialize() promises independently owned data, so + // detach those slices here. + return value instanceof Uint8Array ? value.slice() : value; + }), ...(column.scale === undefined ? {} : { scale: column.scale }), ...(column.precisionBits === undefined ? {} @@ -1062,7 +1081,8 @@ function signedLittleEndianValue( } interface NullLayout { - nulls: boolean[]; + /** Per-row null flags, or null when the column carries no nulls. */ + nulls: boolean[] | null; nonNullCount: number; } @@ -1100,7 +1120,13 @@ function readCount( maximum: number, label: string, ): number { - const value = readQwpVarint(reader); + const value = readQwpVarintSmall(reader); + if (typeof value === "number") { + if (value > maximum) { + throw new QwpProtocolError(`${label} out of range: ${value}`); + } + return value; + } if (value > BigInt(maximum)) { throw new QwpProtocolError(`${label} out of range: ${value}`); } @@ -1112,8 +1138,8 @@ function readNullLayout(reader: QwpByteReader, rowCount: number): NullLayout { if (flag !== 0 && flag !== 1) { throw new QwpProtocolError(`invalid column null flag: ${flag}`); } + if (flag === 0) return { nulls: null, nonNullCount: rowCount }; const nulls = new Array(rowCount).fill(false); - if (flag === 0) return { nulls, nonNullCount: rowCount }; const bitmap = reader.readBytes( Math.ceil(rowCount / 8), @@ -1129,14 +1155,18 @@ function readNullLayout(reader: QwpByteReader, rowCount: number): NullLayout { return { nulls, nonNullCount }; } -function expandNulls( - dense: readonly T[], +function expandNulls( + dense: QwpResultValue[], layout: NullLayout, ): QwpResultValue[] { - const values = new Array(layout.nulls.length); + const nulls = layout.nulls; + // Every dense producer returns a fresh array of exactly nonNullCount + // elements, so a null-free column can hand it over without a copy. + if (nulls === null) return dense; + const values = new Array(nulls.length); let denseIndex = 0; - for (let row = 0; row < layout.nulls.length; row++) { - values[row] = layout.nulls[row] ? null : dense[denseIndex++]; + for (let row = 0; row < nulls.length; row++) { + values[row] = nulls[row] ? null : dense[denseIndex++]; } return values; } @@ -1230,21 +1260,165 @@ function decodeGorillaValues(reader: QwpByteReader, count: number): bigint[] { return values; } +// Array.from({ length }, mapper) takes the generic array-like path; a +// preallocated array with an indexed loop avoids that overhead while still +// reading values in order. +function fillArray(count: number, mapper: (index: number) => T): T[] { + const values = new Array(count); + for (let index = 0; index < count; index++) values[index] = mapper(index); + return values; +} + +// The helpers below duplicate fillArray on purpose. V8 tracks an elements kind +// per allocation site: fillArray's `new Array` is shared with schema, bigint, +// and object callers, so V8 pretransitions it to generic elements, and every +// double stored there becomes a boxed HeapNumber (~24 instead of 8 bytes per +// element, and slower consumer loops). Arrays handed to users that hold only +// numbers -- DOUBLE_ARRAY elements, null-free numeric columns from +// materialize(), and the numeric cases of readFixedWidthValues() -- get their +// own sites so they keep unboxed SMI/double elements. Do not fold these back +// into fillArray, and never store null or a non-number through them. +function readFloat64Values(reader: QwpByteReader, count: number): number[] { + const values = new Array(count); + for (let index = 0; index < count; index++) { + values[index] = reader.readFloat64("double array element"); + } + return values; +} + +const INTEGER_COLUMN_TYPES: ReadonlySet = new Set([ + QWP_COLUMN_TYPE.BYTE, + QWP_COLUMN_TYPE.SHORT, + QWP_COLUMN_TYPE.INT, + QWP_COLUMN_TYPE.IPV4, +]); +const FLOAT_COLUMN_TYPES: ReadonlySet = new Set([ + QWP_COLUMN_TYPE.FLOAT, + QWP_COLUMN_TYPE.DOUBLE, +]); + +function fillIntegers( + count: number, + mapper: (index: number) => number, +): number[] { + const values = new Array(count); + for (let index = 0; index < count; index++) values[index] = mapper(index); + return values; +} + +function fillFloats( + count: number, + mapper: (index: number) => number, +): number[] { + const values = new Array(count); + for (let index = 0; index < count; index++) values[index] = mapper(index); + return values; +} + +// Fixed-width columns: one bounds check for the whole column, then direct +// little-endian DataView reads, instead of a closure call and a bounds-checked +// reader method per value. Truncation throws the same "truncated QWP payload +// while reading