- 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>
211 lines
9.5 KiB
Python
211 lines
9.5 KiB
Python
"""Разница 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()
|