Skip to content
Open
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
c1fed91
profiling first pass
dawoodkhan82 Feb 27, 2026
ca4ee69
Merge branch 'main' into frontend-profiling
dawoodkhan82 Feb 27, 2026
32d6053
Merge branch 'main' into frontend-profiling
dawoodkhan82 Feb 28, 2026
4baf7f1
Merge branch 'main' into frontend-profiling
dawoodkhan82 Mar 3, 2026
39e62d8
update workflow
dawoodkhan82 Mar 3, 2026
351f944
clean
dawoodkhan82 Mar 3, 2026
3ecd223
Merge branch 'main' into frontend-profiling
dawoodkhan82 Mar 3, 2026
08466aa
test fixes
dawoodkhan82 Mar 3, 2026
00f01d2
Merge branch 'main' into frontend-profiling
dawoodkhan82 Mar 4, 2026
55087b2
fix
dawoodkhan82 Mar 4, 2026
d018a6f
more updates
dawoodkhan82 Mar 5, 2026
1f9bb97
Merge branch 'main' into frontend-profiling
dawoodkhan82 Mar 5, 2026
41c2aff
update job
dawoodkhan82 Mar 5, 2026
411e37c
format
dawoodkhan82 Mar 5, 2026
2a64152
update workflow
dawoodkhan82 Mar 6, 2026
f198ff9
Merge branch 'main' into frontend-profiling
dawoodkhan82 Mar 6, 2026
0877ba6
cleanup
dawoodkhan82 Mar 6, 2026
dd7c7ce
print results
dawoodkhan82 Mar 6, 2026
68cba79
update workflow
dawoodkhan82 Mar 16, 2026
d315f90
new demo + test fixes
dawoodkhan82 Mar 16, 2026
586deaa
Merge branch 'main' into frontend-profiling
dawoodkhan82 Mar 16, 2026
4dd1d9a
format
dawoodkhan82 Mar 16, 2026
cdc53f7
workflow fix
dawoodkhan82 Mar 16, 2026
43ecb69
fix tests
dawoodkhan82 Mar 16, 2026
bd4f2ec
notebook fix
dawoodkhan82 Mar 17, 2026
2c59db9
workflow fix
dawoodkhan82 Mar 17, 2026
3a21339
nb fix
dawoodkhan82 Mar 17, 2026
6fc4c32
test
dawoodkhan82 Mar 17, 2026
ed3cb1f
Merge branch 'main' into frontend-profiling
dawoodkhan82 Mar 17, 2026
b7f9981
format
dawoodkhan82 Mar 17, 2026
f736ac7
Merge branch 'main' into frontend-profiling
freddyaboulton Mar 17, 2026
03a9aaf
import
dawoodkhan82 Mar 17, 2026
3380f35
add warmup iteration
dawoodkhan82 Mar 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 158 additions & 0 deletions .github/workflows/frontend_profiling.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
name: "frontend-profiling"

on:
pull_request:
types: [opened, synchronize, reopened]

concurrency:
group: "frontend-profiling-${{ github.event.pull_request.number }}"
cancel-in-progress: true

permissions: {}

jobs:
benchmark:
permissions:
contents: read
name: "frontend-benchmark"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

- uses: "gradio-app/gradio/.github/actions/changes@main"
id: changes
with:
filter: "functional"
token: ${{ secrets.GITHUB_TOKEN }}

- name: Find latest release tag
if: steps.changes.outputs.should_run == 'true'
id: latest_tag
run: echo "tag=$(git tag --list 'gradio@*' --sort=-v:refname | head -n 1)" >> "$GITHUB_OUTPUT"

- name: Checkout latest release
if: steps.changes.outputs.should_run == 'true'
run: git checkout ${{ steps.latest_tag.outputs.tag }}

- name: Install dependencies
if: steps.changes.outputs.should_run == 'true'
uses: "gradio-app/gradio/.github/actions/install-all-deps@main"
with:
python_version: "3.10"
os: "ubuntu-latest"

- name: Install Playwright
if: steps.changes.outputs.should_run == 'true'
run: pnpm exec playwright install chromium

- name: Checkout benchmark spec from PR
if: steps.changes.outputs.should_run == 'true'
run: git checkout ${{ github.event.pull_request.head.sha }} -- js/spa/test/tabs_performance.spec.ts

- name: Run benchmark (base)
if: steps.changes.outputs.should_run == 'true'
run: |
. venv/bin/activate
PERF_RESULTS_FILE=/tmp/bench_base.json pnpm exec playwright test \
--config .config/playwright.config.js \
js/spa/test/tabs_performance.spec.ts

- name: Checkout PR branch
if: steps.changes.outputs.should_run == 'true'
run: git checkout ${{ github.event.pull_request.head.sha }}

- name: Install and build PR
if: steps.changes.outputs.should_run == 'true'
run: |
. venv/bin/activate
uv pip install -e client/python
uv pip install -e ".[oauth,mcp]"
pnpm install --no-frozen-lockfile
pnpm build

- name: Run benchmark (PR)
if: steps.changes.outputs.should_run == 'true'
run: |
. venv/bin/activate
PERF_RESULTS_FILE=/tmp/bench_pr.json pnpm exec playwright test \
--config .config/playwright.config.js \
js/spa/test/tabs_performance.spec.ts

- name: Compare results
if: always() && steps.changes.outputs.should_run == 'true'
run: |
node -e "
const fs = require('fs');

let pr, base;
try {
pr = JSON.parse(fs.readFileSync('/tmp/bench_pr.json', 'utf8'));
base = JSON.parse(fs.readFileSync('/tmp/bench_base.json', 'utf8'));
} catch (e) {
console.warn('Could not read benchmark results:', e.message);
process.exit(0);
}

const metrics = [
{ name: 'DOM Content Loaded', key: 'dom_content_loaded_ms', unit: 'ms', warn: 0.25, fail: 0.50 },
{ name: 'Page Load', key: 'page_load_ms', unit: 'ms', warn: 0.25, fail: 0.50 },
{ name: 'LCP', key: 'lcp_ms', unit: 'ms', warn: 0.25, fail: 0.50 },
{ name: 'Tab Navigation', key: 'tab_nav_ms', unit: 'ms', warn: 0.25, fail: 0.50 },
{ name: 'JS Size', key: 'total_js_kb', unit: 'KB', warn: 0.10, fail: 0.25 },
{ name: 'CSS Size', key: 'total_css_kb', unit: 'KB', warn: 0.10, fail: 0.25 },
];

const pad = (s, n) => String(s).padEnd(n);
const rpad = (s, n) => String(s).padStart(n);

console.log('');
console.log('='.repeat(78));
console.log(' Frontend Performance Benchmark');
console.log(' Base: ${{ steps.latest_tag.outputs.tag }}');
console.log('='.repeat(78));
console.log('');
console.log(pad('Metric', 22) + rpad('Base', 12) + rpad('PR', 12) + rpad('Change', 12) + ' Status');
console.log('-'.repeat(78));

let shouldFail = false;
let md = '## Frontend Performance Benchmark\n\n';
md += '| Metric | Base | PR | Change | Status |\n';
md += '|--------|------|----|--------|--------|\n';

for (const m of metrics) {
const bv = base[m.key];
const pv = pr[m.key];
if (bv === undefined || pv === undefined) continue;
const pct = bv === 0 ? 0 : (pv - bv) / bv;
const pctStr = (pct * 100).toFixed(1) + '%';
const changeStr = (pct > 0 ? '+' : '') + pctStr;
const absDiff = pv - bv;
const minAbsDiff = m.unit === 'ms' ? 200 : 0;
let status = 'OK';
let mdStatus = '✅';
if (pct > m.fail && absDiff > minAbsDiff) { status = 'FAIL'; mdStatus = '❌ FAIL'; shouldFail = true; }
else if (pct > m.warn && absDiff > minAbsDiff) { status = 'WARNING'; mdStatus = '⚠️ WARNING'; }
console.log(pad(m.name, 22) + rpad(bv + ' ' + m.unit, 12) + rpad(pv + ' ' + m.unit, 12) + rpad(changeStr, 12) + ' ' + status);
md += '| ' + m.name + ' | ' + bv + ' ' + m.unit + ' | ' + pv + ' ' + m.unit + ' | ' + changeStr + ' | ' + mdStatus + ' |\n';
}

console.log('-'.repeat(78));
console.log(pad('JS Resources', 22) + rpad(base.js_resource_count || '-', 12) + rpad(pr.js_resource_count || '-', 12));
console.log(pad('CSS Resources', 22) + rpad(base.css_resource_count || '-', 12) + rpad(pr.css_resource_count || '-', 12));
console.log('');

md += '\n| JS Resources | ' + (base.js_resource_count || '-') + ' | ' + (pr.js_resource_count || '-') + ' | | |\n';
md += '| CSS Resources | ' + (base.css_resource_count || '-') + ' | ' + (pr.css_resource_count || '-') + ' | | |\n';
md += '\n> Base: \`${{ steps.latest_tag.outputs.tag }}\` | Thresholds: timing warns at +25%/fails at +50%, size warns at +10%/fails at +25%.\n';

fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, md);

if (shouldFail) {
console.log('❌ Frontend performance regression detected.');
process.exit(1);
} else {
console.log('✅ No performance regressions detected.');
}
"
122 changes: 122 additions & 0 deletions js/spa/test/tabs_performance.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { test as base, expect } from "@playwright/test";
import { launchGradioApp, killGradioApp } from "@self/tootils/app-launcher";
import fs from "fs";

const PERF_RESULTS_FILE =
process.env.PERF_RESULTS_FILE || "/tmp/perf_results.json";

const ITERATIONS = 5;

function median(values: number[]): number {
const sorted = [...values].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 !== 0
? sorted[mid]
: Math.round((sorted[mid - 1] + sorted[mid]) / 2);
}

const test = base.extend<{ perfPage: import("@playwright/test").Page }>({
perfPage: async ({ page }, use, testInfo) => {
const { port, process: appProcess } = await launchGradioApp(
"tabs",
testInfo.workerIndex,
60000
);

const url = `http://localhost:${port}`;

const resourceSizes = { js: 0, css: 0, jsCount: 0, cssCount: 0 };
const responseHandler = (response: any): void => {
const rUrl = response.url();
const headers = response.headers();
const bytes = parseInt(headers["content-length"] || "0", 10);
if (rUrl.endsWith(".js") || rUrl.endsWith(".mjs")) {
resourceSizes.js += bytes;
resourceSizes.jsCount++;
} else if (rUrl.endsWith(".css")) {
resourceSizes.css += bytes;
resourceSizes.cssCount++;
}
};

page.on("response", responseHandler);
await page.goto(url);
await page.waitForLoadState("networkidle");
page.removeListener("response", responseHandler);

const domContentLoadedValues: number[] = [];
const pageLoadValues: number[] = [];
const lcpValues: number[] = [];
const tabNavValues: number[] = [];

for (let i = 0; i < ITERATIONS; i++) {
await page.goto(url);
await page.waitForLoadState("networkidle");

await page.evaluate(() => {
(window as any).__lcpValue = 0;
new PerformanceObserver((list) => {
const entries = list.getEntries();
if (entries.length > 0) {
(window as any).__lcpValue = entries[entries.length - 1].startTime;
}
}).observe({ type: "largest-contentful-paint", buffered: true });
});

await page.waitForTimeout(500);

const timings = await page.evaluate(() => {
const nav = performance.getEntriesByType(
"navigation"
)[0] as PerformanceNavigationTiming;
return {
domContentLoaded: Math.round(nav.domContentLoadedEventEnd),
pageLoad: Math.round(nav.loadEventEnd),
lcp: Math.round((window as any).__lcpValue || 0)
};
});

domContentLoadedValues.push(timings.domContentLoaded);
pageLoadValues.push(timings.pageLoad);
lcpValues.push(timings.lcp);

await page.evaluate(() => performance.mark("nav-start"));
await page.click('button[data-tab-id="a2"]');
await page.locator("text=Text 2!").waitFor({ state: "visible" });
const navDuration = await page.evaluate(() => {
performance.mark("nav-end");
const m = performance.measure("tab-nav", "nav-start", "nav-end");
return Math.round(m.duration);
});
tabNavValues.push(navDuration);
}

const perfMetrics = {
dom_content_loaded_ms: median(domContentLoadedValues),
page_load_ms: median(pageLoadValues),
lcp_ms: median(lcpValues),
tab_nav_ms: median(tabNavValues),
total_js_kb: Math.round(resourceSizes.js / 1024),
total_css_kb: Math.round(resourceSizes.css / 1024),
js_resource_count: resourceSizes.jsCount,
css_resource_count: resourceSizes.cssCount
};

await page.evaluate(
(m) => ((window as any).__perfMetrics = m),
perfMetrics
);

await use(page);
killGradioApp(appProcess);
}
});

test("collect frontend performance metrics", async ({ perfPage: page }) => {
const metrics = await page.evaluate(() => (window as any).__perfMetrics);
fs.writeFileSync(PERF_RESULTS_FILE, JSON.stringify(metrics, null, 2));
expect(metrics.dom_content_loaded_ms).toBeGreaterThan(0);
expect(metrics.page_load_ms).toBeGreaterThan(0);
expect(metrics.tab_nav_ms).toBeGreaterThan(0);
expect(metrics.total_js_kb).toBeGreaterThan(0);
});
Loading