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

211 lines
9.5 KiB
Python
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.

"""Разница heap snapshots сценария памяти и retainers выживших новых объектов.
cd perf && .venv/bin/python analysis/heapdiff.py \
results/20261026-s1/artifacts/memory-cycle10.heapsnapshot results/20261026-s1/artifacts/memory-cycle20.heapsnapshot \
--out report-data/20261026-s1/analysis [--base results/20261026-s1/artifacts/memory-cycle00.heapsnapshot]
Снимки одной сессии (memory.spec.ts, одна вкладка без перезагрузки): id объектов V8 стабильны между снимками,
поэтому «новые» объекты снимка B - это id, которых нет в A (созданы между снимками и пережили GC перед снимком B).
Вывод (<out>/heapdiff.json и heapdiff.md):
- сводка по классам (как Summary в DevTools: конструктор / (closure) имя / (string) / (array) ...): число и self size в A и B, дельты;
- новые выжившие объекты B по классам;
- для топа классов по self size новых объектов - пути удержания (retainers): кратчайший путь от GC-корня по сильным
рёбрам (без weak), агрегированный по подписи ближайших 6 шагов (класс[ребро]); сколько объектов и байт удерживает подпись.
Без «утечки нет / есть»: только что выросло и чем удерживается.
"""
from __future__ import annotations
import argparse
import json
from collections import Counter, defaultdict, deque
from pathlib import Path
class Snapshot:
def __init__(self, path: Path):
d = json.loads(path.read_bytes())
meta = d["snapshot"]["meta"]
self.nf = meta["node_fields"]
self.ef = meta["edge_fields"]
self.ntypes = meta["node_types"][0]
self.etypes = meta["edge_types"][0]
self.nodes = d["nodes"]
self.edges = d["edges"]
self.strings = d["strings"]
self.NF = len(self.nf)
self.EF = len(self.ef)
self.i_type = self.nf.index("type")
self.i_name = self.nf.index("name")
self.i_id = self.nf.index("id")
self.i_size = self.nf.index("self_size")
self.i_ec = self.nf.index("edge_count")
self.i_det = self.nf.index("detachedness") if "detachedness" in self.nf else None
self.e_type = self.ef.index("type")
self.e_name = self.ef.index("name_or_index")
self.e_to = self.ef.index("to_node")
self.n = len(self.nodes) // self.NF
# первое ребро каждого узла
self.first_edge = [0] * (self.n + 1)
acc = 0
for i in range(self.n):
self.first_edge[i] = acc
acc += self.nodes[i * self.NF + self.i_ec] * self.EF
self.first_edge[self.n] = acc
def typ(self, i):
return self.ntypes[self.nodes[i * self.NF + self.i_type]]
def name(self, i):
return self.strings[self.nodes[i * self.NF + self.i_name]]
def oid(self, i):
return self.nodes[i * self.NF + self.i_id]
def size(self, i):
return self.nodes[i * self.NF + self.i_size]
def cls(self, i) -> str:
t, n = self.typ(i), self.name(i)
if t == "object":
return n.split(" ")[0] if n.startswith("Detached ") is False else n
if t == "closure":
return f"(closure) {n or '(anonymous)'}"
if t in ("string", "concatenated string", "sliced string"):
return "(string)"
if t == "array":
return f"(array) {n}" if n else "(array)"
if t == "native":
return f"(native) {n.split(' ')[0]}"
if t in ("hidden", "code", "object shape"):
return f"({t}) {n.split(' ')[0][:40]}" if t == "object shape" else f"({t})"
return f"({t})"
def edges_of(self, i):
for e in range(self.first_edge[i], self.first_edge[i + 1], self.EF):
et = self.etypes[self.edges[e + self.e_type]]
nm = self.edges[e + self.e_name]
to = self.edges[e + self.e_to] // self.NF
yield et, nm, to
def edge_label(self, et, nm):
if et in ("element", "hidden"):
return f"[{nm}]"
return str(self.strings[nm]) if isinstance(nm, int) and nm < len(self.strings) else str(nm)
def bfs_parents(self):
"""кратчайшие пути от корня (узел 0) по сильным рёбрам: parent[i] = (родитель, тип ребра, имя)."""
parent = [None] * self.n
parent[0] = (-1, "", "")
q = deque([0])
while q:
i = q.popleft()
for et, nm, to in self.edges_of(i):
if et in ("weak", "shortcut") or parent[to] is not None:
continue
parent[to] = (i, et, nm)
q.append(to)
return parent
def summary(self):
cnt, sz = Counter(), Counter()
for i in range(self.n):
c = self.cls(i)
cnt[c] += 1
sz[c] += self.size(i)
return cnt, sz
def path_sig(s: Snapshot, parent, i, hops=6):
out = []
cur = i
for _ in range(hops):
p = parent[cur]
if p is None:
out.append("<недостижим>")
break
pi, et, nm = p
if pi < 0:
break
out.append(f"{s.cls(pi)}.{s.edge_label(et, nm)}")
cur = pi
return " <- ".join(out)
def main(argv=None):
ap = argparse.ArgumentParser()
ap.add_argument("a")
ap.add_argument("b")
ap.add_argument("--base", help="ещё более ранний снимок (цикл 0) - только для сводки")
ap.add_argument("--out", required=True)
ap.add_argument("--top", type=int, default=12)
args = ap.parse_args(argv)
out = Path(args.out)
out.mkdir(parents=True, exist_ok=True)
A, B = Snapshot(Path(args.a)), Snapshot(Path(args.b))
ca, sa = A.summary()
cb, sb = B.summary()
total = {"a_self_bytes": sum(sa.values()), "b_self_bytes": sum(sb.values()), "a_nodes": A.n, "b_nodes": B.n}
if args.base:
Z = Snapshot(Path(args.base))
cz, sz_ = Z.summary()
total.update(base_self_bytes=sum(sz_.values()), base_nodes=Z.n)
del Z
classes = set(ca) | set(cb)
diff = sorted(((c, cb[c] - ca[c], sb[c] - sa[c], ca[c], cb[c], sa[c], sb[c]) for c in classes), key=lambda r: -r[2])
ids_a = {A.oid(i) for i in range(A.n)}
new = [i for i in range(B.n) if B.oid(i) not in ids_a]
new_cnt, new_sz = Counter(), Counter()
for i in new:
c = B.cls(i)
new_cnt[c] += 1
new_sz[c] += B.size(i)
parent = B.bfs_parents()
top_classes = [c for c, _ in new_sz.most_common(args.top)]
retainers = {}
by_cls = defaultdict(list)
for i in new:
by_cls[B.cls(i)].append(i)
for c in top_classes:
sig_n, sig_b = Counter(), Counter()
for i in by_cls[c]:
sg = path_sig(B, parent, i)
sig_n[sg] += 1
sig_b[sg] += B.size(i)
retainers[c] = [[sg, sig_n[sg], sig_b[sg]] for sg, _ in sig_b.most_common(5)]
# детачнутые DOM-узлы в B (detachedness == 2 - detached)
detached = Counter()
if B.i_det is not None:
for i in range(B.n):
if B.nodes[i * B.NF + B.i_det] == 2:
detached[B.cls(i)] += 1
res = {
"a": args.a, "b": args.b, "total": total,
"class_diff_top": [list(r) for r in diff[:30]],
"class_diff_bottom": [list(r) for r in diff[-10:]],
"new_objects": {"count": len(new), "self_bytes": sum(B.size(i) for i in new)},
"new_by_class": [[c, new_cnt[c], new_sz[c]] for c, _ in new_sz.most_common(30)],
"retainers": retainers,
"detached_in_b": detached.most_common(15),
}
(out / "heapdiff.json").write_text(json.dumps(res, ensure_ascii=False, indent=1))
md = ["# Heap snapshots: разница и retainers (heapdiff.py)", "",
f"A = `{args.a}`, B = `{args.b}`" + (f", база = `{args.base}`" if args.base else ""), "",
f"Self size всех узлов: " + ", ".join(f"{k} {v}" for k, v in total.items()), "",
f"Новых выживших объектов в B (id нет в A): {len(new)}, {res['new_objects']['self_bytes']} байт self size.", "",
"## Классы с наибольшим ростом self size (B - A)", "", "| класс | Δ число | Δ байт | число A | число B | байт A | байт B |", "|---|---|---|---|---|---|---|"]
md += [f"| {c} | {dn} | {ds} | {na} | {nb} | {xa} | {xb} |" for c, dn, ds, na, nb, xa, xb in diff[:20]]
md += ["", "## Новые выжившие объекты B по классам", "", "| класс | число | байт |", "|---|---|---|"]
md += [f"| {c} | {n} | {b} |" for c, n, b in res["new_by_class"][:20]]
md += ["", "## Пути удержания новых объектов (кратчайший путь от корня, 6 ближайших шагов; объектов / байт)", ""]
for c, rows in retainers.items():
md.append(f"- **{c}**")
md += [f" - {n} / {b} B: `{sg}`" for sg, n, b in rows]
md += ["", "## Detached-узлы в B", ""] + [f"- {c}: {n}" for c, n in res["detached_in_b"]]
(out / "heapdiff.md").write_text("\n".join(md) + "\n")
print("\n".join(md[:60]))
if __name__ == "__main__":
main()