scripts/benchmark-plots.py 1#!/usr/bin/env python32"""Compare complete plotting workflows at equal input, geometry and PNG size.3 4Requires numpy, matplotlib, pillow, psutil, and the built LayMesh packages.5Runs each workflow in isolated processes; samples process-tree RSS every 10 ms.6The bridge inherently also converts the Matplotlib Figure and launches the CLI.7"""8from __future__ import annotations9import argparse10import json11import os12from pathlib import Path13import platform14import statistics15import subprocess16import sys17import tempfile18import time19 20ROOT = Path(__file__).resolve().parents[1]21 22 23def cpu_name():24 if platform.processor():25 return platform.processor()26 if Path('/proc/cpuinfo').exists():27 for line in Path('/proc/cpuinfo').read_text().splitlines():28 if line.startswith('model name'):29 return line.split(':', 1)[1].strip()30 return platform.machine()31 32 33def python_worker(config_path: str):34 import io35 import warnings36 import matplotlib37 matplotlib.use("Agg")38 import matplotlib.pyplot as plt39 from matplotlib.font_manager import FontProperties40 import numpy as np41 sys.path.insert(0, str(ROOT / "python"))42 from laymesh import render_source43 cfg = json.loads(Path(config_path).read_text())44 font = FontProperties(family="DejaVu Sans", size=8)45 plt.rcParams.update({"path.simplify": False, "agg.path.chunksize": 0,46 "font.size": 8, "axes.linewidth": .6, "svg.fonttype": "path"})47 samples, messages = [], []48 for _ in range(cfg["iterations"]):49 start = time.perf_counter()50 raw = json.loads(Path(cfg["data"]).read_text())51 # Match native plot frame: page 120x90 mm; chart at (10,10), size 100x70;52 # margins left/top/right/bottom = 18/6/8/18 mm.53 fig = plt.figure(figsize=(120 / 25.4, 90 / 25.4), facecolor="white")54 ax = fig.add_axes([28 / 120, 28 / 90, 74 / 120, 46 / 90])55 ax.set_xlim(0, 1); ax.set_ylim(0, 1)56 ticks = [0, .25, .5, .75, 1]57 ax.set_xticks(ticks, [f"{v:.2f}" for v in ticks], fontproperties=font)58 ax.set_yticks(ticks, [f"{v:.2f}" for v in ticks], fontproperties=font)59 ax.set_xlabel("x", fontproperties=font); ax.set_ylabel("y", fontproperties=font)60 ax.spines[["top", "right"]].set_visible(False)61 ax.tick_params(length=1.2 * 72 / 25.4, width=.6, pad=1.2 * 72 / 25.4)62 if cfg["kind"] == "line":63 ax.plot(raw["x"], raw["y"], color="#0072B2", linewidth=.6)64 elif cfg["kind"] == "scatter":65 ax.scatter(raw["x"], raw["y"], color="#0072B2", s=9, linewidths=0)66 else:67 ax.imshow(np.asarray(raw), extent=(0, 1, 0, 1), origin="lower", aspect="auto",68 cmap="viridis", vmin=0, vmax=1, interpolation="nearest")69 with warnings.catch_warnings(record=True) as caught:70 warnings.simplefilter("always")71 if cfg["workflow"] == "bridge":72 result = render_source('page=canvas(size=(120 mm,90 mm),background="#ffffff")\npicture=image(src={{fig}})\npage.add(picture,size=(120 mm, 90 mm))',73 namespace={"fig": fig}, base_dir=Path(cfg["output"]).parent,74 output=cfg["output"], dpi=254, plot_dpi=254)75 preview_bytes = len(result.preview_svg.encode())76 else:77 stream = io.StringIO(); fig.savefig(stream, format="svg")78 preview_bytes = len(stream.getvalue().encode())79 fig.savefig(cfg["output"], format="png", dpi=254)80 messages.extend(str(w.message) for w in caught)81 plt.close(fig)82 samples.append({"seconds": time.perf_counter() - start, "preview_bytes": preview_bytes,83 "output_bytes": Path(cfg["output"]).stat().st_size})84 print(json.dumps({"samples": samples, "warnings": sorted(set(messages)),85 "versions": {"python": platform.python_version(), "matplotlib": matplotlib.__version__, "numpy": np.__version__}}))86 87 88def native_worker(config_path):89 sys.path.insert(0, str(ROOT / "python"))90 from laymesh.bridge import _command91 cfg=json.loads(Path(config_path).read_text()); samples=[]; warnings=[]92 for _ in range(cfg["iterations"]):93 start=time.perf_counter();preview=Path(cfg["output"]).with_suffix('.svg')94 for output in [preview,Path(cfg["output"])]:95 cmd=[*_command(),'render',cfg['lay'],'-o',str(output)]96 if output.suffix=='.png':cmd+=['--dpi','254']97 result=subprocess.run(cmd,capture_output=True,text=True,check=True)98 warnings.extend(result.stderr.splitlines())99 samples.append({'seconds':time.perf_counter()-start,'preview_bytes':preview.stat().st_size,'output_bytes':Path(cfg['output']).stat().st_size})100 print(json.dumps({'samples':samples,'warnings':sorted(set(warnings)), 'versions':{'engine':subprocess.check_output([*_command(),'--version'],text=True).strip()}}))101 102 103def measured(command):104 import psutil105 # Temporary files avoid deadlock if a child writes a long warning or error.106 with tempfile.TemporaryFile() as out, tempfile.TemporaryFile() as err:107 start = time.perf_counter()108 process = subprocess.Popen(command, cwd=ROOT, stdout=out, stderr=err)109 peak = 0110 while process.poll() is None:111 try:112 parent = psutil.Process(process.pid)113 rss = 0114 for child in [parent, *parent.children(recursive=True)]:115 try:116 rss += child.memory_info().rss117 except psutil.Error:118 pass119 peak = max(peak, rss)120 except psutil.Error:121 pass122 time.sleep(.01)123 elapsed = time.perf_counter() - start124 out.seek(0); stdout = out.read().decode()125 err.seek(0); stderr = err.read().decode()126 if process.returncode:127 raise RuntimeError(f"Worker failed ({process.returncode}): {stderr[-4000:]}\n{stdout[-1000:]}")128 payload = json.loads(stdout.strip().splitlines()[-1])129 return {"wall_seconds": elapsed, "peak_tree_rss_mib": peak / 1024**2, **payload}130 131 132def main():133 parser = argparse.ArgumentParser(description=__doc__)134 parser.add_argument("--worker")135 parser.add_argument("--native-worker")136 parser.add_argument("--repeats", type=int, default=3)137 parser.add_argument("--output", type=Path, default=ROOT / "docs/benchmarks/native-plots.json")138 args = parser.parse_args()139 if args.native_worker:140 native_worker(args.native_worker); return141 if args.worker:142 python_worker(args.worker); return143 if args.repeats < 2:144 parser.error("--repeats must be at least 2 (one warm-up plus measured repetitions)")145 import numpy as np146 from PIL import Image147 workloads = [(kind, n) for kind in ("line", "scatter") for n in (1000, 100000)] + [("heatmap", n) for n in (64, 512)]148 results = []149 with tempfile.TemporaryDirectory(prefix="laymesh-plot-benchmark-") as folder:150 temp = Path(folder)151 for kind, n in workloads:152 data = temp / f"{kind}-{n}.json"153 if kind == "heatmap":154 yy, xx = np.mgrid[0:n, 0:n] / max(1, n - 1)155 values = (.5 + .45 * np.sin(8 * xx) * np.cos(8 * yy)).tolist()156 else:157 x = np.linspace(0, 1, n)158 values = {"x": x.tolist(), "y": (.5 + .35 * np.sin(x * 20)).tolist()}159 data.write_text(json.dumps(values, separators=(",", ":")))160 lay = temp / f"{kind}-{n}.lay"161 quote = lambda v: json.dumps(str(v))162 source = f'''page=canvas(size=(120 mm,90 mm),background="#ffffff")163s=plot_style(font_family={quote('DejaVu Sans')},font_size=8 pt,line_width=0.6 pt)164p=plot(size=(100 mm,70 mm),x=axis(label="x",range=(0,1),ticks=[0,0.25,0.5,0.75,1],format=".2f"),y=axis(label="y",range=(0,1),ticks=[0,0.25,0.5,0.75,1],format=".2f"),style=s,margins=(18 mm,6 mm,8 mm,18 mm))165'''166 if kind == "heatmap":167 source += f'z=array(src={quote(data)})\np.heatmap(z=z,extent=(0,1,0,1),vmin=0,vmax=1)\n'168 else:169 source += f'd=table(src={quote(data)})\np.{kind}(x=d["x"],y=d["y"],color="#0072B2")\n'170 source += 'page.add(p,offset=(10 mm,10 mm))\n'171 lay.write_text(source)172 for workflow in ("native", "matplotlib", "bridge"):173 cfg = {"workflow": workflow, "kind": kind, "size": n, "lay": str(lay), "data": str(data),174 "output": str(temp / f"{kind}-{n}-{workflow}.png")}175 config = temp / "worker.json"176 command = [sys.executable, __file__, "--native-worker", str(config)] if workflow == "native" else [sys.executable, __file__, "--worker", str(config)]177 config.write_text(json.dumps({**cfg, "iterations": 1}))178 cold = [measured(command) for _ in range(args.repeats)]179 config.write_text(json.dumps({**cfg, "iterations": args.repeats + 1}))180 warm = measured(command)181 with Image.open(cfg["output"]) as image:182 if image.size != (1200, 900):183 raise RuntimeError(f"Mismatched PNG size: {workflow} {image.size}")184 record = {"kind": kind, "size": n, "workflow": workflow, "cold": cold, "warm": warm,185 "cold_median_seconds": statistics.median(r["wall_seconds"] for r in cold),186 "warm_median_seconds": statistics.median(s["seconds"] for s in warm["samples"][1:]),187 "peak_tree_rss_mib": max(warm["peak_tree_rss_mib"], *(r["peak_tree_rss_mib"] for r in cold))}188 results.append(record)189 print(f'{kind} {n} {workflow}: cold {record["cold_median_seconds"]:.3f}s, warm {record["warm_median_seconds"]:.3f}s, tree RSS {record["peak_tree_rss_mib"]:.1f} MiB', flush=True)190 report = {"environment": {"platform": platform.platform(), "cpu": cpu_name(), "logical_cpus": os.cpu_count(),191 "python": platform.python_version(), "engine": "Rust native CLI (startup included in every export)"},192 "method": {"repeats": args.repeats, "png_pixels": [1200,900], "page_mm": [120,90], "dpi": 254,193 "deliverables": "SVG preview in memory plus PNG on disk", "data_preparation_timed": False,194 "input_loading_timed": True, "sampling": False, "matplotlib_path_simplify": False,195 "warmup_discarded": 1, "rss": "10 ms sampled sum of worker and descendant process RSS; approximate, shared pages may be counted more than once",196 "limits": "Typography/rasterizers differ; bridge may rasterize the complete Figure. This compares workflows, not isolated rasterizer throughput. PNG file size is compression-dependent. Preview text embedding differs."},197 "results": results}198 args.output.parent.mkdir(parents=True, exist_ok=True)199 args.output.write_text(json.dumps(report, indent=2) + "\n")200 print(f"Saved {args.output}")201 202 203if __name__ == "__main__":204 main()