|
| 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