Skip to content

Commit 8bdccf1

Browse files
Jamesclaude
andcommitted
bench(chat-history): ground-truth USS memory, stats rigor, honest scale-dependence
Upgrade the benchmark to a defensible standard after audit: - Real byte metric: renderer-process USS/RSS via psutil (measureUAM verified unavailable in headless Chromium even when crossOriginIsolated). Per-cell fresh persistent context located by unique user-data-dir; max single renderer isolates the page's DOM cost. Node count kept as the structural proxy. - Statistical rigor: repeats with 1 warm-up discarded, median +/- (max-min) dispersion published per cell, raw per-run values retained in the JSON, environment (Chromium build/CPU/RAM/OS) captured. - Corrected claims the byte metric overturned: odysseus-dev#4998 DOES reduce memory (~15-30%, detaching frees render memory though node count is unchanged) and the win is scale-dependent (ours is slightly WORSE below ~1000 msgs; material only at 2000+). Node-count-only framing overstated the gap. - Fixed a misleading caption: the append-lag columns do NOT capture odysseus-dev#4998's benefit (appends hit the live region, not collapsed nodes); odysseus-dev#4998 targets scroll-repaint lag, which this harness does not yet measure. Recorded result (medians of 4, 1 warmup dropped) in results/bench.{json,md} with per-run raw values; e.g. USS@5000: naive 116.8 / detach 81.5 / evict 50.8 MB. The detach@5000 USS shows a flagged outlier (one run 119.4) — dispersion is published, not hidden; large-n cells need more repeats. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent f8479a7 commit 8bdccf1

4 files changed

Lines changed: 1170 additions & 345 deletions

File tree

docs/dev/chat-history-benchmark.md

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -56,13 +56,32 @@ clean, legible signal that captures #4998's retention (20005 vs 5). Instruments
5656
2. **`JSHeapUsedSize`** sampled **after forced `window.gc()`** — JS-side retention. Corroborates.
5757
3. **`LayoutDuration` + `RecalcStyleDuration`** deltas across a fixed trajectory — the lag axis
5858
(#4998's own metric; showing bounded strategies match it neutralizes "yours is heavier").
59-
4. **`performance.measureUserAgentSpecificMemory()` DOM-bytes** — authoritative DOM byte breakdown.
60-
Optional corroboration; requires a COOP/COEP cross-origin-isolated server. Not a dependency (1–3
61-
work today and are decisive).
62-
5. **Real-app process RSS** — the fork's Qt-wrapper `[MEM]` telemetry on the actual target, as
63-
ground-truth confirmation that the harness curves reflect real RSS. Referenced, not re-derived here.
64-
65-
Flags: `--enable-precise-memory-info`, `--js-flags=--expose-gc`.
59+
4. **Renderer-process USS/RSS** (via `psutil`, the *page's* renderer process) — the **ground-truth
60+
byte metric**: the real OS-level private memory that actually OOMs. This is the headline memory
61+
number; node count (1) is its structural proxy. `measureUserAgentSpecificMemory()` was intended
62+
here but is **unavailable in headless Chromium** — it throws `SecurityError: not available` even
63+
when `self.crossOriginIsolated === true` (verified 2026-07-08). Renderer USS is a stronger metric
64+
anyway (real process memory, not an in-page estimate). The harness reads the *max* single renderer's
65+
USS (one page → one content renderer holds the DOM; a spare/prewarm renderer is excluded), recording
66+
`renderers` so the assumption is auditable. Each cell runs in a **fresh persistent context** (clean
67+
renderer) located by a unique `--user-data-dir`.
68+
5. **Real-app process RSS** — the fork's Qt-wrapper `[MEM]` telemetry on the actual QtWebEngine target,
69+
as ecological confirmation that the harness curves reflect real RSS. Referenced, not re-derived here;
70+
the harness→real-app link remains an assumption, not a measurement (a stated limitation).
71+
72+
Flags: `--enable-precise-memory-info`, `--js-flags=--expose-gc`. Statistics: median of ≥4 kept runs per
73+
cell with one warm-up run discarded; dispersion (max−min) published alongside each median; environment
74+
(Chromium build, CPU, RAM, OS) captured into the results JSON.
75+
76+
### Scale dependence (a load-bearing honesty point)
77+
78+
The memory difference is **negligible below ~1000 messages** — a few thousand DOM nodes cost a few MB,
79+
swamped by Chromium's ~40 MB renderer baseline, so all strategies sit within noise there. The eviction
80+
win only becomes material at large histories (measured divergence at ~2000+, large at 5000). Any claim
81+
must be stated *with its history-length regime*; "bounds memory" is true at scale, not at n=200. The
82+
byte metric (4) is what exposes this — a node-count-only benchmark would imply a difference that does
83+
not exist in bytes at small n, and would *overstate* the gap by ignoring that #4998's detaching frees
84+
real render memory even though node count is unchanged.
6685

6786
### Instrument caveat (decisive — read before trusting any memory number)
6887

tests/bench/chat_history_bench.py

Lines changed: 146 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,13 @@
2424
import argparse
2525
import json
2626
import pathlib
27+
import platform
28+
import shutil
2729
import statistics
2830
import sys
31+
import tempfile
32+
33+
import psutil
2934

3035
ROOT = pathlib.Path(__file__).resolve().parent.parent.parent
3136
CHAT_HISTORY_JS = ROOT / "static/js/chatHistory.js"
@@ -170,15 +175,63 @@ def layout_cost(cdp) -> dict:
170175
"layout_count": m["LayoutCount"], "style_count": m["RecalcStyleCount"]}
171176

172177

178+
# --- Real process memory (ground truth — what actually OOMs) -----------------
179+
# measureUserAgentSpecificMemory() is unavailable in headless Chromium even when
180+
# crossOriginIsolated, so we read the renderer process's private memory (USS) and
181+
# RSS from the OS via psutil. USS = memory unique to the renderer (freed if it
182+
# exits) — the honest byte-level DOM-retention signal that node count only proxies.
183+
def find_browser_pid(marker: str):
184+
for p in psutil.process_iter(["pid", "cmdline"]):
185+
try:
186+
joined = " ".join(p.info["cmdline"] or [])
187+
if marker in joined and "--type=" not in joined:
188+
return p.info["pid"]
189+
except (psutil.NoSuchProcess, psutil.AccessDenied):
190+
continue
191+
return None
192+
193+
194+
def renderer_mem(browser_pid: int) -> dict:
195+
"""Private memory of the page's renderer (GC-settled caller).
196+
197+
Reports the MAX single renderer's USS/RSS — with one page there is one content
198+
renderer holding the DOM; a spare/prewarm renderer (~baseline) is excluded so the
199+
number isolates the page's DOM cost rather than a constant offset. renderers>1 is
200+
recorded so the assumption is auditable.
201+
"""
202+
try:
203+
proc = psutil.Process(browser_pid)
204+
except psutil.NoSuchProcess:
205+
return {"uss_mb": None, "rss_mb": None, "renderers": 0}
206+
best = None
207+
renderers = 0
208+
for p in [proc] + proc.children(recursive=True):
209+
try:
210+
if "--type=renderer" in " ".join(p.cmdline()):
211+
renderers += 1
212+
mi = p.memory_full_info()
213+
if best is None or mi.uss > best[0]:
214+
best = (mi.uss, mi.rss)
215+
except (psutil.NoSuchProcess, psutil.AccessDenied):
216+
continue
217+
if best is None:
218+
return {"uss_mb": None, "rss_mb": None, "renderers": 0}
219+
return {"uss_mb": round(best[0] / 1e6, 2), "rss_mb": round(best[1] / 1e6, 2), "renderers": renderers}
220+
221+
173222
# ---------------------------------------------------------------------------
174223
# Runner
175224
# ---------------------------------------------------------------------------
176225
def run_cell(pw, arm: str, n: int) -> dict:
177-
browser = pw.chromium.launch(headless=True, args=LAUNCH_ARGS)
226+
# Fresh persistent context per cell → clean renderer process, and a unique
227+
# user-data-dir so we can locate that process for OS-level memory sampling.
228+
udd = tempfile.mkdtemp(prefix="chbench_")
229+
ctx = pw.chromium.launch_persistent_context(udd, headless=True, args=LAUNCH_ARGS, viewport=VIEWPORT)
230+
browser_pid = find_browser_pid(udd)
178231
try:
179-
page = browser.new_context(viewport=VIEWPORT).new_page()
232+
page = ctx.new_page()
180233
page.set_content(_HARNESS_HTML)
181-
cdp = page.context.new_cdp_session(page)
234+
cdp = ctx.new_cdp_session(page)
182235
cdp.send("Performance.enable")
183236

184237
load_arm(page, arm, n)
@@ -224,35 +277,49 @@ def run_cell(pw, arm: str, n: int) -> dict:
224277
# and the detach arm's kept-node retention are measured (fairness cuts both ways).
225278
page.evaluate(_scroll_to_top_js())
226279
mem_peak = sample_mem(page, cdp)
280+
# GC-settle before the OS memory read so USS reflects retained, not transient.
281+
page.evaluate("window.gc && (window.gc(), window.gc());")
282+
page.wait_for_timeout(250)
283+
rmem = renderer_mem(browser_pid) if browser_pid else {"uss_mb": None, "rss_mb": None, "renderers": 0}
227284

228285
return {
229286
"arm": arm, "n": n,
230287
"nodes_loaded": mem_loaded["nodes"], "nodes_peak": mem_peak["nodes"],
231288
"jsheap_loaded": mem_loaded["jsheap"], "jsheap_peak": mem_peak["jsheap"],
232289
"listeners_peak": mem_peak["listeners"],
290+
"uss_mb": rmem["uss_mb"], "rss_mb": rmem["rss_mb"],
233291
"append_layout_ms": round(lay_after["layout_ms"] - lay_before["layout_ms"], 2),
234292
"append_style_ms": round(lay_after["style_ms"] - lay_before["style_ms"], 2),
235293
}
236294
finally:
237-
browser.close()
295+
ctx.close()
296+
shutil.rmtree(udd, ignore_errors=True)
238297

239298

240299
def median_cell(pw, arm, n, repeats):
241-
runs = [run_cell(pw, arm, n) for _ in range(repeats)]
242-
out = {"arm": arm, "n": n, "repeats": repeats}
243-
for k in ("nodes_loaded", "nodes_peak", "jsheap_loaded", "jsheap_peak",
244-
"listeners_peak", "append_layout_ms", "append_style_ms"):
245-
vals = [r[k] for r in runs]
300+
# Warm-up discard: the first run of a fresh process pays one-time JIT/allocation
301+
# costs, so run repeats+1 and drop run 0. Report median + spread (max-min) over
302+
# the kept runs; keep the raw values in the artifact for full transparency.
303+
runs = [run_cell(pw, arm, n) for _ in range(repeats + 1)][1:]
304+
out = {"arm": arm, "n": n, "repeats_kept": len(runs)}
305+
keys = ("nodes_loaded", "nodes_peak", "jsheap_loaded", "jsheap_peak", "listeners_peak",
306+
"uss_mb", "rss_mb", "append_layout_ms", "append_style_ms")
307+
for k in keys:
308+
vals = [r[k] for r in runs if r[k] is not None]
309+
if not vals:
310+
out[k] = out[k + "_spread"] = None
311+
continue
246312
out[k] = round(statistics.median(vals), 2)
247313
out[k + "_spread"] = round(max(vals) - min(vals), 2)
314+
out[k + "_raw"] = vals
248315
return out
249316

250317

251318
def main():
252319
ap = argparse.ArgumentParser()
253320
ap.add_argument("--lengths", default="100,500,2000")
254321
ap.add_argument("--arms", default="naive,detach,evict")
255-
ap.add_argument("--repeats", type=int, default=3)
322+
ap.add_argument("--repeats", type=int, default=7)
256323
args = ap.parse_args()
257324
lengths = [int(x) for x in args.lengths.split(",")]
258325
arms = [a.strip() for a in args.arms.split(",")]
@@ -265,42 +332,86 @@ def main():
265332

266333
rows = []
267334
with sync_playwright() as pw:
335+
vb = pw.chromium.launch(headless=True)
336+
chromium_version = vb.version
337+
vb.close()
338+
env = {
339+
"platform": platform.platform(),
340+
"python": platform.python_version(),
341+
"chromium_version": chromium_version,
342+
"cpu_count": psutil.cpu_count(),
343+
"total_ram_gb": round(psutil.virtual_memory().total / 1e9, 1),
344+
"repeats_kept": args.repeats, "warmup_runs_discarded": 1,
345+
"memory_metric": "renderer-process USS/RSS via psutil (measureUAM unavailable headless); "
346+
"node count is the structural proxy; JS heap is JS-side only",
347+
"launch_args": LAUNCH_ARGS,
348+
}
268349
for n in lengths:
269350
for arm in arms:
270351
cell = median_cell(pw, arm, n, args.repeats)
271352
rows.append(cell)
353+
uss = cell["uss_mb"]
272354
print(f" {arm:7} n={n:5} nodes={cell['nodes_peak']:7} "
273-
f"jsheap={cell['jsheap_peak']/1e6:6.2f}MB "
274-
f"append_layout={cell['append_layout_ms']:7}ms")
355+
f"USS={uss if uss is None else f'{uss:6.1f}'}MB "
356+
f"jsheap={cell['jsheap_peak']/1e6:5.2f}MB "
357+
f"append_layout={cell['append_layout_ms']:6}ms style={cell['append_style_ms']:6}ms")
275358

276359
RESULTS_DIR.mkdir(parents=True, exist_ok=True)
277-
(RESULTS_DIR / "bench.json").write_text(json.dumps(rows, indent=2))
278-
_write_markdown(rows, lengths, arms)
360+
(RESULTS_DIR / "bench.json").write_text(json.dumps({"env": env, "results": rows}, indent=2))
361+
_write_markdown(rows, lengths, arms, env)
279362
print(f"\nWrote {RESULTS_DIR/'bench.json'} and {RESULTS_DIR/'bench.md'}")
280363

281364

282-
def _write_markdown(rows, lengths, arms):
283-
by = {(r["arm"], r["n"]): r for r in rows}
284-
lines = ["# Chat-history benchmark results (generated)\n",
285-
"Retained DOM nodes at the top of history (lower = memory bounded), post-GC JS heap "
286-
"(JS-side retention only — does NOT include #4998's detached DOM, which lives in C++/Blink "
287-
"memory; see the methodology doc's instrument caveat), and append layout cost (streaming "
288-
"into a large list). Generated by `tests/bench/chat_history_bench.py`; do not hand-edit.\n",
289-
"## Retained DOM nodes at top of history\n",
290-
"| n | " + " | ".join(arms) + " |", "|" + "---|" * (len(arms) + 1)]
291-
for n in lengths:
292-
lines.append(f"| {n} | " + " | ".join(str(by[(a, n)]["nodes_peak"]) for a in arms) + " |")
293-
lines.append("\n## Post-GC JS heap at top (MB)\n")
294-
lines.append("| n | " + " | ".join(arms) + " |")
295-
lines.append("|" + "---|" * (len(arms) + 1))
296-
for n in lengths:
297-
lines.append(f"| {n} | " + " | ".join(f"{by[(a,n)]['jsheap_peak']/1e6:.2f}" for a in arms) + " |")
298-
lines.append("\n## Append layout ms (streaming 25 msgs into an n-message list)\n")
299-
lines.append("| n | " + " | ".join(arms) + " |")
300-
lines.append("|" + "---|" * (len(arms) + 1))
365+
def _cell(v, spread):
366+
if v is None:
367+
return "—"
368+
return f"{v} ±{spread}" if spread else f"{v}"
369+
370+
371+
def _table(by, lengths, arms, key, fmt=lambda x: x):
372+
lines = ["| n | " + " | ".join(arms) + " |", "|" + "---|" * (len(arms) + 1)]
301373
for n in lengths:
302-
lines.append(f"| {n} | " + " | ".join(str(by[(a,n)]["append_layout_ms"]) for a in arms) + " |")
303-
(RESULTS_DIR / "bench.md").write_text("\n".join(lines) + "\n")
374+
cells = []
375+
for a in arms:
376+
r = by[(a, n)]
377+
v = r.get(key)
378+
cells.append(_cell(fmt(v) if v is not None else None, r.get(key + "_spread")))
379+
lines.append(f"| {n} | " + " | ".join(cells) + " |")
380+
return lines
381+
382+
383+
def _write_markdown(rows, lengths, arms, env):
384+
by = {(r["arm"], r["n"]): r for r in rows}
385+
L = ["# Chat-history benchmark results (generated)\n",
386+
"Generated by `tests/bench/chat_history_bench.py` (do not hand-edit). Values are medians over "
387+
f"{env['repeats_kept']} kept runs (1 warm-up discarded), shown as `median ±(max-min)`.\n",
388+
"## Environment\n",
389+
f"- Chromium {env['chromium_version']}, {env['platform']}",
390+
f"- {env['cpu_count']} CPUs, {env['total_ram_gb']} GB RAM, Python {env['python']}",
391+
f"- Memory metric: {env['memory_metric']}\n",
392+
"## Renderer process USS at top of history (MB) — ground-truth private memory\n",
393+
"The real OS-level memory unique to the renderer (what OOMs). Lower = bounded.\n"]
394+
L += _table(by, lengths, arms, "uss_mb")
395+
L.append("\n## Retained DOM nodes at top of history — structural memory proxy\n")
396+
L.append("Counts detached-but-referenced nodes, so it captures #4998's retention.\n")
397+
L += _table(by, lengths, arms, "nodes_peak")
398+
L.append("\n## Renderer process RSS at top of history (MB)\n")
399+
L += _table(by, lengths, arms, "rss_mb")
400+
L.append("\n## Post-GC JS heap at top (MB) — JS-side retention only\n")
401+
L.append("Does NOT include #4998's detached DOM (C++/Blink); includes the fork's `_all` strings.\n")
402+
L += _table(by, lengths, arms, "jsheap_peak", fmt=lambda b: round(b / 1e6, 2))
403+
L.append("\n## Append LAYOUT ms (stream 25 msgs into an n-message list)\n")
404+
L.append("Cost of streaming new messages into a large list. `evict` wins because its list stays "
405+
"bounded. **This does NOT capture #4998's lag benefit**: appends land in the live region, "
406+
"not collapsed nodes, so #4998 ≈ naive here. #4998 targets *scroll-repaint* lag, which this "
407+
"harness does not yet measure — do not read this column as \"#4998 fails at lag.\"\n")
408+
L += _table(by, lengths, arms, "append_layout_ms")
409+
L.append("\n## Append STYLE-recalc ms\n")
410+
L.append("Same caveat as layout: measured during append (live region), so #4998's collapse does not "
411+
"help here (the data confirms #4998 ≈ naive). Its style/paint win during scrolling is "
412+
"unmeasured.\n")
413+
L += _table(by, lengths, arms, "append_style_ms")
414+
(RESULTS_DIR / "bench.md").write_text("\n".join(L) + "\n")
304415

305416

306417
if __name__ == "__main__":

0 commit comments

Comments
 (0)