Refactor: centralize chip native run ownership - #1650
Conversation
|
Important Review skippedAuto incremental reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
📝 WalkthroughWalkthroughThe PR adds bounded concurrent native-run preparation for eligible backends, asynchronous L2 submission with ChangesConcurrent native-run pipeline
Estimated code review effort: 5 (Critical) | ~120 minutes Sequence Diagram(s)sequenceDiagram
participant Client
participant Worker
participant ChipWorker
participant Runtime
participant RunHandle
Client->>Worker: submit L2 task
Worker->>ChipWorker: prepare native run
ChipWorker->>Runtime: reserve and prepare run identity
Worker->>ChipWorker: launch FIFO front run
ChipWorker->>Runtime: launch native run
Worker-->>Client: return RunHandle
Client->>RunHandle: wait
RunHandle->>Worker: poll and finalize
Worker->>ChipWorker: finalize native run
Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 8
🧹 Nitpick comments (8)
src/common/platform/onboard/host/device_runner_base.cpp (1)
1356-1359: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low valueGuard the division by
cores_per_blockdim_.
block_dim_ = worker_count_ / cores_per_blockdim_introduces a division on a field that the previous code only multiplied by. If any arch leavescores_per_blockdim_at 0, this is undefined behaviour instead of a benign zero.🛡️ Proposed guard
void DeviceRunnerBase::activate_launch_shape(const Runtime &runtime) { worker_count_ = runtime.get_worker_count(); - block_dim_ = worker_count_ / cores_per_blockdim_; + block_dim_ = cores_per_blockdim_ > 0 ? worker_count_ / cores_per_blockdim_ : 0; }🤖 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 `@src/common/platform/onboard/host/device_runner_base.cpp` around lines 1356 - 1359, Update DeviceRunnerBase::activate_launch_shape to guard cores_per_blockdim_ before dividing worker_count_. Preserve the benign zero behavior by setting block_dim_ to zero when cores_per_blockdim_ is zero; otherwise retain the existing worker-count division.src/common/platform/onboard/host/c_api_shared.cpp (2)
711-713: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueExplain why
runner_resources_ownedis set before provisioning.Line 711 sets the flag to
truebeforeprovision_native_run_resourcesruns at Line 712. On a provisioning failure,cleanup_failed_preparetherefore callsabandon_native_run_resourcesfor a slot that provisioning did not complete.That is the right choice for a partial provision, but the pre-set reads as a sequencing mistake. Add a one-line comment stating that a failed provision may still hold partial resources, so ownership is claimed before the attempt.
🤖 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 `@src/common/platform/onboard/host/c_api_shared.cpp` around lines 711 - 713, In the preparation flow around runner_resources_owned and provision_native_run_resources, add a concise one-line comment explaining that provisioning failure may leave partial native resources held, so ownership must be claimed before the provisioning attempt for cleanup_failed_prepare to release them.
697-701: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueA rejected reservation drops its allocated trace invocation.
Line 676 allocates
trace_invand Line 677 stampstrace_start_ns. Whentry_reserve_native_runfails, Line 700 returns without callingemit_native_run_host_wall. Every other failure path routes throughcleanup_failed_prepare, which emits the span. Admission rejections therefore leave a gap in the host trace exactly where contention analysis needs a record.🤖 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 `@src/common/platform/onboard/host/c_api_shared.cpp` around lines 697 - 701, Update the admission-rejection branch in the native-run preparation flow around try_reserve_native_run so it emits the allocated trace invocation via the existing cleanup_failed_prepare path or equivalent before destroying state and returning. Preserve the current error logging and -1 return while ensuring rejected reservations produce the same host-wall trace record as other failure paths.src/common/platform/onboard/host/device_runner_base.h (1)
132-135: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value
pipeline_slot(),selected_arena_bank(), andarena_bank()can now throw.These accessors were plain field reads. They now route through the pthread TLS helper, which throws on key-creation or allocation failure.
arena_bank()at Line 1021 is on thesetup_static_arenaandacquire_pooled_*paths, andpipeline_slot()is read at the top ofDeviceRunner::run. Callers that previously treated these as infallible now have a new exception edge.Document the new throwing contract on the declarations so callers on
noexceptpaths do not adopt them by accident.Also applies to: 148-148, 1021-1021
🤖 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 `@src/common/platform/onboard/host/device_runner_base.h` around lines 132 - 135, Document on the declarations of pipeline_slot(), selected_arena_bank(), and arena_bank() that each accessor may throw due to pthread TLS key creation or allocation failure. Keep the existing signatures unchanged and make the contract visible to callers before they use these accessors in run, setup_static_arena, or acquire_pooled_* paths.src/common/worker/chip_worker.cpp (1)
684-688: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueSimplify the occupancy check.
occupied != 0 && occupied != 1isoccupied > 1. The direct form states the intent: at most one predecessor may exist.♻️ Proposed simplification
- if (occupied != 0 && occupied != 1) { + if (occupied > 1) {🤖 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 `@src/common/worker/chip_worker.cpp` around lines 684 - 688, In the occupancy validation near the prepare_native_run ownership check, replace the `occupied != 0 && occupied != 1` condition with the equivalent `occupied > 1` check, preserving the existing runtime error and identity formatting.src/common/hierarchical/worker_manager.cpp (1)
318-325: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueMake the non-queued mapping explicit in
dispatch.
dispatchpasses nostaged_run_id, soenqueue_dispatchcannot returnSTAGED_IDENTITY_CHANGEDtoday. Theelsebranch nevertheless reports any future non-STOPPINGresult as"endpoint capacity exceeded".dispatch_preparedalready switches on each enumerator. Match that shape so a new enumerator cannot be reported under the wrong message.♻️ Proposed change
if (result == EnqueueDispatchResult::STOPPING) { complete_unpublished(d, "WorkerThread::dispatch: worker is stopping"); - } else { + } else if (result == EnqueueDispatchResult::CAPACITY_EXCEEDED) { complete_unpublished(d, "WorkerThread::dispatch: endpoint capacity exceeded"); + } else { + complete_unpublished(d, "WorkerThread::dispatch: enqueue rejected the dispatch"); }🤖 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 `@src/common/hierarchical/worker_manager.cpp` around lines 318 - 325, Update dispatch to explicitly handle every non-queued EnqueueDispatchResult, matching dispatch_prepared’s switch structure. Preserve the STOPPING message, map the currently impossible STAGED_IDENTITY_CHANGED result explicitly, and ensure any newly added enumerator cannot fall through to “endpoint capacity exceeded.”python/simpler/worker.py (1)
2405-2414: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winShare one diagnostics predicate.
config_has_diagnosticshere andWorker._l2_config_has_diagnosticsat lines 8194-8202 contain the identical field list. Both must trackCallConfig::diagnostics_any(). Extract one module-level helper and call it from both sites, so a new diagnostic flag cannot be added to only one copy.♻️ Proposed shared helper
def _call_config_has_diagnostics(config: CallConfig) -> bool: # Mirrors CallConfig::diagnostics_any(). return bool( config.enable_l2_swimlane or config.enable_dump_args or config.enable_pmu or config.enable_dep_gen or config.enable_scope_stats )- def config_has_diagnostics(config: CallConfig) -> bool: - # Mirrors CallConfig::diagnostics_any(); these modes share native - # diagnostic state and therefore use the serial prepare fallback. - return bool( - config.enable_l2_swimlane - or config.enable_dump_args - or config.enable_pmu - or config.enable_dep_gen - or config.enable_scope_stats - ) + config_has_diagnostics = _call_config_has_diagnostics🤖 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/worker.py` around lines 2405 - 2414, Extract the shared field-list logic from the local config_has_diagnostics function into a module-level _call_config_has_diagnostics(config: CallConfig) helper, preserving the fields that mirror CallConfig::diagnostics_any(). Update both config_has_diagnostics and Worker._l2_config_has_diagnostics to delegate to this helper so future diagnostic flags are maintained in one place.tests/ut/py/test_worker/test_host_worker.py (1)
405-432: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueLet the harness vary the frame run id.
publishwrites a constantrun_idof 5 into every frame. The new concurrency tests therefore stage an active frame and a successor frame that share one run id and differ only bydispatch_id. Production stages a successor that belongs to a different run. The tests still prove dispatch-id ordering, so nothing is wrong today. Ifrun_two_frame_looplater gates preparation on run identity, these tests would pass without exercising that gate. Add arun_idparameter with a default of 5 and give the successor a distinct value in the concurrent-prepare tests.🤖 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 `@tests/ut/py/test_worker/test_host_worker.py` around lines 405 - 432, Update the test harness publish method to accept a run_id parameter defaulting to 5, and write that value into _OFF_FRAME_RUN_ID instead of the hardcoded constant. In the concurrent-prepare tests, pass a distinct run_id for the successor frame while preserving the existing default for other callers.
🤖 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.
Inline comments:
In `@docs/worker-manager.md`:
- Around line 241-247: Update the registry-control deferral sentence in the
worker-manager documentation to wait for both the active native run and any
prepared successor holding a native token. Keep the rule aligned with
run_two_frame_loop, where controls remain deferred while any staged frame owns a
native token.
In `@python/simpler/worker.py`:
- Around line 8570-8583: Update _wait_run_handle_accepted so it advances the L2
FIFO in bounded steps, rechecking the target run state after each
_l2_progress_locked call rather than allowing one call to run through terminal
completion. Return as soon as the target phase is no longer "prepared", while
preserving unknown-run and propagated-error handling; leave the orchestrator
path unchanged.
In `@src/common/platform/onboard/host/c_api_shared.cpp`:
- Around line 873-881: Update simpler_finalize_run to wrap
capture_native_run_thread_selection, select_pipeline_slot, and select_arena_bank
in exception handling that releases runner_claimed and runner_reserved, calls
destroy_native_run_state, and returns -1 on selection failure or thrown
exceptions. Apply the equivalent exception guard to simpler_launch_run around
its native thread selection calls, preserving its existing claim-release
behavior.
In `@src/common/platform/onboard/host/device_runner_base.cpp`:
- Around line 1659-1665: Protect the native_launch_signal_ read in
DeviceRunnerBase::publish_task_accepted with native_run_mu_, matching the
synchronization used by try_acquire_native_run and release_native_run. Hold the
mutex while copying or checking the pointer and notifying it, so the pointer
cannot race with updates or destruction; preserve the existing accepted_state
release store.
- Around line 72-107: Update the native-run selection flow around
run_selection(), select_pipeline_slot(), and select_arena_bank() so selection
state is isolated per DeviceRunnerBase instance rather than shared by thread
alone. Attach or copy NativeRunThreadSelection to the owning runner, or make the
existing TLS state resolve through a runner-specific key, ensuring contexts used
on the same host thread cannot reuse each other’s arena bank, retained buffers,
or run identity.
In `@src/common/platform/onboard/host/device_runner_base.h`:
- Around line 136-140: Remove noexcept from restore_native_run_thread_selection
in both its declaration and definition, allowing exceptions from run_selection
during create_thread’s initial TLS allocation to propagate safely instead of
terminating the process.
In `@src/common/worker/chip_worker.cpp`:
- Around line 814-826: Re-validate both lease_generation and run_epoch under
native_run_mu_ before writing completion state in poll_native_run and
wait_native_run. If the slot identity no longer matches, do not update the
successor state; otherwise preserve the existing writes to phase REAPED and, in
wait_native_run, wait_rc.
In
`@tests/st/a2a3/host_build_graph/native_run_lifecycle/test_native_run_lifecycle.py`:
- Around line 196-199: Remove the timing-dependent torch.count_nonzero assertion
from the direct L2 submit test, while retaining the run_handle._terminal
assertion to verify submit returns a non-completed compatibility handle. Keep
the subsequent run_handle.wait(30.0) lifecycle check unchanged.
---
Nitpick comments:
In `@python/simpler/worker.py`:
- Around line 2405-2414: Extract the shared field-list logic from the local
config_has_diagnostics function into a module-level
_call_config_has_diagnostics(config: CallConfig) helper, preserving the fields
that mirror CallConfig::diagnostics_any(). Update both config_has_diagnostics
and Worker._l2_config_has_diagnostics to delegate to this helper so future
diagnostic flags are maintained in one place.
In `@src/common/hierarchical/worker_manager.cpp`:
- Around line 318-325: Update dispatch to explicitly handle every non-queued
EnqueueDispatchResult, matching dispatch_prepared’s switch structure. Preserve
the STOPPING message, map the currently impossible STAGED_IDENTITY_CHANGED
result explicitly, and ensure any newly added enumerator cannot fall through to
“endpoint capacity exceeded.”
In `@src/common/platform/onboard/host/c_api_shared.cpp`:
- Around line 711-713: In the preparation flow around runner_resources_owned and
provision_native_run_resources, add a concise one-line comment explaining that
provisioning failure may leave partial native resources held, so ownership must
be claimed before the provisioning attempt for cleanup_failed_prepare to release
them.
- Around line 697-701: Update the admission-rejection branch in the native-run
preparation flow around try_reserve_native_run so it emits the allocated trace
invocation via the existing cleanup_failed_prepare path or equivalent before
destroying state and returning. Preserve the current error logging and -1 return
while ensuring rejected reservations produce the same host-wall trace record as
other failure paths.
In `@src/common/platform/onboard/host/device_runner_base.cpp`:
- Around line 1356-1359: Update DeviceRunnerBase::activate_launch_shape to guard
cores_per_blockdim_ before dividing worker_count_. Preserve the benign zero
behavior by setting block_dim_ to zero when cores_per_blockdim_ is zero;
otherwise retain the existing worker-count division.
In `@src/common/platform/onboard/host/device_runner_base.h`:
- Around line 132-135: Document on the declarations of pipeline_slot(),
selected_arena_bank(), and arena_bank() that each accessor may throw due to
pthread TLS key creation or allocation failure. Keep the existing signatures
unchanged and make the contract visible to callers before they use these
accessors in run, setup_static_arena, or acquire_pooled_* paths.
In `@src/common/worker/chip_worker.cpp`:
- Around line 684-688: In the occupancy validation near the prepare_native_run
ownership check, replace the `occupied != 0 && occupied != 1` condition with the
equivalent `occupied > 1` check, preserving the existing runtime error and
identity formatting.
In `@tests/ut/py/test_worker/test_host_worker.py`:
- Around line 405-432: Update the test harness publish method to accept a run_id
parameter defaulting to 5, and write that value into _OFF_FRAME_RUN_ID instead
of the hardcoded constant. In the concurrent-prepare tests, pass a distinct
run_id for the successor frame while preserving the existing default for other
callers.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 6baf5a84-ef87-4133-9053-7b0416dc8098
📒 Files selected for processing (24)
docs/task-flow.mddocs/worker-manager.mdpython/bindings/task_interface.cpppython/simpler/task_interface.pypython/simpler/worker.pysrc/a2a3/platform/onboard/host/device_runner.cppsrc/a2a3/platform/onboard/host/device_runner.hsrc/common/hierarchical/scheduler.cppsrc/common/hierarchical/worker_manager.cppsrc/common/hierarchical/worker_manager.hsrc/common/log/include/common/strace.hsrc/common/platform/onboard/host/c_api_shared.cppsrc/common/platform/onboard/host/device_runner_base.cppsrc/common/platform/onboard/host/device_runner_base.hsrc/common/worker/chip_worker.cppsrc/common/worker/chip_worker.hsrc/common/worker/native_run_state.hsrc/common/worker/pto_runtime_c_api.htests/st/a2a3/host_build_graph/native_run_lifecycle/kernels/orchestration/long_vector_orch.cpptests/st/a2a3/host_build_graph/native_run_lifecycle/test_native_run_lifecycle.pytests/ut/cpp/hierarchical/test_run_stream_slots.cpptests/ut/cpp/hierarchical/test_scheduler.cpptests/ut/py/test_worker/test_host_worker.pytests/ut/py/test_worker/test_startup_readiness.py
b160bcf to
6526943
Compare
|
Reviewed against 1. This reverts D1, which merged 4 hours ago in #1587The title says "align native prepared lane with v2 ownership", but the diff also undoes the uniform host-runtime pipeline ABI:
The commit message justifies this as "keep pipeline metadata optional for older runtimes". There are no older runtimes: If the v2 ownership rework genuinely needs a symbol to be optional, please name which one and why, rather than reverting the set. 2. Deleting the sim
|
774646d to
bc204de
Compare
bc204de to
8a70285
Compare
|
@ChaoWao Addressed the review summary:
Final validation includes all pre-commit hooks, 1093 Python unit tests, 78 C++ |
e84ad57 to
bfc79c5
Compare
|
Splitting out PR-A of this change as an independent, narrower PR: #1685 ("Refactor: bind platform host callbacks per run instead of per thread"). #1685 lands only the platform-layer prerequisite this PR bundles first:
It deliberately excludes the other two concerns still on this branch:
Implemented independently from this branch (not pushed to it). Once #1685 merges, the remaining two can rebase onto it — |
Split the platform HostApi into an immutable HostApiFunctions table plus a per-run HostApi value object that binds it to one runner and one run's pipeline slot / arena bank. Carry the run's resource selection and trace identity across the C ABI in a required NativeRunDescriptor, and delete the pthread-TLS resource-selection mechanism (NativeRunThreadSelection, both pthread keys, capture/restore) and the four setter exports it made necessary. Launch acceptance moves from a TLS setter into the per-run launch signal. Bump PTO_PIPELINE_CONTRACT_ABI_VERSION 1 -> 2 so a stale host_runtime.so (whose simpler_run/prepare_run still take the old flat argument list) is rejected at load rather than crashing at the first call. PR-A of the hw-native-sys#1650 three-way split; PR-B (direct-L2 async lane) and PR-C (HostTensorAccessScope RAII) follow. Co-Authored-By: Claude <noreply@anthropic.com>
Split the platform HostApi into an immutable HostApiFunctions table plus a per-run HostApi value object that binds it to one runner and one run's pipeline slot / arena bank. Carry the run's resource selection and trace identity across the C ABI in a required NativeRunDescriptor, and delete the pthread-TLS resource-selection mechanism (NativeRunThreadSelection, both pthread keys, capture/restore) and the four setter exports it made necessary. Launch acceptance moves from a TLS setter into the per-run launch signal. Bump PTO_PIPELINE_CONTRACT_ABI_VERSION 1 -> 2 so a stale host_runtime.so (whose simpler_run/prepare_run still take the old flat argument list) is rejected at load rather than crashing at the first call. PR-A of the hw-native-sys#1650 three-way split; PR-B (direct-L2 async lane) and PR-C (HostTensorAccessScope RAII) follow. Co-Authored-By: Claude <noreply@anthropic.com>
Split the platform HostApi into an immutable HostApiFunctions table plus a per-run HostApi value object that binds it to one runner and one run's pipeline slot / arena bank. Carry the run's resource selection and trace identity across the C ABI in a required NativeRunDescriptor, and delete the pthread-TLS resource-selection mechanism (NativeRunThreadSelection, both pthread keys, capture/restore) and the four setter exports it made necessary. Launch acceptance moves from a TLS setter into the per-run launch signal. Bump PTO_PIPELINE_CONTRACT_ABI_VERSION 1 -> 2 so a stale host_runtime.so (whose simpler_run/prepare_run still take the old flat argument list) is rejected at load rather than crashing at the first call. PR-A of the hw-native-sys#1650 three-way split; PR-B (direct-L2 async lane) and PR-C (HostTensorAccessScope RAII) follow. Co-Authored-By: Claude <noreply@anthropic.com>
272f745 to
7f94381
Compare
7f94381 to
d3f1b35
Compare
5646b42 to
b277c25
Compare
|
Reviewed at The
|
| Rejection reason | Status |
|---|---|
Python _L2NativeRun/FIFO/phase/poison as a third lifecycle authority |
✅ grep _L2NativeRun = 0 hits; Python now only reads chip_run.lane_poisoned / .done() / .launched |
Dispatch-path sleep(0) |
✅ Gone. The four remaining time.sleep(_STARTUP_POLL_INTERVAL_S) are all in _await_children_ready / _abort_hierarchical / _reap_child_groups — the init/teardown paths rule 5 explicitly exempts |
C++ ChipRunLane must own the lifecycle |
✅ chip_run_lane.{h,cpp}, single-mutex FIFO state machine |
| "Public direct-chip capacity two only after capacity-one is proven" | ✅ ChipWorker::run() is submit + wait_until(max()), so public capacity stays one; the lane's 2 slots serve the child loop's existing two-frame staging |
The HBG-globals removal is correctly deferred — your Scope section says W1a + E2 only, and the record agrees that's a separate stage.
What I fixed
ChipWorker::run() used to make one blocking call into the platform:
int rc = run_fn_(device_ctx_, rt, callable_id, args, &config, &descriptor); // pre-PRsimpler_run blocks inside the device layer on aclrtSynchronizeStreamWithTimeout — no host CPU. It became:
ChipRun run = run_lane_->submit(...);
(void)run.wait_until(ChipRun::Deadline::max());and wait_until re-entered progress() in a tight loop with no pause and no wakeup primitive. progress() calls poll_native_run, so each iteration is a real device poll — and with Deadline::max() the Clock::now() >= deadline exit can never fire.
I measured it rather than eyeballing it, by temporarily instrumenting your own test harness (completion delayed 200 ms on a second thread, reverted afterwards):
[MEASURE] 200 ms wait -> 636336 poll_run calls
~3.2 M/s against the stubbed poll; in production every one is an rtSetDevice plus a device read. So every blocking chip run saturated a core for its duration, and the chip child did it for its whole lifetime.
This is legal under codestyle.md rule 5 by the letter — a dispatch-path wait may spin. But the rule names this exact scenario and prescribes the remedy:
When an idle spin is genuinely too expensive to leave running (a forked child that would hold a core for its whole lifetime), the answer is a blocking wakeup primitive, not a sleep — blocking costs no CPU and wakes in single-digit microseconds.
The primitive was already in the lane: drain_front() calls wait_native_run at line 202. An unbounded waiter now blocks on the launched front through that same call. Only the front can be LAUNCHED, so its completion is what lets any waiter in the FIFO advance; block_on_front() returns whether it actually blocked, so a waiter with nothing blockable falls back to the existing poll loop rather than spinning on a no-op. Finite deadlines are unchanged — they need polling to honor the deadline, and their loop terminates on its own.
Same measurement after the change: 1 poll_run call.
Worth flagging the framing: rejection reason #4 was the sleep(0). Removing it was right, but the replacement went to a raw spin rather than to the wakeup primitive the rule points at — so that concern was arguably worse, not resolved.
Two smaller things, not fixed
- The spin was structurally untestable. The stubbed
poll_runreturnsCOMPLETEas soon asg_completeis set, so everywait_untilin all 12 cases returns on its first pass —ExpiredWaitLeavesTheRunLivepasses an already-elapsed deadline, so it too does one iteration. That's why CI was 18/18 green with this in it. Ag_poll_countbound on a delayed completion (the harness already has the counter) would pin the invariant. I left this to you since it's your harness. ChipRun::wait_until_launchedhas zero callers — dead API on arrival. Worth deleting or wiring up.
Validation of the pushed commit
- 91/91 cpp UT under CI's own
-LE requires_hardwarefilter (your 12 lane tests included, unchanged) - 1190 passed / 13 skipped — full
tests/ut/py - a2a3 onboard sweep: 54 passed, plus 24 passed / 2 skipped in the resource phase
- a2a3sim sweep: 45 passed, plus 21 passed / 4 skipped
One note before merge
This branch forked at 9a03d92c (#1728), but upstream/main is now d1ec68a4 (#1739), which deleted WorkerEndpoint::run / run_with_accept / dispatch_process and touched worker_manager.{h,cpp} — files this PR also edits. Worth a rebase to confirm they don't collide.
ChipWorker::run() is submit + wait_until(Deadline::max()), and wait_until re-entered progress() in a tight loop. progress() polls the device via poll_native_run, so every blocking chip run held a core for its whole duration; with an infinite deadline the Clock::now() exit could never fire. Measured against the lane's own stubbed poll, a 200 ms run took 636336 poll_run calls (~3.2 M/s) — in production each one is an rtSetDevice plus a device read. The chip child does this for its entire lifetime. codestyle rule 5 permits a dispatch-path wait to spin, but names this exact case — "a forked child that would hold a core for its whole lifetime" — and sends it to a blocking wakeup primitive rather than a sleep or a busy loop. The lane already had one: drain_front() calls wait_native_run, which blocks in the device layer at no host CPU cost. An unbounded waiter now blocks on the launched front through that same primitive. Only the front can be LAUNCHED, so its completion is what lets any waiter in the FIFO advance; block_on_front reports whether it blocked, so a waiter with nothing blockable yet falls back to the existing poll loop rather than spinning on a no-op. Finite deadlines are unchanged — they need polling to honor the deadline, and their loop terminates on its own. The same measurement after the change: 1 poll_run call. Verified: 91/91 cpp UT under CI's own -LE requires_hardware filter (12 lane tests included, unchanged); 1190 passed / 13 skipped py UT; a2a3 onboard sweep 54 passed plus 24 passed / 2 skipped in the resource phase; a2a3sim sweep 45 passed plus 21 passed / 4 skipped.
5e4e186 to
7560854
Compare
|
Rebased onto Three commits had landed since you forked at The one conflict that needed a judgement call#1729 (
Taking either side wholesale would have been wrong: keeping the blob binding would have resurrected an API main deleted and bypassed the new import-registry resolution (so remote/imported tensor descriptors would never be materialized to local bases); taking main's alone would have dropped the lane. Resolution: the lane keeps its ownership, but adopts main's materialized contract.
Two smaller hunks were mechanical: your deletion of Please sanity-check the materialization placement. Doing it in Validation of the rebased branch
|
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.
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.
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.
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-#1650/#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` (#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.
Summary
ChipRunLane/ChipRunas the C++ owner of native FIFO admission, generation, prepare/launch/poll/finalize, terminal state, poison, drain, and closeWorker.submit()blockingVALIDATED_ONLY/NATIVE_PREPARED) and bump the task protocol to v3Scope
This is W1a + E2 only. It does not add public asynchronous submit, W1d cleanup/folding, B5 compatibility, or W1b.
Testing
ChipRunLane: 12/12 passed; scheduler: 63/63 passedtask_20260807_044537_40856118009task_20260807_050221_7162399608passedtask_20260807_061626_126859527092passedtask_20260807_064449_306323420340passed