Skip to content

Optimize: serialize provenance-guarded device ops per worker, not process-wide - #1702

Open
lterrac wants to merge 1 commit into
hw-native-sys:mainfrom
lterrac:perf/per-worker-device-op-locks
Open

Optimize: serialize provenance-guarded device ops per worker, not process-wide#1702
lterrac wants to merge 1 commit into
hw-native-sys:mainfrom
lterrac:perf/per-worker-device-op-locks

Conversation

@lterrac

@lterrac lterrac commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Rebased onto current main. Two commits: a fix, and a draft for a second lock that can be dropped independently.

The problem

_child_prov_lock is held across the native half of malloc / free / copy_to / copy_from, so every device op of every next-level worker serializes on one lock belonging to the parent worker. The bindings already release the GIL there, which makes it easy to miss: unrelated Python threads keep running, but a copy_to on chip 0 blocks a malloc on chip 1 for its whole duration.

Measured with per-shard timestamps on both sides of an 8-chip upload: all 8 orchestrator threads enter Orchestrator.copy_to within 14 µs of each other, then each chip child starts its copy within ~1 ms of the previous one finishing, each at full link speed. Inside LocalMailboxEndpoint::control_copy_to, wait_lock is 0.0000s — the per-worker mailbox mutexes are never contended, the threads simply arrive one at a time.

Commit 1 — per-worker provenance locks

_child_prov_lock stays the bookkeeping lock (each provenance mutation/read still atomic, safety-first ordering unchanged: record after a successful alloc, revoke before a native free); a per-worker lock now wraps the native call. Same-worker ops stay mutually exclusive, different workers overlap. The per-worker lock is always taken before _child_prov_lock, never the reverse, so the two cannot deadlock.

This modifies an existing test. test_free_holds_lock_across_native_free pinned the wide behaviour; it now asserts the narrower exclusion the code provides — that worker's lock held across the native free, _child_prov_lock released, revoke committed first. Sufficient because provenance is keyed by (worker_id, ptr) and the revoke commits before the native free, so a concurrent dispatch reads the table under _child_prov_lock and finds the address already gone, or is about a different chip. Flagged explicitly: it pins a deliberate decision, so if the reasoning does not hold, the answer is to revert the free path rather than to keep the test green another way.

Commit 2 — draft: shared/exclusive run-admission lock

This alone is not enough on this base, as @YunjiQin identified: _control_reservation (#1541) takes _submit_mu and holds it across the same native call, at the same per-worker granularity. My original numbers were measured on 9922afdb, which predates #1541 — corrected below.

A control command that belongs to no run needs "no run may be admitted while I run", a property of the worker; two commands on different chips can both have it at once. So _submit_mu becomes shared/exclusive: run admission takes it exclusively, control takes it shared. Writer-preferring, so control traffic cannot starve a submit; the reservation's thread-local re-entrancy short-circuits before the lock and is untouched.

Kept as a separate commit precisely so it can be dropped or replaced — it changes the serializer #1541 introduced, and the shape is the author's call.

Measured

8 × 910B2, one upload thread per shard, fresh shared-memory bands so every page is read cold exactly once (reusing a band measures the warm path and inflates everything):

base threaded serial
with #1541, commit 1 only 6.5 – 8.0 GB/s 8.6 – 10.3 GB/s
with #1541, both commits 19.2 – 34.0 GB/s 9.4 – 10.5 GB/s
without #1541 (the base I first measured), commit 1 only 22.7 – 30.7 GB/s 8.6 – 10.5 GB/s

Serial is identical across bases, so the difference is the lock and not the setup. For reference on the same node, 8 independent processes doing raw cold H2D from a shared mapping reach ~29–34 GB/s aggregate.

Tests

  • tests/ut/py/test_shared_exclusive_lock.py (new): shared holders overlap, each mode excludes the other, a waiting writer blocks new readers.
  • tests/ut/py/test_worker/test_child_addr_guard.py: updated as described; the file passes in full (49 tests with the new one included).

Open question

Whether "no run may be admitted while I run" is the whole invariant _control_reservation carries, or whether something also relies on control commands excluding each other. That decides commit 2 — happy to implement a different shape or hand it over.

st-pod-onboard-a2a3 fails on vector_add_mixed_l3 / poll_native_run failed; PR #1722 fails identically on the same job while #1709 / #1714 / #1718 / #1723 pass, so it reads as a pod-runner flake rather than this branch.

The dependent pypto change (hw-native-sys/pypto#2292) is a no-op without this, so there is no merge-order constraint between them.

@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

Auto incremental reviews are disabled on this repository.

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: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 570d44e0-4820-4ac9-a1c0-8f2056099895

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
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main change: per-worker serialization for provenance-guarded device operations.
Description check ✅ Passed The description directly explains the locking changes, rationale, performance results, tests, and open design question.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
python/simpler/orchestrator.py (1)

637-647: 🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Release _child_prov_lock before the native free.

Lines [637-647] keep the process-wide _child_prov_lock held through self._o.free(wid, p). A slow free on one worker therefore blocks frees and provenance operations for every other worker. Keep _child_prov_worker_lock(wid) around the native call, but scope _child_prov_lock to validation and revocation only.

Proposed fix
-        with self._worker._child_prov_worker_lock(wid), self._worker._child_prov_lock:
+        with self._worker._child_prov_worker_lock(wid):
             # Safety-first commit barrier: revoke provenance BEFORE the native
             # free. If the native free succeeds and an async unwind (e.g. a
             # KeyboardInterrupt delivered after the binding returns) fires before
             # a post-free clear could run, a freed address would stay live and a
             # later copy/dispatch would re-authorize it — a UAF. Revoking first
             # turns a native-free failure into a terminal leak (recoverable) but
             # never re-authorizes a maybe-freed address.
-            self._worker._child_prov_require_malloc_base(wid, p, api="free")
-            self._worker._child_prov_clear_malloc(wid, p)
+            with self._worker._child_prov_lock:
+                self._worker._child_prov_require_malloc_base(wid, p, api="free")
+                self._worker._child_prov_clear_malloc(wid, p)
             self._o.free(wid, p)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@python/simpler/orchestrator.py` around lines 637 - 647, Restructure the
cleanup block around _child_prov_worker_lock so _child_prov_lock is held only
while _child_prov_require_malloc_base and _child_prov_clear_malloc execute.
Release _child_prov_lock before calling self._o.free(wid, p), while retaining
_child_prov_worker_lock(wid) across the native free.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@python/simpler/orchestrator.py`:
- Around line 637-647: Restructure the cleanup block around
_child_prov_worker_lock so _child_prov_lock is held only while
_child_prov_require_malloc_base and _child_prov_clear_malloc execute. Release
_child_prov_lock before calling self._o.free(wid, p), while retaining
_child_prov_worker_lock(wid) across the native free.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 3c0cf1fb-c8f9-4f33-9847-a245f982e997

📥 Commits

Reviewing files that changed from the base of the PR and between b535fa2 and bdd0df5.

📒 Files selected for processing (2)
  • python/simpler/orchestrator.py
  • python/simpler/worker.py

@lterrac

lterrac commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai good catch — that was a real inconsistency in my own change, fixed in 19c89e5.

The free path still combined both locks in a single with, so it kept the process-wide _child_prov_lock held across self._o.free(...): exactly the thing this PR removes from malloc / copy_to / copy_from. I took your structure verbatim — bookkeeping lock nested around the revoke, per-worker lock retained across the native call — which is also stricter than scoping the two sequentially: revocation and the native free stay atomic with respect to other ops on the same worker, so the safety-first barrier is unchanged.

Validated on 4 × 910B2 with the isolated upload harness (which frees every shard after each band, so the free path runs 16 times per run): no deadlock, threaded 17.6 / 30.2 GB/s vs serial 10.5 / 9.9 GB/s. ruff check passes.

@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

@lterrac Confirmed. The revised lock scope removes process-wide serialization from self._o.free(...) while preserving same-worker exclusion across provenance revocation and native free. The reported deadlock and throughput validation also covers the relevant free-path behavior.

🐇✅


✏️ Learnings added
Learnt from: lterrac
URL: https://github.com/hw-native-sys/simpler/pull/1702

Timestamp: 2026-08-05T13:28:55.692Z
Learning: In `python/simpler/orchestrator.py`, the provenance-guarded free path must hold the per-worker lock across provenance revocation and `self._o.free(...)` so operations on the same worker remain atomic. It must hold `_child_prov_lock` only for provenance bookkeeping, not across the native free call, so different workers can free concurrently.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


If you found this review helpful, would you consider giving us a shout-out on X?

Thank you for using CodeRabbit!

You are interacting with an AI system.

@YunjiQin

YunjiQin commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

The lock split itself looks right, but I think the 57.1s → 10.1s number is no longer attributable to this diff on main, and the change may currently be a no-op on the path it targets. Worth re-measuring before this lands with that claim attached.

The measured baseline predates a second serializer on the same path

Validation was done on simpler 9922afdb (2026-07-27). direct_control / _control_reservation arrived in 9a2a5f71"Update: make whole-run FIFO admission failure-safe" (#1541, 2026-08-02):

$ git merge-base --is-ancestor 9a2a5f71 9922afdb   # measured base
1541 NOT in 9922afdb
$ git merge-base --is-ancestor 9a2a5f71 b535fa21   # this PR's base
1541 IS in PR base

So on the branch this PR actually sits on, _child_prov_lock is no longer the only serializer wrapping the native call.

Call path

For an L3+ copy_to issued from a thread that is not on a graph callback's stack — which is what a caller-side upload thread pool is:

Worker.copy_to(dst, src, size, worker_id=7)             worker.py:8102
│
├─ with self._operation_lease("copy_to")
│     └─ refcount lease, guards close() only — not exclusive ✓ concurrent
│
└─ self._orch.copy_to(worker_id, dst, src, size)
   └─ Orchestrator.copy_to                              orchestrator.py:648
      │
      ├─ with self._control_admission("copy_to")
      │  └─ direct_control(worker, self._o, ...)         orchestrator.py:216
      │     │
      │     ├─ frame = _callback_frame_for(worker)      ← thread-local
      │     │
      │     ├─ [frame is not None]  in this worker's orch fn
      │     │    └─ native_orch.await_run_admission(frame.run_id)
      │     │       yield                               ← no lock ✓
      │     │
      │     └─ [frame is None]      pool / plain user thread
      │          └─ worker._control_reservation(api)     worker.py:8677
      │             └─ with self._submit_mu:            ← ★ EXCLUSIVE
      │                ├─ raise if _ordered_cleanup_error
      │                ├─ raise if any run still in flight
      │                └─ yield                         ← _submit_mu still held
      │
      ├─ with self._worker._child_prov_worker_lock(wid)  ← this PR
      │     with self._worker._child_prov_lock: require_live_range(...)
      │     self._o.copy_to(...)                        ← native H2D
      │
      └─ exit _control_admission → release _submit_mu

direct_control is a context manager and the native call happens after its yield, so _submit_mu covers the transfer itself, not just the admission check. That is deliberate — its docstring says so explicitly:

the reservation is held for the whole call in both — a check that only samples state leaves the command itself outside the decision it just made

A call that belongs to no run is ordered only by being alone: it takes the same serializer submission uses, so no run can be admitted between the check and the command.

_submit_mu is the same mutex _submit_locked uses to serialize graph construction, and it is per-Worker, i.e. shared across all of that worker's children — exactly the granularity _child_prov_lock had.

What that implies for the 8-thread upload

A thread pool spawned by the caller has no _CallbackFrame, whether or not it was spawned from inside a callback — the frame stack is thread-local. So all 8 shard threads take the frame is None branch:

state at upload time outcome on main
no run in flight all 8 queue on _submit_mu; native H2D still strictly back-to-back
a run not yet waited on first thread hits RuntimeError: copy_to: N run(s) still in flight

Which matches the symptom described in the PR body — threads entering Orchestrator.copy_to within 14 µs of each other, each child starting ~1 ms after the previous one finished, and the endpoints' per-worker mutexes never contended. On 9922afdb that fingerprint pointed at _child_prov_lock; on this base the same fingerprint is what _submit_mu produces.

Suggested

  1. Re-run the 8-chip upload on this PR's head as-is. If it still shows 57s, the remaining serializer is _submit_mu and the number in the commit message needs to change.
  2. If it does, _control_reservation needs the same treatment — but it is harder than the provenance lock. What it guarantees is "no run can be admitted while this command runs", which is a property of the worker, not of one chip. Splitting it per-worker-id is not sound on its own; it would need something like a shared/exclusive split (control commands share, _submit_locked takes exclusive) so concurrent per-chip control still excludes run admission as a group. Happy to sketch that separately if useful.
  3. Either way the _child_prov_lock split here is still correct and still required — it just cannot be the whole fix on this base.

Two smaller notes:

  • Worker.copy_to at level 2 (worker.py:8105-8110) bypasses _control_admission and takes _child_prov_lock directly around the native copy. Untouched by this PR, and correct for a single chip — just noting the two levels now guard differently.
  • The commit message calls _child_prov_lock "process-wide"; it is threading.Lock() per Worker instance (worker.py:4046). Same practical effect for a single root worker, but "the parent worker's lock" is the accurate phrasing.

@lterrac

lterrac commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

@YunjiQin thank you — you were right, and the measurement backs every step of your analysis. I re-ran on this PR's base as you suggested and rebased the branch onto current main.

Your hypothesis, measured

9a2a5f71 (#1541) is indeed absent from the base I originally measured (9922afdb) and present here, and submit_mu_in_reservation is live in the running stack. With only the provenance-lock split applied, on a base that contains #1541, an 8-chip upload gains nothing:

threaded serial
base with #1541, provenance locks only 6.5 – 8.0 GB/s 8.6 – 10.3 GB/s
the base I had measured (no #1541) 22.7 – 30.7 GB/s 8.6 – 10.5 GB/s

Serial is identical across the two bases, so this is not a setup difference — _submit_mu simply takes the place of _child_prov_lock, exactly as you described. The commit message no longer claims the old numbers: it now states the 6.5–8.0 vs 8.6–10.3 result and says plainly that the split is necessary but not sufficient on this base.

A draft for the second lock, kept separate

Rather than reply with a question I prototyped your suggestion and measured it, so the decision has a number attached. It is a separate commit (6dc98e4d) precisely so it can be dropped or replaced without touching the provenance fix:

  • _submit_mu becomes a shared/exclusive lock: run admission takes it exclusively, _control_reservation takes it shared. Writer-preferring, so control traffic cannot starve a submit. The reservation's thread-local re-entrancy short-circuits before the lock, so that path is unchanged.
  • Same base, same harness: threaded 19.2 – 34.0 GB/s vs serial 9.4 – 10.5.
  • Four unit tests in tests/ut/py/test_shared_exclusive_lock.py (shared holders overlap, each mode excludes the other, waiting-writer blocks new readers).

What I am not sure about is whether "no run may be admitted while I run" is the whole invariant _control_reservation is carrying, or whether something else relies on control commands excluding each other. That is your call — if the shape you had in mind differs, I am happy to drop the draft and implement yours instead, or to hand it over entirely.

Two smaller points from your review:

  • "process-wide" — corrected; the commit now says the lock belongs to the parent worker.
  • level 2 Worker.copy_to — left untouched deliberately: with a single chip there is nothing to overlap, so the wide lock costs nothing there. Worth a comment if you would like the asymmetry recorded in the code.

The dependent pypto change (hw-native-sys/pypto#2292, concurrent shard upload) is a no-op without this, so there is no ordering constraint between them.

lterrac added a commit to lterrac/pypto that referenced this pull request Aug 6, 2026
alloc_stacked_tensor uploads shard i to worker i in a serial loop, so a
rank-stacked resident weight moves at single-chip H2D bandwidth no matter
how many chips the group spans. Each shard targets a different chip
worker and nothing orders them, so drive them from a thread pool.

Rolling back needs a little more care than the serial loop: a concurrent
failure can land anywhere in the group, so the successes are no longer a
prefix of ids. Collect them by index and free them against their own
worker before re-raising, instead of zipping shards with ids positionally.

Measured on 8 x 910B2 uploading DeepSeek V4 Flash W8A8's 346 GB of
rank-stacked weights (per-shard 11.5 GB):

  upload   57.1s -> 10.4s   (6.0 -> 33.2 GB/s)
  startup  82.0s -> 35.1s

This needs the matching Simpler change (hw-native-sys/simpler#1702) to
pay off: Simpler holds one process-wide lock across the native half of
malloc / copy_to, which serializes the group regardless of how the
caller issues it. Without that change this commit is a no-op, not a
regression.
@lterrac

lterrac commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

CI feedback addressed. Two failures, only one of them mine.

ut — mine, and deliberate. test_free_holds_lock_across_native_free asserted that the parent worker's _child_prov_lock is held across the native free, which is exactly what this PR stops doing. I have modified that existing test rather than leave it passing by accident — flagging it explicitly because it pins a documented decision, and if you disagree with the reasoning the right outcome is to revert my change to the free path, not to keep the test green some other way.

It now asserts the narrower exclusion the code actually provides:

  • that worker's own lock is held across the native free (same-worker free/copy/dispatch still cannot interleave with a half-completed free),
  • _child_prov_lock is not held during the native call,
  • the revoke has already committed when the native free runs.

The argument that the narrower form is sufficient: provenance is keyed by (worker_id, ptr) and the revoke commits before the native free, so a concurrent dispatch reads the table under _child_prov_lock and either finds this address already gone or is about a different chip entirely. What the wide lock added on top of that was cross-chip exclusion, which is the cost this PR is removing.

Verified on main + this branch: tests/ut/py/test_worker/test_child_addr_guard.py and the new tests/ut/py/test_shared_exclusive_lock.py49 passed.

st-pod-onboard-a2a3 — not mine. It fails on pod examples failed: vector_add_mixed_l3 with poll_native_run failed; PR #1722 fails with the identical signature on the same job, and the job passes on #1709 / #1714 / #1718 / #1723. Looks like a shared pod-runner flake rather than anything this branch does — happy to be told otherwise if you recognise it.

Branch rebased onto current main and force-pushed; the two commits are unchanged in substance (the test update is folded into the provenance-lock commit, and the _submit_mu draft is still separate and still droppable).

@lterrac
lterrac force-pushed the perf/per-worker-device-op-locks branch 2 times, most recently from 961e9c5 to 3c20734 Compare August 7, 2026 08:21
Two locks are held across the *native* half of malloc / free / copy_to /
copy_from, and either one alone is enough to serialize every device op of
every next-level worker onto a single chip's pace. The bindings already
release the GIL there, which makes the effect easy to miss: unrelated
Python threads keep running, but a `copy_to` on chip 0 blocks a `malloc`
on chip 1 for its whole duration.

`_child_prov_lock` (provenance) stays the bookkeeping lock — it still
makes each provenance mutation/read atomic, and the safety-first ordering
is unchanged (record after a successful alloc, revoke before a native
free) — and a per-worker lock is taken around the native call instead.
Ops on the same worker stay mutually exclusive, so a copy can still never
overlap that buffer's free; ops on different workers now overlap. The
per-worker lock is always acquired before `_child_prov_lock` and never the
reverse, so the pair cannot deadlock.

`_submit_mu`, taken through `_control_reservation` (hw-native-sys#1541), is the other
one: a control command that belongs to no run holds it across the native
call, so with the provenance fix alone it becomes the serializer — all 8
threads enter `Orchestrator.copy_to` within 14 us of each other, then each
child starts its copy within ~1 ms of the previous one finishing, at full
link speed. What such a command needs is "no run may be admitted while I
run", which is a property of the worker, and two commands on different
chips can both have that at the same time. So `_submit_mu` becomes a
shared/exclusive lock: run admission takes it exclusively, control takes
it shared. Writer-preferring, so control traffic cannot starve a submit.
The reservation's re-entrancy is untouched: it short-circuits on the
thread-local set before reaching the lock.

Measured on 8 x 910B2, one upload thread per shard, fresh shared-memory
bands so every page is read cold exactly once:

  provenance locks only   threaded  6.5-8.0 GB/s   serial 8.6-10.3
  + run-admission lock    threaded 19.2-34.0 GB/s  serial 9.4-10.5

This narrows an invariant an existing test pins down, so that test is
updated rather than left passing by accident:
`test_free_holds_lock_across_native_free` asserted that the *parent
worker's* lock is held across the native free. It now asserts the narrower
exclusion actually needed — that worker's own lock held across the native
call, `_child_prov_lock` released, and the revoke committed first.
Provenance is keyed by (worker_id, ptr) and revoked before the native
free, so a concurrent dispatch reads the table under `_child_prov_lock`
and finds the address already gone, or is about a different chip entirely.

The shared/exclusive shape for hw-native-sys#1541's serializer is the part most open to
discussion; it can be replaced with a different design without touching
the provenance change.
@lterrac
lterrac force-pushed the perf/per-worker-device-op-locks branch from 3c20734 to 6259971 Compare August 7, 2026 08:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants