Skip to content

Navigation Menu

Sign in
Sign up

perf(runtime): proposal — scope the sandbox lock so runs can overlap - #501

Draft
clairbee wants to merge 1 commit into
devel from
claude/partcad-performance-analysis-y1yaen-h1-sandbox-concurrency
Draft

perf(runtime): proposal — scope the sandbox lock so runs can overlap #501
clairbee wants to merge 1 commit into
devel from
claude/partcad-performance-analysis-y1yaen-h1-sandbox-concurrency

Conversation

@clairbee

@clairbee clairbee commented Aug 8, 2026

Copy link
×ばつ format combination and `asyncio.gather`s them on one loop (`project.py:1220`). They all share that thread's single `asyncio.Lock` from `get_async_lock()`, so `gather` executes them strictly one at a time. ### The lock is not gratuitous `runtime_python.py:511-523` documents a real hazard: installing `build123d` pulls `cadquery-ocp-novtk`, which overwrites the very same OCP native module `cadquery-ocp` installs. An install slipping in between another part's `CADQUERY_OCP` re-assertion and its actual run leaves that run importing a half-installed OCP and dying with an unrelated-looking `ImportError`. **That hazard must survive this change.** But the mutual exclusion it motivates is *(venv, install-vs-run)*, while what is implemented is *(runtime, any run)* — and sessionless runs, which install nothing at that point, are serialized along with everything else. ### What is *not* the problem - Not `FileLock` contention: that is cross-process, and this reproduces in a single process. - Not thread-pool sizing: the pool is sized correctly (`sync_threads.py:44-70`), it is simply blocked. - Not `Shape.get_wrapped()`'s own lock (`shape.py:209`), though it has the same blocking-lock-across-`await` shape and is worth fixing in passing (task 5.3). Its scope is one shape, so it does not serialize unrelated work. --- ## One claim that is unverified `VenvLock` builds its `FileLock` with `thread_local=False` (`runtime_python.py:146`). Reading `filelock`'s semantics, that keeps the reentrancy counter shared across threads rather than thread-local, which would mean a second thread's `acquire()` on an already-held lock object increments the counter and returns immediately — i.e. `VenvLock` provides **no intra-process mutual exclusion today**, and all of it comes from the `RLock`. That reading was not verified against the pinned `filelock` version. **Task 1.4 settles it before anything is designed around it.** It is called out here so it is not mistaken for an established fact. --- ## Proposed direction (details in `design.md`) - **D1** — a readers/writer gate keyed on the environment being mutated. Writers: `ensure*` when the install guard is absent, and v-env creation. Readers: runs that install nothing. A run holds a read on the sandbox key and, when a session is in play, on the v-env key — in that order, so no cycle is possible. Keep `runtime_python_conda.py`'s global conda lock outermost. - **D2a** (recommended) — keep `threading` primitives for cross-thread coordination but acquire them off-loop (`await asyncio.to_thread(...)`), so waiting parks the coroutine instead of freezing the thread. D2b (one owner loop, `asyncio` primitives throughout) is cleaner long-term but a much larger blast radius. - **D3** — an explicit process-wide concurrency cap from `user_config.threads_max`. Removing the lock uncovers unbounded fan-out; each sandbox process is CPU- and memory-heavy, so this is resource correctness, not polish. - **D4** — leave the guard/reassert bookkeeping (`invalidate_dependent_guards()`, `needs_reassert()`, `FORCE_REINSTALL_FLAGS`) alone. A diff touching both is very hard to review. Three open questions are recorded at the end of `design.md` for whoever implements this. --- ## How to measure No benchmark exists in the repo, so **task 2 builds the baseline before any code is written**. `design.md` carries a self-contained recipe needing no telemetry backend: monkeypatch `PythonRuntime.run_async_onced` to record `(start, end)` spans, then report wall clock, total sandbox busy time, run count, and peak overlap. - **Expected reading today:** `peak_overlap == 1` and `busy ≈ wall`. That is the signature of full serialization, and it is the "before" number to paste into this PR. - **Acceptance:** `peak_overlap` reaches `min(runs, cap)`, and wall clock for N independent parts drops toward `busy / peak_overlap` plus overhead. **State the machine's core count** — the result is meaningless without it. Workload: packages under `examples/` (`produce_part_step`, `produce_part_cadquery_primitive`, `produce_part_build123d_primitive`, `produce_assembly_assy`). A mixed CadQuery/build123d package is both the interesting performance case and the race case. --- ## Risks the implementer must handle | Risk | Mitigation | |---|---| | Reintroducing the OCP-clobbering race — it manifests as a native crash with **no traceback and no stderr** | Task 4 is a dedicated regression test; assert on that exact signature, which `runtime_python.py:601-616` already diagnoses | | Deadlock from mixed ordering with `runtime_python_conda.py`'s global conda lock | D1's fixed acquisition order; task 3.5 audits every acquisition site | | Memory exhaustion once many OCP interpreters run at once | D3's semaphore; task 6.4 records peak RSS | | A race that reproduces one time in ten lands green | Task 4.3 runs the regression ≥20 times, under `pytest -n 4` | --- ## Relationship to the other two proposals Three independent findings, three independent PRs, no ordering dependency between them: - **This one** lets sandbox work spread across cores. - **`sandbox-process-reuse`** reduces how much sandbox work there is (7 interpreter starts for one transformed part rendered to 4 formats). It is complementary — this PR is a prerequisite for its benefit being visible on a multi-core machine, but neither subsumes the other. - **`shape-cache-efficiency`** lowers the incremental-rebuild floor (a cache hit currently reads and MD5s the whole source model). --- ## Validation status Only markdown under `openspec/changes/` is added — no Python is touched, so `pytest`/`behave` outcomes are unaffected. **The pre-commit hooks did not run locally:** this session's container has no working Docker daemon, so the dev container documented in the root `AGENTS.md` could not be started, and host-level `pre-commit` is deliberately not used (host tool versions are not the pinned ones). CI will run the gates. Everything in `tasks.md` is written to be run inside the dev container. --- _Generated by [Claude Code](https://claude.ai/code/session_018enkjjYJCdP34QJKxDVSbt)_" data-view-component="true"> Copy Markdown
Contributor

Copilot Summary

This PR contains a proposal only — no behavior change. It adds an OpenSpec change under
openspec/changes/sandbox-run-concurrency/ (proposal.md, design.md, tasks.md, and a delta spec) recording
a performance finding in partcad/ together with a design, a measurement recipe, and the regression coverage
the fix needs. It is one of three sibling proposals from the same read-through; see "Relationship to the other
two" below.

The implementation is deliberately not included: the finding should be measured on real hardware before code is
written, and tasks.md starts with exactly that. Work through tasks.md in order — it is the handoff.


The finding

Every piece of CAD work PartCAD does happens in a sandboxed Python subprocess, and today no two of those
subprocesses ever run at the same time
, on any machine, regardless of core count.

PythonRuntime.async_lock() (partcad/src/partcad/runtime_python.py:265) acquires two mutual-exclusion
primitives before yielding:

async def async_lock(self, session=None):
 async with self.get_async_lock(): # asyncio.Lock, per (thread, loop, runtime)
 with self.lock: # threading.RLock, ONE per runtime instance
 venv = session["hash"] if session is not None else None
 with VenvLock(self, venv):
 yield

run_async_onced() (runtime_python.py:510) wraps that context manager around the entire subprocess
lifetime — provisioning, asyncio.create_subprocess_exec (line 561), await p.communicate() (line 571), and
the output handling — returning only at line 618.

There is effectively one PythonRuntime instance per process: Context.get_python_runtime()
(context.py:1028) memoizes by python_runtime + "-" + version, and virtually every call site asks for
"3.11" / sandbox_versions.DEFAULT_PYTHON_VERSION. One instance ⇒ one self.lock ⇒ one threading.RLock
gating every sandbox execution in the process.

Two consequences, both load-bearing

1. The thread pool is inert. ThreadPoolManager sizes a pool at cpu_count - 1 (sync_threads.py:44) and
Part.get_shape() dispatches each part's instantiation onto it (part.py:34). Every one of those threads then
blocks on the same RLock while another thread's subprocess runs. Worse: a blocking lock acquired inside a
coroutine stalls the whole OS thread, and therefore that thread's entire event loop — so pending aiofiles
cache I/O and any other async work scheduled there stalls too. The slowdown is not confined to sandbox runs.

2. Gathered renders are serial. Project.render_async() (project.py:1179) builds a coroutine for every
shape ×ばつ format combination and asyncio.gathers them on one loop (project.py:1220). They all share that
thread's single asyncio.Lock from get_async_lock(), so gather executes them strictly one at a time.

The lock is not gratuitous

runtime_python.py:511-523 documents a real hazard: installing build123d pulls cadquery-ocp-novtk, which
overwrites the very same OCP native module cadquery-ocp installs. An install slipping in between another
part's CADQUERY_OCP re-assertion and its actual run leaves that run importing a half-installed OCP and dying
with an unrelated-looking ImportError. That hazard must survive this change.

But the mutual exclusion it motivates is (venv, install-vs-run), while what is implemented is (runtime, any
run)
— and sessionless runs, which install nothing at that point, are serialized along with everything else.

What is not the problem

  • Not FileLock contention: that is cross-process, and this reproduces in a single process.
  • Not thread-pool sizing: the pool is sized correctly (sync_threads.py:44-70), it is simply blocked.
  • Not Shape.get_wrapped()'s own lock (shape.py:209), though it has the same blocking-lock-across-await
    shape and is worth fixing in passing (task 5.3). Its scope is one shape, so it does not serialize unrelated
    work.

One claim that is unverified

VenvLock builds its FileLock with thread_local=False (runtime_python.py:146). Reading filelock's
semantics, that keeps the reentrancy counter shared across threads rather than thread-local, which would mean a
second thread's acquire() on an already-held lock object increments the counter and returns immediately —
i.e. VenvLock provides no intra-process mutual exclusion today, and all of it comes from the RLock.

That reading was not verified against the pinned filelock version. Task 1.4 settles it before anything is
designed around it.
It is called out here so it is not mistaken for an established fact.


Proposed direction (details in design.md)

  • D1 — a readers/writer gate keyed on the environment being mutated. Writers: ensure* when the install
    guard is absent, and v-env creation. Readers: runs that install nothing. A run holds a read on the sandbox
    key and, when a session is in play, on the v-env key — in that order, so no cycle is possible. Keep
    runtime_python_conda.py's global conda lock outermost.
  • D2a (recommended) — keep threading primitives for cross-thread coordination but acquire them off-loop
    (await asyncio.to_thread(...)), so waiting parks the coroutine instead of freezing the thread. D2b (one
    owner loop, asyncio primitives throughout) is cleaner long-term but a much larger blast radius.
  • D3 — an explicit process-wide concurrency cap from user_config.threads_max. Removing the lock uncovers
    unbounded fan-out; each sandbox process is CPU- and memory-heavy, so this is resource correctness, not polish.
  • D4 — leave the guard/reassert bookkeeping (invalidate_dependent_guards(), needs_reassert(),
    FORCE_REINSTALL_FLAGS) alone. A diff touching both is very hard to review.

Three open questions are recorded at the end of design.md for whoever implements this.


How to measure

No benchmark exists in the repo, so task 2 builds the baseline before any code is written. design.md
carries a self-contained recipe needing no telemetry backend: monkeypatch PythonRuntime.run_async_onced to
record (start, end) spans, then report wall clock, total sandbox busy time, run count, and peak overlap.

  • Expected reading today: peak_overlap == 1 and busy ≈ wall. That is the signature of full
    serialization, and it is the "before" number to paste into this PR.
  • Acceptance: peak_overlap reaches min(runs, cap), and wall clock for N independent parts drops toward
    busy / peak_overlap plus overhead. State the machine's core count — the result is meaningless without
    it.

Workload: packages under examples/ (produce_part_step, produce_part_cadquery_primitive,
produce_part_build123d_primitive, produce_assembly_assy). A mixed CadQuery/build123d package is both the
interesting performance case and the race case.


Risks the implementer must handle

Risk Mitigation
Reintroducing the OCP-clobbering race — it manifests as a native crash with no traceback and no stderr Task 4 is a dedicated regression test; assert on that exact signature, which runtime_python.py:601-616 already diagnoses
Deadlock from mixed ordering with runtime_python_conda.py's global conda lock D1's fixed acquisition order; task 3.5 audits every acquisition site
Memory exhaustion once many OCP interpreters run at once D3's semaphore; task 6.4 records peak RSS
A race that reproduces one time in ten lands green Task 4.3 runs the regression ≥20 times, under pytest -n 4

Relationship to the other two proposals

Three independent findings, three independent PRs, no ordering dependency between them:

  • This one lets sandbox work spread across cores.
  • sandbox-process-reuse reduces how much sandbox work there is (7 interpreter starts for one transformed
    part rendered to 4 formats). It is complementary — this PR is a prerequisite for its benefit being visible on
    a multi-core machine, but neither subsumes the other.
  • shape-cache-efficiency lowers the incremental-rebuild floor (a cache hit currently reads and MD5s the
    whole source model).

Validation status

Only markdown under openspec/changes/ is added — no Python is touched, so pytest/behave outcomes are
unaffected. The pre-commit hooks did not run locally: this session's container has no working Docker daemon,
so the dev container documented in the root AGENTS.md could not be started, and host-level pre-commit is
deliberately not used (host tool versions are not the pinned ones). CI will run the gates. Everything in
tasks.md is written to be run inside the dev container.


Generated by Claude Code

Every sandboxed CAD subprocess in a process is serialized today:
PythonRuntime.async_lock() holds a single per-runtime threading.RLock
across the entire subprocess lifetime, and Context.get_python_runtime()
hands out one runtime instance for the version every call site asks for.
The thread pool sized to cpu_count-1 therefore never runs two sandbox
processes at once, and Project.render_async()'s asyncio.gather executes
strictly serially on the shared per-thread asyncio.Lock.
This adds the OpenSpec change proposal only: the finding with its
evidence, a readers/writer design keyed on the environment being
mutated, a measurement recipe to establish the baseline, and the
regression coverage needed to keep the OCP-clobbering race dead. No
behavior change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018enkjjYJCdP34QJKxDVSbt 

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 782bc151-0e0d-4035-b03d-dea26c0c43f6

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands.

codecov Bot commented Aug 8, 2026
edited
Loading

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ All tests successful. No failed tests found.
see 42 files with indirect coverage changes

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Reviewers

No reviews

Assignees

No one assigned

Labels

None yet

Projects

None yet

Milestone

No milestone

Development

Successfully merging this pull request may close these issues.

2 participants

AltStyle によって変換されたページ (->オリジナル) /