2424import argparse
2525import json
2626import pathlib
27+ import platform
28+ import shutil
2729import statistics
2830import sys
31+ import tempfile
32+
33+ import psutil
2934
3035ROOT = pathlib .Path (__file__ ).resolve ().parent .parent .parent
3136CHAT_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# ---------------------------------------------------------------------------
176225def 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
240299def 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
251318def 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"\n Wrote { 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
306417if __name__ == "__main__" :
0 commit comments