diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ca89813..537076f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,9 @@ jobs: - name: Test (unit + property + worker + type-level, coverage-gated) run: pnpm test -- --coverage + - name: Bundle-size budget (tree-shaken min+gzip) + run: pnpm size + # --------------------------------------------------------------------------- # Runtime lanes (P3): prove the SHIPPED dist actually runs on each claimed runtime. # Each lane builds first (the smoke imports packages/ecsia/dist), then runs the SAME diff --git a/bundle-budget.json b/bundle-budget.json new file mode 100644 index 0000000..4bdc86d --- /dev/null +++ b/bundle-budget.json @@ -0,0 +1,17 @@ +{ + "_comment": "Bundle-size budgets: max min+gzip BYTES per tree-shaken entry (scripts/size-check.mjs). The honest \"lean install\" number. Ratchet DOWN with `node scripts/size-check.mjs --update` when a build shrinks; CI fails if a build grows past budget*1.03.", + "budgets": { + "kernel": { + "gzip": 39987, + "min": 126142 + }, + "core-min": { + "gzip": 30154, + "min": 97265 + }, + "full-umbrella": { + "gzip": 54587, + "min": 171999 + } + } +} diff --git a/package.json b/package.json index bbd28ad..1e2ebc6 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,8 @@ "smoke:runtime": "node scripts/runtime-smoke.mjs", "smoke:browser:bundle": "node scripts/browser-smoke/build.mjs", "smoke:browser": "playwright test --config scripts/browser-smoke/playwright.config.ts", + "size": "node scripts/size-check.mjs", + "size:update": "node scripts/size-check.mjs --update", "docs:api": "typedoc", "docs:check": "node scripts/check-doc-snippets.mjs", "docs:dev": "vitepress dev website", diff --git a/scripts/size-check.mjs b/scripts/size-check.mjs new file mode 100644 index 0000000..2dc4bc4 --- /dev/null +++ b/scripts/size-check.mjs @@ -0,0 +1,119 @@ +// Bundle-size budget. Bundles representative tree-shaken entry points against the BUILT dist, +// minifies (esbuild) + gzips (zlib), and asserts each stays under a committed budget. This is the +// honest "lean install" number — what a bundler ships for a real app, NOT the unminified source. +// Run after `pnpm build`. `--update` rewrites the budget to the measured sizes (the ratchet). +// +// Why per-import, not one number: ecsia is batteries-included but `sideEffects: false`, so a typed +// data + scheduler app pulls only the kernel; relations / serialization / topics drop unless used. +// The budgets pin that tree-shaking stays honest. + +import { build } from 'esbuild' +import { gzipSync } from 'node:zlib' +import { readFileSync, writeFileSync, existsSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { dirname, resolve } from 'node:path' + +const HERE = dirname(fileURLToPath(import.meta.url)) +const ROOT = resolve(HERE, '..') +const BUDGET_PATH = resolve(ROOT, 'bundle-budget.json') + +// Each entry is the smallest realistic import for a use-case. Measured tree-shaken: the umbrella is +// pure static re-exports, so unused subsystems drop. +const ENTRIES = [ + { + name: 'kernel', + note: 'typed data + systems + scheduler (the typical app)', + code: `import { createWorld, defineComponent, defineSystem, createScheduler, read, write } from 'ecsia' + console.log(createWorld, defineComponent, defineSystem, createScheduler, read, write)`, + }, + { + name: 'core-min', + note: 'just a world + a component (@ecsia/core alone)', + code: `import { createWorld, defineComponent } from '@ecsia/core' + console.log(createWorld, defineComponent)`, + }, + { + name: 'full-umbrella', + note: 'everything re-exported from the umbrella (the upper bound)', + code: `import * as ecsia from 'ecsia' + console.log(ecsia)`, + }, +] + +const alias = { + ecsia: resolve(ROOT, 'packages/ecsia/dist/index.js'), + '@ecsia/core': resolve(ROOT, 'packages/core/dist/index.js'), + '@ecsia/schema': resolve(ROOT, 'packages/schema/dist/index.js'), + '@ecsia/scheduler': resolve(ROOT, 'packages/scheduler/dist/index.js'), + '@ecsia/relations': resolve(ROOT, 'packages/relations/dist/index.js'), + '@ecsia/serialization': resolve(ROOT, 'packages/serialization/dist/index.js'), +} + +async function measure(entry) { + const out = await build({ + stdin: { contents: entry.code, resolveDir: ROOT, loader: 'js' }, + bundle: true, + minify: true, + format: 'esm', + platform: 'neutral', + write: false, + legalComments: 'none', + alias, + // node:worker_threads is only reached on the threaded path via dynamic import — never in a + // browser bundle; mark it external so it doesn't inflate the measured size. + external: ['node:worker_threads', 'node:url'], + }) + const code = out.outputFiles[0].contents + return { min: code.length, gzip: gzipSync(code).length } +} + +async function main() { + for (const a of Object.values(alias)) { + if (!existsSync(a)) { + console.error(`size-check: missing build artifact ${a} — run \`pnpm build\` first.`) + process.exit(1) + } + } + const update = process.argv.includes('--update') + const budget = existsSync(BUDGET_PATH) + ? JSON.parse(readFileSync(BUDGET_PATH, 'utf8')) + : { _comment: '', budgets: {} } + + const results = {} + let failed = false + const TOLERANCE = 1.03 // 3% headroom so a trivial change doesn't trip the gate; ratchet with --update + for (const entry of ENTRIES) { + const { min, gzip } = await measure(entry) + results[entry.name] = { gzip, min } + const limit = budget.budgets?.[entry.name]?.gzip + const status = limit === undefined ? 'NEW' : gzip <= Math.ceil(limit * TOLERANCE) ? 'ok' : 'OVER' + if (status === 'OVER') failed = true + console.log( + ` ${entry.name.padEnd(16)} ${String(gzip).padStart(6)} B gz (${String(min).padStart(6)} B min) ` + + `${limit === undefined ? '(no budget)' : `budget ${limit} B`} ${status}` + + ` — ${entry.note}`, + ) + } + + if (update) { + const next = { + _comment: + 'Bundle-size budgets: max min+gzip BYTES per tree-shaken entry (scripts/size-check.mjs). The honest "lean install" number. Ratchet DOWN with `node scripts/size-check.mjs --update` when a build shrinks; CI fails if a build grows past budget*1.03.', + budgets: Object.fromEntries(Object.entries(results).map(([k, v]) => [k, { gzip: v.gzip, min: v.min }])), + } + writeFileSync(BUDGET_PATH, JSON.stringify(next, null, 2) + '\n') + console.log(`\nsize-check: budget written to ${BUDGET_PATH}`) + return + } + + if (failed) { + console.error('\nsize-check: FAILED — a bundle grew past its budget. Investigate, or `--update` to ratchet if intended.') + process.exit(1) + } + console.log('\nsize-check: OK — all bundles within budget.') +} + +main().catch((err) => { + console.error(err) + process.exit(1) +}) diff --git a/website/guide/performance.md b/website/guide/performance.md index e458526..3fef9d8 100644 --- a/website/guide/performance.md +++ b/website/guide/performance.md @@ -179,14 +179,23 @@ pnpm bench:macro:pool # the worker-pool speedup sweep on its own, with the pri ## Regression guard -CI does **not** run `bench:report`. Wall-clock time on shared CI runners is noise — a slow neighbor on -the runner would flap the suite — so we deliberately **do not assert milliseconds in CI**. What CI -*does* guard is correctness and behavior, with counter-based assertions: the worker-pool smoke test (a -quick check that it compiles and runs, not a measurement) asserts every threaded configuration is -byte-identical to single-thread (the `byte-identical` column above), and the bench builders are -cross-checked so neither `eachChunk` nor `bindColumns` can silently diverge from `.each`. Performance -regressions are caught by re-running `bench:report` on a fixed machine and comparing `RESULTS.json`, -not by a CI timer. +CI does **not** assert milliseconds — wall-clock time on a shared runner is noise. It guards +performance two ways instead. First, correctness: the worker-pool smoke test asserts every threaded +configuration is byte-identical to single-thread (the `byte-identical` column above), and the bench +builders are cross-checked so neither `eachChunk` nor `bindColumns` can silently diverge from `.each`. +Second, a dedicated bench job asserts each iteration path's ns/entity **ratio against a same-run bitECS +control** stays under a committed ceiling — the ratio cancels runner drift, so it catches a real +regression (say, `bindColumns` deopting) without flapping on a slow neighbor. The absolute published +numbers still come from `bench:report` on a fixed machine. + +## Bundle size + +The kernel — typed data, systems, queries, and the scheduler — is about **40 KB min+gzip**; just a +world and components (`@ecsia/core` alone) is ~30 KB, and importing *everything* from the umbrella is +~55 KB. ecsia is batteries-included, so it is larger than a minimal core like bitECS (~5 KB); the +packages are `sideEffects: false`, so a bundler ships only the subsystems you import — relations, +serialization, and topics drop unless used. A CI budget (`pnpm size`) holds these numbers in place so +a change can't quietly inflate the install. ## See also