Andrey Epifancev 8e9f53a478 perf: скрипты разбора и сводные данные прогонов 20261026
- 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>
2026-10-26 12:43:46 +04:00

284 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Профиль горячих точек (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();
}
});
}