отказ от лишнего реста

This commit is contained in:
gorbenko.pavel
2026-08-21 14:47:03 +05:00
parent b75a0758e7
commit 7f08a819a9
2 changed files with 191 additions and 142 deletions
+84 -34
View File
@@ -67,12 +67,44 @@ interface IStat {
maxMs: number;
}
/** Накопленное по одной операции за ВСЕ её прогоны — база для среднего. */
/**
* Сэмплы одной метки по всем прогонам — база для МЕДИАНЫ, а не среднего.
*
* Среднее одним выбросом (случайный 429 с ретраем, холодный кеш на первом
* заходе) утаскивает за собой всю картину надолго — а именно выбросы здесь
* основной случай, не редкость. Медиана его игнорирует: важнее «как обычно»,
* а не «как в сумме с той единственной аномалией».
*/
interface ILabelSamples {
/** totalMs метки за каждый прогон, где она встретилась (метка не обязана быть в каждом). */
totalMs: number[];
calls: number[];
/** Худший случай за всё время — в отличие от totalMs, это не медиана, а running max. */
maxMs: number;
}
// Сколько последних прогонов метки хранить ради медианы. Не безлимитный рост:
// в долгой сессии старые сэмплы устаревают быстрее, чем на них смотрят, а лимит
// защищает от утечки памяти на вкладке, оставленной открытой на весь день.
const MAX_SAMPLES = 50;
function pushSample(list: number[], value: number): void {
list.push(value);
if (list.length > MAX_SAMPLES) list.shift();
}
function median(values: number[]): number {
if (values.length === 0) return 0;
const sorted = [...values].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 === 1 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
}
/** Накопленное по одной операции за ВСЕ её прогоны — база для медианы. */
interface IRunHistory {
runs: number;
wallTotalMs: number;
stages: Map<string, IStat>;
requests: Map<string, IStat>;
wallSamples: number[];
stages: Map<string, ILabelSamples>;
requests: Map<string, ILabelSamples>;
}
// Текущий (ещё не напечатанный) прогон.
@@ -142,6 +174,18 @@ function installFlag(): void {
set: (value: unknown): void => {
enabled = !!value;
writeStoredFlag(enabled);
if (!enabled) {
// Тишина СРАЗУ, а не после того, что уже накопилось. Без этого operация,
// стартовавшая ДО выключения (perfScope с сетевым шагом внутри) или
// ждущая debounce-таймера (см. scheduleIdleFlush), всё равно печатала
// итог ПОСЛЕ выключения — выглядело так, будто window.rpPerf = false
// не действует.
if (idleTimer !== undefined) {
window.clearTimeout(idleTimer);
idleTimer = undefined;
}
reset();
}
console.log(`${PREFIX} замер ${enabled ? 'включён' : 'выключен'} (запомнено до отмены)`);
}
});
@@ -181,7 +225,7 @@ function scheduleIdleFlush(): void {
if (idleTimer !== undefined) window.clearTimeout(idleTimer);
idleTimer = window.setTimeout(() => {
idleTimer = undefined;
if (openScopes === 0) print();
if (openScopes === 0 && perfEnabled()) print();
}, IDLE_FLUSH_MS);
}
@@ -223,7 +267,11 @@ export async function perfScope<T>(label: string, fn: () => Promise<T>): Promise
return await fn();
} finally {
openScopes -= 1;
if (openScopes === 0) print();
// perfEnabled() — на выходе, а не только на входе: операцию могли выключить,
// пока она ещё выполнялась (см. setter window.rpPerf выше), и тогда печатать
// её итог уже не нужно — accumulated-данные к этому моменту сам setter уже
// сбросил.
if (openScopes === 0 && perfEnabled()) print();
}
}
@@ -307,19 +355,19 @@ function reset(): void {
* Ключ, под которым прогоны копятся в историю.
*
* Номер страницы из метки убираем: пролистать вкладку по страницам — самый
* удобный способ набрать несколько замеров одной и той же операции, и усреднять
* их надо ВМЕСТЕ. Иначе каждая страница завела бы свою историю из одного
* прогона, и среднее никогда бы не набралось.
* удобный способ набрать несколько замеров одной и той же операции, и считать
* медиану надо по ним ВМЕСТЕ. Иначе каждая страница завела бы свою историю из
* одного прогона, и медиане не по чему было бы считаться.
*/
function historyKey(title: string): string {
return title.replace(/\s*стр\.\d+/g, '').trim();
}
function mergeInto(target: Map<string, IStat>, source: Map<string, IStat>): void {
function recordSamples(target: Map<string, ILabelSamples>, source: Map<string, IStat>): void {
source.forEach((s, label) => {
const t = target.get(label) ?? { calls: 0, totalMs: 0, maxMs: 0 };
t.calls += s.calls;
t.totalMs += s.totalMs;
const t = target.get(label) ?? { totalMs: [], calls: [], maxMs: 0 };
pushSample(t.totalMs, s.totalMs);
pushSample(t.calls, s.calls);
t.maxMs = Math.max(t.maxMs, s.maxMs);
target.set(label, t);
});
@@ -346,8 +394,10 @@ function totalCalls(source: Map<string, IStat>): number {
* window.rpPerfDetail(), и её зовут тогда, когда строка показала, что смотреть
* есть на что.
*
* Среднее в строке важнее текущего значения: одиночный сетевой замер шумит
* настолько, что по нему нельзя сказать, стало лучше или просто повезло.
* Медиана в строке важнее текущего значения: одиночный сетевой замер шумит
* настолько, что по нему нельзя сказать, стало лучше или просто повезло, а
* среднее в этом же шуме утаскивает один выброс (холодный кеш, ретрай на 429)
* далеко за пределы того, что происходит обычно.
*/
function print(): void {
if (idleTimer !== undefined) {
@@ -361,15 +411,13 @@ function print(): void {
const key = historyKey(title);
const hist: IRunHistory = history.get(key) ?? {
runs: 0,
wallTotalMs: 0,
stages: new Map<string, IStat>(),
requests: new Map<string, IStat>()
wallSamples: [],
stages: new Map<string, ILabelSamples>(),
requests: new Map<string, ILabelSamples>()
};
hist.runs += 1;
hist.wallTotalMs += wallMs;
mergeInto(hist.stages, stages);
mergeInto(hist.requests, requests);
pushSample(hist.wallSamples, wallMs);
recordSamples(hist.stages, stages);
recordSamples(hist.requests, requests);
history.set(key, hist);
// Дольше всего — по этапам, если разметка есть; иначе по видам запросов
@@ -378,7 +426,7 @@ function print(): void {
console.log(
`${PREFIX} ${key} | ожидание ${Math.round(wallMs)} мс` +
` (сред. ${Math.round(hist.wallTotalMs / hist.runs)} за ${hist.runs})` +
` (мед. ${Math.round(median(hist.wallSamples))} за ${hist.wallSamples.length})` +
` | запросов ${totalCalls(requests)}` +
(top ? ` | дольше всего: ${top}` : '')
);
@@ -386,18 +434,20 @@ function print(): void {
reset();
}
/** Строки полной раскладки по одной операции — средние за все её прогоны. */
/** Строки полной раскладки по одной операции — медианы за все её прогоны. */
function detailLines(key: string, hist: IRunHistory): string[] {
const lines = [`${PREFIX} ${key} - прогонов ${hist.runs}, ожидание в среднем ${Math.round(hist.wallTotalMs / hist.runs)} мс`];
const lines = [
`${PREFIX} ${key} - прогонов ${hist.wallSamples.length}, ожидание медиана ${Math.round(median(hist.wallSamples))} мс`
];
const dump = (name: string, source: Map<string, IStat>): void => {
const dump = (name: string, source: Map<string, ILabelSamples>): void => {
if (source.size === 0) return;
lines.push(` ${name} (в среднем за прогон)`);
lines.push(` ${name} (медиана за прогон)`);
Array.from(source.entries())
.sort((a, b) => b[1].totalMs - a[1].totalMs)
.sort((a, b) => median(b[1].totalMs) - median(a[1].totalMs))
.forEach(([label, s]) => {
lines.push(
` ${Math.round(s.totalMs / hist.runs)} мс, вызовов ${(s.calls / hist.runs).toFixed(1)}, макс ${Math.round(s.maxMs)} - ${label}`
` ${Math.round(median(s.totalMs))} мс, вызовов ${Math.round(median(s.calls))}, макс ${Math.round(s.maxMs)} - ${label}`
);
});
};
@@ -425,9 +475,9 @@ function installDetailApi(): void {
return text;
};
// Старые прогоны тянут среднее назад, и «стало быстрее» проявится в нём
// только через несколько заходов — перед сравнением «до/после» серию надо
// обнулять.
// Старые прогоны остаются в окне (см. MAX_SAMPLES) и тянут медиану назад —
// «стало быстрее» проявится в ней только после того, как их наберётся
// больше половины окна. Перед сравнением «до/после» серию надо обнулять.
w.rpPerfReset = (): void => {
history.clear();
reset();