281 lines
11 KiB
JavaScript
281 lines
11 KiB
JavaScript
// profiler.js — opt-in 30-second performance recorder for LinkRouter.
|
|
|
|
const STORAGE_KEY = "linkrouter.profiler.last";
|
|
const DEFAULT_DURATION_MS = 30000;
|
|
const MAX_SAMPLES = 5000;
|
|
const MAX_EVENTS = 500;
|
|
|
|
const clock = () => globalThis.performance?.now?.() ?? Date.now();
|
|
const rounded = (v) => Math.round((v || 0) * 1000) / 1000;
|
|
|
|
function average(values) {
|
|
return values.length ? values.reduce((a, b) => a + b, 0) / values.length : 0;
|
|
}
|
|
|
|
function percentile(values, p) {
|
|
if (!values.length) return 0;
|
|
const sorted = [...values].sort((a, b) => a - b);
|
|
return sorted[Math.min(sorted.length - 1, Math.floor((sorted.length - 1) * p))];
|
|
}
|
|
|
|
function phaseSummary(samples, phase) {
|
|
const rows = samples.filter((s) => s.phase === phase);
|
|
const draw = rows.map((s) => s.drawMs);
|
|
const route = rows.map((s) => s.route?.durationMs || 0);
|
|
return {
|
|
frames: rows.length,
|
|
avgDrawMs: rounded(average(draw)),
|
|
maxDrawMs: rounded(Math.max(0, ...draw)),
|
|
avgRouteMs: rounded(average(route)),
|
|
routerBuildMs: rounded(rows.reduce((n, s) => n + (s.route?.buildMs || 0), 0)),
|
|
connectorMs: rounded(rows.reduce((n, s) => n + (s.route?.connectorMs || 0), 0)),
|
|
progressiveFrames: rows.filter((s) => s.route?.progressive).length,
|
|
dragFrames: rows.filter((s) => s.route?.dragging).length,
|
|
graphRebuilds: rows.filter((s) => s.route?.graphRebuilt).length,
|
|
deferredGraphFrames: rows.filter((s) => s.route?.graphDeferred).length,
|
|
heldDirectReroutes: rows.reduce((n, s) => n + (s.route?.heldDirectReroutes || 0), 0),
|
|
heldCollisionReroutes: rows.reduce((n, s) => n + (s.route?.heldCollisionReroutes || 0), 0),
|
|
heldCleanupReroutes: rows.reduce((n, s) => n + (s.route?.heldCleanupReroutes || 0), 0),
|
|
reroutedLinks: rows.reduce((n, s) => n + (s.route?.reroutedLinks || 0), 0),
|
|
};
|
|
}
|
|
|
|
export function buildProfilerReport(session, reason = "manual") {
|
|
const samples = session.samples || [];
|
|
const routes = samples.map((s) => s.route).filter(Boolean);
|
|
const drawTimes = samples.map((s) => s.drawMs);
|
|
const routeTimes = routes.map((r) => r.durationMs || 0);
|
|
const longTasks = (session.events || []).filter((e) => e.kind === "long-task");
|
|
const startedAt = session.startedWall || Date.now();
|
|
const endedAt = Date.now();
|
|
return {
|
|
version: 1,
|
|
startedAt: new Date(startedAt).toISOString(),
|
|
endedAt: new Date(endedAt).toISOString(),
|
|
stoppedBy: reason,
|
|
durationMs: Math.round(Math.max(0, clock() - (session.startedAt || clock()))),
|
|
privacy: "Timings, viewport values, cache state, node IDs and geometry deltas only.",
|
|
summary: {
|
|
canvasDraws: samples.length,
|
|
avgDrawMs: rounded(average(drawTimes)),
|
|
p95DrawMs: rounded(percentile(drawTimes, 0.95)),
|
|
maxDrawMs: rounded(Math.max(0, ...drawTimes)),
|
|
avgRouteMs: rounded(average(routeTimes)),
|
|
maxRouteMs: rounded(Math.max(0, ...routeTimes)),
|
|
routerBuildMs: rounded(routes.reduce((n, r) => n + (r.buildMs || 0), 0)),
|
|
connectorMs: rounded(routes.reduce((n, r) => n + (r.connectorMs || 0), 0)),
|
|
connectorCalls: routes.reduce((n, r) => n + (r.connectorCalls || 0), 0),
|
|
progressiveFrames: routes.filter((r) => r.progressive).length,
|
|
dragFrames: routes.filter((r) => r.dragging).length,
|
|
maxHiddenDraggedLinks: Math.max(0, ...routes.map((r) => r.hiddenDraggedLinks || 0)),
|
|
routeCalls: routes.length,
|
|
routeFastHits: routes.filter((r) => r.fastHit).length,
|
|
graphRebuilds: routes.filter((r) => r.graphRebuilt).length,
|
|
deferredGraphFrames: routes.filter((r) => r.graphDeferred).length,
|
|
heldDirectReroutes: routes.reduce((n, r) => n + (r.heldDirectReroutes || 0), 0),
|
|
heldCollisionReroutes: routes.reduce((n, r) => n + (r.heldCollisionReroutes || 0), 0),
|
|
heldCleanupReroutes: routes.reduce((n, r) => n + (r.heldCleanupReroutes || 0), 0),
|
|
maxPauseQueueRemaining: Math.max(0, ...routes.map((r) => r.pauseQueueRemaining || 0)),
|
|
reroutedLinks: routes.reduce((n, r) => n + (r.reroutedLinks || 0), 0),
|
|
positionChanges: routes.reduce((n, r) => n + (r.positionChanges || 0), 0),
|
|
sizeChanges: routes.reduce((n, r) => n + (r.sizeChanges || 0), 0),
|
|
batchHits: samples.filter((s) => s.batch?.hit).length,
|
|
batchBuilds: samples.filter((s) => s.batch && !s.batch.hit && !s.batch.unsupported).length,
|
|
strokeCalls: samples.reduce((n, s) => n + (s.strokeCalls || 0), 0),
|
|
longTasks: longTasks.length,
|
|
longTaskTotalMs: rounded(longTasks.reduce((n, e) => n + e.durationMs, 0)),
|
|
maxEventLoopDelayMs: rounded(session.maxEventLoopDelayMs || 0),
|
|
},
|
|
byPhase: {
|
|
idle: phaseSummary(samples, "idle"),
|
|
pan: phaseSummary(samples, "pan"),
|
|
zoom: phaseSummary(samples, "zoom"),
|
|
},
|
|
events: session.events || [],
|
|
samples,
|
|
};
|
|
}
|
|
|
|
class LinkRouterProfiler {
|
|
constructor() {
|
|
this.active = false;
|
|
this.onChange = null;
|
|
this.lastReport = this.loadLastReport();
|
|
this.session = null;
|
|
this._timer = null;
|
|
this._lagTimer = null;
|
|
this._observer = null;
|
|
this._currentFrame = null;
|
|
this.saving = false;
|
|
this.lastSaveOk = null;
|
|
this.lastSavedFile = null;
|
|
}
|
|
|
|
loadLastReport() {
|
|
try {
|
|
return JSON.parse(localStorage.getItem(STORAGE_KEY) || "null");
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
start(durationMs = DEFAULT_DURATION_MS) {
|
|
if (this.active) return;
|
|
this.active = true;
|
|
this.session = {
|
|
startedAt: clock(),
|
|
startedWall: Date.now(),
|
|
durationMs,
|
|
samples: [],
|
|
events: [],
|
|
lastViewport: null,
|
|
maxEventLoopDelayMs: 0,
|
|
};
|
|
this._timer = setTimeout(
|
|
() => this.stop("automatic-" + Math.round(durationMs / 1000) + "s"),
|
|
durationMs,
|
|
);
|
|
let expected = clock() + 100;
|
|
this._lagTimer = setInterval(() => {
|
|
const now = clock();
|
|
const delay = Math.max(0, now - expected);
|
|
expected = now + 100;
|
|
if (this.session) this.session.maxEventLoopDelayMs = Math.max(this.session.maxEventLoopDelayMs, delay);
|
|
if (delay >= 16) this.addEvent({ kind: "event-loop-delay", durationMs: rounded(delay) });
|
|
}, 100);
|
|
try {
|
|
this._observer = new PerformanceObserver((list) => {
|
|
for (const e of list.getEntries())
|
|
this.addEvent({ kind: "long-task", durationMs: rounded(e.duration) });
|
|
});
|
|
this._observer.observe({ entryTypes: ["longtask"] });
|
|
} catch {}
|
|
this.onChange?.();
|
|
}
|
|
|
|
stop(reason = "manual") {
|
|
if (!this.active || !this.session) return this.lastReport;
|
|
this.active = false;
|
|
clearTimeout(this._timer);
|
|
clearInterval(this._lagTimer);
|
|
this._observer?.disconnect?.();
|
|
this._timer = this._lagTimer = this._observer = null;
|
|
this._currentFrame = null;
|
|
this.lastReport = buildProfilerReport(this.session, reason);
|
|
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(this.lastReport)); } catch {}
|
|
console.info("[LinkRouter Profiler] recording complete", this.lastReport);
|
|
this.session = null;
|
|
this.onChange?.();
|
|
this.saveLastReport(); // fire-and-forget: backend writes it to .disabled/LinkRouter_log
|
|
return this.lastReport;
|
|
}
|
|
|
|
// Browser JS cannot write files: the companion POST endpoint in
|
|
// __init__.py persists every report to custom_nodes/.disabled/LinkRouter_log.
|
|
async saveLastReport() {
|
|
if (!this.lastReport) return false;
|
|
this.saving = true;
|
|
this.onChange?.();
|
|
let ok = false;
|
|
try {
|
|
const res = await fetch("/linkrouter/write_log", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(this.lastReport),
|
|
});
|
|
const data = await res.json().catch(() => null);
|
|
ok = !!(res.ok && data?.ok);
|
|
if (ok && data?.file) this.lastSavedFile = data.file;
|
|
} catch {}
|
|
this.saving = false;
|
|
this.lastSaveOk = ok;
|
|
if (!ok)
|
|
console.warn("[LinkRouter Profiler] log save failed — restart ComfyUI so the backend endpoint loads?");
|
|
this.onChange?.();
|
|
return ok;
|
|
}
|
|
|
|
addEvent(event) {
|
|
if (!this.active || !this.session || this.session.events.length >= MAX_EVENTS) return;
|
|
this.session.events.push({ atMs: rounded(clock() - this.session.startedAt), ...event });
|
|
}
|
|
|
|
beginFrame(canvas) {
|
|
if (!this.active || !this.session) return null;
|
|
const scale = +(canvas?.ds?.scale || 1);
|
|
const offset = canvas?.ds?.offset || [0, 0];
|
|
const viewport = { scale, x: +offset[0] || 0, y: +offset[1] || 0 };
|
|
const last = this.session.lastViewport;
|
|
let phase = "idle";
|
|
if (last) {
|
|
if (Math.abs(scale - last.scale) > 0.0001) phase = "zoom";
|
|
else if (Math.abs(viewport.x - last.x) > 0.01 || Math.abs(viewport.y - last.y) > 0.01)
|
|
phase = "pan";
|
|
}
|
|
this.session.lastViewport = viewport;
|
|
const frame = {
|
|
startedAt: clock(),
|
|
atMs: rounded(clock() - this.session.startedAt),
|
|
phase,
|
|
scale: rounded(scale),
|
|
offset: [rounded(viewport.x), rounded(viewport.y)],
|
|
route: null,
|
|
batch: null,
|
|
};
|
|
this._currentFrame = frame;
|
|
return frame;
|
|
}
|
|
|
|
recordRoute(stats) {
|
|
if (!this.active || !this._currentFrame) return;
|
|
this._currentFrame.route = stats;
|
|
if (stats.graphRebuilt) {
|
|
this.addEvent({
|
|
kind: "graph-rebuild",
|
|
phase: this._currentFrame.phase,
|
|
durationMs: stats.durationMs,
|
|
positionChanges: stats.positionChanges || 0,
|
|
sizeChanges: stats.sizeChanges || 0,
|
|
newNodes: stats.newNodes || 0,
|
|
deletedNodes: stats.deletedNodes || 0,
|
|
reroutedLinks: stats.reroutedLinks || 0,
|
|
routerBuildMs: stats.buildMs || 0,
|
|
connectorMs: stats.connectorMs || 0,
|
|
connectorCalls: stats.connectorCalls || 0,
|
|
progressive: !!stats.progressive,
|
|
queuedLinks: stats.queuedLinks || 0,
|
|
batchRemaining: stats.batchRemaining || 0,
|
|
requestedDragMode: stats.requestedDragMode || null,
|
|
effectiveDragMode: stats.effectiveDragMode || null,
|
|
draggedNodes: stats.draggedNodes || 0,
|
|
draggedLinks: stats.draggedLinks || 0,
|
|
collisionLinks: stats.collisionLinks || 0,
|
|
activeLinks: stats.activeLinks || 0,
|
|
predictedMs: stats.predictedMs || 0,
|
|
hiddenDraggedLinks: stats.hiddenDraggedLinks || 0,
|
|
geometry: stats.geometry || [],
|
|
});
|
|
}
|
|
}
|
|
|
|
recordBatch(stats) {
|
|
if (this.active && this._currentFrame) this._currentFrame.batch = stats;
|
|
}
|
|
|
|
endFrame(frame, stats = {}) {
|
|
if (!frame || !this.active || !this.session) return;
|
|
frame.drawMs = rounded(clock() - frame.startedAt);
|
|
frame.links = stats.links || 0;
|
|
frame.strokeCalls = stats.strokeCalls || 0;
|
|
frame.batched = !!stats.batched;
|
|
if (stats.fallback) frame.fallback = true;
|
|
delete frame.startedAt;
|
|
if (this.session.samples.length < MAX_SAMPLES) this.session.samples.push(frame);
|
|
if (frame.drawMs >= 16)
|
|
this.addEvent({ kind: "slow-link-frame", phase: frame.phase, durationMs: frame.drawMs });
|
|
if (this._currentFrame === frame) this._currentFrame = null;
|
|
}
|
|
}
|
|
|
|
export const profiler = new LinkRouterProfiler();
|