OverviewFilesCommitsIssuesDocsReleasesPeers

pgh / test / bench / fixtures / build-fixtures.js

225 lines · javascript ref:
/**
 * Build fixture apps for benchmarks.
 *
 * Each fixture is a directory we can publish, install, and time. The
 * shapes are picked to bisect cost contributors:
 *
 *   bench-tiny    — 5 deps, all empty stubs.  Measures fixed per-app overhead.
 *   bench-medium  — 50 deps, real JS content. Measures dep-loop cost at scale.
 *   bench-large   — 400 deps, varied size.    Hyper-tps shape; bottleneck stress.
 *   bench-blob-2g — 1 dep with a single 2 GB blob.  Measures bulk-transfer perf
 *                   (raw throughput; what hyper-deploy / NetworkFetcher actually
 *                   give us on one giant file).
 *   bench-many-2g — 2000 deps, ~1 MB each.    Measures metadata throughput at
 *                   the same total size (compare against blob-2g — divergence
 *                   exposes per-tree overhead).
 *
 * Generated content is deterministic per fixture: same seed → same bytes →
 * identical tree hashes across machines and runs. This is what makes
 * cross-machine comparisons meaningful.
 *
 * Usage:
 *   node test/bench/fixtures/build-fixtures.js [--out <dir>] [--only <fixture>]
 *
 *   --out <dir>      base output dir (default: ./.bench-fixtures)
 *   --only <name>    build just one fixture (e.g. bench-tiny)
 *   --skip-blobs     skip the 2GB fixtures (saves a lot of disk + time)
 */

import fs from 'fs'
import path from 'path'
import crypto from 'crypto'

const __filename = new URL(import.meta.url).pathname
const __dirname = path.dirname(__filename)

function flag (name) {
  const i = process.argv.indexOf(name)
  return i === -1 ? undefined : process.argv[i + 1]
}
function bool (name) { return process.argv.includes(name) }

const OUT = flag('--out') || path.join(__dirname, '..', '..', '..', '.bench-fixtures')
const ONLY = flag('--only')
const SKIP_BLOBS = bool('--skip-blobs')

fs.mkdirSync(OUT, { recursive: true })

/**
 * Deterministic byte generator. Seeded chacha20-ish via repeated SHA-256
 * is overkill but the content has to look random enough that lazy
 * dedup heuristics (e.g. compression) don't compress it to zero. Plain
 * 'a' × N would otherwise let gzip dominate.
 */
function* deterministicBytes (seed, totalBytes) {
  let block = crypto.createHash('sha256').update(seed).digest()
  let written = 0
  while (written < totalBytes) {
    const remaining = totalBytes - written
    const chunk = block.slice(0, Math.min(block.length, remaining))
    written += chunk.length
    yield chunk
    block = crypto.createHash('sha256').update(block).digest()
  }
}

function writeBytes (filePath, seed, totalBytes) {
  fs.mkdirSync(path.dirname(filePath), { recursive: true })
  const fd = fs.openSync(filePath, 'w')
  try {
    for (const chunk of deterministicBytes(seed, totalBytes)) {
      fs.writeSync(fd, chunk)
    }
  } finally {
    fs.closeSync(fd)
  }
}

function makeFixtureRoot (name) {
  const root = path.join(OUT, name)
  if (fs.existsSync(root)) fs.rmSync(root, { recursive: true, force: true })
  fs.mkdirSync(root, { recursive: true })
  return root
}

function writePkg (root, pkg) {
  fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify(pkg, null, 2) + '\n')
}

function writeIndex (root, body) {
  fs.writeFileSync(path.join(root, 'index.js'), body)
}

/**
 * Build a single dep package inside the parent's node_modules.
 */
function makeDep (parentRoot, depName, opts = {}) {
  const depRoot = path.join(parentRoot, 'node_modules', depName)
  fs.mkdirSync(depRoot, { recursive: true })
  fs.writeFileSync(path.join(depRoot, 'package.json'), JSON.stringify({
    name: depName,
    version: '1.0.0',
    main: 'index.js',
    ...(opts.pkg || {})
  }, null, 2) + '\n')
  fs.writeFileSync(path.join(depRoot, 'index.js'),
    opts.body || `module.exports = ${JSON.stringify(depName)}\n`)
  if (opts.payloadBytes) {
    writeBytes(path.join(depRoot, 'payload.bin'), depName + '-' + opts.seed,
      opts.payloadBytes)
  }
  return depRoot
}

// ── Builders ─────────────────────────────────────────────────────

function buildTiny () {
  const root = makeFixtureRoot('bench-tiny')
  const deps = ['alpha', 'beta', 'gamma', 'delta', 'epsilon']
  writePkg(root, {
    name: 'bench-tiny', version: '1.0.0', main: 'index.js',
    dependencies: Object.fromEntries(deps.map(d => [d, '1.0.0'])),
    pgh: { bin: 'bench-tiny', entrypoint: 'index.js', runtime: 'node' }
  })
  writeIndex(root, `// bench-tiny — 5 stub deps, no payload\nconsole.log('bench-tiny up')\n`)
  for (const d of deps) makeDep(root, d, { seed: 'tiny' })
  return root
}

function buildMedium () {
  const root = makeFixtureRoot('bench-medium')
  const count = 50
  const deps = Array.from({ length: count }, (_, i) => `med-${String(i).padStart(3, '0')}`)
  writePkg(root, {
    name: 'bench-medium', version: '1.0.0', main: 'index.js',
    dependencies: Object.fromEntries(deps.map(d => [d, '1.0.0'])),
    pgh: { bin: 'bench-medium', entrypoint: 'index.js', runtime: 'node' }
  })
  writeIndex(root, `console.log('bench-medium up — ${count} deps')\n`)
  // Each dep carries ~40 KB of deterministic content (2 MB total).
  for (const d of deps) makeDep(root, d, { seed: 'medium-' + d, payloadBytes: 40 * 1024 })
  return root
}

function buildLarge () {
  const root = makeFixtureRoot('bench-large')
  const count = 400
  const deps = Array.from({ length: count }, (_, i) => `lg-${String(i).padStart(4, '0')}`)
  writePkg(root, {
    name: 'bench-large', version: '1.0.0', main: 'index.js',
    dependencies: Object.fromEntries(deps.map(d => [d, '1.0.0'])),
    pgh: { bin: 'bench-large', entrypoint: 'index.js', runtime: 'node' }
  })
  writeIndex(root, `console.log('bench-large up — ${count} deps')\n`)
  // 50 KB per dep, varied a little so trees differ. 400 × 50 KB ≈ 20 MB total.
  for (let i = 0; i < count; i++) {
    const d = deps[i]
    const size = 30 * 1024 + (i % 7) * 4 * 1024 // 30-54 KB
    makeDep(root, d, { seed: 'large-' + d, payloadBytes: size })
  }
  return root
}

function buildBlob2G () {
  const root = makeFixtureRoot('bench-blob-2g')
  writePkg(root, {
    name: 'bench-blob-2g', version: '1.0.0', main: 'index.js',
    dependencies: { 'big-payload': '1.0.0' },
    pgh: { bin: 'bench-blob-2g', entrypoint: 'index.js', runtime: 'node' }
  })
  writeIndex(root, `console.log('bench-blob-2g up')\n`)
  // 2 GB single blob inside one dep. This is the "what does NetworkFetcher
  // actually deliver on a big file" test.
  const TWO_GB = 2 * 1024 * 1024 * 1024
  makeDep(root, 'big-payload', { seed: 'blob2g', payloadBytes: TWO_GB })
  return root
}

function buildMany2G () {
  const root = makeFixtureRoot('bench-many-2g')
  const count = 2000
  const perDepKB = 1024 // 1 MB per dep × 2000 = 2 GB total
  const deps = Array.from({ length: count }, (_, i) => `m2g-${String(i).padStart(4, '0')}`)
  writePkg(root, {
    name: 'bench-many-2g', version: '1.0.0', main: 'index.js',
    dependencies: Object.fromEntries(deps.map(d => [d, '1.0.0'])),
    pgh: { bin: 'bench-many-2g', entrypoint: 'index.js', runtime: 'node' }
  })
  writeIndex(root, `console.log('bench-many-2g up — ${count} deps × 1MB')\n`)
  for (const d of deps) {
    makeDep(root, d, { seed: 'many2g-' + d, payloadBytes: perDepKB * 1024 })
  }
  return root
}

// ── Dispatch ─────────────────────────────────────────────────────

const BUILDERS = {
  'bench-tiny':    buildTiny,
  'bench-medium':  buildMedium,
  'bench-large':   buildLarge,
  'bench-blob-2g': buildBlob2G,
  'bench-many-2g': buildMany2G
}

const fixtures = ONLY ? [ONLY] : Object.keys(BUILDERS)

for (const name of fixtures) {
  if (!BUILDERS[name]) {
    console.error('unknown fixture: ' + name)
    process.exit(2)
  }
  if (SKIP_BLOBS && name.includes('-2g')) {
    console.log('skipping ' + name + ' (--skip-blobs)')
    continue
  }
  console.log('building ' + name + '...')
  const t0 = Date.now()
  const root = BUILDERS[name]()
  // Quick size report
  const cmd = `du -sh "${root}" 2>/dev/null | awk '{print $1}'`
  let sz = 'unknown'
  try { sz = (await import('child_process')).execSync(cmd).toString().trim() } catch {}
  console.log('  ' + name + ': ' + sz + ' in ' + ((Date.now() - t0) / 1000).toFixed(1) + 's → ' + root)
}