|
| 1 | +#!/usr/bin/env node |
| 2 | + |
| 3 | +const {parseArgs} = require('node:util') |
| 4 | +const {readFile} = require('node:fs/promises') |
| 5 | +const {DuckDBInstance} = require('@duckdb/node-api') |
| 6 | +const {Bench: Benchmark} = require('tinybench') |
| 7 | +const {basename} = require('node:path') |
| 8 | + |
| 9 | +// adapted from https://stackoverflow.com/a/55297611/1072129 |
| 10 | +const quantile = (sorted, q) => { |
| 11 | + const pos = (sorted.length - 1) * q |
| 12 | + const base = Math.floor(pos) |
| 13 | + const rest = pos - base |
| 14 | + if (base + 1 < sorted.length) { |
| 15 | + return sorted[base] + rest * (sorted[base + 1] - sorted[base]) |
| 16 | + } else { |
| 17 | + return sorted[base] |
| 18 | + } |
| 19 | +} |
| 20 | + |
| 21 | +const { |
| 22 | + values: flags, |
| 23 | + positionals: args, |
| 24 | +} = parseArgs({ |
| 25 | + options: { |
| 26 | + 'help': { |
| 27 | + type: 'boolean', |
| 28 | + short: 'h', |
| 29 | + }, |
| 30 | + }, |
| 31 | + allowPositionals: true, |
| 32 | +}) |
| 33 | + |
| 34 | +if (flags.help) { |
| 35 | + process.stdout.write(` |
| 36 | +Usage: |
| 37 | + benchmark [options] [--] <db-file> <sql-file> ... |
| 38 | +\n`) |
| 39 | + process.exit(0) |
| 40 | +} |
| 41 | + |
| 42 | +;(async () => { |
| 43 | + |
| 44 | +const [pathToDb, ...queryFiles] = args |
| 45 | +if (!pathToDb) { |
| 46 | + console.error('you must pass the path to a DuckDB db file') |
| 47 | + process.exit(1) |
| 48 | +} |
| 49 | +if (queryFiles.length === 0) { |
| 50 | + console.error('you must pass >0 SQL files') |
| 51 | + process.exit(1) |
| 52 | +} |
| 53 | +const instance = await DuckDBInstance.create(pathToDb, { |
| 54 | + access_mode: 'READ_ONLY', |
| 55 | +}) |
| 56 | +const db = await instance.connect() |
| 57 | + |
| 58 | +await db.run(`\ |
| 59 | +INSTALL spatial; |
| 60 | +LOAD spatial; |
| 61 | +`) |
| 62 | + |
| 63 | +const queriesByName = new Map() |
| 64 | +const benchmark = new Benchmark({ |
| 65 | + // - The default minimum number of iterations is too high. |
| 66 | + // - The default minimum time is too low. |
| 67 | + warmup: true, |
| 68 | + warmupIterations: 1, |
| 69 | + warmupTime: 5000, // 5s |
| 70 | + iterations: 3, |
| 71 | + time: 10000, // 10s |
| 72 | +}) |
| 73 | +await Promise.all( |
| 74 | + queryFiles |
| 75 | + .filter(queryFile => queryFile.slice(-9) !== '.skip.sql') |
| 76 | + .map(async (queryFile) => { |
| 77 | + const name = basename(queryFile) |
| 78 | + const query = await readFile(queryFile, {encoding: 'utf8'}) |
| 79 | + queriesByName.set(name, query) |
| 80 | + benchmark.add(name, async () => { |
| 81 | + await db.run(query) |
| 82 | + }) |
| 83 | + }), |
| 84 | +) |
| 85 | + |
| 86 | +// do all queries once, to make sure they work |
| 87 | +for (const [name, query] of queriesByName.entries()) { |
| 88 | + try { |
| 89 | + await db.run(query) |
| 90 | + } catch (err) { |
| 91 | + err.benchmark = name |
| 92 | + err.query = query |
| 93 | + throw err |
| 94 | + } |
| 95 | +} |
| 96 | + |
| 97 | +benchmark.addEventListener('cycle', (ev) => { |
| 98 | + const {task} = ev |
| 99 | + const query = queriesByName.get(task.name) |
| 100 | + if ('error' in task.result) { |
| 101 | + console.error(task.result) |
| 102 | + process.exit(1) |
| 103 | + } |
| 104 | + const samples = Array.from(task.result.samples).sort() |
| 105 | + console.log(JSON.stringify({ |
| 106 | + query, |
| 107 | + avg: task.result.latency.mean, |
| 108 | + min: task.result.latency.min, |
| 109 | + p25: quantile(samples, .25), |
| 110 | + p50: task.result.latency.p50, |
| 111 | + p75: task.result.latency.p75, |
| 112 | + p95: quantile(samples, .95), |
| 113 | + p99: task.result.latency.p99, |
| 114 | + max: task.result.latency.max, |
| 115 | + iterations: task.result.samples.length, |
| 116 | + })) |
| 117 | +}) |
| 118 | + |
| 119 | +await benchmark.run() |
| 120 | + |
| 121 | +})() |
| 122 | +.catch((err) => { |
| 123 | + console.error(err) |
| 124 | + process.exit(1) |
| 125 | +}) |
0 commit comments