diff --git a/packages/cli/README.md b/packages/cli/README.md index a6e5f75..5ed256a 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -18,7 +18,7 @@ node-assets pipeline input.glb ktx2 draco output.glb The command syntax is: ```text -node-assets pipeline [operation...] +node-assets pipeline [operation...] [--stats] [--benchmark] ``` | Element | Supported values | @@ -32,6 +32,41 @@ Without specifying operations, the CLI reads the input and writes it back out as Run `node-assets --help` for command help or `node-assets --version` for the installed version. +### Run reports + +```sh +node-assets pipeline input.glb draco output.glb --stats --benchmark +``` + +Use either flag independently or both together. Reports appear after the output +file has been written successfully. Without these flags, no run report is printed. + +| Flag | Report | +| ------------- | ------ | +| `--stats` | Total size before and after for the named input and output files. | +| `--benchmark` | Completion time, user and system CPU time, RSS, peak RSS, and heap used. | + +Size statistics compare serialized file sizes, not decoded asset sizes. The input +size excludes external buffers and images referenced by a `.gltf` or `.glb` file. + +Benchmark timing covers pipeline creation (including library loading), execution, +output writing, and disposal, but excludes argument parsing, input validation, and +report printing. CPU times are process-wide and may include worker threads. RSS +(resident set size) and heap used are process-wide snapshots at completion; peak +RSS is the process-lifetime high-water mark, not a per-pipeline memory delta. + +The total size before selects a shared unit for both size totals, using +1024-based units (B, KiB, MiB, GiB, TiB, PiB). Memory values scale independently. +Bytes remain whole numbers; larger units use two decimal places. For example, +2,088 bytes before and 776 bytes after display as `2.04 KiB` and `0.76 KiB`. + +Completion time selects a shared unit for all timing rows: milliseconds (`ms`), +seconds (`s`), minutes (`min`), or hours (`h`), with two decimal places. For +example, a 2,538.93 ms run displays as `2.54 s`, and 115.06 ms of system CPU time +displays as `0.12 s`. Size and time unit selection advances to the next unit when +rounding reaches its boundary. The selected unit is kept for the other rows, +even when their values would otherwise use a different unit. + ## Develop locally From the repository root: diff --git a/packages/cli/src/benchmark.ts b/packages/cli/src/benchmark.ts new file mode 100644 index 0000000..3d30c3d --- /dev/null +++ b/packages/cli/src/benchmark.ts @@ -0,0 +1,24 @@ +import { createDurationFormatter, formatBytes } from "./format"; + +export function startBenchmark(): () => string { + const startedAt = process.hrtime.bigint(); + const initialCpuUsage = process.cpuUsage(); + + return () => { + const elapsedMilliseconds = Number(process.hrtime.bigint() - startedAt) / 1_000_000; + const cpuUsage = process.cpuUsage(initialCpuUsage); + const memoryUsage = process.memoryUsage(); + const resourceUsage = process.resourceUsage(); + const formatDuration = createDurationFormatter(elapsedMilliseconds); + + return [ + "Benchmark:", + ` Completion time: ${formatDuration(elapsedMilliseconds)}`, + ` CPU time (user): ${formatDuration(cpuUsage.user / 1_000)}`, + ` CPU time (system): ${formatDuration(cpuUsage.system / 1_000)}`, + ` RSS: ${formatBytes(memoryUsage.rss)}`, + ` Peak RSS (process lifetime): ${formatBytes(resourceUsage.maxRSS * 1_024)}`, + ` Heap used: ${formatBytes(memoryUsage.heapUsed)}`, + ].join("\n"); + }; +} diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 812b5b7..7199659 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -3,6 +3,8 @@ import { resolve } from "node:path"; import { parseArgs } from "node:util"; import { version } from "../package.json"; +import { startBenchmark } from "./benchmark"; +import { createByteFormatter } from "./format"; import { createPipelineAsync, getPipelineDefinitions } from "./pipeline"; export async function runCliAsync(args: string[]): Promise { @@ -13,6 +15,8 @@ export async function runCliAsync(args: string[]): Promise { options: { help: { type: "boolean", short: "h" }, version: { type: "boolean", short: "v" }, + stats: { type: "boolean" }, + benchmark: { type: "boolean" }, }, }); @@ -36,25 +40,38 @@ export async function runCliAsync(args: string[]): Promise { const inputPath = resolve(input); const outputPath = resolve(output); - if (!(await stat(inputPath)).isFile()) { + const inputFile = await stat(inputPath); + if (!inputFile.isFile()) { throw new Error(`Input is not a regular file: ${inputPath}`); } + const finishBenchmark = values.benchmark ? startBenchmark() : undefined; const asset = await createPipelineAsync({ inputPath, outputPath, blockNames: remaining }); + let outputSize: number; try { const file = await asset.executeAsync(); await writeFile(outputPath, new Uint8Array(await file.arrayBuffer()), { flag: "wx" }); - console.log(`Wrote ${outputPath}`); + outputSize = file.size; } finally { asset.dispose(); } + const benchmarkReport = finishBenchmark?.(); + + console.log(`Wrote ${outputPath}`); + if (values.stats) { + const formatSize = createByteFormatter(inputFile.size); + console.log(`Stats:\n Total size before: ${formatSize(inputFile.size)}\n Total size after: ${formatSize(outputSize)}`); + } + if (benchmarkReport !== undefined) { + console.log(benchmarkReport); + } } function printHelp(): void { const { inputs, outputs, operations } = getPipelineDefinitions(); console.log( [ - "Usage: node-assets pipeline [operations...] ", + "Usage: node-assets pipeline [operations...] [--stats] [--benchmark]", "", `Supported file types:`, `Input: ${inputs.flatMap(({ extensions }) => extensions).join(", ")}`, @@ -67,6 +84,8 @@ function printHelp(): void { "Options:", " -h, --help Show this help", " -v, --version Show the CLI version", + " --stats Show named input/output file sizes in readable units", + " --benchmark Show completion time, CPU time, RSS, and heap usage", " -- End options before hyphen-prefixed paths", "", "Example: node-assets pipeline input.gltf ktx2 draco output.glb", diff --git a/packages/cli/src/format.ts b/packages/cli/src/format.ts new file mode 100644 index 0000000..3568b37 --- /dev/null +++ b/packages/cli/src/format.ts @@ -0,0 +1,46 @@ +export function createByteFormatter(referenceBytes: number): (bytes: number) => string { + return createFormatter({ + referenceValue: referenceBytes, + baseUnit: "B", + basePrecision: 0, + steps: ["KiB", "MiB", "GiB", "TiB", "PiB"].map((unit) => ({ unit, factor: 1_024 })), + }); +} + +export function createDurationFormatter(referenceMilliseconds: number): (milliseconds: number) => string { + return createFormatter({ + referenceValue: referenceMilliseconds, + baseUnit: "ms", + basePrecision: 2, + steps: [ + { unit: "s", factor: 1_000 }, + { unit: "min", factor: 60 }, + { unit: "h", factor: 60 }, + ], + }); +} + +export function formatBytes(bytes: number): string { + return createByteFormatter(bytes)(bytes); +} + +interface FormatterOptions { + readonly referenceValue: number; + readonly baseUnit: string; + readonly basePrecision: number; + readonly steps: readonly { readonly unit: string; readonly factor: number }[]; +} + +function createFormatter({ referenceValue, baseUnit, basePrecision, steps }: FormatterOptions): (value: number) => string { + let divisor = 1; + let unit = baseUnit; + for (const step of steps) { + if (Number((referenceValue / divisor).toFixed(2)) < step.factor) { + break; + } + divisor *= step.factor; + unit = step.unit; + } + const precision = divisor === 1 ? basePrecision : 2; + return (value) => `${(value / divisor).toFixed(precision)} ${unit}`; +} diff --git a/packages/core/docs/usage.md b/packages/core/docs/usage.md index e2c0206..d154f5c 100644 --- a/packages/core/docs/usage.md +++ b/packages/core/docs/usage.md @@ -16,6 +16,27 @@ const asset = new NodeAsset({ const result = await asset.executeAsync(); ``` +# Example: CLI run reports + +```sh +node-assets pipeline input.glb draco output.glb --stats --benchmark +``` + +Both flags are optional and can be used independently. `--stats` reports the total +size before and after, comparing the named input file with the written +output file. External buffers and images referenced by a glTF input are not +included in the input file size. + +`--benchmark` reports elapsed time and CPU time for pipeline creation, execution, +output writing, and disposal. It also reports process RSS and heap usage at +completion, plus peak RSS over the process lifetime. Reports are printed only +after a successful run. The total size before selects the unit for both size +totals, using 1024-based units (B, KiB, MiB, GiB, TiB, PiB). Completion time selects +the unit for all timing rows (ms, s, min, h). Memory values scale independently. +Times and sizes above bytes use two decimal places; bytes remain whole numbers. +Unit selection advances to the next unit when rounding reaches its boundary. +See the [CLI guide](../../cli/README.md) for command syntax. + # Example: Compressing GLB Same as before, but now add KTX2 texture encoding and Draco geometry encoding. diff --git a/tests/e2e/cli.test.ts b/tests/e2e/cli.test.ts index 694c9e3..8b2fa98 100644 --- a/tests/e2e/cli.test.ts +++ b/tests/e2e/cli.test.ts @@ -1,6 +1,6 @@ -import { cp, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { cp, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises"; import { join, relative } from "node:path"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { NodeIO } from "@gltf-transform/core"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; @@ -38,7 +38,7 @@ describe("Node Assets CLI", () => { const result = await runNodeAsync([launcher, ...args], directory); expect(result.code).toBe(0); expect(result.stderr).toBe(""); - for (const term of ["pipeline", ".gltf", ".glb", "draco", "meshopt", "ktx2"]) { + for (const term of ["pipeline", ".gltf", ".glb", "draco", "meshopt", "ktx2", "--stats", "--benchmark"]) { expect(result.stdout).toContain(term); } }); @@ -80,6 +80,8 @@ describe("Node Assets CLI", () => { const output = join(directory, `roundtrip-${extension}.glb`); const result = await runNodeAsync([launcher, "pipeline", `input.${extension}`, output], directory); expect(result.code).toBe(0); + expect(result.stdout).not.toContain("Stats:"); + expect(result.stdout).not.toContain("Benchmark:"); const document = await new NodeIO().read(output); expect(document.getRoot().listMeshes()[0]?.listPrimitives()[0]?.getAttribute("POSITION")?.getArray()).toEqual(new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0])); const parsed = await readGlbAsync(output); @@ -87,6 +89,123 @@ describe("Node Assets CLI", () => { expect(parsed.json.extensionsUsed ?? []).not.toContain("EXT_meshopt_compression"); }); + it.each([ + { extension: "gltf", flags: ["--stats"] }, + { extension: "glb", flags: ["--stats"] }, + { extension: "glb", flags: ["--benchmark"] }, + { extension: "glb", flags: ["--stats", "--benchmark"] }, + ])("reports requested metrics for $extension with $flags", async ({ extension, flags }) => { + const source = join(directory, `input.${extension}`); + const output = join(directory, `report-${extension}-${flags.join("-")}.glb`); + const result = await runNodeAsync([launcher, "pipeline", source, "draco", output, ...flags], directory); + expect(result.code).toBe(0); + expect(result.stderr).toBe(""); + expect((await readGlbAsync(output)).json.extensionsUsed).toContain("KHR_draco_mesh_compression"); + expect(result.stdout).toContain(`Wrote ${output}`); + + if (flags.includes("--stats")) { + expect(result.stdout).toContain(`Stats:\n Total size before: ${(await stat(source)).size} B\n Total size after: ${(await stat(output)).size} B`); + } else { + expect(result.stdout).not.toContain("Stats:"); + } + + if (flags.includes("--benchmark")) { + expect(result.stdout).toContain("Benchmark:"); + for (const label of ["Completion time", "CPU time (user)", "CPU time (system)"]) { + const line = result.stdout.split("\n").find((line) => line.startsWith(` ${label}: `)); + expect(line).toMatch(/: \d+\.\d{2} (?:ms|s|min|h)$/u); + const value = Number(line?.split(": ")[1]?.split(" ")[0]); + expect(value).toBeGreaterThanOrEqual(0); + if (label === "Completion time") { + expect(value).toBeGreaterThan(0); + } + } + for (const label of ["RSS", "Peak RSS (process lifetime)", "Heap used"]) { + const line = result.stdout.split("\n").find((line) => line.startsWith(` ${label}: `)); + expect(line).toMatch(/: (?:\d+ B|\d+\.\d{2} (?:KiB|MiB|GiB|TiB|PiB))$/u); + expect(Number(line?.split(": ")[1]?.split(" ")[0])).toBeGreaterThan(0); + expect(Number(line?.split(": ")[1]?.split(" ")[0])).toBeLessThan(1_024); + } + } else { + expect(result.stdout).not.toContain("Benchmark:"); + } + }); + + it.each([ + { bytes: 1_023, expected: "1023 B", divisor: 1 }, + { bytes: 1_024, expected: "1.00 KiB", divisor: 1_024 }, + { bytes: 1_536, expected: "1.50 KiB", divisor: 1_024 }, + { bytes: 1_048_575, expected: "1.00 MiB", divisor: 1_048_576 }, + { bytes: 1_048_576, expected: "1.00 MiB", divisor: 1_048_576 }, + { bytes: 1_310_720, expected: "1.25 MiB", divisor: 1_048_576 }, + ])("formats input sizes of $bytes bytes as $expected and keeps the unit for output", async ({ bytes, expected, divisor }) => { + const source = join(directory, `size-${bytes}.gltf`); + const output = join(directory, `size-${bytes}.glb`); + await writeFile(source, generateGltfJson().padEnd(bytes, " ")); + + const result = await runNodeAsync([launcher, "pipeline", source, output, "--stats"], directory); + expect(result.code).toBe(0); + expect(result.stdout).toContain(`Total size before: ${expected}`); + const outputBytes = (await stat(output)).size; + expect(result.stdout).toContain(`Total size after: ${divisor === 1 ? outputBytes : (outputBytes / divisor).toFixed(2)} ${expected.split(" ")[1]}`); + }); + + it.each([ + { padding: 960, unit: "B" }, + { padding: 2_048, unit: "KiB" }, + ])("formats output sizes using the input's $unit unit even when output grows", async ({ padding, unit }) => { + const source = join(directory, `large-output-${unit}.gltf`); + const output = join(directory, `large-output-${unit}.glb`); + await writeFile(source, JSON.stringify({ asset: { version: "2.0" }, extras: { label: "x".repeat(padding) } })); + + const result = await runNodeAsync([launcher, "pipeline", source, output, "--stats"], directory); + expect(result.code).toBe(0); + const outputBytes = (await stat(output)).size; + expect(outputBytes).toBeGreaterThan(1_024); + expect(outputBytes).toBeLessThan(1_048_576); + expect(result.stdout).toContain(`Total size after: ${unit === "B" ? outputBytes : (outputBytes / 1_024).toFixed(2)} ${unit}`); + }); + + it.each([ + { milliseconds: 0, completion: "0.00 ms", user: "0.00 ms", system: "0.00 ms" }, + { milliseconds: 12.34, completion: "12.34 ms", user: "24.68 ms", system: "6.17 ms" }, + { milliseconds: 999.994, completion: "999.99 ms", user: "1999.99 ms", system: "500.00 ms" }, + { milliseconds: 999.999, completion: "1.00 s", user: "2.00 s", system: "0.50 s" }, + { milliseconds: 1_000, completion: "1.00 s", user: "2.00 s", system: "0.50 s" }, + { milliseconds: 2_538.93, completion: "2.54 s", user: "5.08 s", system: "1.27 s" }, + { milliseconds: 59_999, completion: "1.00 min", user: "2.00 min", system: "0.50 min" }, + { milliseconds: 60_000, completion: "1.00 min", user: "2.00 min", system: "0.50 min" }, + { milliseconds: 72_471.94, completion: "1.21 min", user: "2.42 min", system: "0.60 min" }, + { milliseconds: 3_599_990, completion: "1.00 h", user: "2.00 h", system: "0.50 h" }, + { milliseconds: 3_600_000, completion: "1.00 h", user: "2.00 h", system: "0.50 h" }, + { milliseconds: 5_400_000, completion: "1.50 h", user: "3.00 h", system: "0.75 h" }, + ])("formats $milliseconds ms using the completion-time unit for every timing row", async ({ milliseconds, completion, user, system }) => { + const output = join(directory, `timing-${milliseconds}.glb`); + // Keep the real pipeline, but control clocks in a child process to cover long runs without waiting. + const script = ` + const { runCliAsync } = await import(${JSON.stringify(new URL("../dist/cli.js", pathToFileURL(launcher)).href)}); + let started = false; + process.hrtime.bigint = () => { + if (!started) { + started = true; + return 0n; + } + return ${BigInt(Math.round(milliseconds * 1_000_000))}n; + }; + process.cpuUsage = (previous) => previous === undefined + ? { user: 0, system: 0 } + : ${JSON.stringify({ user: Math.round(milliseconds * 2_000), system: Math.round(milliseconds * 500) })}; + await runCliAsync(${JSON.stringify(["pipeline", input, output, "--benchmark"])}); + `; + const result = await runNodeAsync(["--input-type=module", "--eval", script], directory); + expect(result.code).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).toContain(`Completion time: ${completion}`); + expect(result.stdout).toContain(`CPU time (user): ${user}`); + expect(result.stdout).toContain(`CPU time (system): ${system}`); + expect((await readGlbAsync(output)).json.meshes).toHaveLength(1); + }); + it("resolves sibling glTF resources from the input path", async () => { const sourceDirectory = join(directory, "external"); await mkdir(sourceDirectory); @@ -182,13 +301,16 @@ describe("Node Assets CLI", () => { 60_000 ); - it("does not overwrite an existing destination", async () => { - const output = join(directory, "existing.glb"); + it.each([[], ["--stats", "--benchmark"]].map((flags) => ({ flags })))("does not overwrite an existing destination with $flags", async ({ flags }) => { + const output = join(directory, `existing-${flags.join("-")}.glb`); const original = Buffer.from("keep this file"); await writeFile(output, original); - const result = await runNodeAsync([launcher, "pipeline", input, output], directory); + const result = await runNodeAsync([launcher, ...flags, "pipeline", input, output], directory); expect(result.code).toBe(1); expect(result.stderr.trim()).not.toBe(""); + expect(result.stdout).not.toContain("Wrote "); + expect(result.stdout).not.toContain("Stats:"); + expect(result.stdout).not.toContain("Benchmark:"); expect(await readFile(output)).toEqual(original); });