Skip to content

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

Merged
ChaoZheng109 merged 1 commit into
hw-native-sys:mainfrom
lterrac:perf/per-worker-device-op-locks
Aug 11, 2026
Merged

Optimize: serialize provenance-guarded device ops per worker, not process-wide#1702
ChaoZheng109 merged 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: 4953bb2b-9d58-41a6-8990-ca2d248df542

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 3 times, most recently from 3c20734 to 6259971 Compare August 7, 2026 08:24
@ChaoZheng109

Copy link
Copy Markdown
Collaborator

This branch is 19 commits behind main (fork point 29904412, 2026-08-07 → main a8d7ce12), 10 of them touching worker.py / orchestrator.py, and it no longer merges cleanly — it needs a rebase onto current main.

⚠️ The important part: this is not a mechanical rebase, and it must not roll any of the mainline refactors back. Two specific traps:

  1. The edited functions moved. This PR inserts the per-worker lock into Orchestrator.malloc/free/copy_to/copy_from, but on current main those are thin delegators — the provenance + native-call logic now lives in Worker.malloc/free/copy_to/copy_from (worker.py, ~9295–9486), after Refactor: centralize chip native run ownership #1650 / Update: cut task args over to the self-describing Tensor wire ABI #1729. Re-apply the split there; do not restore the old orchestrator.py shape, or the rebase silently reverts those refactors.

  2. worker.py "auto-merges" into a broken state. This PR turns _submit_mu into a _SharedExclusiveLock, while Fix: reject Buffer release while an in-flight run still references it #1751 added a new bare usage on main:

    with self._submit_mu, self._hierarchical_start_cv:   # worker.py:9660

    _SharedExclusiveLock has no __enter__, so a textual merge compiles-then-crashes at runtime. That new Buffer-release site needs an explicit .exclusive() / .shared() decision (safe default: .exclusive(), matching the old plain-lock behavior).

tl;dr: rebase onto main, re-apply both changes on top of the current structure, and keep every mainline change intact — the win here is additive, not a reversion.

@lterrac
lterrac force-pushed the perf/per-worker-device-op-locks branch from 6259971 to d220247 Compare August 10, 2026 14:17
@lterrac

lterrac commented Aug 10, 2026

Copy link
Copy Markdown
Contributor Author

@ChaoZheng109 thank you — both traps you named were real, and one of them was real twice. Force-pushed.

Please read this as a rewrite, not a rebase. The base moved, the edited functions moved, there is a new C++ change, and every number is re-measured with a different harness. If you reviewed the previous version, none of your reading carries over.

Your two traps

1. The functions moved. Re-applied in Worker.alloc_child_tensor / free / copy_to / copy_from after #1650/#1729, exactly as you said. orchestrator.py is now byte-identical to main — the thin delegators are left alone, so no mainline refactor is rolled back. The four sites now take the per-worker lock around the native call and keep _child_prov_lock for the bookkeeping only; the new level == 2 branch (_chip_worker, no admission fence) is handled on the same footing.

2. The bare _submit_mu in release_buffer. Confirmed: a textual merge compiles and then crashes, since _SharedExclusiveLock has no __enter__. It takes .exclusive() now. I went with exclusive rather than shared even though shared would satisfy the "never mid-callback" argument in that docstring, because it keeps the ordering identical to the plain lock it used to be and buffer release is not on a hot path — a comment records that choice.

The same trap also existed in three tests, which your review could not have seen and I only found by running the suite: test_create_buffer.py and test_remote_l3_lifecycle.py stand a plain threading.Lock in for _submit_mu, and test_release_buffer.py::test_serializes_with_a_racing_orchestration_callback takes it as a context manager. All three are updated to the real type / .exclusive(). They pin the serializer's identity, not its granularity, so what each asserts is unchanged.

A third serializer, and it is why the old numbers did not carry over

Re-measuring on this base gave no gain at all from the two lock splits, which sent me looking. Worker.malloc / free / copy_to / copy_from are bound in worker_bind.h as plain lambdas with no call guard, so the GIL is held for the whole native call — while 31 other methods in that same file already release it. With the locks split but the GIL held, eight threads still cannot overlap.

So there are three serializers in series, and removing any one of them measures as noise. That is the whole reason this looked like a dead end twice: the two lock splits alone did nothing, and an earlier attempt at the GIL guards alone did nothing either.

This adds C++ to the PR (worker_bind.h, four nb::call_guard<nb::gil_scoped_release>()), which is surface neither you nor @YunjiQin has reviewed, so please look there first. I checked that none of the four re-enters Python — Worker::copy_to resolves the worker and forwards to its endpoint's control op, mailbox_mu_ is per-endpoint, and there is no host callback, PyObject or gil_scoped_acquire anywhere on that path — but that is exactly the kind of claim I would rather have a second pair of eyes on.

Measured on this code

Pure simpler, no pypto, no kernels: an L3 Worker over 8 chips, one born-shared 2.15 GB host buffer per chip, uploaded through Worker.copy_to outside a run — the control path the resident-weight upload uses. Same work with a thread per chip, then strictly sequentially. overlap_factor = sum of per-copy wall times over the wall time of the batch, so 1 is "back to back" and 8 is "fully overlapped".

threaded serial (control) overlap_factor
main, unpatched 19.99-22.11 GB/s 20.69-22.82 GB/s 1.4-1.8
+ the two lock splits 20.86 GB/s 21.80 GB/s 1.1-1.6
+ the GIL guards 86.85-90.03 GB/s 21.47-21.81 GB/s 6.4-6.5

4.14x over sequential within the same build. Two caveats I would rather state than have you find: the serial column is the control and stays in 20.7-22.8 GB/s across all three builds, and absolute throughput drifts ~10% between sessions — so the claim rests on the in-build threaded/serial ratio and on the overlap factor, not on any single absolute figure.

Tests

Full tests/ut/py run in a container on real hardware: 1308 passed, 4 failed. The four are pre-existing and environmental, not mine — test_unwritable_cache_dir_falls_back_to_plain_compilation (running as root, so an unwritable directory is writable) and three process-reaping tests. Demonstrated rather than assumed: running test_startup_readiness.py on both builds in the same session, test_second_child_failure_reaps_first fails on unpatched main and passes on this branch, so that file is simply flaky here.

One piece of context for scope: pypto currently pins simpler 3165cc89, which predates #1650, so nothing downstream sees this until that pin advances. The companion change on the pypto side (hw-native-sys/pypto#2292, concurrent shard upload) is a no-op without this one and vice versa — either alone leaves a serializer in place.

@lterrac
lterrac force-pushed the perf/per-worker-device-op-locks branch from d220247 to d33bd45 Compare August 10, 2026 14:27
@lterrac

lterrac commented Aug 10, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto 8616c736 — it conflicted, and the conflict was worth looking at rather than just resolving.

Of the five commits that landed in the meantime, exactly one touches this PR's files: #1757, "give L2 the same in-flight protection release_buffer() gives L3+" — i.e. the very method this PR changes. The textual conflicts were two twins in test fixtures, where main added w._chip_run_touched_identities = {} next to the _submit_mu line; both are resolved keeping mainline's line and this PR's type change.

The part that needed judgement rather than merging is release_buffer itself. #1757 splits it into an L3+ check and an independent L2 check, and its new docstring is explicit that the two "run sequentially rather than under one shared lock", with the L2 side ordered by _registry_lock instead. So _submit_mu.exclusive() now guards only the L3+ branch, which is exactly the branch the "never mid-callback" argument is about — the note I left beside it still holds after their change. I verified that rather than assuming it, and left their prose alone.

Re-verified on the rebased base, since a test run on the old base would not carry over a rewrite of this method: full tests/ut/py, 1315 passed, 3 failed. The three are the same pre-existing environmental ones as before (test_unwritable_cache_dir_falls_back_to_plain_compilation, plus two process-reaping tests) — no new failure anywhere near release_buffer or the L2 buffer path. test_second_child_failure_reaps_first, which appeared in an earlier run, did not fail here, consistent with it being flaky in this environment: it also fails on unpatched main and passes on this branch when the file is run repeatedly.

Three things serialize the device-memory ops, in series, so removing any one of
them alone measures as noise — which is why this took a while to pin down. A
`copy_to` on chip 0 blocks a `malloc` on chip 1 for its whole duration, and the
eight chips of an 8-way upload run strictly back to back.

`_child_prov_lock` 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`, 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. 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.

The third is the **GIL**. `Worker.malloc / free / copy_to / copy_from` are bound
as plain lambdas in `worker_bind.h` with no call guard, so the interpreter lock is
held for the whole native call while 31 other methods in that same file already
release it. With the two Python locks split but the GIL still held, eight threads
still cannot overlap: measured `overlap_factor` (sum of per-copy wall times over
the wall time of the batch) stayed at 1.1-1.6 out of a possible 8. These four get
`nb::call_guard<nb::gil_scoped_release>()`; none of them re-enters Python, and the
descriptors are converted before the call.

Re-applied on the post-hw-native-sys#1650/hw-native-sys#1729 structure rather than rebased textually. The
provenance and native-call logic moved out of `Orchestrator` into `Worker`, so
the split now lives in `Worker.alloc_child_tensor / free / copy_to / copy_from`
and `orchestrator.py` is left exactly as main has it. `release_buffer` (hw-native-sys#1751)
took `_submit_mu` bare, which a textual merge would have compiled and then
crashed on, since `_SharedExclusiveLock` has no `__enter__`; it now takes it
exclusively, keeping the ordering it had as a plain lock. Both traps were called
out by @ChaoZheng109 in review.

Three tests stand a plain `threading.Lock` in for `_submit_mu` or take it as a
context manager, so they are updated to the real type and to `.exclusive()`
(`test_create_buffer.py`, `test_remote_l3_lifecycle.py`,
`test_release_buffer.py::test_serializes_with_a_racing_orchestration_callback`).
They pin the serializer's *identity*, not its granularity, and the exclusive form
is what graph construction now takes, so the property each one asserts is
unchanged.

This also 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.

Measured on this code, on 8 x 910B2, with a pure-simpler harness (no pypto, no
kernels): an L3 Worker over 8 chips, one born-shared 2.15 GB host buffer per chip,
uploaded through `Worker.copy_to` outside a run — the same control path the
resident-weight upload uses. The same work is done once with a thread per chip and
once strictly sequentially; `overlap_factor` is the sum of the per-copy wall times
over the wall time of the batch, so 1 means "back to back" and 8 means "fully
overlapped".

| | threaded | serial | overlap_factor |
|---|---:|---:|---:|
| main, unpatched            | 19.99-22.11 GB/s | 20.69-22.82 GB/s | 1.4-1.8 |
| + the two lock splits      | 20.86 GB/s | 21.80 GB/s | 1.1-1.6 |
| + the GIL guards (this PR) | **86.85-90.03 GB/s** | 21.47-21.81 GB/s | **6.4-6.5** |

**4.14x** over sequential on the same build, where before there was none: the eight
copies now all start together instead of queueing. Two things keep that honest.
The serial column is the control and stays in 20.7-22.8 GB/s across all three
builds, so the gain is concurrency and not a faster machine. And absolute
throughput drifts about 10% between sessions — which is why the claim rests on the
threaded/serial ratio measured *within* a build, and on the overlap factor, rather
than on any single absolute number.

The table also shows why this took three attempts to see. The two lock splits move
neither throughput nor overlap; an earlier attempt at the GIL guards alone measured
as noise too. With three serializers in series, removing any one of them changes
nothing measurable, and only the last one removed appears to "cause" the win.
@lterrac
lterrac force-pushed the perf/per-worker-device-op-locks branch from d33bd45 to ab7ed7b Compare August 11, 2026 07:38
@ChaoZheng109
ChaoZheng109 merged commit 510905d into hw-native-sys:main Aug 11, 2026
19 checks passed
lterrac added a commit to lterrac/pypto that referenced this pull request Aug 11, 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.

What this is worth, and when
----------------------------

On the Simpler commit this repo currently pins (3165cc89) this change is a
**no-op, not a regression**: Simpler serializes the native half of
malloc / copy_to three times over — a process-wide provenance lock, the
run-admission lock taken by control commands, and the GIL, which its
bindings did not release for those four entry points. Any one of the three
is enough to flatten the group, so issuing the shards concurrently changes
nothing there. Measured at 0%.

hw-native-sys/simpler#1702 removes all three. In its own 8-chip harness,
driving the same control path this function drives, the per-copy overlap
factor goes from 1.4-1.8 to 6.4-6.5 and threaded throughput from ~21 GB/s
to 87-90 GB/s, 4.14x over sequential on the same build. That is the gain
this commit exists to expose.

It is not reachable from here yet. This repo pins Simpler 82 commits behind
that merge, and the pin cannot simply be advanced: Simpler moved device
memory onto Buffer handles (hw-native-sys#1650 / hw-native-sys#1729), while this file still calls
`self._w.malloc(nbytes, worker_id)` and
`self._w.copy_to(dst_ptr, src_ptr, nbytes, worker_id)` with raw pointers.
Advancing the pin is a port, and until it lands there is no stack on which
the end-to-end effect of this commit can be measured.

An earlier revision of this message quoted 57.1s -> 10.4s for the upload
and 82.0s -> 35.1s for startup on DeepSeek V4 Flash W8A8. **Those figures
are withdrawn.** They were taken on a stack that combined the pre-refactor
Simpler with an out-of-tree lock patch and a serving-side loader change, and
none of the three is what this commit would ship against. They will be
re-measured end to end once the pin advances.
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.

3 participants