diff --git a/demo/performance/README.md b/demo/performance/README.md index c318a6e4..0b93832a 100644 --- a/demo/performance/README.md +++ b/demo/performance/README.md @@ -99,6 +99,22 @@ still below the known failed pre-optimization result (3501.5 ms). This aggregate is a catastrophic guard; the full workflow's base-relative comparison remains the detector for smaller regressions. +The isometric `spaceSwitchMs` ceiling is 2200 ms, shared by the smoke, the full +isometric profile and its Stage 3 dense twin (#675). The metric is one cold +floor switch that builds the second floor's 2.5D geometry, so it grows with +every 2.5D stage, and the original 1800 ms from #89 stage 1 had become the level +itself. The hosted-runner smoke median rose from 1500 ms (2026-09-09 to 09-12, +12 runs) to 1668 ms (09-19 to 09-25, 6 runs) and 1798 ms after #649 (9 runs, σ +42 ms, maximum 1867.7 ms). The median inside a run is already stable (re-run +attempts of one SHA differ by 0.4 %), separate runs of one SHA differ by up to +5.5 %, and the level itself moved by 20 %: more samples would not change the +verdict, so the ceiling is the lever. 2200 ms leaves 17.8 % over that maximum +and stays below the only real isometric regression of the window, 2719.6 ms +(#583 before its lattice fix, 2026-09-16); doubling today's level fails by a +wide margin. Occasional hosted runners are about a quarter faster, which a +ceiling does not mind. Smaller growth stays the job of the full workflow's +base-relative comparison. + The dedicated `performance.yml` workflow is the full comparison. It runs on every `main` promotion, weekly and on manual dispatch for an important beta or performance-sensitive change. It checks out the candidate and its base SHA, diff --git a/demo/performance/budgets-isometric-smoke.json b/demo/performance/budgets-isometric-smoke.json index bf79fb33..31b1913d 100644 --- a/demo/performance/budgets-isometric-smoke.json +++ b/demo/performance/budgets-isometric-smoke.json @@ -17,7 +17,7 @@ }, "spaceSwitchMs": { "stat": "median", - "hardMaxMs": 1800 + "hardMaxMs": 2200 }, "stateUpdateMs": { "stat": "median", diff --git a/demo/performance/budgets-isometric-stage3-dense.json b/demo/performance/budgets-isometric-stage3-dense.json index 65264d4b..d3d92b5b 100644 --- a/demo/performance/budgets-isometric-stage3-dense.json +++ b/demo/performance/budgets-isometric-stage3-dense.json @@ -8,7 +8,7 @@ "viewToggleMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 100, "hardMaxMs": 1500 }, "openingUpdateMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 75, "hardMaxMs": 1000 }, "overlayInteractionMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 75, "hardMaxMs": 1000 }, - "spaceSwitchMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 100, "hardMaxMs": 1800 }, + "spaceSwitchMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 100, "hardMaxMs": 2200 }, "stateUpdateMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 75, "hardMaxMs": 1000 }, "resizePreviewMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 150, "hardMaxMs": 2200 }, "panZoomMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 60, "hardMaxMs": 600 }, diff --git a/demo/performance/budgets-large-house-isometric.json b/demo/performance/budgets-large-house-isometric.json index 668ae0fb..d4c34409 100644 --- a/demo/performance/budgets-large-house-isometric.json +++ b/demo/performance/budgets-large-house-isometric.json @@ -6,7 +6,7 @@ "modelReadyMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 200, "hardMaxMs": 3000 }, "firstStableRenderMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 250, "hardMaxMs": 3500 }, "viewToggleMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 100, "hardMaxMs": 1500 }, - "spaceSwitchMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 100, "hardMaxMs": 1800 }, + "spaceSwitchMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 100, "hardMaxMs": 2200 }, "stateUpdateMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 75, "hardMaxMs": 1000 }, "resizePreviewMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 150, "hardMaxMs": 2200 }, "panZoomMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 60, "hardMaxMs": 600 }, diff --git a/test/performance-budget.test.mjs b/test/performance-budget.test.mjs index e2ca2a46..d44b6b50 100644 --- a/test/performance-budget.test.mjs +++ b/test/performance-budget.test.mjs @@ -188,6 +188,33 @@ import { readFileSync } from 'node:fs'; const readBudget = (name) => JSON.parse(readFileSync(new URL(`../demo/performance/${name}`, import.meta.url), 'utf8')); +// Синтетический отчёт для `compare --absolute-only`: все метрики по 1 мс, +// кроме переданных в `timings`. +const absoluteSmokeReport = (smoke, timings = {}) => ({ + schema: 2, profile: smoke.profile, sourceSha: '1'.repeat(40), buildFingerprint: 'fixture', + runtime: { node: 'v22.0.0', chromium: '1.2.3', platform: 'linux', arch: 'x64' }, + fixture: { rooms: 60 }, + summary: Object.fromEntries(Object.keys(smoke.timings).map((metric) => { + const value = timings[metric] ?? 1; + return [metric, { median: value, p95: value, min: value, max: value }]; + })), + longTasks: { maxSingleMs: 1, countP95: 1, totalP95Ms: 1 }, + ...(smoke.profile === 'large-house-isometric-v1' ? { effectiveProjection: ['iso'] } : {}), + rows: [0, 1, 2].map(() => ({ + heapGrowthBytes: 1, preciseGc: true, + longTasks: Object.fromEntries(['load', ...Object.keys(smoke.longTaskWindows ?? {})] + .map((name) => [name, { supported: true, count: 1, maxMs: 1, totalMs: 1 }])), + cacheEntries: { ...smoke.cacheEntries }, + cacheGrowth: Object.fromEntries(Object.keys(smoke.cacheGrowth).map((key) => [key, 0])), + renderedDevices: smoke.renderedDevices, + ...(smoke.profile === 'large-house-isometric-v1' + ? { + effectiveProjection: 'iso', + isoStructuralBuilds: { supported: true, initial: 1, beforeHaUpdate: 2, afterHaUpdate: 2, haUpdateDelta: 0 }, + } : {}), + })), +}); + for (const [smokeName, fullName] of [ ['budgets-isometric-smoke.json', 'budgets-large-house-isometric.json'], ['budgets-interaction-smoke.json', 'budgets-large-house-interaction.json'], @@ -214,33 +241,11 @@ for (const [smokeName, fullName] of [ // Пригодность для --absolute-only: синтетический отчёт под потолками // проходит, первый кадр как у de215578 (9 870 мс) — красный. - const build = (firstFrame) => ({ - schema: 2, profile: smoke.profile, sourceSha: '1'.repeat(40), buildFingerprint: 'fixture', - runtime: { node: 'v22.0.0', chromium: '1.2.3', platform: 'linux', arch: 'x64' }, - fixture: { rooms: 60 }, - summary: Object.fromEntries(Object.keys(smoke.timings).map((metric) => { - const value = metric === 'firstStableRenderMs' ? firstFrame : 1; - return [metric, { median: value, p95: value, min: value, max: value }]; - })), - longTasks: { maxSingleMs: 1, countP95: 1, totalP95Ms: 1 }, - ...(smoke.profile === 'large-house-isometric-v1' ? { effectiveProjection: ['iso'] } : {}), - rows: [0, 1, 2].map(() => ({ - heapGrowthBytes: 1, preciseGc: true, - longTasks: Object.fromEntries(['load', ...Object.keys(smoke.longTaskWindows ?? {})] - .map((name) => [name, { supported: true, count: 1, maxMs: 1, totalMs: 1 }])), - cacheEntries: { ...smoke.cacheEntries }, - cacheGrowth: Object.fromEntries(Object.keys(smoke.cacheGrowth).map((key) => [key, 0])), - renderedDevices: smoke.renderedDevices, - ...(smoke.profile === 'large-house-isometric-v1' - ? { - effectiveProjection: 'iso', - isoStructuralBuilds: { supported: true, initial: 1, beforeHaUpdate: 2, afterHaUpdate: 2, haUpdateDelta: 0 }, - } : {}), - })), - }); - const ok = evaluatePerformanceBudget({ candidate: build(1), budgets: smoke, absoluteOnly: true }); + const ok = evaluatePerformanceBudget({ candidate: absoluteSmokeReport(smoke), budgets: smoke, absoluteOnly: true }); assert.deepEqual(ok.failures, [], 'отчёт под потолками обязан проходить'); - const regressed = evaluatePerformanceBudget({ candidate: build(9870), budgets: smoke, absoluteOnly: true }); + const regressed = evaluatePerformanceBudget({ + candidate: absoluteSmokeReport(smoke, { firstStableRenderMs: 9870 }), budgets: smoke, absoluteOnly: true, + }); assert.ok(regressed.failures.some((check) => check.id === 'timing.firstStableRenderMs.median'), 'первый кадр 9 870 мс обязан краснеть'); }); @@ -257,6 +262,33 @@ test('interaction aggregate keeps hosted-runner headroom without weakening compo assert.equal(smoke.timings.editorSeriesMs.hardMaxMs, 750); }); +// #675: обоснование потолка и ряд замеров — demo/performance/README.md, +// «CI contracts». Три файла держат одно число: смок повторяет полный профиль +// (#473 AC4), плотный двойник — исторический (#160). +test('isometric space switch ceiling covers the 2.5D runner level and still catches #583 (#675)', () => { + const files = [ + 'budgets-isometric-smoke.json', + 'budgets-large-house-isometric.json', + 'budgets-isometric-stage3-dense.json', + ]; + for (const file of files) + assert.equal(readBudget(file).timings.spaceSwitchMs.hardMaxMs, 2200, `${file}: общий потолок spaceSwitchMs`); + const smoke = readBudget('budgets-isometric-smoke.json'); + const red = (ms) => evaluatePerformanceBudget({ + candidate: absoluteSmokeReport(smoke, { spaceSwitchMs: ms }), budgets: smoke, absoluteOnly: true, + }).failures.some((check) => check.id === 'timing.spaceSwitchMs.median'); + assert.equal(red(1867.7), false, 'максимум уровня 2.5D на hosted-раннере (прогон 36306131709) проходит'); + assert.equal(red(2719.6), true, 'провал #583 до решётки (прогон 35070397356) краснеет и по spaceSwitchMs'); + assert.equal(red(2 * 1798.3), true, 'удвоение текущего уровня краснеет'); + // Потолок — не детектор тренда: полный прогон по-прежнему сравнивает с базой + // того же раннера, и рычагом не стали ни коэффициент, ни допуск. + for (const file of files.slice(1)) { + const budget = readBudget(file).timings.spaceSwitchMs; + assert.equal(budget.maxRegressionRatio, 0.2, `${file}: коэффициент не рычаг`); + assert.equal(budget.noiseAllowanceMs, 100, `${file}: допуск не рычаг`); + } +}); + test('boundary collision search restores the ordinary isometric allowances (#585)', () => { const isometric = readBudget('budgets-large-house-isometric.json'); const dense = readBudget('budgets-isometric-stage3-dense.json');