perf: memtrend считает с цикла 1, плато по второй половине

Refs #202

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-26 02:19:55 +04:00
parent fa5dff8c6a
commit 4a539d803f
3 changed files with 61 additions and 10 deletions

View File

@ -95,10 +95,16 @@ cd .. && .venv/bin/python -m perfkit.modals_inventory results/m
cd perf/e2e
PERF_RUN_ID=mem npx playwright test scenarios/memory.spec.ts # полный прогон, около часа
PERF_RUN_ID=memsmall PERF_MEM_CYCLES=2 PERF_MEM_SNAPSHOTS=0,2 npx playwright test scenarios/memory.spec.ts # малый
cd .. && .venv/bin/python -m perfkit.memtrend results/mem # наклоны МНК на цикл
cd .. && .venv/bin/python -m perfkit.memtrend results/mem [--from-cycle 1] # наклоны МНК на цикл
```
- Метрики записи: `cycle`, `heap_used_mb` (после gc), `documents`, `nodes`, `js_event_listeners` (CDP `Memory.getDOMCounters`), `ws_open` (обёртка над `window.WebSocket`), `ws_created`, `edits_ok`, `cycle_s`, `relogin`.
- Heap snapshots на циклах 0, 10, 20 (`PERF_MEM_SNAPSHOTS`) - `perf/results/<run_id>/artifacts/memory-cycleNN.heapsnapshot`, в git не попадают; открываются в Chrome DevTools (Memory). Разбор retainers - Task 10.
- Переменные: `PERF_MEM_CYCLES` (20), `PERF_MEM_SHEET1/2` (`FORM_x/SHEET/direction|null`), `PERF_TEST_TIMEOUT_MS` (3 ч). Токен (60 мин) перевыпускается по API без перезагрузки, если до конца < 10 мин.
- Любая неудачная правка (нет эха, отказ сервера, нет редактируемых ячеек) - ошибка сценария.
Как читать `perfkit.memtrend`:
- Цикл 0 - базовая точка: страница `/tasks` до первого открытия листа (heap 7 МБ, nodes 289). Скачок цикл 0 -> 1 - загрузка кода/данных первого листа, а не рост. Поэтому МНК по умолчанию считается с цикла 1 (`--from-cycle N`); вывод печатает использованные циклы и число точек.
- Вторым блоком печатается наклон по второй половине (циклы 11-20) - индикатор плато: если он заметно меньше общего, рост затухает.
- Snapshots на циклах 10 и 20 слегка влияют на heap следующего замера (после снимка остаются служебные структуры/GC), поэтому единичные скачки в точках 11 и 21 не интерпретировать.
- Результат прогона t8-mem (с цикла 1): heap 0.046 МБ/цикл (вторая половина 0.027), nodes 0, listeners 0, ws_open = 1 всегда. DOM, listeners и WS стабильны; остаточный рост JS-heap ~27-46 КБ/цикл неубедителен на 20 циклах - кандидат на diff retainers снимков 10 и 20 (Task 10).

View File

@ -1,11 +1,12 @@
"""Тренд памяти по записям сценария memory (Task 8).
CLI: python -m perfkit.memtrend <run_dir>
Читает <run_dir>/memory.jsonl, сортирует записи по metrics.cycle и печатает наклон МНК
CLI: python -m perfkit.memtrend <run_dir> [--from-cycle N]
Читает <run_dir>/memory.jsonl (по умолчанию с цикла 1, цикл 0 - базовая точка), сортирует записи по metrics.cycle и печатает наклон МНК
(единиц метрики на цикл) для heap_used_mb, nodes, js_event_listeners.
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
@ -37,16 +38,28 @@ def load_memory(run_dir) -> list[dict]:
return sorted(recs, key=lambda r: r["metrics"]["cycle"])
def main(argv: list[str] | None = None) -> int:
argv = sys.argv[1:] if argv is None else argv
if len(argv) != 1:
print("usage: python -m perfkit.memtrend <run_dir>", file=sys.stderr)
return 2
recs = load_memory(argv[0])
print(f"циклов (записей): {len(recs)}")
def _print_slopes(title: str, recs: list[dict]) -> None:
cs = [r["metrics"]["cycle"] for r in recs]
print(f"{title}: циклы {cs[0]}-{cs[-1]}, {len(recs)} точек")
for m in METRICS:
vals = [float(r["metrics"][m]) for r in recs]
print(f"{m}: {slope_per_cycle(vals):.3f} на цикл (первый {vals[0]:.1f}, последний {vals[-1]:.1f})")
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(prog="python -m perfkit.memtrend")
ap.add_argument("run_dir")
ap.add_argument("--from-cycle", type=int, default=1,
help="первый цикл в МНК (по умолчанию 1: цикл 0 - базовая точка, страница /tasks до открытия листа)")
args = ap.parse_args(sys.argv[1:] if argv is None else argv)
recs = load_memory(args.run_dir)
used = [r for r in recs if r["metrics"]["cycle"] >= args.from_cycle]
print(f"записей memory: {len(recs)}")
_print_slopes(f"наклон с цикла {args.from_cycle}", used)
last = used[-1]["metrics"]["cycle"]
half = [r for r in used if r["metrics"]["cycle"] > last // 2]
if len(half) >= 2 and len(half) < len(used):
_print_slopes(f"вторая половина (циклы {half[0]['metrics']['cycle']}-{last}), индикатор плато", half)
return 0

View File

@ -36,3 +36,35 @@ def test_cli_prints_three_slopes(tmp_path, capsys):
out = capsys.readouterr().out
assert "heap_used_mb" in out and "nodes" in out and "js_event_listeners" in out
assert "2.000" in out and "5.000" in out and "0.000" in out
def _write(tmp_path, heap):
rows = [{"scenario": "memory", "metrics": {"cycle": c, "heap_used_mb": h, "nodes": 350 if c else 289, "js_event_listeners": 336 if c else 322}} for c, h in enumerate(heap)]
(tmp_path / "memory.jsonl").write_text("\n".join(json.dumps(r) for r in rows) + "\n")
def test_cli_skips_baseline_step_by_default(tmp_path, capsys):
# ступенька на цикле 0 (7 -> 11), дальше +1 на цикл, 0 для nodes/listeners
_write(tmp_path, [7, 11, 12, 13, 14, 15, 16])
assert main([str(tmp_path)]) == 0
out = capsys.readouterr().out
assert "циклы 1-6" in out and "6 точек" in out
assert "heap_used_mb: 1.000" in out
assert "nodes: 0.000" in out and "js_event_listeners: 0.000" in out
def test_cli_from_cycle_zero_includes_step(tmp_path, capsys):
_write(tmp_path, [7, 11, 12, 13, 14, 15, 16])
assert main([str(tmp_path), "--from-cycle", "0"]) == 0
out = capsys.readouterr().out
assert "циклы 0-6" in out and "7 точек" in out
assert "heap_used_mb: 1.000" not in out.split("вторая половина")[0]
def test_cli_second_half_slope(tmp_path, capsys):
# рост до цикла 10, потом плато: вторая половина (циклы 11-20) = 0
_write(tmp_path, [7] + [11 + min(c, 10) for c in range(0, 20)])
assert main([str(tmp_path)]) == 0
out = capsys.readouterr().out
assert "вторая половина (циклы 11-20)" in out
assert "heap_used_mb: 0.000" in out.split("вторая половина")[1]