@forgeax/engine-profiler
v0.3.8
Published
CPU profile capture, bounded recording, and deterministic offline analysis for forgeax-engine.
Readme
@forgeax/engine-profiler
One capability: opt-in, bounded CPU capture with a schema-valid offline artifact. Start a capture, drive the App and Render loop, finish it, then inspect the same
ProfileCapturein memory, through the CLI, or through the Remoteprofilerroot.
One-screen takeoff
import {
buildProfileModel,
compareProfileCaptures,
createProfiler,
validateProfileCapture,
} from '@forgeax/engine-profiler';
const profiler = createProfiler();
const started = profiler.startCapture({ frameLimit: 120, eventLimit: 1024 });
if (!started.ok) throw started.error;
// Drive the App or Render loop here.
const finished = started.value.finish();
if (!finished.ok) throw finished.error;
const checked = validateProfileCapture(finished.value);
if (!checked.ok) throw checked.error;
const model = buildProfileModel(checked.value);
if (!model.ok) throw model.error;
console.log(model.value.summary.completeness.status, model.value.summary.recordCount);Pass the same profiler to createApp({ renderer, world, profiler }) or to the canvas-form options. The capability is opt-in; an App without it does not create capture records or artifacts.
Progressive disclosure
| Need | Public entry | Result |
|:--|:--|:--|
| Start a bounded capture | createProfiler().startCapture(limits) | Result<RecorderSession, ProfilerError> |
| Finish and retain the artifact | session.finish() | Result<ProfileCapture, ProfilerError> |
| Validate persisted JSON | validateProfileCapture(value) | schema and semantic validation |
| Build an offline summary | buildProfileModel(capture) | frame and phase projections |
| Query from a shell | forgeax debug profile summary --artifact artifact.json --json | structured JSON on stdout |
ProfileCapture is the portable boundary. It carries the fixed version, time unit, bounded frame and event evidence, owner phase catalog, and completeness status. complete, partial, and overflow are explicit outcomes; an overflow artifact remains useful and records its affected frame range.
Compare two imported captures without adding product policy. The projection preserves each side's
summary and completeness, unions phases by the full source / parentSource / parentPhase /
phase identity, and leaves an absent or null fact unavailable. Present count and skip deltas, plus
p95 duration deltas when both sides use a compatible time unit, are right minus left:
const comparison = compareProfileCaptures(leftJson, rightJson);
if (!comparison.ok) {
console.error(comparison.error.detail.side, comparison.error.detail.path);
} else {
console.log(comparison.value.phases);
}Limits and allocation evidence
Always set both frameLimit and eventLimit to positive safe integers. The recorder retains bounded scalar event storage during frames and materializes fixed records only when finish() builds the artifact, reports dropped events after overflow, and never presents an overflow artifact as complete. A host can pass allocationReport to createProfiler to count profiler-owned event object allocations; the deterministic D-6 gate requires zero allocations while the profiler is off.
const allocationReport = { profilerEventObjectAllocations: 0 };
const profiler = createProfiler({ allocationReport });The phase catalog is exposed as profiler.phaseCatalog. App and Render owners publish their catalogs;
consumers should read that relation rather than copy phase names into another list.
registerPhaseCatalog(source, phases) returns an idempotent lease disposer. The App frame-loop and
Renderer release their own definitions at terminal teardown, so reusing a Profiler does not retain a
dead owner. A finished ProfileCapture keeps an immutable catalog snapshot.
The default capture detail is owner, which records the stable App/Render phases with the normal
profiler overhead budget. For a bounded attribution pass, opt into nested; Render then records
producer-owned children such as record/validation and record/graph-execute, and each child carries
its parentSource and parentPhase so offline totals do not double-count nested time.
const started = profiler.startCapture({
frameLimit: 120,
eventLimit: 2048,
detail: 'nested',
});Offline and CLI workflow
forgeax debug profile summary --artifact profile-capture.json --json
forgeax debug profile frame --artifact profile-capture.json --frame-id 12 --json
forgeax debug profile phase --artifact profile-capture.json --source render --phase record --json
forgeax debug profile compare --left-file before.json --right-file after.json --jsonThe CLI reads one ProfileCapture JSON object from --file or two objects from
compare --left-file/--right-file and emits structured JSON. Compare input failures identify the
left or right side. It does not reconnect to a live App. The same artifact can be checked before analysis:
This is a copyable schema-valid complete artifact. The owner catalog is part of the artifact, so a consumer can validate and analyze it without a separately maintained phase list.
{
"schemaVersion": "1.0",
"captureId": "capture-0001",
"timeUnit": "microseconds",
"frameLimit": 1,
"eventLimit": 8,
"phaseCatalog": {
"app": [
"frame-total",
"world-update-primary",
"draw-source",
"world-update-injected",
"renderer-draw"
],
"render": ["extract", "bind-groups", "features", "sort", "record"]
},
"records": [
{
"kind": "phase",
"source": "app",
"frameId": 1,
"phase": "frame-total",
"startMicros": 1000,
"endMicros": 1100,
"durationMicros": 100
}
],
"completeness": {
"status": "complete",
"retainedEventCount": 1,
"droppedEventCount": 0
}
}const input = JSON.parse(text);
const result = validateProfileCapture(input);
if (!result.ok) {
console.error(result.error.code, result.error.detail.path, result.error.hint);
}Errors and boundaries
Expected failures return Result; inspect .ok, then use .error.code, .error.expected, .error.hint, and .error.detail. The closed error union is defined in packages/profiler/src/errors.ts; keep exhaustive handling there instead of parsing messages or reproducing the member list in a consumer.
Profiler owns capture records, bounds, allocation evidence, and offline projections. It does not own GPU timestamps, ECS scheduling, a browser UI, a network method, or a live trace service. Remote exposure remains the host's explicit profiler root opt-in through the existing eval and introspect methods.
GPU pass timing stays outside ProfileCapture. The Render package owns the
opt-in, draw() receipt, receipt-bound observation, pass facts, and recovery;
the benchmark package owns its separate fail-closed validator. App and Runtime
may transparently forward gpuPassTiming, but they do not create a session or
second controller. Membership timing is producer-specific and is not generic
accepted evidence. Keep CPU phase names, units, and schema unchanged when a
GPU timing capability is present.
Public surface
| Entry | Purpose |
|:--|:--|
| createProfiler(options?) | Creates an opt-in bounded recorder with an optional sink, clock, catalog, and allocation report. |
| profiler.registerPhaseCatalog(source, phases) | Registers one owner catalog and returns its idempotent removal lease. |
| ProfileCapture | Versioned artifact accepted by validation, model building, and the CLI. |
| validateProfileCapture(value) | Validates schema and semantic invariants before offline use. |
| buildProfileModel(capture) | Projects retained records into summaries without changing the artifact. |
| compareProfileCaptures(left, right) | Projects two validated captures into side summaries and a deterministic phase union. |
| createProfileClock() | Supplies the default monotonic microsecond clock. |
The package root is the only supported import path for these entries. See schema/profile-capture.schema.json for the artifact contract and scripts/bench/profiler-overhead.mjs for the deterministic D-6 consumer gate. The gate alternates profiler-off/on windows, warms each new capture before sampling, keeps finalization outside the measured window, and uses $median(((p95_{on,i} - p95_{off,i}) / p95_{off,i}) \times 100)$ across paired groups. The owner-mode ceiling is 20%; allocation, overflow, and phase-catalog evidence remain hard-failing.
