Deploy web Studio / deploy (push) Successful in 3m46s
Measured with the new tools/bench_web.py on the web Studio's house demo, CPU slowed 6x:
- Idle: running CSS animations repainted the whole drawing every frame (99% of the main
thread in a window). Animations now step on about ten times a second by default
("light", 5% idle); the new toolbar button switches light / smooth / off and the browser
remembers it; [build] animation sets the default for built pages; view.animation().
Pan and zoom gestures pause them. Status LEDs pause off-screen and with the rest now.
- Pan 28 -> 10 ms, zoom 69 -> 11 ms per step: gestures move the drawing as one compositor
layer (transform) and set the viewBox once they end; --iso-k is set on the tags group
only and only when it changes; the stage box is read once per frame.
- Hover 22 -> 15 ms per move: the tooltip is placed once per frame on its own layer and
measured only when its text changes; the Studio's x/y readout updates ten times a second
in a contained box; hover work in the Studio runs once per frame.
- Long object lists skip layout of rows out of sight (content-visibility).
- WASM built with opt-level 3: engine render 11.7 -> 9.4 ms for 10% more download.
Checks: Studio 102/102 (animation modes, pan as a layer, contained readout), web 87/87.
252 lines
10 KiB
Python
252 lines
10 KiB
Python
"""How the web Studio and a static page run on a slow machine.
|
|
|
|
Serves dist/web (build it first: python tools/build_web.py) and opens it and
|
|
an example's static page (isokit build examples/house) in headless Edge with
|
|
the CPU slowed down (CDP Emulation.setCPUThrottlingRate; 6x is roughly a
|
|
low-power laptop next to a desktop). For each page it prints the load time,
|
|
DOM size, the main thread's busy share while idle (animations), frames per
|
|
main-thread time per input step and long frames (over 50 ms) while panning,
|
|
zooming and hovering, and how long a
|
|
rotation, a level switch and an edit take until painted.
|
|
|
|
uv run --with playwright python tools/bench_web.py [--rate 6] [--headed] [--anim light|smooth|off]
|
|
[--json out.json] [--only studio|page]
|
|
|
|
Use --headed for the idle figures: headless Edge renders no frames for CSS
|
|
animations when nothing else changes, so their cost only shows in a window.
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
from playwright.sync_api import sync_playwright
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
DIST = ROOT / "dist" / "web"
|
|
PAGE = ROOT / "examples" / "house" / "dist" / "index.html"
|
|
PORT = 7941
|
|
ANIM = None
|
|
|
|
# in the page: collect long animation frames (over 50 ms, Chromium's LoAF API);
|
|
# no requestAnimationFrame loop, which would make headless Chrome render flat out.
|
|
# JS_TIMED times an action until the view's SVG has new content and two frames
|
|
# have been painted.
|
|
JS_SETUP = """() => {
|
|
if (window.__bench) return;
|
|
window.__bench = { loaf: [] };
|
|
try {
|
|
new PerformanceObserver(l => l.getEntries().forEach(e => window.__bench.loaf.push(e.duration)))
|
|
.observe({ type: 'long-animation-frame', buffered: false });
|
|
} catch (e) { /* not supported */ }
|
|
}"""
|
|
JS_TIMED = """async (code) => {
|
|
const svg = document.querySelector('.iso-stage .iso-svg');
|
|
const t0 = performance.now();
|
|
const changed = new Promise(r => { const mo = new MutationObserver(() => { mo.disconnect(); r(); }); mo.observe(svg, { childList: true }); });
|
|
(0, eval)(code);
|
|
const late = await Promise.race([changed.then(() => false), new Promise(r => setTimeout(() => r(true), 30000))]);
|
|
if (late) return -1;
|
|
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
|
|
return performance.now() - t0;
|
|
}"""
|
|
|
|
|
|
T0 = time.perf_counter()
|
|
|
|
|
|
def log(what):
|
|
print(f" [{time.perf_counter() - T0:6.1f}s] {what}", flush=True)
|
|
|
|
|
|
def metrics(cdp):
|
|
m = {x["name"]: x["value"] for x in cdp.send("Performance.getMetrics")["metrics"]}
|
|
return m
|
|
|
|
|
|
def busy(cdp, pg, run, steps=1):
|
|
"""Run `run()` (`steps` input events); answer a dict: wall seconds, the main
|
|
thread's busy share, its ms per step, and the long frames (count, longest)."""
|
|
pg.evaluate(JS_SETUP)
|
|
pg.evaluate("window.__bench.loaf = []")
|
|
m0, t0 = metrics(cdp), time.perf_counter()
|
|
run()
|
|
pg.wait_for_timeout(300)
|
|
m1, t1 = metrics(cdp), time.perf_counter()
|
|
loaf = pg.evaluate("window.__bench.loaf")
|
|
wall, task = t1 - t0, m1["TaskDuration"] - m0["TaskDuration"]
|
|
return {"s": wall, "busy": task / wall, "ms_step": 1000 * task / steps, "long": len(loaf), "max_ms": max(loaf or [0])}
|
|
|
|
|
|
def gestures(pg, cdp, stage, out, wheel_mod=None):
|
|
b = stage.bounding_box()
|
|
cx, cy = b["x"] + b["width"] / 2, b["y"] + b["height"] / 2
|
|
out["nodes"] = pg.evaluate("document.querySelectorAll('.iso-stage .iso-svg *').length")
|
|
log("idle")
|
|
out["idle_busy"] = busy(cdp, pg, lambda: pg.wait_for_timeout(4000))["busy"]
|
|
|
|
def pan():
|
|
# the middle button: always the camera, in the Studio and on a page
|
|
pg.mouse.move(cx - 150, cy)
|
|
pg.mouse.down(button="middle")
|
|
for i in range(90):
|
|
pg.mouse.move(cx - 150 + 300 * (i % 45) / 45, cy + 60 * ((i // 45) % 2))
|
|
pg.wait_for_timeout(16)
|
|
pg.mouse.up(button="middle")
|
|
|
|
def zoom():
|
|
pg.mouse.move(cx, cy)
|
|
if wheel_mod:
|
|
pg.keyboard.down(wheel_mod)
|
|
for i in range(24):
|
|
pg.mouse.wheel(0, -60 if i < 12 else 60)
|
|
pg.wait_for_timeout(30)
|
|
if wheel_mod:
|
|
pg.keyboard.up(wheel_mod)
|
|
|
|
def hover():
|
|
for i in range(90):
|
|
pg.mouse.move(b["x"] + b["width"] * (0.2 + 0.6 * i / 90), cy + 40 * ((i // 10) % 2))
|
|
pg.wait_for_timeout(16)
|
|
|
|
pg.evaluate("__isokit_view().focus([0, 0, 0], 2.2)")
|
|
pg.wait_for_timeout(600)
|
|
for name, fn, n in [("pan", pan, 90), ("zoom", zoom, 24), ("hover", hover, 90)]:
|
|
log(name)
|
|
r = busy(cdp, pg, fn, n)
|
|
for k, v in r.items():
|
|
out[name + "_" + k] = v
|
|
pg.wait_for_timeout(500)
|
|
log("rotate")
|
|
out["rotate_ms"] = pg.evaluate(JS_TIMED, "__isokit_view().setRotation((__isokit_view().getRotation() + 1) % 4)")
|
|
pg.wait_for_timeout(400)
|
|
log("level")
|
|
# to another level and back
|
|
lv0 = pg.evaluate("__isokit_view().getLevel()")
|
|
other = pg.evaluate("[...document.querySelectorAll('.iso-seg button')].map(b => b.dataset.level).find(l => l !== __isokit_view().getLevel())")
|
|
out["level_ms"] = pg.evaluate(JS_TIMED, f"__isokit_view().setLevel('{other}')")
|
|
pg.wait_for_timeout(400)
|
|
pg.evaluate(JS_TIMED, f"__isokit_view().setLevel('{lv0}')")
|
|
pg.wait_for_timeout(400)
|
|
|
|
|
|
def open_page(b, rate):
|
|
pg = b.new_page(viewport={"width": 1366, "height": 768})
|
|
if ANIM:
|
|
pg.add_init_script(f"try {{ localStorage.setItem('isokit-anim', '{ANIM}'); }} catch (e) {{}}")
|
|
pg.bring_to_front()
|
|
cdp = pg.context.new_cdp_session(pg)
|
|
cdp.send("Performance.enable")
|
|
cdp.send("Emulation.setCPUThrottlingRate", {"rate": rate})
|
|
errs = []
|
|
pg.on("pageerror", lambda e: errs.append(str(e)))
|
|
return pg, cdp, errs
|
|
|
|
|
|
def bench_studio(b, rate):
|
|
out = {}
|
|
pg, cdp, errs = open_page(b, rate)
|
|
t0 = time.perf_counter()
|
|
pg.goto(f"http://127.0.0.1:{PORT}/")
|
|
pg.wait_for_selector(".iso-svg [data-obj]", timeout=120000)
|
|
out["load_s"] = time.perf_counter() - t0
|
|
pg.add_init_script("window.__isokit_view = () => window.__studio.view")
|
|
pg.evaluate("window.__isokit_view = () => window.__studio.view")
|
|
pg.locator("#wb-projects").click()
|
|
t0 = time.perf_counter()
|
|
pg.locator("#wb-nhouse").click()
|
|
pg.wait_for_load_state()
|
|
pg.wait_for_function("window.__studio && __studio.S.doc && __studio.S.doc.objects.length > 50 && document.querySelector('.iso-svg [data-obj]')", timeout=120000)
|
|
out["open_house_s"] = time.perf_counter() - t0
|
|
pg.evaluate("window.__isokit_view = () => window.__studio.view")
|
|
pg.wait_for_timeout(1000)
|
|
gestures(pg, cdp, pg.locator(".iso-stage"), out)
|
|
out["edit_ms"] = pg.evaluate(JS_TIMED, "__studio.op({op: 'add', type: 'tree', x: 14, y: -5})")
|
|
pg.wait_for_timeout(300)
|
|
out["select_ms"] = pg.evaluate("""() => { const t0 = performance.now(); __studio.select('sofa'); return new Promise(r => requestAnimationFrame(() => requestAnimationFrame(() => r(performance.now() - t0)))); }""")
|
|
out["errors"] = errs
|
|
pg.close()
|
|
return out
|
|
|
|
|
|
def bench_page(b, rate):
|
|
out = {}
|
|
pg, cdp, errs = open_page(b, rate)
|
|
t0 = time.perf_counter()
|
|
pg.goto(PAGE.as_uri())
|
|
pg.wait_for_selector(".iso-svg [data-obj]", timeout=120000)
|
|
out["load_s"] = time.perf_counter() - t0
|
|
pg.evaluate("window.__isokit_view = () => window.__isokit.view")
|
|
pg.wait_for_timeout(1000)
|
|
gestures(pg, cdp, pg.locator(".iso-stage"), out, wheel_mod="Control")
|
|
out["errors"] = errs
|
|
pg.close()
|
|
return out
|
|
|
|
|
|
ROWS = [
|
|
("load_s", "load (s)", "{:.2f}"),
|
|
("open_house_s", "open house demo (s)", "{:.2f}"),
|
|
("nodes", "SVG nodes", "{:.0f}"),
|
|
("idle_busy", "idle: main thread busy", "{:.0%}"),
|
|
("pan_ms_step", "pan: main thread ms per move", "{:.1f}"),
|
|
("pan_busy", "pan: busy", "{:.0%}"),
|
|
("pan_long", "pan: frames over 50 ms", "{:.0f}"),
|
|
("pan_max_ms", "pan: longest frame (ms)", "{:.0f}"),
|
|
("zoom_ms_step", "zoom: ms per wheel step", "{:.1f}"),
|
|
("zoom_long", "zoom: frames over 50 ms", "{:.0f}"),
|
|
("zoom_max_ms", "zoom: longest frame (ms)", "{:.0f}"),
|
|
("hover_ms_step", "hover: ms per move", "{:.1f}"),
|
|
("hover_long", "hover: frames over 50 ms", "{:.0f}"),
|
|
("rotate_ms", "rotate until painted (ms)", "{:.0f}"),
|
|
("level_ms", "level switch until painted (ms)", "{:.0f}"),
|
|
("edit_ms", "edit until painted (ms)", "{:.0f}"),
|
|
("select_ms", "select until painted (ms)", "{:.0f}"),
|
|
]
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--rate", type=float, default=6.0)
|
|
ap.add_argument("--json")
|
|
ap.add_argument("--only", choices=["studio", "page"])
|
|
ap.add_argument("--headed", action="store_true")
|
|
ap.add_argument("--anim", choices=["light", "smooth", "off"])
|
|
a = ap.parse_args()
|
|
global ANIM
|
|
ANIM = a.anim
|
|
if not (DIST / "index.html").exists():
|
|
sys.exit("no dist/web: run python tools/build_web.py first")
|
|
srv = subprocess.Popen([sys.executable, "-m", "http.server", str(PORT), "--bind", "127.0.0.1", "--directory", str(DIST)],
|
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
res = {}
|
|
try:
|
|
time.sleep(0.8)
|
|
with sync_playwright() as p:
|
|
b = p.chromium.launch(channel="msedge", headless=not a.headed, args=["--window-position=0,0"])
|
|
if a.only != "page":
|
|
res["studio"] = bench_studio(b, a.rate)
|
|
if a.only != "studio" and PAGE.exists():
|
|
res["page"] = bench_page(b, a.rate)
|
|
b.close()
|
|
finally:
|
|
srv.terminate()
|
|
cols = list(res)
|
|
print(f"CPU slowed {a.rate:g}x, {'window' if a.headed else 'headless'}, animation {a.anim or 'default'}")
|
|
print(f"{'':34}" + "".join(f"{c:>12}" for c in cols))
|
|
for k, lab, fmt in ROWS:
|
|
if any(k in res[c] for c in cols):
|
|
print(f"{lab:34}" + "".join(f"{(fmt.format(res[c][k]) if k in res[c] else '-'):>12}" for c in cols))
|
|
for c in cols:
|
|
if res[c].get("errors"):
|
|
print(c, "errors:", res[c]["errors"])
|
|
if a.json:
|
|
Path(a.json).write_text(json.dumps(res, indent=1))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|