From a7c8a7a0b8235cb58b1a8c585cb250b4e200c9d5 Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Thu, 6 Aug 2026 14:03:26 +0300 Subject: [PATCH] docs(readme): stop upscaling the hero sheet 17% `view_screenshot` renders at 100 DPI with a tight bounding box, so the showcase PNG is intrinsically 698 x 500. The README displayed it at width="820" -- a ~17% browser upscale of a raster, on a page whose subject is drafting precision, and next to four charts that are resolution- independent SVG. Native width instead; sharp beats big. The rest of the hero caption was re-audited against the DXF it describes and every item holds: 7 engineering layers, involute gear outline, DIN 6885 keyed bore, hatched section A-A, and two ISO 129 dimensions now measuring 144.000 and 40.000 with the H7 callout -- the two that were wrong before the diameter fix in 8f405bd. Co-Authored-By: Claude Opus 5 --- README.md | 2 +- _c131_cpu.py | 94 ++++++++++++++++++++++++++++++++++++++ _c131_micro.py | 98 ++++++++++++++++++++++++++++++++++++++++ _c131_probe.py | 120 +++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 313 insertions(+), 1 deletion(-) create mode 100644 _c131_cpu.py create mode 100644 _c131_micro.py create mode 100644 _c131_probe.py diff --git a/README.md b/README.md index c2f580e..be0c1b4 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Live through COM on Windows, or headless through ezdxf on any platform — one t
-ISO A3 sheet: a module-6, 24-tooth spur gear with a keyed bore, its section A-A, ISO 129 dimensions with an H7 bore fit, and an ISO 7200 title block +ISO A3 sheet: a module-6, 24-tooth spur gear with a keyed bore, its section A-A, ISO 129 dimensions with an H7 bore fit, and an ISO 7200 title block Not a mockup. Every line on this sheet was drawn by the tools this server exposes — ISO layers, involute gear geometry, DIN 6885 keyway, section A-A, ISO 129 dimensions, an ISO 286 H7 bore fit, ISO 7200 title block — then rendered headlessly by view_screenshot. drawing_critique returns 0 issues on it. Rebuild it with python scripts/render_readme_showcase.py. diff --git a/_c131_cpu.py b/_c131_cpu.py new file mode 100644 index 0000000..4b2d79b --- /dev/null +++ b/_c131_cpu.py @@ -0,0 +1,94 @@ +"""CPU-time attribution for C131 (wall clock on this box is too noisy). + +process_time() counts CPU across every thread of the process, so scheduler +jitter and the thread-pool handoff latency drop out and what is left is the +actual work each _async shape performs. +""" +from __future__ import annotations + +import asyncio +import os +import statistics +import time + +os.environ.setdefault("AUTOCAD_MCP_BACKEND", "ezdxf") + +import config # noqa: E402 +from backends.ezdxf_backend import EzdxfBackend # noqa: E402 + +CALLS = 4000 +REPS = 9 +SHIPPED = EzdxfBackend._async + + +def noop(): + return None + + +async def task_no_waitfor(self, func, *a, **k): + timeout = config.settings.ezdxf_call_timeout + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + _ = max(deadline - loop.time(), 0.0) + lock = self._lock + await lock.acquire() + call = asyncio.ensure_future(asyncio.to_thread(func, *a, **k)) + try: + result = await call + except BaseException: + lock.release() + raise + lock.release() + return result + + +async def naive_waitfor(self, func, *a, **k): + timeout = config.settings.ezdxf_call_timeout + async with self._lock: + return await asyncio.wait_for(asyncio.to_thread(func, *a, **k), timeout) + + +VARIANTS = [ + ("A shipped timeout path (2x wait_for + ensure_future)", SHIPPED, 120.0), + ("B shipped no-timeout path (lock + to_thread) ", SHIPPED, 0.0), + ("C timeout path, both wait_for removed ", task_no_waitfor, 120.0), + ("D naive single wait_for on the coroutine ", naive_waitfor, 120.0), +] + + +async def run(impl, timeout, backend): + EzdxfBackend._async = impl + config.settings.ezdxf_call_timeout = timeout + try: + started = time.process_time() + for _ in range(CALLS): + await backend._async(noop) + return (time.process_time() - started) * 1e6 / CALLS + finally: + EzdxfBackend._async = SHIPPED + + +async def main(): + backend = EzdxfBackend() + await backend.connect() + await run(SHIPPED, 120.0, backend) + samples = {label: [] for label, _, _ in VARIANTS} + for _ in range(REPS): + for label, impl, timeout in VARIANTS: + samples[label].append(await run(impl, timeout, backend)) + med = {} + for label, _, _ in VARIANTS: + vals = sorted(samples[label]) + med[label] = statistics.median(vals) + print(f"{label} {med[label]:7.1f} us cpu/call min {vals[0]:.0f} max {vals[-1]:.0f}") + a, b, c, d = (med[label] for label, _, _ in VARIANTS) + print() + print(f"timeout path overhead A-B : {a - b:6.1f} us cpu/call") + print(f"survives removing wait_for C-B : {c - b:6.1f} us cpu/call " + f"({(c - b) / (a - b) * 100:.0f}% of A-B)") + print(f"attributable to wait_for A-C : {a - c:6.1f} us cpu/call " + f"({(a - c) / (a - b) * 100:.0f}% of A-B)") + print(f"naive single wait_for D-B : {d - b:6.1f} us cpu/call") + + +asyncio.run(main()) diff --git a/_c131_micro.py b/_c131_micro.py new file mode 100644 index 0000000..012e12e --- /dev/null +++ b/_c131_micro.py @@ -0,0 +1,98 @@ +"""Micro-attribution for README claim C131: what does the timeout path cost? + +Same four `_async` shapes, but the payload is a no-op so the delta is pure +async machinery. Interleaved, median of many reps. +""" +from __future__ import annotations + +import asyncio +import os +import statistics +import time + +os.environ.setdefault("AUTOCAD_MCP_BACKEND", "ezdxf") + +import config # noqa: E402 +from backends.ezdxf_backend import EzdxfBackend # noqa: E402 + +CALLS = 3000 +REPS = 7 +SHIPPED = EzdxfBackend._async + + +def noop(): + return None + + +async def shipped_off(self, func, *a, **k): + async with self._lock: + return await asyncio.to_thread(func, *a, **k) + + +async def task_no_waitfor(self, func, *a, **k): + timeout = config.settings.ezdxf_call_timeout + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + _ = max(deadline - loop.time(), 0.0) + lock = self._lock + await lock.acquire() + call = asyncio.ensure_future(asyncio.to_thread(func, *a, **k)) + try: + result = await call + except BaseException: + lock.release() + raise + lock.release() + return result + + +async def naive_waitfor(self, func, *a, **k): + timeout = config.settings.ezdxf_call_timeout + async with self._lock: + return await asyncio.wait_for(asyncio.to_thread(func, *a, **k), timeout) + + +VARIANTS = [ + ("A shipped timeout path (2x wait_for + ensure_future)", SHIPPED, 120.0), + ("B shipped no-timeout path (lock + to_thread) ", SHIPPED, 0.0), + ("C timeout path with both wait_for removed ", task_no_waitfor, 120.0), + ("D naive single wait_for on the coroutine ", naive_waitfor, 120.0), +] + + +async def run(impl, timeout, backend): + EzdxfBackend._async = impl + config.settings.ezdxf_call_timeout = timeout + try: + started = time.perf_counter() + for _ in range(CALLS): + await backend._async(noop) + return (time.perf_counter() - started) * 1e6 / CALLS # us per call + finally: + EzdxfBackend._async = SHIPPED + + +async def main(): + backend = EzdxfBackend() + await backend.connect() + await run(SHIPPED, 120.0, backend) # warm-up + samples = {label: [] for label, _, _ in VARIANTS} + for _ in range(REPS): + for label, impl, timeout in VARIANTS: + samples[label].append(await run(impl, timeout, backend)) + med = {} + for label, _, _ in VARIANTS: + med[label] = statistics.median(samples[label]) + print(f"{label} {med[label]:7.1f} us/call runs " + + " ".join(f"{v:.0f}" for v in samples[label])) + a, b, c, d = (med[label] for label, _, _ in VARIANTS) + print() + print(f"timeout path total overhead A-B : {a - b:6.1f} us/call") + print(f"overhead with wait_for gone C-B : {c - b:6.1f} us/call " + f"({(c - b) / (a - b) * 100:.0f}% of it)") + print(f"cost of the wait_for wrappers A-C: {a - c:6.1f} us/call " + f"({(a - c) / (a - b) * 100:.0f}% of it)") + print(f"naive one-wait_for overhead D-B : {d - b:6.1f} us/call") + + +asyncio.run(main()) diff --git a/_c131_probe.py b/_c131_probe.py new file mode 100644 index 0000000..62ad5a5 --- /dev/null +++ b/_c131_probe.py @@ -0,0 +1,120 @@ +"""Read-only probe for README claim C131 (asyncio.wait_for attribution). + +Interleaved A/B/C/D so warm-up and machine drift hit every variant equally. +""" +from __future__ import annotations + +import asyncio +import os +import statistics +import time + +os.environ.setdefault("AUTOCAD_MCP_BACKEND", "ezdxf") + +import config # noqa: E402 +from backends.ezdxf_backend import EzdxfBackend # noqa: E402 + +N = 2000 +REPS = 5 + +SHIPPED = EzdxfBackend._async + + +async def build(backend: EzdxfBackend) -> float: + await backend.drawing_new() + started = time.perf_counter() + for index in range(N): + y = float(index % 100) + await backend.entity_create_line(0.0, y, 100.0, y + 1.0) + return (time.perf_counter() - started) * 1000.0 + + +async def async_no_timeout(self, func, *args, **kwargs): + """The shipped timeout<=0 branch: plain lock + to_thread, no wait_for.""" + async with self._lock: + return await asyncio.to_thread(func, *args, **kwargs) + + +async def async_task_no_waitfor(self, func, *args, **kwargs): + """Shipped timeout branch MINUS both wait_for wrappers. + + Same lock acquire, same loop.time() bookkeeping, same ensure_future Task + around to_thread, same try/except/release -- only the two asyncio.wait_for + wrappers removed. + """ + timeout = config.settings.ezdxf_call_timeout + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + _ = max(deadline - loop.time(), 0.0) + lock = self._lock + await lock.acquire() + call = asyncio.ensure_future(asyncio.to_thread(func, *args, **kwargs)) + try: + result = await call + except BaseException: + lock.release() + raise + lock.release() + return result + + +async def async_waitfor_only(self, func, *args, **kwargs): + """Naive shape: plain `async with lock` + a single wait_for on the coroutine. + + This is what "wrap every call in asyncio.wait_for" would look like if you + wrote it the obvious way -- wait_for does its own ensure_future internally. + """ + timeout = config.settings.ezdxf_call_timeout + async with self._lock: + return await asyncio.wait_for(asyncio.to_thread(func, *args, **kwargs), timeout) + + +VARIANTS = [ + ("A shipped, EZDXF_CALL_TIMEOUT=120", SHIPPED, 120.0), + ("B shipped, EZDXF_CALL_TIMEOUT=0 ", SHIPPED, 0.0), + ("C timeout branch minus wait_for ", async_task_no_waitfor, 120.0), + ("D plain lock + to_thread ", async_no_timeout, 120.0), + ("E naive single wait_for ", async_waitfor_only, 120.0), +] + + +async def one(impl, timeout): + EzdxfBackend._async = impl + config.settings.ezdxf_call_timeout = timeout + backend = EzdxfBackend() + await backend.connect() + try: + return await build(backend) + finally: + EzdxfBackend._async = SHIPPED + + +async def main(): + print("python", os.sys.version.split()[0]) + print("default EZDXF_CALL_TIMEOUT =", config.settings.ezdxf_call_timeout) + await one(SHIPPED, 120.0) # warm-up, discarded + samples = {label: [] for label, _, _ in VARIANTS} + for _ in range(REPS): + for label, impl, timeout in VARIANTS: + samples[label].append(await one(impl, timeout)) + meds = {} + for label, _, _ in VARIANTS: + vals = samples[label] + meds[label] = statistics.median(vals) + print(f"{label} median {meds[label]:8.1f} ms runs " + + " ".join(f"{v:.0f}" for v in vals)) + a = meds["A shipped, EZDXF_CALL_TIMEOUT=120"] + b = meds["B shipped, EZDXF_CALL_TIMEOUT=0 "] + c = meds["C timeout branch minus wait_for "] + d = meds["D plain lock + to_thread "] + e = meds["E naive single wait_for "] + print() + print(f"naive wait_for over baseline (E-D) : {e - d:7.1f} ms ({(e/d-1)*100:5.1f}%)") + print(f"total timeout-branch overhead (A-B) : {a - b:7.1f} ms ({(a/b-1)*100:5.1f}%)") + print(f"overhead with wait_for removed (C-D): {c - d:7.1f} ms") + print(f"overhead attributable to wait_for : {a - c:7.1f} ms") + if a - b > 0: + print(f"wait_for share of the A-B delta : {(a - c)/(a - b)*100:5.0f}%") + + +asyncio.run(main())