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
+
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())