- perf/analysis: разбор trace (timeline_breakdown), CPU-профилей с source maps (cpuprofile_top), heap snapshots (heapdiff), циклов памяти, сравнения масштабов, ручной EXPLAIN вложенных запросов (explain_manual), API против X-DB-Time-Ms (api_split), сводка горячих точек; тесты ядра в perf/tests/test_analysis.py - perf/e2e/scenarios/hotspots.spec.ts: CPU-профиль, рендеры React, мутации DOM после фильтра, рост CSS-правил по циклам (вне run-all) - prod-сборка с source maps: PERF_SOURCEMAP=1, perf/stand.sh web-sourcemap (:8082) - perf/report-data/20261026-s1, 20261026-s2 (summary, sql, analysis, explain-manual), t5-types (иллюстрация CPU 6x) - design.md: решения T10b без обсуждения Refs #202 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
284 lines
16 KiB
TypeScript
284 lines
16 KiB
TypeScript
/**
|
||
* Профиль горячих точек (Task 10b): что именно работает на main thread во время одного действия.
|
||
* Не входит в run-all: это разбор, а не матрица замеров. Цифры времени - из scroll.spec (prod-сборка без профайлера).
|
||
*
|
||
* PERF_HOT_MODE=cpu CDP Profiler (сэмплы 200 мкс) на prod-сборке с source maps:
|
||
* perf/stand.sh web-sourcemap; PERF_WEB=http://localhost:8082
|
||
* -> artifacts/hot-<target>-<action>.cpuprofile, разбор - analysis/cpuprofile_top.py
|
||
* PERF_HOT_MODE=react счётчики рендеров React (lib/reactProfile.ts) на профилирующей сборке:
|
||
* perf/stand.sh web-profiling; PERF_WEB=http://localhost:8081
|
||
* PERF_HOT_TARGETS через запятую `<target>:<action>`; target как в scroll.spec без оси
|
||
* (FORM_2/AHR/null, project/LIMIT/1/2026, svod/svod1_MAIN, svod/svod2_GO, ...),
|
||
* action: y | x | filter (ввод «1» в поиск свода и наблюдение 30 с) |
|
||
* cssgrowth (циклы «лист -> скролл 40 шагов -> /tasks» без перезагрузки; после каждого цикла -
|
||
* число CSS-правил документа, в т.ч. @media, примеры последних @media-правил и main thread
|
||
* на шаг скролла этого цикла)
|
||
* PERF_HOT_CYCLES циклов для cssgrowth (по умолчанию 6)
|
||
* PERF_HOT_SHEET2 второй лист цикла cssgrowth (например FORM_4/STRUCTURE/null, как в memory.spec); счётчик
|
||
* window.matchMedia (вызовы, нетто addEventListener, топ мест вызова) пишется по циклам
|
||
* PERF_HOT_STEPS шагов колеса 200 px вперёд, затем столько же обратно (по умолчанию 40)
|
||
* PERF_HOT_FILTER_MS окно наблюдения после ввода фильтра (по умолчанию 30000)
|
||
* PERF_HOT_ROLE роль (по умолчанию rf для листов и отчётов, dfip для сводов)
|
||
*
|
||
* Запись: results/<run_id>/hotspot.jsonl, scenario hotspot_cpu | hotspot_react; метрики main_thread_ms, steps;
|
||
* react - renders_top (топ-30 по self_ms), commits, total_renders; filter - хронология мутаций DOM контейнера
|
||
* (по секундам), самая длинная серия кадров без мутаций, примеры изменяемых узлов/атрибутов.
|
||
*/
|
||
import { test, type Page } from '@playwright/test';
|
||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||
import path from 'node:path';
|
||
import { loadManifest, scale } from '../lib/manifest';
|
||
import { loginAs, WEB } from '../lib/auth';
|
||
import { mainThreadMs } from '../lib/cdp';
|
||
import { REACT_HOOK_SCRIPT, profileStart, profileStop } from '../lib/reactProfile';
|
||
import { append, runDir } from '../lib/results';
|
||
|
||
const CONTAINER = '.MuiTableContainer-root';
|
||
const MODE = (process.env.PERF_HOT_MODE ?? 'cpu') as 'cpu' | 'react';
|
||
const STEPS = Number(process.env.PERF_HOT_STEPS ?? 40);
|
||
const FILTER_MS = Number(process.env.PERF_HOT_FILTER_MS ?? 30_000);
|
||
const LOAD_TIMEOUT = Number(process.env.PERF_LOAD_TIMEOUT_MS ?? 900_000);
|
||
const TARGETS = (process.env.PERF_HOT_TARGETS ?? 'FORM_2/AHR/null:y').split(',').map((s) => s.trim()).filter(Boolean);
|
||
|
||
interface Resolved { name: string; url: string; kind: 'sheet' | 'project' | 'svod1' | 'svod2' | 'summaries'; tabIndex?: number }
|
||
|
||
function resolve(name: string): Resolved {
|
||
const m = loadManifest();
|
||
const sh = m.sheets.find((s) => s.owner === 'rf' && `${s.form_type}/${s.sheet}/${s.direction ?? 'null'}` === name);
|
||
if (sh) return { name, url: sh.url, kind: 'sheet' };
|
||
const pm = /^project\/(\w+)\/(\d+)\/(\d+)$/.exec(name);
|
||
if (pm) {
|
||
const p = m.project_reports.find((r) => r.report_type === pm[1] && r.project_id === Number(pm[2]) && r.year === Number(pm[3]));
|
||
if (p) return { name, url: p.url, kind: 'project' };
|
||
}
|
||
const s1 = ['MAIN', 'FORM_1', 'FORM_2', 'FORM_3', 'FORM_4'].indexOf(name.replace('svod/svod1_', ''));
|
||
if (name.startsWith('svod/svod1_') && s1 >= 0) return { name, url: '/svod', kind: 'svod1', tabIndex: s1 };
|
||
const s2 = ['GO', 'RF'].indexOf(name.replace('svod/svod2_', ''));
|
||
if (name.startsWith('svod/svod2_') && s2 >= 0) return { name, url: '/svod/2', kind: 'svod2', tabIndex: s2 };
|
||
if (name === 'svod/projects_summaries') return { name, url: '/projects/summaries', kind: 'summaries' };
|
||
throw new Error(`неизвестная цель ${name}`);
|
||
}
|
||
|
||
const raf = () => new Promise<void>((r) => requestAnimationFrame(() => r()));
|
||
|
||
async function waitRows(page: Page) {
|
||
const t0 = Date.now();
|
||
for (;;) {
|
||
const n = await page.evaluate((sel) => document.querySelectorAll(`${sel} tbody tr`).length, CONTAINER);
|
||
if (n >= 20) return n;
|
||
if (Date.now() - t0 > LOAD_TIMEOUT) throw new Error(`rows=${n} после ${LOAD_TIMEOUT} мс`);
|
||
await page.waitForTimeout(500);
|
||
}
|
||
}
|
||
|
||
async function wheelSteps(page: Page, axis: 'y' | 'x', n: number): Promise<number> {
|
||
const box = await page.locator(CONTAINER).first().boundingBox();
|
||
if (!box) throw new Error('нет контейнера');
|
||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||
let steps = 0;
|
||
for (const dir of [1, -1]) {
|
||
for (let i = 0; i < n; i++) {
|
||
if (axis === 'y') await page.mouse.wheel(0, 200 * dir); else await page.mouse.wheel(200 * dir, 0);
|
||
await page.evaluate(raf);
|
||
steps++;
|
||
}
|
||
}
|
||
return steps;
|
||
}
|
||
|
||
/** Наблюдение за DOM после ввода фильтра: мутации по секундам, кадры без мутаций, примеры узлов. */
|
||
const ARM_MUTATIONS = (sel: string) => {
|
||
const root = (document.querySelector(sel) ?? document.body) as HTMLElement;
|
||
const st: any = { t0: performance.now(), perSec: {} as Record<number, number>, frames: [] as number[], samples: {} as Record<string, number>, longtasks: [] as number[][], dirty: 0 };
|
||
const desc = (n: Node | null): string => {
|
||
if (!n) return '?';
|
||
const el = (n.nodeType === 1 ? n : n.parentElement) as HTMLElement | null;
|
||
if (!el) return '#text';
|
||
const cls = (typeof el.className === 'string' ? el.className : '').split(/\s+/).filter((c) => c && !/^css-/.test(c)).slice(0, 2).join('.');
|
||
return `${el.tagName.toLowerCase()}${cls ? '.' + cls : ''}`;
|
||
};
|
||
st.mo = new MutationObserver((list) => {
|
||
const sec = Math.floor((performance.now() - st.t0) / 1000);
|
||
st.perSec[sec] = (st.perSec[sec] ?? 0) + list.length;
|
||
st.dirty += list.length;
|
||
for (const m of list) {
|
||
const k = `${m.type}:${desc(m.target)}${m.attributeName ? '@' + m.attributeName : ''}`;
|
||
st.samples[k] = (st.samples[k] ?? 0) + 1;
|
||
}
|
||
});
|
||
st.mo.observe(root, { childList: true, subtree: true, characterData: true, attributes: true });
|
||
try {
|
||
st.po = new PerformanceObserver((l) => { for (const e of l.getEntries()) st.longtasks.push([Math.round(e.startTime - st.t0), Math.round(e.duration)]); });
|
||
st.po.observe({ type: 'longtask', buffered: false });
|
||
} catch { /* ignore */ }
|
||
const tick = () => { st.frames.push(st.dirty); st.dirty = 0; if (!st.stop) requestAnimationFrame(tick); };
|
||
requestAnimationFrame(tick);
|
||
(window as any).__mut = st;
|
||
};
|
||
|
||
const COLLECT_MUTATIONS = () => {
|
||
const st = (window as any).__mut;
|
||
st.stop = true; st.mo.disconnect(); st.po?.disconnect();
|
||
let run = 0, best = 0, firstMut = -1, quietPairs = 0;
|
||
st.frames.forEach((d: number, i: number) => {
|
||
if (d > 0 && firstMut < 0) firstMut = i;
|
||
if (d === 0) { run++; if (run === 2 && firstMut >= 0) quietPairs++; } else run = 0;
|
||
if (firstMut >= 0) best = Math.max(best, run);
|
||
});
|
||
const top = Object.entries(st.samples as Record<string, number>).sort((a, b) => b[1] - a[1]).slice(0, 15);
|
||
return {
|
||
per_sec: st.perSec, frames: st.frames.length, frames_with_mutation: st.frames.filter((d: number) => d > 0).length,
|
||
first_mutation_frame: firstMut, max_quiet_run_after_first: best, quiet_pairs_after_first: quietPairs,
|
||
mutation_kinds_top: top, long_tasks: st.longtasks.slice(0, 50), long_task_n: st.longtasks.length,
|
||
};
|
||
};
|
||
|
||
const CSS_STATS = () => {
|
||
let rules = 0, media = 0;
|
||
const lastMedia: string[] = [];
|
||
const walk = (list: CSSRuleList) => {
|
||
for (const r of Array.from(list)) {
|
||
rules++;
|
||
if (r instanceof CSSMediaRule) { media++; lastMedia.push(r.cssText.slice(0, 220)); if (lastMedia.length > 400) lastMedia.shift(); }
|
||
const inner = (r as any).cssRules as CSSRuleList | undefined;
|
||
if (inner && !(r instanceof CSSMediaRule)) walk(inner);
|
||
}
|
||
};
|
||
for (const sh of Array.from(document.styleSheets)) { try { walk(sh.cssRules); } catch { /* cross-origin */ } }
|
||
// уникальные «шаблоны» последних @media-правил (класс css-<hash> заменён на css-*)
|
||
const shapes: Record<string, number> = {};
|
||
for (const t of lastMedia) { const k = t.replace(/css-[\w-]+/g, 'css-*').replace(/\d+(\.\d+)?px/g, 'Npx'); shapes[k] = (shapes[k] ?? 0) + 1; }
|
||
return { css_rules: rules, css_media_rules: media, style_elements: document.querySelectorAll('style').length,
|
||
media_shapes_last400: Object.entries(shapes).sort((a, b) => b[1] - a[1]).slice(0, 8) };
|
||
};
|
||
|
||
/** Счётчик window.matchMedia (каждый вызов создаёт MediaQueryList/MediaQuerySet в Blink) и топ мест вызова. */
|
||
const MATCHMEDIA_SCRIPT = `(() => {
|
||
if (window.__mm) return;
|
||
const st = { calls: 0, listeners: 0, stacks: {} };
|
||
const orig = window.matchMedia.bind(window);
|
||
window.matchMedia = (q) => {
|
||
st.calls++;
|
||
const fr = (new Error().stack || '').split('\\n').slice(2, 5).map((l) => l.trim().replace(/https?:\\/\\/[^/]+\\//, '')).join(' < ');
|
||
const k = q + ' @ ' + fr;
|
||
st.stacks[k] = (st.stacks[k] || 0) + 1;
|
||
const mql = orig(q);
|
||
const add = mql.addEventListener.bind(mql);
|
||
mql.addEventListener = (...a) => { st.listeners++; return add(...a); };
|
||
const rem = mql.removeEventListener.bind(mql);
|
||
mql.removeEventListener = (...a) => { st.listeners--; return rem(...a); };
|
||
return mql;
|
||
};
|
||
window.__mm = st;
|
||
})();`;
|
||
|
||
const MM_STATS = () => {
|
||
const st = (window as any).__mm;
|
||
if (!st) return {};
|
||
const top = Object.entries(st.stacks as Record<string, number>).sort((a, b) => b[1] - a[1]).slice(0, 6);
|
||
return { matchmedia_calls: st.calls, matchmedia_listeners_net: st.listeners, matchmedia_top: top };
|
||
};
|
||
|
||
async function spaGo(page: Page, url: string): Promise<void> {
|
||
await page.evaluate((u) => { window.history.pushState({}, '', u); window.dispatchEvent(new PopStateEvent('popstate', { state: {} })); }, url);
|
||
}
|
||
|
||
const safe = (s: string) => s.replace(/[^\w.-]+/g, '_');
|
||
|
||
for (const spec of TARGETS) {
|
||
const [name, action = 'y'] = spec.split(':');
|
||
test(`hotspot ${MODE} ${name} ${action}`, async ({ browser }) => {
|
||
test.setTimeout(LOAD_TIMEOUT + 30 * 60_000);
|
||
const t = resolve(name);
|
||
const m = loadManifest();
|
||
const role = process.env.PERF_HOT_ROLE ?? (t.kind === 'sheet' || t.kind === 'project' ? 'rf' : 'dfip');
|
||
const ctx = await browser.newContext({ viewport: { width: 1600, height: 900 } });
|
||
try {
|
||
await loginAs(ctx, m.users[role]);
|
||
if (MODE === 'react') await ctx.addInitScript(REACT_HOOK_SCRIPT);
|
||
if (action === 'cssgrowth') await ctx.addInitScript(MATCHMEDIA_SCRIPT);
|
||
const page = await ctx.newPage();
|
||
await page.goto(WEB + t.url);
|
||
if (t.tabIndex) await page.getByRole('tab').nth(t.tabIndex).click();
|
||
if (t.kind === 'svod2') {
|
||
await page.getByLabel('ССП/РФ').click();
|
||
await page.getByRole('option', { name: 'Все ССП/РФ' }).click();
|
||
await page.keyboard.press('Escape');
|
||
await page.getByRole('button', { name: 'Загрузить' }).click();
|
||
}
|
||
const rows = await waitRows(page);
|
||
await page.waitForTimeout(1500);
|
||
const cdp = await ctx.newCDPSession(page);
|
||
const profiling = action !== 'cssgrowth'; // cssgrowth - долгие циклы, профиль не нужен
|
||
if (!profiling) { /* только счётчики CSS */ } else if (MODE === 'cpu') {
|
||
await cdp.send('Profiler.enable');
|
||
await cdp.send('Profiler.setSamplingInterval', { interval: 200 });
|
||
await cdp.send('Profiler.start');
|
||
} else {
|
||
await profileStart(page);
|
||
}
|
||
const mt0 = await mainThreadMs(page);
|
||
let steps = 0;
|
||
let mutations: any;
|
||
const metricsExtra: Record<string, unknown> = {};
|
||
if (action === 'cssgrowth') {
|
||
const sheet2 = process.env.PERF_HOT_SHEET2 ? resolve(process.env.PERF_HOT_SHEET2) : null;
|
||
const cycles: any[] = [{ cycle: 0, ...(await page.evaluate(CSS_STATS)), ...(await page.evaluate(MM_STATS)) }];
|
||
for (let c = 1; c <= Number(process.env.PERF_HOT_CYCLES ?? 6); c++) {
|
||
const c0 = await mainThreadMs(page);
|
||
const st = await wheelSteps(page, 'y', STEPS);
|
||
const scroll_ms_per_step = (await mainThreadMs(page) - c0) / st;
|
||
steps += st;
|
||
await spaGo(page, '/tasks');
|
||
await page.waitForFunction(() => location.pathname === '/tasks', null, { timeout: 60_000 });
|
||
await page.waitForTimeout(1000);
|
||
if (sheet2) {
|
||
// второй лист цикла, как в memory.spec (FORM_4/STRUCTURE): открыть, прокрутить, уйти на /tasks
|
||
await spaGo(page, sheet2.url);
|
||
await waitRows(page);
|
||
await wheelSteps(page, 'y', STEPS);
|
||
await spaGo(page, '/tasks');
|
||
await page.waitForFunction(() => location.pathname === '/tasks', null, { timeout: 60_000 });
|
||
await page.waitForTimeout(1000);
|
||
}
|
||
await spaGo(page, t.url);
|
||
await waitRows(page);
|
||
await page.waitForTimeout(1000);
|
||
cycles.push({ cycle: c, scroll_ms_per_step: Math.round(scroll_ms_per_step * 10) / 10, ...(await page.evaluate(CSS_STATS)), ...(await page.evaluate(MM_STATS)) });
|
||
}
|
||
mutations = undefined;
|
||
(metricsExtra as any).css_cycles = cycles;
|
||
} else if (action === 'filter') {
|
||
await page.evaluate(ARM_MUTATIONS, CONTAINER);
|
||
const input = page.locator('.MuiInputBase-root:has(.MuiInputAdornment-positionStart) input').first();
|
||
await input.fill('1');
|
||
await page.waitForTimeout(FILTER_MS);
|
||
mutations = await page.evaluate(COLLECT_MUTATIONS);
|
||
} else {
|
||
steps = await wheelSteps(page, action as 'y' | 'x', STEPS);
|
||
}
|
||
const mt1 = await mainThreadMs(page);
|
||
const metrics: Record<string, unknown> = { main_thread_ms: mt1 - mt0, steps, rows_visible_before: rows };
|
||
if (steps) metrics.main_thread_ms_per_step = (mt1 - mt0) / steps;
|
||
const artifacts: string[] = [];
|
||
if (!profiling) { /* нет профиля */ } else if (MODE === 'cpu') {
|
||
const { profile } = (await cdp.send('Profiler.stop')) as any;
|
||
const file = path.join(runDir(), 'artifacts', `hot-${safe(name)}-${action}.cpuprofile`);
|
||
mkdirSync(path.dirname(file), { recursive: true });
|
||
writeFileSync(file, JSON.stringify(profile));
|
||
artifacts.push(path.relative(runDir(), file));
|
||
} else {
|
||
const p = await profileStop(page, 30);
|
||
Object.assign(metrics, { commits: p.commits, total_renders: p.total_renders, total_self_ms: p.total_self_ms,
|
||
renders_per_step: steps ? p.total_renders / steps : undefined, commits_per_step: steps ? p.commits / steps : undefined });
|
||
metrics.renders_top = p.top as unknown as number; // нечисловое - aggregate игнорирует
|
||
}
|
||
if (mutations) metrics.mutations = mutations;
|
||
Object.assign(metrics, metricsExtra);
|
||
append({ scenario: `hotspot_${MODE}`, target: `${name}:${action}`, role, scale: scale(), cpu_rate: 1, repeat: 0, metrics, artifacts, incomplete: false } as any);
|
||
} finally {
|
||
await ctx.close();
|
||
}
|
||
});
|
||
}
|