Files

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();