"""Разница 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). Вывод (/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()