Skip to content

Commit b5cd2d2

Browse files
rustyconoverclaude
andcommitted
perf(hotpath): cut per-dispatch and per-batch overhead on the stdio path
Targets the vgi-benchmarks subprocess path (arrow-js + stdio) plus the flechette backend. Backend-agnostic and both-backend-specific wins: - server.ts: skip the per-dispatch request-batch serializeBatch (access-log IPC re-encode) unless a dispatchHook is installed — the default path saves ~17us (arrow-js) / ~10us (flechette) per call. Replace the per-request getMethods() map copy with Protocol.getMethod()/methodNames(). - arrow-js conformBatchToSchema: add a `mutated` flag + per-child identity skip so a already-conformant batch returns unchanged (1.81us -> 0.19us); drain() returns the single buffered chunk without copying. - flechette deserializeBatch: drop the Proxy wrapper (defeated V8 inline caches across request parsing) for direct metadata/numRows assignment. - flechette toFlechetteType: memoize by type-object identity (was re-deriving the nested type tree per batch). - wire/writer.ts: fd target writes inline and returns a shared resolved promise, skipping the promise-chain + microtask hop per emitted batch. - wire/response.ts coerceInt64: cache int64 field names per schema and skip the record clone when no coercion is needed. - wire/request.ts: hoist BigInt safe-integer bounds to module constants. Verified: 200 vgi-rpc unit tests + 235 vgi-typescript e2e tests pass on both backends; typecheck + biome clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 414f726 commit b5cd2d2

8 files changed

Lines changed: 131 additions & 30 deletions

File tree

src/arrow/impl-arrowjs/index.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,13 @@ export function createIncrementalEncoder(s: VgiSchema): IncrementalEncoder {
165165
writer.reset(undefined, s as unknown as A_Schema);
166166
const drain = (): Uint8Array => {
167167
const values = (writer as any)._sink._values as Uint8Array[];
168+
// Common case: the encoder buffered exactly one chunk for this batch —
169+
// hand it straight through instead of copying it into a fresh buffer.
170+
if (values.length === 1) {
171+
const only = values[0];
172+
values.length = 0;
173+
return only;
174+
}
168175
const total = values.reduce((n, c) => n + c.length, 0);
169176
const out = new Uint8Array(total);
170177
let off = 0;
@@ -401,15 +408,25 @@ export function conformBatchToSchema(batch: VgiBatch, schema: VgiSchema): VgiBat
401408
}
402409
}
403410

411+
let mutated = false;
404412
const children = s.fields.map((f, i) => {
405413
const srcChild = a.data.children[i];
406414
const srcType = srcChild.type;
407415
const dstType = f.type;
408416

417+
// Already the exact schema type object (batches this process built from
418+
// `schema` hit this for every column) — no clone, no rebuild needed.
419+
if (srcType === dstType) return srcChild;
420+
409421
if (!_needsValueCast(srcType, dstType)) {
422+
// Same logical type but a different type object (e.g. a generic `Float`
423+
// from a foreign IPC round-trip vs the schema's `Float64`). Swap the
424+
// type via clone so the writer doesn't silently drop the batch.
425+
mutated = true;
410426
return srcChild.clone(dstType);
411427
}
412428
if (_isNumeric(srcType) && _isNumeric(dstType)) {
429+
mutated = true;
413430
const col = a.getChildAt(i)!;
414431
const values: number[] = [];
415432
for (let r = 0; r < a.numRows; r++) {
@@ -418,9 +435,14 @@ export function conformBatchToSchema(batch: VgiBatch, schema: VgiSchema): VgiBat
418435
}
419436
return a_vectorFromArray(values, dstType).data[0];
420437
}
438+
mutated = true;
421439
return srcChild.clone(dstType);
422440
});
423441

442+
// Every column already carried the schema's exact type — the batch is
443+
// conformant as-is, so avoid the struct/makeData/RecordBatch rebuild.
444+
if (!mutated) return batch;
445+
424446
const structType = new A_Struct(s.fields);
425447
const data = a_makeData({
426448
type: structType,

src/arrow/impl-flechette/index.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -157,13 +157,17 @@ export function deserializeBatch(bytes: Uint8Array): VgiBatch {
157157
const meta = needRowsFallback ? readFirstRecordBatchMeta(bytes) : null;
158158
const wantRows = meta !== null && meta.numRows > 0;
159159
if (!wantRows && !wantMeta) return table as VgiBatch;
160-
return new Proxy(table, {
161-
get(target, prop, receiver) {
162-
if (wantRows && prop === "numRows") return meta!.numRows;
163-
if (wantMeta && prop === "metadata") return recordMd;
164-
return Reflect.get(target, prop, receiver);
165-
},
166-
}) as unknown as VgiBatch;
160+
// Assign the two overrides directly rather than wrapping in a Proxy: a Proxy
161+
// routes every downstream property access (`.schema`, `.getChildAt(i)`, …) on
162+
// the request batch through a trap + Reflect.get, defeating V8's inline
163+
// caches across the entire request-parsing loop. `metadata` is non-shadowing
164+
// on flechette's Table (same as attachBatchMetadata); `numRows` is a real
165+
// property, so override it via defineProperty.
166+
if (wantMeta) table.metadata = recordMd;
167+
if (wantRows) {
168+
Object.defineProperty(table, "numRows", { value: meta!.numRows, configurable: true, enumerable: true });
169+
}
170+
return table as VgiBatch;
167171
}
168172

169173
// ----- Construction --------------------------------------------------------

src/arrow/impl-flechette/normalize-type.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,13 +44,27 @@ function fField(f: any): any {
4444
return f_field(f.name, toFlechetteType(f.type), f.nullable ?? true, f.metadata ?? null);
4545
}
4646

47+
// Memoize by input-type object identity. Type objects come from registered
48+
// schemas and are immutable, and the flechette-native result is itself an
49+
// immutable value type used read-only — so caching lets every batch reuse one
50+
// rebuilt type (and its nested children) instead of re-deriving the tree.
51+
const _flechetteTypeCache = new WeakMap<object, any>();
52+
4753
/**
4854
* Rebuild `type` as a flechette-native DataType. Idempotent for types that are
4955
* already native (reconstructed from the same structural props). Unknown
5056
* typeIds pass through unchanged as a safety net.
5157
*/
5258
export function toFlechetteType(type: any): any {
5359
if (type == null) return nullType();
60+
const cached = _flechetteTypeCache.get(type);
61+
if (cached !== undefined) return cached;
62+
const built = _buildFlechetteType(type);
63+
_flechetteTypeCache.set(type, built);
64+
return built;
65+
}
66+
67+
function _buildFlechetteType(type: any): any {
5468
switch (type.typeId) {
5569
case Type.Null:
5670
return nullType();

src/protocol.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,4 +168,15 @@ export class Protocol {
168168
getMethods(): Map<string, MethodDefinition> {
169169
return new Map(this._methods);
170170
}
171+
172+
/** Look up a single method without copying the whole map — for the
173+
* per-request dispatch path. Callers must not mutate the returned value. */
174+
getMethod(name: string): MethodDefinition | undefined {
175+
return this._methods.get(name);
176+
}
177+
178+
/** Registered method names, sorted — for diagnostics/error messages only. */
179+
methodNames(): string[] {
180+
return [...this._methods.keys()].sort();
181+
}
171182
}

src/server.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -275,10 +275,9 @@ export class VgiRpcServer {
275275
}
276276

277277
// Look up method
278-
const methods = this.protocol.getMethods();
279-
const method = methods.get(methodName);
278+
const method = this.protocol.getMethod(methodName);
280279
if (!method) {
281-
const available = [...methods.keys()].sort();
280+
const available = this.protocol.methodNames();
282281
const err = new MethodNotImplementedError(
283282
`Unknown method: '${methodName}'. Available methods: [${available.join(", ")}]`,
284283
);
@@ -307,11 +306,15 @@ export class VgiRpcServer {
307306
const methodType = method.type === MethodType.UNARY ? "unary" : "stream";
308307

309308
// Capture self-contained IPC bytes of the request batch for the access log.
309+
// Only pay the full IPC re-encode when a hook will actually read them — the
310+
// default (no dispatchHook) path skips it entirely.
310311
let requestData: Uint8Array | undefined;
311-
try {
312-
requestData = serializeBatch(batch as any);
313-
} catch {
314-
// best-effort; observability must not fail dispatch
312+
if (this.dispatchHook) {
313+
try {
314+
requestData = serializeBatch(batch as any);
315+
} catch {
316+
// best-effort; observability must not fail dispatch
317+
}
315318
}
316319

317320
let streamId: string | undefined;

src/wire/request.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ import { REQUEST_ID_KEY, REQUEST_VERSION, REQUEST_VERSION_KEY, RPC_METHOD_KEY }
66
import { RpcError, VersionError } from "../errors.js";
77
import { isOpaquePassthroughType } from "./opaque.js";
88

9+
// Safe-integer bounds as BigInt, hoisted so per-field parsing doesn't
10+
// re-allocate them on every bigint-valued parameter of every request.
11+
const MIN_SAFE_BIG = BigInt(Number.MIN_SAFE_INTEGER);
12+
const MAX_SAFE_BIG = BigInt(Number.MAX_SAFE_INTEGER);
13+
914
export interface ParsedRequest {
1015
methodName: string;
1116
requestVersion: string;
@@ -108,7 +113,7 @@ export function parseRequest(schema: VgiSchema, batch: VgiBatch): ParsedRequest
108113
// make the builder apply a `*scale` multiplication (Decimal) or
109114
// `* 10^unit` (Time/Timestamp), corrupting the value.
110115
if (typeof value === "bigint" && !isOpaquePassthroughType(field.type)) {
111-
if (value >= BigInt(Number.MIN_SAFE_INTEGER) && value <= BigInt(Number.MAX_SAFE_INTEGER)) {
116+
if (value >= MIN_SAFE_BIG && value <= MAX_SAFE_BIG) {
112117
value = Number(value);
113118
}
114119
}

src/wire/response.ts

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,24 +17,57 @@ import {
1717
SERVER_ID_KEY,
1818
} from "../constants.js";
1919

20+
/**
21+
* Names of the Int64 fields in a schema, computed once per schema object.
22+
* `VgiSchema` is treated as immutable everywhere, so identity-keying is safe.
23+
*/
24+
const _int64FieldsCache = new WeakMap<VgiSchema, readonly string[]>();
25+
function int64FieldNames(schema: VgiSchema): readonly string[] {
26+
let names = _int64FieldsCache.get(schema);
27+
if (names === undefined) {
28+
const out: string[] = [];
29+
for (const f of schema.fields) {
30+
if (isInt(f.type) && (f.type as any).bitWidth === 64) out.push(f.name);
31+
}
32+
names = out;
33+
_int64FieldsCache.set(schema, names);
34+
}
35+
return names;
36+
}
37+
2038
/**
2139
* Coerce values for Int64 schema fields from Number to BigInt.
22-
* Handles both single values and arrays. Returns a new record with coerced values.
40+
* Handles both single values and arrays. Returns a new record with coerced
41+
* values, or the original record untouched when no coercion is needed.
2342
*/
2443
export function coerceInt64(schema: VgiSchema, values: Record<string, any>): Record<string, any> {
25-
const result: Record<string, any> = { ...values };
26-
for (const f of schema.fields) {
27-
const val = result[f.name];
44+
const int64Fields = int64FieldNames(schema);
45+
if (int64Fields.length === 0) return values;
46+
47+
let result: Record<string, any> | null = null;
48+
for (const name of int64Fields) {
49+
const val = values[name];
2850
if (val === undefined) continue;
29-
if (!isInt(f.type) || (f.type as any).bitWidth !== 64) continue;
3051

3152
if (Array.isArray(val)) {
32-
result[f.name] = val.map((v: any) => (typeof v === "number" ? BigInt(v) : v));
53+
// Clone lazily and only map when a Number element is actually present.
54+
let mapped: any[] | null = null;
55+
for (let i = 0; i < val.length; i++) {
56+
if (typeof val[i] === "number") {
57+
if (mapped === null) mapped = val.slice();
58+
mapped[i] = BigInt(val[i]);
59+
}
60+
}
61+
if (mapped !== null) {
62+
result ??= { ...values };
63+
result[name] = mapped;
64+
}
3365
} else if (typeof val === "number") {
34-
result[f.name] = BigInt(val);
66+
result ??= { ...values };
67+
result[name] = BigInt(val);
3568
}
3669
}
37-
return result;
70+
return result ?? values;
3871
}
3972

4073
/**

src/wire/writer.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ import { createIncrementalEncoder, serializeBatches } from "../arrow/index.js";
77

88
const STDOUT_FD = 1;
99

10+
// Shared resolved promise returned by the synchronous fd write path so we
11+
// don't allocate a fresh Promise per emitted batch.
12+
const RESOLVED: Promise<void> = Promise.resolve();
13+
1014
// Resolve node:fs via indirect-string require so esbuild/wrangler don't
1115
// statically pull node:fs into the bundle. Workers (Cloudflare workerd) never
1216
// instantiate IpcStreamWriter (no stdio transport on workers), so the
@@ -238,15 +242,20 @@ export class IncrementalStream {
238242
}
239243

240244
private enqueue(bytes: Uint8Array): Promise<void> {
245+
// Fast path: the fd target's write is fully synchronous and a stream's
246+
// target kind is fixed for its lifetime, so nothing is ever async-in-flight
247+
// ahead of us. Write inline and skip the promise-chain + microtask hop —
248+
// this runs once per emitted batch on the stdio producer path.
249+
const target = this.target;
250+
if (target.kind === "fd") {
251+
writeAll(target.fd, bytes);
252+
return RESOLVED;
253+
}
241254
const next = this.writeChain.then(() => {
242-
if (this.target.kind === "fd") {
243-
writeAll(this.target.fd, bytes);
244-
return;
245-
}
246-
if (this.target.kind === "sink") {
247-
return this.target.sink.write(bytes);
255+
if (target.kind === "sink") {
256+
return target.sink.write(bytes);
248257
}
249-
return socketWriteAll(this.target.socket, bytes);
258+
return socketWriteAll(target.socket, bytes);
250259
});
251260
// Swallow rejections on the chain so a single failure doesn't poison
252261
// every subsequent enqueue with an unhandled rejection. The returned

0 commit comments

Comments
 (0)