Compare commits

...

4 Commits

Author SHA1 Message Date
Christian Byrne
c8cfd9091d fix: guard against zero-duration frames producing Infinity FPS
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/10003#discussion_r2938279729
2026-03-27 00:15:39 -07:00
bymyself
f50558879e fix: address CodeRabbit review — timeout failsafe, percentile interpolation, consistent sample filtering 2026-03-15 22:39:34 -07:00
GitHub Action
d255cc4471 [automated] Apply ESLint and Oxfmt fixes 2026-03-16 05:07:15 +00:00
bymyself
4f68bf4361 feat: add FPS percentile measurement and M2 scoreboard to perf reports
- Add measureFPS(durationMs) to PerformanceHelper: RAF-loop FPS collection
  with P5/P50/P95 percentile computation
- Add fpsP5/fpsP50/fpsMean optional fields to PerfMeasurement interface
- Large-graph-idle test now records FPS percentiles via 3s measurement window
- Add renderM2Scoreboard() to perf-report.ts with 3 threshold metrics:
  - P5 FPS ≥52 (primary — captures jank)
  - TBT ≤200ms (idle blocking time)
  - Frame Duration ≤20ms (sustained frame rate floor)
- Scoreboard renders PASS/FAIL at top of every PR performance comment

M2 milestone primary metric: P5 ≥52 FPS on 245-node workflow
2026-03-15 22:04:03 -07:00
3 changed files with 149 additions and 1 deletions

View File

@@ -14,6 +14,14 @@ interface PerfSnapshot {
JSEventListeners: number
}
export interface FPSResult {
p5: number
p50: number
p95: number
mean: number
frameCount: number
}
export interface PerfMeasurement {
name: string
durationMs: number
@@ -29,6 +37,9 @@ export interface PerfMeasurement {
eventListeners: number
totalBlockingTimeMs: number
frameDurationMs: number
fpsP5?: number
fpsP50?: number
fpsMean?: number
}
export class PerformanceHelper {
@@ -129,6 +140,63 @@ export class PerformanceHelper {
}, sampleFrames)
}
async measureFPS(durationMs: number): Promise<FPSResult> {
return this.page.evaluate((duration) => {
return new Promise<FPSResult>((resolve) => {
const frameDurations: number[] = []
let lastTime = 0
const start = performance.now()
let settled = false
const timeout = setTimeout(() => {
if (settled) return
settled = true
resolve({ p5: 0, p50: 0, p95: 0, mean: 0, frameCount: 0 })
}, duration + 5000)
function tick(now: number) {
if (settled) return
if (lastTime > 0) frameDurations.push(now - lastTime)
lastTime = now
if (now - start < duration) {
requestAnimationFrame(tick)
} else {
settled = true
clearTimeout(timeout)
if (frameDurations.length === 0) {
resolve({ p5: 0, p50: 0, p95: 0, mean: 0, frameCount: 0 })
return
}
const fps = frameDurations
.map((d) => 1000 / d)
.filter((f) => Number.isFinite(f))
fps.sort((a, b) => a - b)
if (fps.length === 0) {
resolve({ p5: 0, p50: 0, p95: 0, mean: 0, frameCount: 0 })
return
}
function percentile(p: number): number {
const rank = p * (fps.length - 1)
const lo = Math.floor(rank)
const hi = Math.ceil(rank)
const weight = rank - lo
const loVal = fps[lo] ?? 0
const hiVal = fps[hi] ?? loVal
return loVal + (hiVal - loVal) * weight
}
resolve({
p5: percentile(0.05),
p50: percentile(0.5),
p95: percentile(0.95),
mean: fps.reduce((a, b) => a + b, 0) / fps.length,
frameCount: fps.length
})
}
}
requestAnimationFrame(tick)
})
}, durationMs)
}
async startMeasuring(): Promise<void> {
if (this.snapshot) {
throw new Error(

View File

@@ -120,9 +120,16 @@ test.describe('Performance', { tag: ['@perf'] }, () => {
}
const m = await comfyPage.perf.stopMeasuring('large-graph-idle')
// M2 milestone metric: P5 FPS on 245-node workflow (target: ≥52)
const fps = await comfyPage.perf.measureFPS(3000)
m.fpsP5 = fps.p5
m.fpsP50 = fps.p50
m.fpsMean = fps.mean
recordMeasurement(m)
console.log(
`Large graph idle: ${m.styleRecalcs} style recalcs, ${m.layouts} layouts`
`Large graph idle: ${m.styleRecalcs} recalcs, ${m.layouts} layouts | FPS P5=${fps.p5.toFixed(0)} P50=${fps.p50.toFixed(0)} mean=${fps.mean.toFixed(0)} (${fps.frameCount} frames)`
)
})

View File

@@ -25,8 +25,16 @@ interface PerfMeasurement {
eventListeners: number
totalBlockingTimeMs: number
frameDurationMs: number
fpsP5?: number
fpsP50?: number
fpsMean?: number
}
const M2_TARGET_FPS = 52
const M2_TARGET_TBT_MS = 200
const M2_TARGET_FRAME_DURATION_MS = 20
const M2_TEST_NAME = 'large-graph-idle'
interface PerfReport {
timestamp: string
gitSha: string
@@ -138,6 +146,69 @@ function formatBytes(bytes: number): string {
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
function renderM2Scoreboard(
prGroups: Map<string, PerfMeasurement[]>
): string[] {
const samples = prGroups.get(M2_TEST_NAME)
if (!samples?.length) return []
const scoredSamples = samples.filter(
(s): s is PerfMeasurement & { fpsP5: number } => Number.isFinite(s.fpsP5)
)
if (scoredSamples.length === 0) return []
const fpsValues = scoredSamples.map((s) => s.fpsP5)
const avgP5 = fpsValues.reduce((a, b) => a + b, 0) / fpsValues.length
const passed = avgP5 >= M2_TARGET_FPS
const icon = passed ? '✅' : '🔴'
const p50Values = scoredSamples
.map((s) => s.fpsP50)
.filter((v): v is number => Number.isFinite(v))
const avgP50 =
p50Values.length > 0
? p50Values.reduce((a, b) => a + b, 0) / p50Values.length
: null
const meanValues = scoredSamples
.map((s) => s.fpsMean)
.filter((v): v is number => Number.isFinite(v))
const avgMean =
meanValues.length > 0
? meanValues.reduce((a, b) => a + b, 0) / meanValues.length
: null
const tbtValues = scoredSamples.map((s) => s.totalBlockingTimeMs)
const avgTBT = tbtValues.reduce((a, b) => a + b, 0) / tbtValues.length
const tbtPassed = avgTBT <= M2_TARGET_TBT_MS
const tbtIcon = tbtPassed ? '✅' : '🔴'
const fdValues = scoredSamples.map((s) => s.frameDurationMs)
const avgFD = fdValues.reduce((a, b) => a + b, 0) / fdValues.length
const fdPassed = avgFD <= M2_TARGET_FRAME_DURATION_MS
const fdIcon = fdPassed ? '✅' : '🔴'
const allPassed = passed && tbtPassed && fdPassed
const lines: string[] = [
`### ${allPassed ? '✅' : '🔴'} M2 Perf Target — 245-node workflow`,
'',
`| Metric | Value | Target | Status |`,
`|--------|-------|--------|--------|`,
`| **P5 FPS** | **${avgP5.toFixed(0)}** | ≥${M2_TARGET_FPS} | ${icon} ${passed ? 'PASS' : 'FAIL'} |`,
`| **TBT** | **${avgTBT.toFixed(0)}ms** | ≤${M2_TARGET_TBT_MS}ms | ${tbtIcon} ${tbtPassed ? 'PASS' : 'FAIL'} |`,
`| **Frame Duration** | **${avgFD.toFixed(1)}ms** | ≤${M2_TARGET_FRAME_DURATION_MS}ms | ${fdIcon} ${fdPassed ? 'PASS' : 'FAIL'} |`
]
if (avgP50 !== null) lines.push(`| P50 FPS | ${avgP50.toFixed(0)} | — | — |`)
if (avgMean !== null)
lines.push(`| Mean FPS | ${avgMean.toFixed(0)} | — | — |`)
lines.push(
`| Samples | ${scoredSamples.length} | — | — |`,
'',
`> Legacy baseline: ~60 FPS idle, ~70 FPS zoom. Target = <25% regression.`,
''
)
return lines
}
function renderFullReport(
prGroups: Map<string, PerfMeasurement[]>,
baseline: PerfReport,
@@ -329,6 +400,8 @@ function main() {
const lines: string[] = []
lines.push('## ⚡ Performance Report\n')
lines.push(...renderM2Scoreboard(prGroups))
if (baseline && historical.length >= 2) {
lines.push(...renderFullReport(prGroups, baseline, historical))
} else if (baseline) {