#!/usr/bin/env python3 """analyze.py — turn one bench run's evidence into per-app numbers. analyze.py evidence/ [--all-window] Reads guest.tsv (5 s cgroup samples), containers.tsv (container -> compose project), events.json, memory-peak.tsv and host.tsv. Writes /summary.json and prints a table. Definitions (stated once, used everywhere — METHOD.md repeats them): - used = memory.current - inactive_file (what `docker stats` shows; what the fleet telemetry carries) - anon = anonymous memory (the app's own heap; never reclaimable without swap) - an app's value at a sample = the SUM over its containers at that timestamp - rest window = events rest_start..rest_end (the last 15 min, nothing else being deployed) - work window = the app's deploy_start .. rest_end (first start, any action, rest) - CPU = d(usage_usec)/dt per container, summed per app, as % of the WHOLE N100 (4 cores = 100 %) - cpu_peak_1m = the highest 60 s moving average in the work window - host power = d(RAPL package energy)/dt — CPU package only, not wall power """ import json, os, sys, statistics as st, collections NCPU = 4 def load_tsv(p): rows = [] with open(p) as f: hdr = f.readline().rstrip("\n").split("\t") for line in f: parts = line.rstrip("\n").split("\t") if len(parts) == len(hdr): rows.append(dict(zip(hdr, parts))) return rows def pct(xs, q): xs = sorted(xs) return xs[min(len(xs) - 1, int(q * len(xs)))] if xs else 0 def movavg_max(series, width): """series: sorted [(ts, value)]; highest mean over any window of `width` seconds.""" best, j, s = 0.0, 0, 0.0 for i in range(len(series)): s += series[i][1] while series[i][0] - series[j][0] >= width: s -= series[j][1] j += 1 n = i - j + 1 if series[i][0] - series[0][0] >= width - 5 and n: best = max(best, s / n) if not best and series: best = sum(v for _, v in series) / len(series) return best def analyze(d): g = load_tsv(os.path.join(d, "guest.tsv")) proj = {} cp = os.path.join(d, "containers.tsv") if os.path.exists(cp): for line in open(cp): p = line.rstrip("\n").split("\t") if len(p) >= 2: proj[p[0]] = p[1] or p[0] ev = json.load(open(os.path.join(d, "events.json"))) if os.path.exists(os.path.join(d, "events.json")) else [] t_rest0 = next((e["ts"] for e in ev if e["kind"] == "rest_start"), None) t_rest1 = next((e["ts"] for e in ev if e["kind"] == "rest_end"), None) starts = {e["app"]: e["ts"] for e in ev if e["kind"] == "deploy_start"} # per container time series mem = collections.defaultdict(dict) # app -> ts -> [used, anon] cpu = collections.defaultdict(lambda: collections.defaultdict(float)) # app -> ts -> cores last = {} cpk = collections.defaultdict(lambda: [0, 0]) # container -> [peak used, peak anon] for r in g: ts = int(r["ts"]); name = r["name"]; app = proj.get(name, name) used = int(r["mem_current"]) - int(r["inactive_file"] or 0) anon = int(r["anon"] or 0) m = mem[app].setdefault(ts, [0, 0]); m[0] += used; m[1] += anon cpk[name][0] = max(cpk[name][0], used); cpk[name][1] = max(cpk[name][1], anon) u = int(r["cpu_usage_usec"] or 0) if name in last and ts > last[name][0] and u >= last[name][1]: cpu[app][ts] += (u - last[name][1]) / 1e6 / (ts - last[name][0]) last[name] = (ts, u) peaks = {} climit = {} pp = os.path.join(d, "memory-peak.tsv") if os.path.exists(pp): for r in load_tsv(pp): mx = r.get("memory_max", "") climit[r["name"]] = int(mx) if mx.isdigit() else None app = proj.get(r["name"], r["name"]) try: peaks.setdefault(app, [0, 0]) peaks[app][0] += int(r["memory_peak_bytes"] or 0) peaks[app][1] += int(r["oom_kill"] or 0) except ValueError: pass # Fallback for runs before memory_max was recorded: the applied compose, image -> next memory line al = os.path.join(d, "applied-limits.txt") if os.path.exists(al): img_lim, cur = {}, None for line in open(al): if "image:" in line: cur = line.split("image:", 1)[1].strip().split("@")[0] elif cur and ("memory:" in line or "mem_limit:" in line): v = line.split(":", 2)[-1].strip().strip('"') unit = 1024 ** 3 if v[-1:] in "Gg" else 1024 ** 2 try: img_lim[cur] = int(float(v[:-1]) * unit) except ValueError: pass cur = None for line in open(cp) if os.path.exists(cp) else []: p = line.rstrip("\n").split("\t") if len(p) >= 3 and not climit.get(p[0]) and p[2] in img_lim: climit[p[0]] = img_lim[p[2]] MB = 1024 * 1024 out = {} for app, series in mem.items(): t0 = starts.get(app, min(series)) work = [(t, v) for t, v in sorted(series.items()) if t >= t0 and (t_rest1 is None or t <= t_rest1)] rest = [(t, v) for t, v in work if t_rest0 and t >= t_rest0] cw = sorted((t, c) for t, c in cpu[app].items() if t >= t0 and (t_rest1 is None or t <= t_rest1)) cr = [c for t, c in cw if t_rest0 and t >= t_rest0] if not work: continue o = dict( samples=len(work), rest_samples=len(rest), mem_rest_avg_mb=round(st.mean(v[0] for _, v in rest) / MB) if rest else None, mem_rest_p95_mb=round(pct([v[0] for _, v in rest], .95) / MB) if rest else None, mem_peak_used_mb=round(max(v[0] for _, v in work) / MB), mem_peak_anon_mb=round(max(v[1] for _, v in work) / MB), mem_rest_anon_avg_mb=round(st.mean(v[1] for _, v in rest) / MB) if rest else None, kernel_memory_peak_mb=round(peaks[app][0] / MB) if app in peaks else None, oom_kills=peaks[app][1] if app in peaks else None, cpu_rest_avg_pct=round(100 * st.mean(cr) / NCPU, 2) if cr else None, cpu_peak_1m_pct=round(100 * movavg_max(cw, 60) / NCPU, 1) if cw else None, cpu_peak_5s_pct=round(100 * max((c for _, c in cw), default=0) / NCPU, 1), work_seconds=work[-1][0] - work[0][0], containers={n: dict(peak_used_mb=round(v[0] / MB), peak_anon_mb=round(v[1] / MB), limit_mb=round(climit[n] / MB) if climit.get(n) else None) for n, v in cpk.items() if proj.get(n, n) == app}, ) out[app] = o # host side h = load_tsv(os.path.join(d, "host.tsv")) if os.path.exists(os.path.join(d, "host.tsv")) else [] hs = {} if len(h) > 2: def watts(rows): w = [] for a, b in zip(rows, rows[1:]): dt = int(b["ts"]) - int(a["ts"]); de = int(b["rapl_pkg_uj"]) - int(a["rapl_pkg_uj"]) if dt > 0 and de >= 0: w.append(de / 1e6 / dt) return w rest_h = [r for r in h if t_rest0 and int(r["ts"]) >= t_rest0 and (t_rest1 is None or int(r["ts"]) <= t_rest1)] or h w_all, w_rest = watts(h), watts(rest_h) hs = dict( host_mem_used_rest_avg_mb=round(st.mean((int(r["mem_total_kb"]) - int(r["mem_available_kb"])) / 1024 for r in rest_h)), host_mem_used_peak_mb=round(max((int(r["mem_total_kb"]) - int(r["mem_available_kb"])) / 1024 for r in h)), guest_mem_current_rest_avg_mb=round(st.mean(int(r["guest_mem_current"]) for r in rest_h) / MB), guest_anon_rest_avg_mb=round(st.mean(int(r["guest_anon"]) for r in rest_h) / MB), rapl_pkg_w_rest_avg=round(st.mean(w_rest), 2) if w_rest else None, rapl_pkg_w_peak_5s=round(max(w_all), 2) if w_all else None, rapl_pkg_w_peak_1m=round(movavg_max([(int(b["ts"]), w) for b, w in zip(h[1:], w_all)], 60), 2) if w_all else None, pkg_temp_c_max=round(max(int(r["pkg_temp_mC"]) for r in h) / 1000, 1), ) res = dict(apps=out, host=hs) json.dump(res, open(os.path.join(d, "summary.json"), "w"), indent=1) return res if __name__ == "__main__": res = analyze(sys.argv[1]) print(f"{'app':22} {'rest':>6} {'p95':>6} {'peak':>6} {'anonpk':>6} {'kpeak':>6} {'cpuR%':>6} {'cpu1m%':>7} {'oom':>4}") for a, o in sorted(res["apps"].items()): print(f"{a:22} {str(o['mem_rest_avg_mb']):>6} {str(o['mem_rest_p95_mb']):>6} {o['mem_peak_used_mb']:>6} " f"{o['mem_peak_anon_mb']:>6} {str(o['kernel_memory_peak_mb']):>6} {str(o['cpu_rest_avg_pct']):>6} " f"{str(o['cpu_peak_1m_pct']):>7} {str(o['oom_kills']):>4}") print(json.dumps(res["host"]))