Skip to content

Commit 3abb5a9

Browse files
committed
Add native prepared Parquet batches
1 parent 71b332e commit 3abb5a9

5 files changed

Lines changed: 768 additions & 14 deletions

File tree

benchmark/hypaware.mjs

Lines changed: 392 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,392 @@
1+
import { readFile } from 'node:fs/promises'
2+
import { dirname, resolve } from 'node:path'
3+
import { fileURLToPath, pathToFileURL } from 'node:url'
4+
import { isDeepStrictEqual } from 'node:util'
5+
import { asyncBufferFromFile } from 'hyparquet/src/node.js'
6+
7+
const currentRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
8+
const options = parseArguments(process.argv.slice(2))
9+
if (!options.baseline) {
10+
throw new Error('Pass --baseline <icebird-v0.8.22-directory>')
11+
}
12+
if (options.tables.length === 0) {
13+
throw new Error('Pass at least one --table <hypaware-iceberg-table-directory>')
14+
}
15+
16+
const querySuite = makeQueries(options.since)
17+
const benchmarks = options.query
18+
? querySuite.filter(function selectedQuery(benchmark) {
19+
return benchmark.name.toLowerCase().includes(options.query.toLowerCase())
20+
})
21+
: querySuite
22+
if (benchmarks.length === 0) throw new Error(`No benchmark matched "${options.query}"`)
23+
24+
const baseline = await loadVersion('baseline', resolve(options.baseline), options.tables)
25+
const proposed = await loadVersion('proposed', currentRoot, options.tables)
26+
const versions = { baseline, proposed }
27+
28+
console.log(JSON.stringify({
29+
type: 'environment',
30+
baseline: baseline.version,
31+
proposed: proposed.version,
32+
tables: options.tables,
33+
rows: { baseline: baseline.source.numRows, proposed: proposed.source.numRows },
34+
queries: benchmarks.length,
35+
iterations: options.iterations,
36+
}))
37+
38+
/** @type {Array<{name: string, speedup: number}>} */
39+
const headline = []
40+
for (const benchmark of benchmarks) {
41+
const baselineRows = await runQuery(baseline, benchmark.query)
42+
const proposedRows = await runQuery(proposed, benchmark.query)
43+
if (!isDeepStrictEqual(baselineRows, proposedRows)) {
44+
console.error(JSON.stringify({
45+
type: 'mismatch',
46+
name: benchmark.name,
47+
baselineRows,
48+
proposedRows,
49+
}, jsonReplacer, 2))
50+
throw new Error(`Result mismatch for ${benchmark.name}`)
51+
}
52+
53+
/** @type {Record<'baseline' | 'proposed', Measurement[]>} */
54+
const measurements = { baseline: [], proposed: [] }
55+
for (let iteration = 0; iteration < options.iterations; iteration++) {
56+
const order = iteration % 2 === 0
57+
? /** @type {const} */ (['baseline', 'proposed'])
58+
: /** @type {const} */ (['proposed', 'baseline'])
59+
for (const name of order) {
60+
measurements[name].push(await measure(versions[name], benchmark.query))
61+
}
62+
}
63+
const baselineSummary = summarize(measurements.baseline)
64+
const proposedSummary = summarize(measurements.proposed)
65+
const speedup = baselineSummary.medianMs / proposedSummary.medianMs
66+
headline.push({ name: benchmark.name, speedup })
67+
console.log(JSON.stringify({
68+
type: 'result',
69+
name: benchmark.name,
70+
category: benchmark.category,
71+
resultRows: baselineRows.length,
72+
baseline: baselineSummary,
73+
proposed: proposedSummary,
74+
speedup,
75+
}))
76+
}
77+
78+
console.log(JSON.stringify({
79+
type: 'headline',
80+
geometricMeanSpeedup: geometricMean(headline.map(function speedup(result) { return result.speedup })),
81+
fastest: [...headline].sort(function descending(a, b) { return b.speedup - a.speedup })[0],
82+
slowest: [...headline].sort(function ascending(a, b) { return a.speedup - b.speedup })[0],
83+
}))
84+
85+
/**
86+
* Queries are derived from Hypaware's recent direct SQL history and built-in
87+
* overview panels. The four overview statements are preserved verbatim in
88+
* shape; retrieval probes exercise the wide deferred columns that motivated
89+
* Icebird's native prepared scanner.
90+
*
91+
* @param {string} since
92+
* @returns {Array<{name: string, category: string, query: string}>}
93+
*/
94+
function makeQueries(since) {
95+
return [
96+
{
97+
name: 'overview date counts',
98+
category: 'overview',
99+
query: 'SELECT date, COUNT(*) n FROM messages GROUP BY 1 ORDER BY 1 DESC',
100+
},
101+
{
102+
name: 'overview tool calls and sessions',
103+
category: 'overview',
104+
query: `SELECT tool_name, COUNT(*) calls, COUNT(DISTINCT session_id) sessions
105+
FROM messages WHERE date >= '${since}'
106+
AND part_type = 'tool_call' AND tool_name IS NOT NULL
107+
GROUP BY 1 ORDER BY calls DESC LIMIT 10`,
108+
},
109+
{
110+
name: 'overview provider model tokens',
111+
category: 'overview',
112+
query: `SELECT provider, model,
113+
COALESCE(SUM(CAST(JSON_EXTRACT(attributes, '$.usage.input_tokens') AS BIGINT)), 0) input_tokens,
114+
COALESCE(SUM(COALESCE(CAST(JSON_EXTRACT(attributes, '$.usage.cache_read_tokens') AS BIGINT), 0)
115+
+ COALESCE(CAST(JSON_EXTRACT(attributes, '$.usage.cache_write_tokens') AS BIGINT), 0)), 0) cached_tokens,
116+
COALESCE(SUM(CAST(JSON_EXTRACT(attributes, '$.usage.output_tokens') AS BIGINT)), 0) output_tokens
117+
FROM messages WHERE date >= '${since}'
118+
GROUP BY 1, 2 ORDER BY input_tokens + output_tokens DESC`,
119+
},
120+
{
121+
name: 'overview daily sessions and tokens',
122+
category: 'overview',
123+
query: `SELECT date, COUNT(DISTINCT session_id) sessions,
124+
COALESCE(SUM(CAST(JSON_EXTRACT(attributes, '$.usage.input_tokens') AS BIGINT)), 0) input_tokens,
125+
COALESCE(SUM(CAST(JSON_EXTRACT(attributes, '$.usage.output_tokens') AS BIGINT)), 0) output_tokens
126+
FROM messages WHERE date >= '${since}'
127+
GROUP BY 1 ORDER BY 1 DESC`,
128+
},
129+
{
130+
name: 'selective tool result payload',
131+
category: 'retrieval',
132+
query: `SELECT content_text FROM messages
133+
WHERE part_type = 'tool_result' AND content_text IS NOT NULL LIMIT 100`,
134+
},
135+
{
136+
name: 'recent attributes preview',
137+
category: 'retrieval',
138+
query: `SELECT attributes FROM messages
139+
WHERE part_type = 'tool_call' AND attributes IS NOT NULL LIMIT 100`,
140+
},
141+
{
142+
name: 'largest system prompts',
143+
category: 'top-k',
144+
query: `SELECT session_id, LENGTH(system_text) system_chars FROM messages
145+
WHERE system_text IS NOT NULL ORDER BY system_chars DESC LIMIT 20`,
146+
},
147+
]
148+
}
149+
150+
/**
151+
* @typedef {object} BenchmarkOptions
152+
* @property {string} [baseline]
153+
* @property {string[]} tables
154+
* @property {number} iterations
155+
* @property {string} since
156+
* @property {string} [query]
157+
*/
158+
159+
/**
160+
* @param {string[]} args
161+
* @returns {BenchmarkOptions}
162+
*/
163+
function parseArguments(args) {
164+
/** @type {BenchmarkOptions} */
165+
const parsed = { tables: [], iterations: 3, since: '2026-07-17' }
166+
for (let index = 0; index < args.length; index++) {
167+
const flag = args[index]
168+
const value = args[++index]
169+
if (!value) throw new Error(`Missing value for ${flag}`)
170+
if (flag === '--baseline') parsed.baseline = value
171+
else if (flag === '--table') parsed.tables.push(resolve(value))
172+
else if (flag === '--iterations') parsed.iterations = Number(value)
173+
else if (flag === '--since') parsed.since = value
174+
else if (flag === '--query') parsed.query = value
175+
else throw new Error(`Unknown argument ${flag}`)
176+
}
177+
if (!Number.isInteger(parsed.iterations) || parsed.iterations < 1) {
178+
throw new Error('--iterations must be a positive integer')
179+
}
180+
if (!/^\d{4}-\d{2}-\d{2}$/.test(parsed.since)) {
181+
throw new Error('--since must use YYYY-MM-DD')
182+
}
183+
return parsed
184+
}
185+
186+
/**
187+
* @typedef {object} BenchmarkVersion
188+
* @property {string} name
189+
* @property {string} version
190+
* @property {any} source
191+
* @property {Function} executeSql
192+
* @property {Function} collect
193+
*/
194+
195+
/**
196+
* @param {string} name
197+
* @param {string} root
198+
* @param {string[]} tables
199+
* @returns {Promise<BenchmarkVersion>}
200+
*/
201+
async function loadVersion(name, root, tables) {
202+
const icebird = await import(moduleUrl(resolve(root, 'src/sql/icebergDataSource.js')))
203+
const engine = await import(moduleUrl(resolve(root, 'node_modules/squirreling/src/index.js')))
204+
const packageJson = JSON.parse(await readFile(resolve(root, 'package.json'), 'utf8'))
205+
const dependency = packageJson.dependencies.squirreling
206+
const installedVersion = await squirrelingVersion(root)
207+
const resolver = {
208+
reader(path) {
209+
return asyncBufferFromFile(path.startsWith('file://') ? fileURLToPath(path) : path)
210+
},
211+
}
212+
const partitions = []
213+
for (const table of tables) {
214+
const metadata = await loadTableMetadata(table)
215+
partitions.push(await icebird.icebergDataSource({
216+
tableUrl: metadata.location,
217+
metadata,
218+
resolver,
219+
}))
220+
}
221+
return {
222+
name,
223+
version: dependency === installedVersion
224+
? `${packageJson.version} / squirreling ${installedVersion}`
225+
: `${packageJson.version} / squirreling ${dependency} (package ${installedVersion})`,
226+
source: unionDataSource(partitions),
227+
executeSql: engine.executeSql,
228+
collect: engine.collect,
229+
}
230+
}
231+
232+
/**
233+
* @param {string} root
234+
* @returns {Promise<string>}
235+
*/
236+
async function squirrelingVersion(root) {
237+
const packageJson = JSON.parse(await readFile(resolve(root, 'node_modules/squirreling/package.json'), 'utf8'))
238+
return packageJson.version
239+
}
240+
241+
/**
242+
* @param {string} table
243+
* @returns {Promise<any>}
244+
*/
245+
async function loadTableMetadata(table) {
246+
const version = (await readFile(resolve(table, 'metadata/version-hint.text'), 'utf8')).trim()
247+
return JSON.parse(await readFile(resolve(table, `metadata/v${version}.metadata.json`), 'utf8'))
248+
}
249+
250+
/**
251+
* Combine Hypaware's per-source Iceberg tables without materializing them.
252+
* Legacy scans retain the v0.8.22 behavior; prepared scans concatenate native
253+
* batches and leave global filtering/LIMIT/OFFSET to Squirreling.
254+
*
255+
* @param {any[]} partitions
256+
* @returns {any}
257+
*/
258+
function unionDataSource(partitions) {
259+
if (partitions.length === 0) throw new Error('No table partitions loaded')
260+
const first = partitions[0]
261+
const source = {
262+
columns: first.columns,
263+
numRows: partitions.every(function hasRows(partition) { return partition.numRows !== undefined })
264+
? partitions.reduce(function totalRows(total, partition) { return total + partition.numRows }, 0)
265+
: undefined,
266+
scan(options) {
267+
const scans = partitions.map(function partitionScan(partition) {
268+
return partition.scan({ ...options, limit: undefined, offset: undefined })
269+
})
270+
return {
271+
appliedWhere: scans.every(function applied(scan) { return scan.appliedWhere }),
272+
appliedLimitOffset: false,
273+
async *rows() {
274+
for (const scan of scans) yield* scan.rows()
275+
},
276+
}
277+
},
278+
}
279+
if (first.schema && partitions.every(function prepared(partition) { return partition.prepareScan })) {
280+
source.schema = first.schema
281+
source.prepareScan = function prepareScan(request) {
282+
const prepared = partitions.map(function preparePartition(partition) {
283+
return partition.prepareScan({ ...request, limit: undefined, offset: undefined })
284+
})
285+
return {
286+
schema: prepared[0].schema,
287+
residual: {
288+
filter: request.filter,
289+
limit: request.limit,
290+
offset: request.offset,
291+
},
292+
properties: {
293+
exactRows: request.filter ? undefined : source.numRows,
294+
maxRows: prepared.reduce(function totalRows(total, scan) {
295+
return total + (scan.properties.maxRows ?? 0)
296+
}, 0),
297+
},
298+
async *batches(batchOptions) {
299+
for (const scan of prepared) yield* scan.batches(batchOptions)
300+
},
301+
}
302+
}
303+
}
304+
return source
305+
}
306+
307+
/**
308+
* @param {BenchmarkVersion} version
309+
* @param {string} query
310+
* @returns {Promise<Record<string, unknown>[]>}
311+
*/
312+
function runQuery(version, query) {
313+
return version.collect(version.executeSql({ tables: { messages: version.source }, query }))
314+
}
315+
316+
/**
317+
* @typedef {object} Measurement
318+
* @property {number} ms
319+
* @property {number} peakHeapGrowthMb
320+
*/
321+
322+
/**
323+
* @param {BenchmarkVersion} version
324+
* @param {string} query
325+
* @returns {Promise<Measurement>}
326+
*/
327+
async function measure(version, query) {
328+
globalThis.gc?.()
329+
const before = process.memoryUsage().heapUsed
330+
let peak = before
331+
const sampler = setInterval(function sampleHeap() {
332+
peak = Math.max(peak, process.memoryUsage().heapUsed)
333+
}, 5)
334+
const start = performance.now()
335+
try {
336+
await runQuery(version, query)
337+
} finally {
338+
clearInterval(sampler)
339+
}
340+
const ms = performance.now() - start
341+
peak = Math.max(peak, process.memoryUsage().heapUsed)
342+
return { ms, peakHeapGrowthMb: (peak - before) / 1048576 }
343+
}
344+
345+
/**
346+
* @param {Measurement[]} measurements
347+
* @returns {{medianMs: number, minMs: number, medianPeakHeapGrowthMb: number}}
348+
*/
349+
function summarize(measurements) {
350+
return {
351+
medianMs: median(measurements.map(function milliseconds(value) { return value.ms })),
352+
minMs: Math.min(...measurements.map(function milliseconds(value) { return value.ms })),
353+
medianPeakHeapGrowthMb: median(measurements.map(function heap(value) { return value.peakHeapGrowthMb })),
354+
}
355+
}
356+
357+
/**
358+
* @param {number[]} values
359+
* @returns {number}
360+
*/
361+
function median(values) {
362+
const sorted = [...values].sort(function ascending(a, b) { return a - b })
363+
const midpoint = Math.floor(sorted.length / 2)
364+
return sorted.length % 2 === 0
365+
? (sorted[midpoint - 1] + sorted[midpoint]) / 2
366+
: sorted[midpoint]
367+
}
368+
369+
/**
370+
* @param {number[]} values
371+
* @returns {number}
372+
*/
373+
function geometricMean(values) {
374+
return Math.exp(values.reduce(function sum(total, value) { return total + Math.log(value) }, 0) / values.length)
375+
}
376+
377+
/**
378+
* @param {string} path
379+
* @returns {string}
380+
*/
381+
function moduleUrl(path) {
382+
return pathToFileURL(path).href
383+
}
384+
385+
/**
386+
* @param {string} _key
387+
* @param {unknown} value
388+
* @returns {unknown}
389+
*/
390+
function jsonReplacer(_key, value) {
391+
return typeof value === 'bigint' ? value.toString() : value
392+
}

0 commit comments

Comments
 (0)