morphit/node_modules/cborg/bench/bench.js

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)