From c1b1bbac576c04ba3c7e4796fc097faf84a80717 Mon Sep 17 00:00:00 2001 From: "Alex C. Huber" <91097647+alexchuber@users.noreply.github.com> Date: Thu, 17 Sep 2026 05:30:55 -0400 Subject: [PATCH 1/4] feat: add pipeline stats and benchmark flags Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- packages/cli/README.md | 26 +++++++++++++++- packages/cli/src/benchmark.ts | 21 +++++++++++++ packages/cli/src/cli.ts | 23 ++++++++++++-- packages/core/docs/usage.md | 16 ++++++++++ tests/e2e/cli.test.ts | 56 +++++++++++++++++++++++++++++++---- 5 files changed, 133 insertions(+), 9 deletions(-) create mode 100644 packages/cli/src/benchmark.ts diff --git a/packages/cli/README.md b/packages/cli/README.md index a6e5f75..b71b07f 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,30 @@ 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, in bytes, 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. +Times are reported in milliseconds and memory in bytes. + ## 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..3c06ee2 --- /dev/null +++ b/packages/cli/src/benchmark.ts @@ -0,0 +1,21 @@ +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(); + + return [ + "Benchmark:", + ` Completion time: ${elapsedMilliseconds.toFixed(2)} ms`, + ` CPU time (user): ${(cpuUsage.user / 1_000).toFixed(2)} ms`, + ` CPU time (system): ${(cpuUsage.system / 1_000).toFixed(2)} ms`, + ` RSS: ${memoryUsage.rss} bytes`, + ` Peak RSS (process lifetime): ${resourceUsage.maxRSS * 1_024} bytes`, + ` Heap used: ${memoryUsage.heapUsed} bytes`, + ].join("\n"); + }; +} diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 812b5b7..b5d8368 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -3,6 +3,7 @@ import { resolve } from "node:path"; import { parseArgs } from "node:util"; import { version } from "../package.json"; +import { startBenchmark } from "./benchmark"; import { createPipelineAsync, getPipelineDefinitions } from "./pipeline"; export async function runCliAsync(args: string[]): Promise { @@ -13,6 +14,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 +39,37 @@ 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) { + console.log(`Stats:\n Total size before: ${inputFile.size} bytes\n Total size after: ${outputSize} bytes`); + } + 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 +82,8 @@ function printHelp(): void { "Options:", " -h, --help Show this help", " -v, --version Show the CLI version", + " --stats Show named input/output file sizes in bytes", + " --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/core/docs/usage.md b/packages/core/docs/usage.md index e2c0206..52256ae 100644 --- a/packages/core/docs/usage.md +++ b/packages/core/docs/usage.md @@ -16,6 +16,22 @@ 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 in bytes, 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. 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..fb99d64 100644 --- a/tests/e2e/cli.test.ts +++ b/tests/e2e/cli.test.ts @@ -1,4 +1,4 @@ -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"; @@ -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,47 @@ 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} bytes\n Total size after: ${(await stat(output)).size} bytes`); + } 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$/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+ bytes$/u); + expect(Number(line?.split(": ")[1]?.split(" ")[0])).toBeGreaterThan(0); + } + } else { + expect(result.stdout).not.toContain("Benchmark:"); + } + }); + it("resolves sibling glTF resources from the input path", async () => { const sourceDirectory = join(directory, "external"); await mkdir(sourceDirectory); @@ -182,13 +225,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); }); From 12951b6041e9605ef828c2f1ff65c79772fae7aa Mon Sep 17 00:00:00 2001 From: "Alex C. Huber" <91097647+alexchuber@users.noreply.github.com> Date: Thu, 17 Sep 2026 05:40:19 -0400 Subject: [PATCH 2/4] feat: format CLI sizes in readable units Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- packages/cli/README.md | 8 ++++++-- packages/cli/src/benchmark.ts | 7 ++++--- packages/cli/src/cli.ts | 5 +++-- packages/cli/src/formatBytes.ts | 12 +++++++++++ packages/core/docs/usage.md | 7 +++++-- tests/e2e/cli.test.ts | 36 +++++++++++++++++++++++++++++++-- 6 files changed, 64 insertions(+), 11 deletions(-) create mode 100644 packages/cli/src/formatBytes.ts diff --git a/packages/cli/README.md b/packages/cli/README.md index b71b07f..413cc80 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -43,7 +43,7 @@ file has been written successfully. Without these flags, no run report is printe | Flag | Report | | ------------- | ------ | -| `--stats` | Total size before and after, in bytes, for the named input and output files. | +| `--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 @@ -54,7 +54,11 @@ output writing, and disposal, but excludes argument parsing, input validation, a 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. -Times are reported in milliseconds and memory in bytes. +Times are reported in milliseconds. File sizes and memory automatically use +1024-based units (B, KiB, MiB, GiB, TiB, PiB). Bytes remain whole numbers; larger +units are rounded to two decimal places. Values that round up to 1024 advance +to the next unit. For example, 2,088 bytes displays as `2.04 KiB`, and +116,441,088 bytes displays as `111.05 MiB`. ## Develop locally diff --git a/packages/cli/src/benchmark.ts b/packages/cli/src/benchmark.ts index 3c06ee2..140f24a 100644 --- a/packages/cli/src/benchmark.ts +++ b/packages/cli/src/benchmark.ts @@ -13,9 +13,10 @@ export function startBenchmark(): () => string { ` Completion time: ${elapsedMilliseconds.toFixed(2)} ms`, ` CPU time (user): ${(cpuUsage.user / 1_000).toFixed(2)} ms`, ` CPU time (system): ${(cpuUsage.system / 1_000).toFixed(2)} ms`, - ` RSS: ${memoryUsage.rss} bytes`, - ` Peak RSS (process lifetime): ${resourceUsage.maxRSS * 1_024} bytes`, - ` Heap used: ${memoryUsage.heapUsed} bytes`, + ` RSS: ${formatBytes(memoryUsage.rss)}`, + ` Peak RSS (process lifetime): ${formatBytes(resourceUsage.maxRSS * 1_024)}`, + ` Heap used: ${formatBytes(memoryUsage.heapUsed)}`, ].join("\n"); }; } +import { formatBytes } from "./formatBytes"; diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index b5d8368..99b1cdd 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -4,6 +4,7 @@ import { parseArgs } from "node:util"; import { version } from "../package.json"; import { startBenchmark } from "./benchmark"; +import { formatBytes } from "./formatBytes"; import { createPipelineAsync, getPipelineDefinitions } from "./pipeline"; export async function runCliAsync(args: string[]): Promise { @@ -58,7 +59,7 @@ export async function runCliAsync(args: string[]): Promise { console.log(`Wrote ${outputPath}`); if (values.stats) { - console.log(`Stats:\n Total size before: ${inputFile.size} bytes\n Total size after: ${outputSize} bytes`); + console.log(`Stats:\n Total size before: ${formatBytes(inputFile.size)}\n Total size after: ${formatBytes(outputSize)}`); } if (benchmarkReport !== undefined) { console.log(benchmarkReport); @@ -82,7 +83,7 @@ function printHelp(): void { "Options:", " -h, --help Show this help", " -v, --version Show the CLI version", - " --stats Show named input/output file sizes in bytes", + " --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", "", diff --git a/packages/cli/src/formatBytes.ts b/packages/cli/src/formatBytes.ts new file mode 100644 index 0000000..5073d92 --- /dev/null +++ b/packages/cli/src/formatBytes.ts @@ -0,0 +1,12 @@ +export function formatBytes(bytes: number): string { + let value = bytes; + let unit = "B"; + for (const nextUnit of ["KiB", "MiB", "GiB", "TiB", "PiB"]) { + if (Number(value.toFixed(2)) < 1_024) { + break; + } + value /= 1_024; + unit = nextUnit; + } + return `${unit === "B" ? value : value.toFixed(2)} ${unit}`; +} diff --git a/packages/core/docs/usage.md b/packages/core/docs/usage.md index 52256ae..1e0d0a4 100644 --- a/packages/core/docs/usage.md +++ b/packages/core/docs/usage.md @@ -23,14 +23,17 @@ 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 in bytes, comparing the named input file with the written +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. See the [CLI guide](../../cli/README.md) for command syntax. +after a successful run. File sizes and memory are automatically scaled to +1024-based units (B, KiB, MiB, GiB, TiB, PiB), with two decimal places for units +above bytes. Rounding up to 1024 advances to the next unit. +See the [CLI guide](../../cli/README.md) for command syntax. # Example: Compressing GLB diff --git a/tests/e2e/cli.test.ts b/tests/e2e/cli.test.ts index fb99d64..e88bb85 100644 --- a/tests/e2e/cli.test.ts +++ b/tests/e2e/cli.test.ts @@ -104,7 +104,7 @@ describe("Node Assets CLI", () => { expect(result.stdout).toContain(`Wrote ${output}`); if (flags.includes("--stats")) { - expect(result.stdout).toContain(`Stats:\n Total size before: ${(await stat(source)).size} bytes\n Total size after: ${(await stat(output)).size} bytes`); + 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:"); } @@ -122,14 +122,46 @@ describe("Node Assets CLI", () => { } 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+ bytes$/u); + 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" }, + { bytes: 1_024, expected: "1.00 KiB" }, + { bytes: 1_536, expected: "1.50 KiB" }, + { bytes: 1_048_575, expected: "1.00 MiB" }, + { bytes: 1_048_576, expected: "1.00 MiB" }, + { bytes: 1_310_720, expected: "1.25 MiB" }, + ])("formats input sizes of $bytes bytes as $expected", async ({ bytes, expected }) => { + 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}`); + expect(result.stdout).toContain(`Total size after: ${(await stat(output)).size} B`); + }); + + it("formats output sizes in larger units", async () => { + const source = join(directory, "large-output.gltf"); + const output = join(directory, "large-output.glb"); + await writeFile(source, JSON.stringify({ asset: { version: "2.0" }, extras: { label: "x".repeat(2_048) } })); + + 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: ${(outputBytes / 1_024).toFixed(2)} KiB`); + }); + it("resolves sibling glTF resources from the input path", async () => { const sourceDirectory = join(directory, "external"); await mkdir(sourceDirectory); From 81abb86bbc1f142046e878d1e63ace86e28c8877 Mon Sep 17 00:00:00 2001 From: "Alex C. Huber" <91097647+alexchuber@users.noreply.github.com> Date: Thu, 17 Sep 2026 05:40:58 -0400 Subject: [PATCH 3/4] style: keep benchmark imports at the top Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- packages/cli/src/benchmark.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/benchmark.ts b/packages/cli/src/benchmark.ts index 140f24a..e20abad 100644 --- a/packages/cli/src/benchmark.ts +++ b/packages/cli/src/benchmark.ts @@ -1,3 +1,5 @@ +import { formatBytes } from "./formatBytes"; + export function startBenchmark(): () => string { const startedAt = process.hrtime.bigint(); const initialCpuUsage = process.cpuUsage(); @@ -19,4 +21,3 @@ export function startBenchmark(): () => string { ].join("\n"); }; } -import { formatBytes } from "./formatBytes"; From 0126fade6d1a4f4c77266b13e6a657621494ab7f Mon Sep 17 00:00:00 2001 From: "Alex C. Huber" <91097647+alexchuber@users.noreply.github.com> Date: Thu, 17 Sep 2026 14:27:35 -0400 Subject: [PATCH 4/4] feat: seed size and timing report units Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- packages/cli/README.md | 17 +++++--- packages/cli/src/benchmark.ts | 9 ++-- packages/cli/src/cli.ts | 5 ++- packages/cli/src/format.ts | 46 ++++++++++++++++++++ packages/cli/src/formatBytes.ts | 12 ------ packages/core/docs/usage.md | 8 ++-- tests/e2e/cli.test.ts | 74 ++++++++++++++++++++++++++------- 7 files changed, 130 insertions(+), 41 deletions(-) create mode 100644 packages/cli/src/format.ts delete mode 100644 packages/cli/src/formatBytes.ts diff --git a/packages/cli/README.md b/packages/cli/README.md index 413cc80..5ed256a 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -54,11 +54,18 @@ output writing, and disposal, but excludes argument parsing, input validation, a 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. -Times are reported in milliseconds. File sizes and memory automatically use -1024-based units (B, KiB, MiB, GiB, TiB, PiB). Bytes remain whole numbers; larger -units are rounded to two decimal places. Values that round up to 1024 advance -to the next unit. For example, 2,088 bytes displays as `2.04 KiB`, and -116,441,088 bytes displays as `111.05 MiB`. + +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 diff --git a/packages/cli/src/benchmark.ts b/packages/cli/src/benchmark.ts index e20abad..3d30c3d 100644 --- a/packages/cli/src/benchmark.ts +++ b/packages/cli/src/benchmark.ts @@ -1,4 +1,4 @@ -import { formatBytes } from "./formatBytes"; +import { createDurationFormatter, formatBytes } from "./format"; export function startBenchmark(): () => string { const startedAt = process.hrtime.bigint(); @@ -9,12 +9,13 @@ export function startBenchmark(): () => string { const cpuUsage = process.cpuUsage(initialCpuUsage); const memoryUsage = process.memoryUsage(); const resourceUsage = process.resourceUsage(); + const formatDuration = createDurationFormatter(elapsedMilliseconds); return [ "Benchmark:", - ` Completion time: ${elapsedMilliseconds.toFixed(2)} ms`, - ` CPU time (user): ${(cpuUsage.user / 1_000).toFixed(2)} ms`, - ` CPU time (system): ${(cpuUsage.system / 1_000).toFixed(2)} ms`, + ` 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)}`, diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 99b1cdd..7199659 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -4,7 +4,7 @@ import { parseArgs } from "node:util"; import { version } from "../package.json"; import { startBenchmark } from "./benchmark"; -import { formatBytes } from "./formatBytes"; +import { createByteFormatter } from "./format"; import { createPipelineAsync, getPipelineDefinitions } from "./pipeline"; export async function runCliAsync(args: string[]): Promise { @@ -59,7 +59,8 @@ export async function runCliAsync(args: string[]): Promise { console.log(`Wrote ${outputPath}`); if (values.stats) { - console.log(`Stats:\n Total size before: ${formatBytes(inputFile.size)}\n Total size after: ${formatBytes(outputSize)}`); + 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); 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/cli/src/formatBytes.ts b/packages/cli/src/formatBytes.ts deleted file mode 100644 index 5073d92..0000000 --- a/packages/cli/src/formatBytes.ts +++ /dev/null @@ -1,12 +0,0 @@ -export function formatBytes(bytes: number): string { - let value = bytes; - let unit = "B"; - for (const nextUnit of ["KiB", "MiB", "GiB", "TiB", "PiB"]) { - if (Number(value.toFixed(2)) < 1_024) { - break; - } - value /= 1_024; - unit = nextUnit; - } - return `${unit === "B" ? value : value.toFixed(2)} ${unit}`; -} diff --git a/packages/core/docs/usage.md b/packages/core/docs/usage.md index 1e0d0a4..d154f5c 100644 --- a/packages/core/docs/usage.md +++ b/packages/core/docs/usage.md @@ -30,9 +30,11 @@ 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. File sizes and memory are automatically scaled to -1024-based units (B, KiB, MiB, GiB, TiB, PiB), with two decimal places for units -above bytes. Rounding up to 1024 advances to the next unit. +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 diff --git a/tests/e2e/cli.test.ts b/tests/e2e/cli.test.ts index e88bb85..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, 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"; @@ -113,7 +113,7 @@ describe("Node Assets CLI", () => { 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$/u); + 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") { @@ -132,13 +132,13 @@ describe("Node Assets CLI", () => { }); it.each([ - { bytes: 1_023, expected: "1023 B" }, - { bytes: 1_024, expected: "1.00 KiB" }, - { bytes: 1_536, expected: "1.50 KiB" }, - { bytes: 1_048_575, expected: "1.00 MiB" }, - { bytes: 1_048_576, expected: "1.00 MiB" }, - { bytes: 1_310_720, expected: "1.25 MiB" }, - ])("formats input sizes of $bytes bytes as $expected", async ({ bytes, expected }) => { + { 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, " ")); @@ -146,20 +146,64 @@ describe("Node Assets CLI", () => { const result = await runNodeAsync([launcher, "pipeline", source, output, "--stats"], directory); expect(result.code).toBe(0); expect(result.stdout).toContain(`Total size before: ${expected}`); - expect(result.stdout).toContain(`Total size after: ${(await stat(output)).size} B`); + const outputBytes = (await stat(output)).size; + expect(result.stdout).toContain(`Total size after: ${divisor === 1 ? outputBytes : (outputBytes / divisor).toFixed(2)} ${expected.split(" ")[1]}`); }); - it("formats output sizes in larger units", async () => { - const source = join(directory, "large-output.gltf"); - const output = join(directory, "large-output.glb"); - await writeFile(source, JSON.stringify({ asset: { version: "2.0" }, extras: { label: "x".repeat(2_048) } })); + 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: ${(outputBytes / 1_024).toFixed(2)} KiB`); + 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 () => {