434 lines
13 KiB
JavaScript
434 lines
13 KiB
JavaScript
/**
|
|
* cborg benchmark runner
|
|
*
|
|
* Measures encode/decode performance with realistic IPLD/CBOR workloads.
|
|
* Works in both Node.js and browser environments.
|
|
*
|
|
* Usage:
|
|
* node bench/bench.js # run all benchmarks (dag-cbor mode)
|
|
* node bench/bench.js --mode=raw # run with raw cborg (no tags)
|
|
* node bench/bench.js --suite=bsky # run only bluesky suite
|
|
* node bench/bench.js --json=output.json # write JSON results to file
|
|
* node bench/bench.js --compare=baseline.json # compare to baseline
|
|
* node bench/bench.js --encode-into # use encodeInto instead of encode
|
|
*/
|
|
|
|
import { encode, decode, encodeInto, Token, Type } from '../cborg.js'
|
|
import { generateFixtures, BenchCID } from './fixtures.js'
|
|
|
|
// =============================================================================
|
|
// CID Tag Encoder/Decoder (matches @ipld/dag-cbor implementation)
|
|
// =============================================================================
|
|
|
|
const CID_CBOR_TAG = 42
|
|
|
|
/**
|
|
* CID encoder for CBOR tag 42.
|
|
* Matches the detection logic from @ipld/dag-cbor.
|
|
*
|
|
* @param {any} obj
|
|
* @returns {Token[]|null}
|
|
*/
|
|
function cidEncoder (obj) {
|
|
// Fast-path rejection for non-CID objects (matches dag-cbor)
|
|
if (obj.asCID !== obj && obj['/'] !== obj.bytes) {
|
|
return null
|
|
}
|
|
// At this point we have something CID-like
|
|
if (!(obj instanceof BenchCID)) {
|
|
return null
|
|
}
|
|
// Encode with 0x00 prefix (historical reasons)
|
|
const bytes = new Uint8Array(obj.bytes.byteLength + 1)
|
|
bytes.set(obj.bytes, 1)
|
|
return [
|
|
new Token(Type.tag, CID_CBOR_TAG),
|
|
new Token(Type.bytes, bytes)
|
|
]
|
|
}
|
|
|
|
/**
|
|
* CID decoder for CBOR tag 42.
|
|
*
|
|
* @param {Uint8Array} bytes
|
|
* @returns {BenchCID}
|
|
*/
|
|
function cidDecoder (bytes) {
|
|
if (bytes[0] !== 0) {
|
|
throw new Error('Invalid CID for CBOR tag 42; expected leading 0x00')
|
|
}
|
|
return new BenchCID(bytes.subarray(1))
|
|
}
|
|
|
|
/**
|
|
* Number encoder that rejects NaN and Infinity (matches dag-cbor IPLD constraints)
|
|
*
|
|
* @param {number} num
|
|
* @returns {null}
|
|
*/
|
|
function numberEncoder (num) {
|
|
if (Number.isNaN(num)) {
|
|
throw new Error('`NaN` is not supported by the IPLD Data Model')
|
|
}
|
|
if (num === Infinity || num === -Infinity) {
|
|
throw new Error('`Infinity` is not supported by the IPLD Data Model')
|
|
}
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Undefined encoder that throws (matches strict dag-cbor IPLD constraints)
|
|
*
|
|
* @returns {null}
|
|
*/
|
|
function undefinedEncoder () {
|
|
throw new Error('`undefined` is not supported by the IPLD Data Model')
|
|
}
|
|
|
|
// Strict dag-cbor encode options (Filecoin, micro-benchmarks)
|
|
// Throws on undefined values
|
|
const dagCborEncodeOptions = {
|
|
float64: true,
|
|
typeEncoders: {
|
|
Object: cidEncoder,
|
|
number: numberEncoder,
|
|
undefined: undefinedEncoder
|
|
}
|
|
}
|
|
|
|
// Bluesky encode options - uses ignoreUndefinedProperties instead of throwing
|
|
// This matches Bluesky's actual usage pattern
|
|
const bskyEncodeOptions = {
|
|
float64: true,
|
|
ignoreUndefinedProperties: true,
|
|
typeEncoders: {
|
|
Object: cidEncoder,
|
|
number: numberEncoder
|
|
}
|
|
}
|
|
|
|
// dag-cbor-like decode options
|
|
const dagCborDecodeOptions = {
|
|
allowIndefinite: false,
|
|
coerceUndefinedToNull: true,
|
|
allowNaN: false,
|
|
allowInfinity: false,
|
|
allowBigInt: true,
|
|
strict: true,
|
|
useMaps: false,
|
|
rejectDuplicateMapKeys: true,
|
|
tags: []
|
|
}
|
|
dagCborDecodeOptions.tags[CID_CBOR_TAG] = cidDecoder
|
|
|
|
// Configuration
|
|
const WARMUP_ITERATIONS = 50
|
|
const DEFAULT_DURATION_MS = 1000
|
|
const FIXTURE_SEED = 12345
|
|
|
|
// Parse CLI args (Node.js only, ignored in browser)
|
|
const args = typeof process !== 'undefined' ? process.argv.slice(2) : []
|
|
const opts = {
|
|
json: args.find(a => a.startsWith('--json='))?.split('=')[1] || null,
|
|
suite: args.find(a => a.startsWith('--suite='))?.split('=')[1] || null,
|
|
compare: args.find(a => a.startsWith('--compare='))?.split('=')[1] || null,
|
|
duration: parseInt(args.find(a => a.startsWith('--duration='))?.split('=')[1] || DEFAULT_DURATION_MS),
|
|
encodeInto: args.includes('--encode-into'),
|
|
// Mode: 'dag-cbor' (default) uses tag 42 + strict options, 'raw' uses plain cborg
|
|
mode: args.find(a => a.startsWith('--mode='))?.split('=')[1] || 'dag-cbor'
|
|
}
|
|
|
|
/**
|
|
* Raw mode CID encoder - just encodes as bytes without tag.
|
|
* Needed because BenchCID has self-reference that triggers circular ref detection.
|
|
*/
|
|
function rawCidEncoder (obj) {
|
|
if (obj.asCID !== obj && obj['/'] !== obj.bytes) {
|
|
return null
|
|
}
|
|
if (!(obj instanceof BenchCID)) {
|
|
return null
|
|
}
|
|
return [new Token(Type.bytes, obj.bytes)]
|
|
}
|
|
|
|
// Raw mode encode options - minimal, just handles BenchCID
|
|
const rawEncodeOptions = {
|
|
typeEncoders: {
|
|
Object: rawCidEncoder
|
|
}
|
|
}
|
|
|
|
// Raw mode with ignoreUndefinedProperties for Bluesky data
|
|
const rawBskyEncodeOptions = {
|
|
ignoreUndefinedProperties: true,
|
|
typeEncoders: {
|
|
Object: rawCidEncoder
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get encode/decode options based on mode and suite type
|
|
* @param {'bsky'|'default'} suiteType
|
|
*/
|
|
function getOptions (suiteType = 'default') {
|
|
if (opts.mode === 'raw') {
|
|
return {
|
|
encode: suiteType === 'bsky' ? rawBskyEncodeOptions : rawEncodeOptions,
|
|
decode: {}
|
|
}
|
|
}
|
|
return {
|
|
encode: suiteType === 'bsky' ? bskyEncodeOptions : dagCborEncodeOptions,
|
|
decode: dagCborDecodeOptions
|
|
}
|
|
}
|
|
|
|
// Output helpers - always show progress to console
|
|
const log = console.log.bind(console)
|
|
const write = typeof process !== 'undefined' && process.stdout
|
|
? (s) => process.stdout.write(s)
|
|
: (s) => console.log(s)
|
|
|
|
/**
|
|
* Run a benchmark function for a duration, return ops/sec
|
|
*/
|
|
function bench (fn, durationMs = opts.duration) {
|
|
// Warmup
|
|
for (let i = 0; i < WARMUP_ITERATIONS; i++) fn()
|
|
|
|
// Measure
|
|
const start = performance.now()
|
|
let ops = 0
|
|
while (performance.now() - start < durationMs) {
|
|
fn()
|
|
ops++
|
|
}
|
|
const elapsed = performance.now() - start
|
|
return {
|
|
opsPerSec: Math.round(ops / (elapsed / 1000)),
|
|
totalOps: ops,
|
|
elapsedMs: Math.round(elapsed)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Benchmark a fixture set for encode and decode
|
|
* @param {string} name
|
|
* @param {any[]} fixtures
|
|
* @param {'bsky'|'default'} suiteType - determines which encode options to use
|
|
*/
|
|
function benchFixtures (name, fixtures, suiteType = 'default') {
|
|
const { encode: encodeOptions, decode: decodeOptions } = getOptions(suiteType)
|
|
|
|
// Pre-encode all fixtures for decode benchmark
|
|
const encoded = fixtures.map(f => encode(f, encodeOptions))
|
|
const totalBytes = encoded.reduce((sum, b) => sum + b.length, 0)
|
|
const avgBytes = Math.round(totalBytes / encoded.length)
|
|
|
|
// Setup encodeInto if requested
|
|
let encodeFn = (f) => encode(f, encodeOptions)
|
|
if (opts.encodeInto) {
|
|
const dest = new Uint8Array(1024 * 1024) // 1MB buffer
|
|
encodeFn = (f) => encodeInto(f, dest, encodeOptions)
|
|
}
|
|
|
|
log(` ${name} (${fixtures.length} items, avg ${avgBytes} bytes)`)
|
|
|
|
// Encode benchmark
|
|
write(' encode: ')
|
|
const encResult = bench(() => {
|
|
for (const f of fixtures) encodeFn(f)
|
|
})
|
|
const encOpsPerItem = encResult.opsPerSec * fixtures.length
|
|
const encMBps = Math.round((encResult.opsPerSec * totalBytes) / (1024 * 1024) * 10) / 10
|
|
log(`${encOpsPerItem.toLocaleString()} items/s (${encMBps} MB/s)`)
|
|
|
|
// Decode benchmark
|
|
write(' decode: ')
|
|
const decResult = bench(() => {
|
|
for (const e of encoded) decode(e, decodeOptions)
|
|
})
|
|
const decOpsPerItem = decResult.opsPerSec * encoded.length
|
|
const decMBps = Math.round((decResult.opsPerSec * totalBytes) / (1024 * 1024) * 10) / 10
|
|
log(`${decOpsPerItem.toLocaleString()} items/s (${decMBps} MB/s)`)
|
|
|
|
return {
|
|
name,
|
|
count: fixtures.length,
|
|
avgBytes,
|
|
totalBytes,
|
|
encode: {
|
|
opsPerSec: encResult.opsPerSec,
|
|
itemsPerSec: encOpsPerItem,
|
|
mbPerSec: encMBps
|
|
},
|
|
decode: {
|
|
opsPerSec: decResult.opsPerSec,
|
|
itemsPerSec: decOpsPerItem,
|
|
mbPerSec: decMBps
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Run a suite of benchmarks
|
|
* @param {string} name
|
|
* @param {Object} fixtureGroups
|
|
* @param {'bsky'|'default'} suiteType
|
|
*/
|
|
function runSuite (name, fixtureGroups, suiteType = 'default') {
|
|
log(`\n${name}`)
|
|
log('='.repeat(name.length))
|
|
|
|
const results = []
|
|
for (const [groupName, fixtures] of Object.entries(fixtureGroups)) {
|
|
if (Array.isArray(fixtures) && fixtures.length > 0) {
|
|
results.push(benchFixtures(groupName, fixtures, suiteType))
|
|
}
|
|
}
|
|
return { suite: name, results }
|
|
}
|
|
|
|
/**
|
|
* Main benchmark runner
|
|
*/
|
|
async function main () {
|
|
log('Generating fixtures...')
|
|
const fixtures = generateFixtures(FIXTURE_SEED)
|
|
const modeDesc = opts.mode === 'raw' ? 'raw cborg (no tags)' : 'dag-cbor mode (tag 42 + strict)'
|
|
log(`Done. Running benchmarks in ${modeDesc} (${opts.duration}ms per test)...\n`)
|
|
|
|
const allResults = []
|
|
|
|
// Bluesky suite (string-heavy) - uses ignoreUndefinedProperties
|
|
if (!opts.suite || opts.suite === 'bsky') {
|
|
allResults.push(runSuite('Bluesky (string-heavy)', {
|
|
posts: fixtures.bsky.posts,
|
|
follows: fixtures.bsky.follows,
|
|
likes: fixtures.bsky.likes,
|
|
reposts: fixtures.bsky.reposts,
|
|
profiles: fixtures.bsky.profiles,
|
|
mstNodes: fixtures.bsky.mstNodes
|
|
}, 'bsky'))
|
|
}
|
|
|
|
// Filecoin suite (bytes-heavy)
|
|
if (!opts.suite || opts.suite === 'filecoin') {
|
|
allResults.push(runSuite('Filecoin (bytes-heavy)', {
|
|
messages: fixtures.filecoin.messages,
|
|
blockHeaders: fixtures.filecoin.blockHeaders,
|
|
hamtNodes: fixtures.filecoin.hamtNodes,
|
|
amtNodes: fixtures.filecoin.amtNodes,
|
|
cidArrays: fixtures.filecoin.cidArrays
|
|
}))
|
|
}
|
|
|
|
// Micro-benchmarks
|
|
if (!opts.suite || opts.suite === 'micro') {
|
|
allResults.push(runSuite('Maps (key sorting)', {
|
|
'small (10 keys)': fixtures.micro.mapsSmall,
|
|
'medium (50 keys)': fixtures.micro.mapsMedium,
|
|
'large (200 keys)': fixtures.micro.mapsLarge
|
|
}))
|
|
|
|
allResults.push(runSuite('Nesting depth', {
|
|
'shallow (depth 3)': fixtures.micro.nestedShallow,
|
|
'deep (depth 10)': fixtures.micro.nestedDeep
|
|
}))
|
|
|
|
allResults.push(runSuite('Strings', {
|
|
'short (5-20 chars)': fixtures.micro.stringsShort,
|
|
'medium (20-100 chars)': fixtures.micro.stringsMedium,
|
|
'long (100-500 chars)': fixtures.micro.stringsLong
|
|
}))
|
|
|
|
allResults.push(runSuite('Integers', {
|
|
'small (0-23)': fixtures.micro.integersSmall,
|
|
'medium (0-65535)': fixtures.micro.integersMedium,
|
|
'large (64-bit)': fixtures.micro.integersLarge
|
|
}))
|
|
|
|
allResults.push(runSuite('Bytes', {
|
|
'small (<64)': fixtures.micro.bytesSmall,
|
|
'medium (64-512)': fixtures.micro.bytesMedium,
|
|
'large (1KB+)': fixtures.micro.bytesLarge
|
|
}))
|
|
}
|
|
|
|
// Summary
|
|
log('\n' + '='.repeat(50))
|
|
const allEncodeRates = allResults.flatMap(s => s.results.map(r => r.encode.mbPerSec))
|
|
const allDecodeRates = allResults.flatMap(s => s.results.map(r => r.decode.mbPerSec))
|
|
const avgEncode = Math.round(allEncodeRates.reduce((a, b) => a + b, 0) / allEncodeRates.length * 10) / 10
|
|
const avgDecode = Math.round(allDecodeRates.reduce((a, b) => a + b, 0) / allDecodeRates.length * 10) / 10
|
|
log(`Average throughput: encode ${avgEncode} MB/s, decode ${avgDecode} MB/s`)
|
|
|
|
// JSON output to file
|
|
if (opts.json) {
|
|
const output = {
|
|
timestamp: new Date().toISOString(),
|
|
seed: FIXTURE_SEED,
|
|
duration: opts.duration,
|
|
mode: opts.mode,
|
|
encodeInto: opts.encodeInto,
|
|
suites: allResults,
|
|
summary: { avgEncodeMBps: avgEncode, avgDecodeMBps: avgDecode }
|
|
}
|
|
if (typeof process !== 'undefined') {
|
|
const fs = await import('fs')
|
|
fs.writeFileSync(opts.json, JSON.stringify(output, null, 2) + '\n')
|
|
log(`\nResults written to ${opts.json}`)
|
|
}
|
|
}
|
|
|
|
// Compare to baseline
|
|
if (opts.compare) {
|
|
await compareToBaseline(opts.compare, allResults)
|
|
}
|
|
|
|
return allResults
|
|
}
|
|
|
|
/**
|
|
* Compare results to a baseline file
|
|
*/
|
|
async function compareToBaseline (baselinePath, currentResults) {
|
|
let baseline
|
|
try {
|
|
if (typeof process !== 'undefined') {
|
|
const fs = await import('fs')
|
|
baseline = JSON.parse(fs.readFileSync(baselinePath, 'utf8'))
|
|
} else {
|
|
const response = await fetch(baselinePath)
|
|
baseline = await response.json()
|
|
}
|
|
} catch (e) {
|
|
log(`\nCould not load baseline: ${e.message}`)
|
|
return
|
|
}
|
|
|
|
log('\nComparison to baseline:')
|
|
log('-'.repeat(50))
|
|
|
|
for (const suite of currentResults) {
|
|
const baselineSuite = baseline.suites.find(s => s.suite === suite.suite)
|
|
if (!baselineSuite) continue
|
|
|
|
log(`\n${suite.suite}:`)
|
|
for (const result of suite.results) {
|
|
const baselineResult = baselineSuite.results.find(r => r.name === result.name)
|
|
if (!baselineResult) continue
|
|
|
|
const encDiff = ((result.encode.mbPerSec - baselineResult.encode.mbPerSec) / baselineResult.encode.mbPerSec * 100).toFixed(1)
|
|
const decDiff = ((result.decode.mbPerSec - baselineResult.decode.mbPerSec) / baselineResult.decode.mbPerSec * 100).toFixed(1)
|
|
const encSign = encDiff >= 0 ? '+' : ''
|
|
const decSign = decDiff >= 0 ? '+' : ''
|
|
|
|
log(` ${result.name}: encode ${encSign}${encDiff}%, decode ${decSign}${decDiff}%`)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Run
|
|
main().catch(console.error)
|