@zakkster/lite-profiler-scheduler
v1.0.0
Published
Profiles @zakkster/lite-scheduler priority lanes through its public surface only -- per-lane task-time distributions (avg/p99/max), counts, and single-task budget overruns. No private-field poking, no monkeypatching. Built on lite-ring-buffer + lite-stats
Maintainers
Readme
@zakkster/lite-profiler-scheduler
A profiling facade for @zakkster/lite-scheduler. Point it at a scheduler, route your tasks through it, and read back where each priority lane spends its time -- per-lane task-time distributions (avg / p99 / max), task counts, and single-task budget overruns -- so you can see which lane is eating the frame.
It is an adapter in the lite-profiler family: like the core profiles a render loop and lite-profiler-signal makes that telemetry reactive, this profiles a scheduler. It composes the same primitives -- per-lane samples land in lite-ring-buffer ring buffers and are reduced by lite-stats-math.
It never touches scheduler internals. No private fields, no monkeypatching. See how.
Measuring through the public surface
lite-scheduler is a strict-priority frame-budget manager. Its public API is schedule(fn, priority), shouldYield(), isBusy(), stats(), destroy(). There is no task-start/end hook and no per-lane timing exposed. So how do you profile lanes without reaching inside?
The one public seam where a task's execution can be observed is the function you hand to schedule(). This profiler wraps it:
profiler.schedule(fn, lane)
|
v
scheduler.schedule( () => { t0 = now(); fn(); record(lane, now() - t0); } , lane)
| \_______________ the only thing added _______________/
v
...drains on its own MessageChannel, exactly as before...Samples flow into a per-lane ring buffer; lite-stats-math turns them into avg / p99 / max on demand. The scheduler runs unchanged.
Why overruns are duration-based, not shouldYield()-based
The tempting move is to call scheduler.shouldYield() after each task and call it an overrun when true. It does not mean what it looks like. Measured on a real scheduler (budgetMs: 5, four ~4ms tasks):
task 0 ran 4.02ms shouldYield-after: false
task 1 ran 4.01ms shouldYield-after: true
task 2 ran 4.03ms shouldYield-after: true
task 3 ran 4.01ms shouldYield-after: trueOnce a flush blows its budget, the deadline stays passed across the catch-up flushes, so shouldYield() flags the entire backlog -- not the task that crossed the line. Useless for attribution.
So an overrun here is defined deterministically: a task whose own duration exceeds budgetMs -- a single task that cannot fit in a frame, attributed to the exact lane that scheduled it. No flakiness, no false cascade.
Install
npm install @zakkster/lite-profiler-scheduler @zakkster/lite-schedulerNo peer dependencies. Pulls @zakkster/lite-ring-buffer and @zakkster/lite-stats-math (>= 1.0.1).
Quick start
import { createScheduler, Priority } from "@zakkster/lite-scheduler";
import { createSchedulerProfiler } from "@zakkster/lite-profiler-scheduler";
const scheduler = createScheduler({ budgetMs: 8 });
const profiler = createSchedulerProfiler(scheduler, { budgetMs: 8 }); // match the scheduler's budget
// Route scheduling through the profiler instead of the bare scheduler:
profiler.schedule(() => handleTap(), Priority.UserInput);
profiler.schedule(() => layoutPass(), Priority.Normal);
profiler.schedule(() => prefetch(), Priority.Background);
// Read telemetry whenever you like -- e.g. once a second, off the hot path:
const snap = profiler.snapshot();
console.log(`${snap.totalExecuted} tasks | ${snap.totalOverruns} budget busts | busiest: ${snap.busiestLane}`);
for (const lane of snap.lanes) {
console.log(`${lane.name.padEnd(10)} p99 ${lane.p99Ms.toFixed(2)}ms (${(lane.utilization * 100) | 0}% of frame) x${lane.count} overruns=${lane.overruns}`);
}API
createSchedulerProfiler(scheduler, options?) -> SchedulerProfiler
| option | default | meaning |
| ---------- | -------------- | ------------------------------------------------------------------------ |
| capacity | 256 | per-lane ring-buffer size (number of task-duration samples kept) |
| budgetMs | 10 | frame budget; should match the scheduler's config. Drives overruns + utilization. |
| lanes | [0,1,2,3,4] | which Priority lanes to track |
Throws TypeError if scheduler is not a Scheduler.
Methods
| method | description |
| -------------------------- | ------------------------------------------------------------------------------------ |
| schedule(fn, priority?) | Instrumented enqueue: wraps fn, times it, forwards to the scheduler. |
| wrap(fn, priority?) | Returns a self-timing task you schedule yourself. Reuse to avoid per-call allocation.|
| lane(priority) | LaneStats for a tracked lane, or null. |
| snapshot() | Aggregate across all tracked lanes. |
| reset() | Clear all per-lane buffers and counters. |
| dispose() | Destroy buffers. After dispose, schedule() forwards untimed. |
| budgetMs / tracked | Accessors (the configured budget; the tracked lane ids). |
LaneStats
| field | meaning |
| ------------- | -------------------------------------------------------- |
| priority | the Priority value (0-4) |
| name | "immediate" \| "userInput" \| "normal" \| "background" \| "idle" |
| count | tasks executed in this lane |
| totalMs | summed task time |
| lastMs | most recent task duration |
| avgMs / p99Ms / maxMs | task-duration distribution over the window |
| overruns | tasks whose own duration exceeded budgetMs |
| utilization | p99Ms / budgetMs -- the worst-ish task as a fraction of one frame |
snapshot()
{
budgetMs: number;
totalExecuted: number;
totalOverruns: number;
busiestLane: string | null; // by totalMs
schedulerExecuted: number; // scheduler.stats().totalExecuted, for reconciliation
lanes: LaneStats[];
}schedulerExecuted lets you confirm everything went through the facade: if it exceeds totalExecuted, some tasks were scheduled on the bare scheduler and went unmeasured.
Zero-GC notes
The measurement path allocates nothing: durations land in pre-allocated ring buffers, stats reduce into a reused scratch object, and snapshot()'s per-lane objects are the only allocation -- and only when you ask for them.
schedule() allocates one wrapper closure per call. For hot tasks scheduled every frame, wrap() the task once and schedule the wrapped function repeatedly to make even that allocation-free:
const step = profiler.wrap(() => stepSimulation(), Priority.Normal);
function frame() {
scheduler.schedule(step, Priority.Normal); // no per-schedule closure
requestAnimationFrame(frame);
}Recipes
Watch a single lane. Only care about background work? createSchedulerProfiler(s, { lanes: [Priority.Background] }) -- tasks on other lanes run normally, just untimed.
Alert on budget busts. Poll snapshot().totalOverruns on an interval and warn when it climbs; drill in via the per-lane overruns to find the offending lane.
Reconcile coverage. Compare snapshot().schedulerExecuted to totalExecuted to catch scheduling paths that bypass the profiler.
Reactive lane dashboards. Sample snapshot() from a lite-signal effect (or feed it through lite-profiler-signal's pulse pattern) to drive a live HUD without touching the scheduler's hot path.
Testing
npm test # node --test, against a real lite-scheduler instancelanes-- task time is attributed to the right lane;snapshot()reconciles with the scheduler's owntotalExecutedand names the busiest lane.overruns-- single-task budget busts are flagged per lane; none reported when every task fits.lifecycle--wrap()reuse,reset(), idempotentdispose(), and lane-subset tracking.
Compatibility
| package | range | role |
| ----------------------------- | ---------------- | ---- |
| @zakkster/lite-scheduler | ^1.0.0 | dep |
| @zakkster/lite-ring-buffer | ^1.0.1 | dep |
| @zakkster/lite-stats-math | ^1.0.1 | dep |
ESM only. sideEffects: false. Node 18+ (uses performance.now; the scheduler uses MessageChannel).
Changelog
See CHANGELOG.md.
License
MIT (c) Zahary Shinikchiev [email protected]
