Web performance: light animation, gestures on a compositor layer, no forced layouts
Deploy web Studio / deploy (push) Successful in 3m46s
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.
This commit is contained in:
1 parent
19f4c83ae8
commit
a202283a60
15 files changed
+476
-31
No files matched your search
@@ -0,0 +1,251 @@
|
||||
"""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()
|
||||
Reference in new issue
Block a user