Adds `bun run profile:animation`: it serves an isolated fixture and measures each animation variant directly, so comparing techniques takes seconds instead of an application rebuild plus a streamed response. The result is unambiguous and does not vary with element count, measured from 1 to 32: transform, opacity and filter cost zero extra style recalculations, while the individual rotate property, background-position, border-color and box-shadow each recalculate style 60 times a second, and geometry properties add layout on top. Notably `rotate: 360deg` is not a cheap synonym for `transform: rotate(360deg)`, and will-change, wrapper elements, containment and stepped timing do not make a non-composited property cheap. `scripts/perf/DOCUMENTATION.md` documents all four capture commands, how to stand up a production build to measure against, how to read the artifacts, the validity guarantees the scripts enforce, and the methodology rules, so this can be handed to an agent as the entry point for measuring performance. It is linked from the root guide's documentation anchors. The theme skill gains an animation contract carrying the measured table, and the performance skill points at the tooling documentation.
208 lines
7.7 KiB
JavaScript
208 lines
7.7 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Measures what a CSS animation costs, in isolation.
|
|
*
|
|
* A continuously running animation is one of the few things an idle interface
|
|
* keeps paying for, and the price depends entirely on whether the compositor
|
|
* can drive the animated property. Compositor-driven properties cost a small
|
|
* constant; everything else recalculates style on every frame, roughly an order
|
|
* of magnitude more.
|
|
*
|
|
* Rather than rebuilding the application and streaming a response to answer
|
|
* that question, this command serves a fixture page and measures each variant
|
|
* directly, so a whole comparison takes seconds. Add a variant to
|
|
* `perf/animation-fixture.html` to measure a property or technique that is not
|
|
* listed yet.
|
|
*
|
|
* The output answers two questions the fixture is designed for:
|
|
* - which properties are cheap to animate;
|
|
* - whether cost scales with the number of animated elements (`--count`).
|
|
*/
|
|
|
|
import { createReadStream } from "node:fs"
|
|
import { createServer } from "node:http"
|
|
import { homedir } from "node:os"
|
|
import { dirname, join, resolve } from "node:path"
|
|
import { fileURLToPath } from "node:url"
|
|
import process from "node:process"
|
|
|
|
import { CdpClient, createPageTarget, launchChrome, reservePort, resolveChrome, wait } from "./perf/cdp.mjs"
|
|
import { metricMap, round } from "./perf/metrics.mjs"
|
|
|
|
const scriptDirectory = dirname(fileURLToPath(import.meta.url))
|
|
const fixturePath = join(scriptDirectory, "perf", "animation-fixture.html")
|
|
|
|
const DEFAULT_VARIANTS = [
|
|
"none",
|
|
"transform-rotate",
|
|
"transform-rotate-willchange",
|
|
"transform-rotate-steps",
|
|
"transform-rotate-wrapper",
|
|
"rotate-property",
|
|
"opacity",
|
|
"translate",
|
|
"scale",
|
|
"background-position",
|
|
"border-color",
|
|
"box-shadow",
|
|
"filter",
|
|
"width",
|
|
]
|
|
|
|
const HELP = `Usage: bun run profile:animation -- [options]
|
|
|
|
Measures the idle cost of CSS animations using an isolated fixture page.
|
|
|
|
Options:
|
|
--variant <name> Measure only this variant (repeatable)
|
|
--count <n> Animated elements per variant (default: 2)
|
|
--duration <seconds> Measurement window per variant (default: 10)
|
|
--settle <seconds> Wait before measuring each variant (default: 3)
|
|
--chrome <path> Chrome/Chromium executable
|
|
--profile-dir <path> Reusable isolated Chrome profile
|
|
--headed Show the browser (default: headless)
|
|
--json Print results as JSON
|
|
--help Show this help
|
|
|
|
Variants live in scripts/perf/animation-fixture.html. Add one there to measure
|
|
a property or technique that is not covered.`
|
|
|
|
const parseArgs = (argv) => {
|
|
const options = {
|
|
variants: [],
|
|
count: 2,
|
|
duration: 10,
|
|
settle: 3,
|
|
chrome: null,
|
|
profileDir: join(homedir(), ".openchamber", "browser-profile-google-chrome"),
|
|
headless: true,
|
|
json: false,
|
|
}
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
const value = argv[index]
|
|
if (value === "--help") return { ...options, help: true }
|
|
else if (value === "--headed") options.headless = false
|
|
else if (value === "--json") options.json = true
|
|
else if (value === "--variant") options.variants.push(argv[++index])
|
|
else if (value === "--count") options.count = Number(argv[++index])
|
|
else if (value === "--duration") options.duration = Number(argv[++index])
|
|
else if (value === "--settle") options.settle = Number(argv[++index])
|
|
else if (value === "--chrome") options.chrome = argv[++index]
|
|
else if (value === "--profile-dir") options.profileDir = argv[++index]
|
|
else throw new Error(`Unknown option: ${value}`)
|
|
}
|
|
if (!Number.isFinite(options.duration) || options.duration <= 0) throw new Error("--duration must be a positive number")
|
|
if (!Number.isFinite(options.count) || options.count < 1) throw new Error("--count must be at least 1")
|
|
if (options.variants.length === 0) options.variants = DEFAULT_VARIANTS
|
|
return options
|
|
}
|
|
|
|
/** Serves only the fixture, on an ephemeral loopback port. */
|
|
const startFixtureServer = () => new Promise((resolvePromise, reject) => {
|
|
const server = createServer((_request, response) => {
|
|
response.writeHead(200, { "content-type": "text/html; charset=utf-8" })
|
|
createReadStream(fixturePath).pipe(response)
|
|
})
|
|
server.on("error", reject)
|
|
server.listen(0, "127.0.0.1", () => {
|
|
const address = server.address()
|
|
if (!address || typeof address === "string") {
|
|
server.close()
|
|
reject(new Error("Could not bind the fixture server"))
|
|
return
|
|
}
|
|
resolvePromise({ server, port: address.port })
|
|
})
|
|
})
|
|
|
|
const measureVariant = async (client, url, options) => {
|
|
const loaded = client.once("Page.loadEventFired", 30_000)
|
|
await client.send("Page.navigate", { url })
|
|
await loaded
|
|
await wait(options.settle * 1000)
|
|
|
|
const before = metricMap((await client.send("Performance.getMetrics")).metrics)
|
|
const startedAt = Date.now()
|
|
await wait(options.duration * 1000)
|
|
const elapsedSeconds = (Date.now() - startedAt) / 1000
|
|
const after = metricMap((await client.send("Performance.getMetrics")).metrics)
|
|
|
|
const delta = (name) => Number(after[name] ?? 0) - Number(before[name] ?? 0)
|
|
return {
|
|
recalcStylePerSecond: round(delta("RecalcStyleCount") / elapsedSeconds),
|
|
layoutsPerSecond: round(delta("LayoutCount") / elapsedSeconds),
|
|
mainThreadBusyPercent: round((delta("TaskDuration") / elapsedSeconds) * 100),
|
|
recalcStyleMsPerSecond: round((delta("RecalcStyleDuration") / elapsedSeconds) * 1000),
|
|
}
|
|
}
|
|
|
|
const main = async () => {
|
|
const options = parseArgs(process.argv.slice(2))
|
|
if (options.help) {
|
|
console.log(HELP)
|
|
return
|
|
}
|
|
|
|
const chrome = resolveChrome(options.chrome)
|
|
const { server, port: fixturePort } = await startFixtureServer()
|
|
const debuggingPort = await reservePort()
|
|
const chromeProcess = launchChrome({
|
|
chrome,
|
|
profileDir: resolve(options.profileDir),
|
|
port: debuggingPort,
|
|
headless: options.headless,
|
|
})
|
|
|
|
let client
|
|
const results = []
|
|
try {
|
|
const target = await createPageTarget(debuggingPort)
|
|
client = new CdpClient(target.webSocketDebuggerUrl)
|
|
await client.connect()
|
|
await Promise.all([
|
|
client.send("Page.enable"),
|
|
client.send("Runtime.enable"),
|
|
client.send("Performance.enable"),
|
|
])
|
|
await client.send("Emulation.setDeviceMetricsOverride", {
|
|
width: 1200, height: 800, deviceScaleFactor: 1, mobile: false,
|
|
})
|
|
|
|
console.log(`Measuring ${options.variants.length} variants, ${options.count} element(s) each, ${options.duration}s per variant.\n`)
|
|
for (const variant of options.variants) {
|
|
const url = `http://127.0.0.1:${fixturePort}/?variant=${encodeURIComponent(variant)}&count=${options.count}`
|
|
const measured = await measureVariant(client, url, options)
|
|
results.push({ variant, ...measured })
|
|
if (!options.json) {
|
|
console.log(
|
|
`${variant.padEnd(30)} recalc/s ${String(measured.recalcStylePerSecond).padStart(8)}`
|
|
+ ` layout/s ${String(measured.layoutsPerSecond).padStart(6)}`
|
|
+ ` busy% ${String(measured.mainThreadBusyPercent).padStart(6)}`,
|
|
)
|
|
}
|
|
}
|
|
|
|
if (options.json) {
|
|
console.log(JSON.stringify({ count: options.count, durationSeconds: options.duration, results }, null, 2))
|
|
return
|
|
}
|
|
|
|
const baseline = results.find((entry) => entry.variant === "none")
|
|
if (baseline) {
|
|
console.log(
|
|
`\nA still page costs ${baseline.recalcStylePerSecond} style recalculations per second.`
|
|
+ " Compositor-driven properties add a small constant; anything far above that recalculates every frame.",
|
|
)
|
|
}
|
|
} finally {
|
|
client?.close()
|
|
if (!chromeProcess.killed) chromeProcess.kill("SIGTERM")
|
|
server.close()
|
|
}
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(`Animation profiling failed: ${error.message}`)
|
|
process.exitCode = 1
|
|
})
|