Skip to content

fix(ring-buffer): make structural deadlock detection scope-aware - #1610

Merged
ChaoZheng109 merged 1 commit into
hw-native-sys:mainfrom
Leaf-Salix:fix/scope-aware-structural-deadlock
Jul 31, 2026
Merged

fix(ring-buffer): make structural deadlock detection scope-aware#1610
ChaoZheng109 merged 1 commit into
hw-native-sys:mainfrom
Leaf-Salix:fix/scope-aware-structural-deadlock

Conversation

@Leaf-Salix

@Leaf-Salix Leaf-Salix commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Summary

This change makes the tensormap_and_ringbuffer structural-deadlock check use
scope identity: it reports an immediate fatal only for a cycle that the
blocked orchestrator itself makes impossible to break. Other no-progress
states remain covered by the existing timeout.

Issue

The affected allocation paths are:

  • PTO2TaskAllocator::alloc(), which reserves both a task-window slot and the
    task's output bytes from the ring heap and diagnoses task-window
    (PTO2_ERROR_FLOW_CONTROL_DEADLOCK) or heap
    (PTO2_ERROR_HEAP_RING_DEADLOCK) exhaustion;
  • PTO2DepListPool::ensure_space(), which reserves dependency-list entries for
    a task with live fanin and reports PTO2_ERROR_DEP_POOL_OVERFLOW when it
    gives up.

Insufficient space does not immediately mean allocation has failed. Both paths
wait for the scheduler to make the ring's reclaim head CONSUMED, advance
last_task_alive, reclaim space, and then retry. This is normal backpressure.
They return failure only after another fatal is observed or their own
structural/timeout detector concludes that waiting cannot continue.

The task window, ring heap, and dependency-list pool hold different data, but
all three reclaim in task order using the same ring-local last_task_alive
watermark. Therefore the same head task controls whether any of these blocked
allocations can recover.

The merge-base structural detector turns that wait into an immediate fatal
when the reclaim-head task appears COMPLETED, has released all consumer
references, but has not released its scope reference. The same lifecycle-based
predicate is used for task-window exhaustion, heap exhaustion, and
dependency-pool exhaustion.

Those lifecycle fields do not identify which scope owns the reclaim head.
The head may belong to a closed child, sibling, or earlier serial scope whose
scope_end has already executed. The scheduler can still transition such a
head to CONSUMED, advance last_task_alive, and unblock the allocation.
Because the merge-base check reads the lifecycle fields while the scheduler is
progressing them, it can report a structural fatal immediately before that
valid reclaim.

The missing proof is whether this allocation wait prevents a still-pending
scope_end that is required for this particular head to become CONSUMED.
Lifecycle state alone cannot establish that relationship.

Versions

  • Fix: b7ad6e97e383868ba34326caa3ff2ada733349f2.
  • Upstream merge base: 21e82d5e54eb8dbb922557a102afca8f58a89ea0.
  • PTO-ISA: 83d01313d9bfc247c4b7c8bcf969d1019f0d106f.

Fix

  • Replace the lifecycle/refcount structural predicate in both
    PTO2TaskAllocator::alloc() and PTO2DepListPool::ensure_space() with an
    oldest-open-task identity check.
  • Derive the oldest task retained by any open scope on the blocked ring in
    O(1) from the orchestrator's existing scope_begins and scope_tasks
    bookkeeping.
  • Pass its slot pointer into both allocation paths.
  • When task-window, heap, or dependency-pool space is unavailable, report an
    immediate structural fatal only if that pointer equals the reclaim-head
    slot. Otherwise, keep waiting for reclaim and use the existing approximately
    500 ms no-progress timeout as the fallback.
  • Apply the same behavior to A2/A3 and A5
    tensormap_and_ringbuffer.
  • Distinguish a proven open-scope cycle from a generic reclaim timeout in
    diagnostics and troubleshooting documentation.

No shared-memory fields, task-slot fields, locks, atomics, runtime hooks, or
linear scope scans are added.

Definitions

  • Blocked ring is the ring on which the current task-window, heap, or
    dependency-pool allocation cannot obtain space.
  • An open scope has executed scope_begin but not its matching
    scope_end. scope_tasks records emitted task-slot pointers in order, and
    scope_begins[depth] records where each open scope starts in that array.
  • H (head) is the slot addressed by the blocked ring's currently observed
    last_task_alive: slot_states[last_task_alive & window_mask]. It is the
    oldest slot that this ring has not reclaimed. The reclaim boundary cannot
    move past H until the scheduler makes H CONSUMED.
  • O (oldest open task) is obtained from
    begin = scope_begins[current_ring_id()]. If begin < scope_tasks_size,
    then O = scope_tasks[begin]; otherwise O = nullptr. Because task pointers
    are recorded in emission order, O is the oldest task on the blocked ring
    retained by the shallowest open scope mapped to that ring, including its
    still-open descendants. Its scope reference cannot be released until the
    blocked orchestrator reaches the corresponding scope_end.

H == O compares task-slot pointers, not task IDs or independently sampled
lifecycle fields.

Cases

The same H/O decision applies whether the unavailable resource is a
task-window slot, ring-heap space, or dependency-list capacity.

  1. Current-scope head: H == O

    The current scope has emitted H and then blocks in one of the affected
    allocation paths on the same ring. The orchestrator cannot reach this
    scope's scope_end, so H cannot release its scope reference or become
    CONSUMED. The fix reports an immediate structural fatal.

  2. Open-parent head: H == O

    A nested scope blocks while allocating on a ring also used by an open
    parent, and H is the parent's oldest retained task on that ring. The
    orchestrator cannot leave the child and reach the parent's scope_end, so
    H cannot become CONSUMED. The fix also reports an immediate structural
    fatal. This includes nesting deeper than the number of physical rings,
    where multiple scope depths share the deepest ring.

  3. Earlier sibling head on the same ring: H != O

    Two sibling scopes execute serially on the same ring. The earlier scope has
    executed scope_end, but its head H has not yet transitioned to
    CONSUMED. The later scope is now open and blocks while allocating on that
    ring; O belongs to the later scope, so H != O. The scheduler may still
    consume H and unblock allocation, which makes an immediate structural
    fatal unsafe. The fix keeps waiting. If H never advances, the existing
    timeout reports the sustained no-progress deadlock.

Why this proves a deadlock

When H == O, the blocked allocation prevents the orchestrator from reaching
the scope_end that would release O's scope reference. O therefore cannot
become CONSUMED, while ordered reclamation cannot advance past the same slot
as H. The allocation is waiting for a reclaim that the blocked allocation
itself prevents, so this is a structural cycle and can be reported
immediately.

When H != O, the blocked orchestrator is not proven to pin H. The scheduler
may still consume it and advance the reclaim boundary. The allocator therefore
continues waiting; if the boundary remains stuck, the timeout reports sustained
no progress without misclassifying a transient scheduler state.

Scope depth maps one-to-one to rings until the deepest ring; deeper scopes
share that ring. For that ring, scope_begins selects the shallowest open
scope, so O still includes tasks retained by any open ancestor.

Concurrency and ABA safety

scope_tasks and scope_begins have one orchestrator writer, and the scheduler
does not modify them. While blocked in allocation, that orchestrator cannot
close a scope or change O. Because O remains scope-retained, its slot cannot
be consumed and reused. A concurrent reclaim-boundary read may therefore delay
detection when H != O, but it cannot turn H == O into a false positive.

The dependency-list path uses the new task's slot_state.ring_id, while the
scope helper uses current_ring_id(); prepare_task() establishes that these
refer to the same ring before live-fanin wiring can block.

Assumptions and unchanged concerns

The proof relies on existing runtime contracts: scope references are retained
until scope_end, each scope stack has one orchestrator writer, and a live task
slot is not reused. Those contracts would need to be revisited if early scope
release or multiple orchestrator writers are introduced.

This change does not alter the existing int32_t task-ID lifetime limit,
current_task_index publication ordering, or the allocator's possible
diagnostic-code overwrite by a concurrent fatal. None is used to establish
H == O.

Regression coverage

Focused A2/A3 and A5 tests cover:

  • an allocation blocked by the current open-scope head;
  • an allocation blocked by an open ancestor when nested scopes share the
    deepest ring;
  • a different-scope head that must use the timeout instead of an immediate
    structural fatal;
  • both heap/task allocation and dependency-pool exhaustion.

These cases distinguish oldest-open-task slot identity from the old
COMPLETED + consumers released + scope bit unset approximation.
They use the normal scope-end and scheduler-consume lifecycle and add no
production test hooks or AICPU hot-path logging.

Validation

  • A2/A3 and A5 scope-deadlock and orchestrator-fanin tests passed.
  • The focused scope detector and nested-scope regressions passed 20 repeated
    runs on both architectures.
  • git diff --check origin/main...HEAD and modified-file pre-commit checks
    passed.

Performance

Computing O is O(1): one ring lookup, one scope_begins load, one bounds
check, and at most one scope_tasks load. It adds no scan, lock, atomic
operation, or shared-memory layout change. The allocator compares slots once
per 1024 blocked spins; live-fanin computes O once before the dependency
pool's existing availability check.

An on-device ABBA benchmark compared the two validated commits:

  • Platform: a2a3.
  • Workload:
    tests/st/a2a3/tensormap_and_ringbuffer/multi_round_paged_attention/
    test_multi_round_paged_attention.py, Case1, --skip-golden.
  • Sequence: 10-round base/fix warm-ups, then base/fix/fix/base with 500 rounds
    per measurement.
  • All four measurements reported 500/500 valid device rounds.
  • The measured fix commit was 19fca94a0ae0ccb86203173a1f03b8bfcf0bb846.
    The final commit only amends its message; both commits have source-tree hash
    b04e4b339cc260f55414c3ac9c3e43cd657cfa4a.
Run Host (us) Device (us) Effective (us) Orch (us) Sched (us)
Base 1 1394.5 58.0 35.7 15.3 31.2
Fix 1 1296.3 57.9 35.7 14.8 30.8
Fix 1 vs. base 1 -7.04% -0.17% 0.00% -3.27% -1.28%
Fix 2 1363.6 57.7 35.7 14.8 30.9
Base 2 1307.6 57.1 35.3 14.6 30.7
Fix 2 vs. base 2 +4.28% +1.05% +1.13% +1.37% +0.65%
Base mean 1351.05 57.55 35.50 14.95 30.95
Fix mean 1329.95 57.80 35.70 14.80 30.85
Combined delta -1.56% +0.43% +0.56% -1.00% -0.32%

The paired deltas change direction between the two measurements, and the
combined device, effective, orchestrator, and scheduler deltas remain within
1%. The benchmark therefore shows no stable performance regression from this
change.

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The A2A3 and A5 runtimes now identify the oldest task pinned by an open scope during capacity checks. Structural deadlocks produce immediate diagnostics, while other reclaim stalls use timeout handling. Tests and troubleshooting guidance cover both outcomes.

Changes

Deadlock detection and validation

Layer / File(s) Summary
Open-scope boundary propagation
src/a2a3/runtime/tensormap_and_ringbuffer/runtime/pto_orchestrator.cpp, src/a5/runtime/tensormap_and_ringbuffer/runtime/pto_orchestrator.cpp
Each orchestrator finds the oldest open task on the current ring and passes it to task allocation and dependency-pool checks.
Structural deadlock detection
src/a2a3/runtime/..., src/a5/runtime/...
Allocator and dependency-pool checks compare the reclaim head with the oldest open-scope task. Diagnostics now distinguish structural deadlocks from timeout-based reclaim stalls.
Regression coverage and diagnostic guidance
tests/ut/cpp/..., docs/troubleshooting/device-error-codes/capacity.md, .claude/rules/running-onboard.md
Tests cover structural and timeout outcomes. Documentation classifies the related diagnostics and recommends scope_stats for resource analysis.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant PTO2Orchestrator
  participant PTO2TaskAllocator
  participant PTO2DepListPool
  participant RingHead
  PTO2Orchestrator->>PTO2TaskAllocator: pass oldest open task
  PTO2TaskAllocator->>RingHead: compare reclaim head
  PTO2Orchestrator->>PTO2DepListPool: pass oldest open task
  PTO2DepListPool->>RingHead: compare reclaim head
  RingHead-->>PTO2TaskAllocator: structural deadlock or timeout path
  RingHead-->>PTO2DepListPool: structural deadlock or timeout path
Loading

Possibly related PRs

Poem

A rabbit checks the ring at night,
Finds open tasks that block the flight.
Structural traps now speak out clear,
While timeout clues still persevere.
Tests hop along with carrots bright.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 34.48% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
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 and concisely describes the main change: making ring-buffer structural deadlock detection scope-aware.
Description check ✅ Passed The description directly explains the scope-aware deadlock detection changes, affected allocation paths, regression tests, validation, and performance results.

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.

The task/heap allocator and dependency-list pool currently infer an
immediate structural deadlock from independently sampled task state and
reference counts. A reclaim head from an earlier closed scope can still
become CONSUMED, so this can race normal scheduler reclaim and report a
false fatal.

Pass the oldest task retained by any open scope on the blocked ring to
both allocation paths. Report an immediate fatal only when that slot is
the reclaim head; otherwise preserve the 500 ms no-progress timeout.

Cover current-scope and open-ancestor cycles, different-scope timeout
fallback, and dependency-pool behavior on a2a3 and a5. Update diagnostics
to distinguish proven structural cycles from reclaim timeouts without
adding locks, scans, or shared-memory fields.

Co-authored-by: sunkaixuan2018 <baiyi@mail.ustc.edu.cn>
@Leaf-Salix
Leaf-Salix force-pushed the fix/scope-aware-structural-deadlock branch from 19fca94 to b7ad6e9 Compare July 31, 2026 03:37
@Leaf-Salix

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@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.

🧹 Nitpick comments (1)
src/a2a3/runtime/tensormap_and_ringbuffer/runtime/pto_ring_buffer.h (1)

408-416: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

The structural deadlock check is implemented twice per architecture.

head_is_oldest_open_task proves the structural deadlock by comparing a pointer to the current head-task slot. Each architecture defines this check once as a named method on PTO2TaskAllocator and reimplements the identical comparison inline inside PTO2DepListPool::ensure_space. Two independent copies of this safety-critical proof, per architecture, raise the risk that a future edit updates one copy and not the other.

  • src/a2a3/runtime/tensormap_and_ringbuffer/runtime/pto_ring_buffer.h#L408-L416: keep this as the canonical implementation for a2a3.
  • src/a2a3/runtime/tensormap_and_ringbuffer/runtime/pto_ring_buffer.cpp#L205-L210: replace the inline bool head_is_oldest_open_task = ... computation with a call to a shared a2a3 helper (taking the slot-state array/index and oldest_open_task), instead of reimplementing the comparison.
  • src/a5/runtime/tensormap_and_ringbuffer/runtime/pto_ring_buffer.h#L408-L416: keep this as the canonical implementation for a5.
  • src/a5/runtime/tensormap_and_ringbuffer/runtime/pto_ring_buffer.cpp#L205-L210: replace the inline computation with a call to the shared a5 helper, mirroring the a2a3 fix.
🤖 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/a2a3/runtime/tensormap_and_ringbuffer/runtime/pto_ring_buffer.h` around
lines 408 - 416, The structural deadlock check is duplicated between the
canonical PTO2TaskAllocator::head_is_oldest_open_task method and
PTO2DepListPool::ensure_space. Keep the named method unchanged in
src/a2a3/runtime/tensormap_and_ringbuffer/runtime/pto_ring_buffer.h:408-416 and
src/a5/runtime/tensormap_and_ringbuffer/runtime/pto_ring_buffer.h:408-416; in
src/a2a3/runtime/tensormap_and_ringbuffer/runtime/pto_ring_buffer.cpp:205-210
and src/a5/runtime/tensormap_and_ringbuffer/runtime/pto_ring_buffer.cpp:205-210,
replace each inline comparison with a call to the corresponding shared
architecture helper using the slot-state array/index and oldest_open_task.
🤖 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.

Nitpick comments:
In `@src/a2a3/runtime/tensormap_and_ringbuffer/runtime/pto_ring_buffer.h`:
- Around line 408-416: The structural deadlock check is duplicated between the
canonical PTO2TaskAllocator::head_is_oldest_open_task method and
PTO2DepListPool::ensure_space. Keep the named method unchanged in
src/a2a3/runtime/tensormap_and_ringbuffer/runtime/pto_ring_buffer.h:408-416 and
src/a5/runtime/tensormap_and_ringbuffer/runtime/pto_ring_buffer.h:408-416; in
src/a2a3/runtime/tensormap_and_ringbuffer/runtime/pto_ring_buffer.cpp:205-210
and src/a5/runtime/tensormap_and_ringbuffer/runtime/pto_ring_buffer.cpp:205-210,
replace each inline comparison with a call to the corresponding shared
architecture helper using the slot-state array/index and oldest_open_task.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: e7729690-69d1-4d96-a963-b02a98a7db81

📥 Commits

Reviewing files that changed from the base of the PR and between a07bc90 and b7ad6e9.

📒 Files selected for processing (18)
  • .claude/rules/running-onboard.md
  • docs/troubleshooting/device-error-codes/capacity.md
  • src/a2a3/runtime/host_build_graph/runtime/pto_ring_buffer.h
  • src/a2a3/runtime/tensormap_and_ringbuffer/runtime/pto_orchestrator.cpp
  • src/a2a3/runtime/tensormap_and_ringbuffer/runtime/pto_ring_buffer.cpp
  • src/a2a3/runtime/tensormap_and_ringbuffer/runtime/pto_ring_buffer.h
  • src/a2a3/runtime/tensormap_and_ringbuffer/runtime/pto_runtime2_types.h
  • src/a2a3/runtime/tensormap_and_ringbuffer/runtime/pto_shared_memory.h
  • src/a2a3/runtime/tensormap_and_ringbuffer/runtime/scheduler/pto_scheduler.h
  • src/a5/runtime/tensormap_and_ringbuffer/runtime/pto_orchestrator.cpp
  • src/a5/runtime/tensormap_and_ringbuffer/runtime/pto_ring_buffer.cpp
  • src/a5/runtime/tensormap_and_ringbuffer/runtime/pto_ring_buffer.h
  • src/a5/runtime/tensormap_and_ringbuffer/runtime/pto_runtime2_types.h
  • src/a5/runtime/tensormap_and_ringbuffer/runtime/pto_shared_memory.h
  • tests/ut/cpp/CMakeLists.txt
  • tests/ut/cpp/a2a3/test_orchestrator_fanin.cpp
  • tests/ut/cpp/a5/test_orchestrator_fanin.cpp
  • tests/ut/cpp/common/test_scope_deadlock_detection.cpp

@Leaf-Salix

Copy link
Copy Markdown
Contributor Author

Regarding the CodeRabbit maintainability nit: I am keeping the two checks local to their allocation paths. The task allocator derives the head slot from its private slot_states_/window_mask_, while the dependency-list pool derives it from the ring header. The only common operation left after those path-specific lookups is a null check plus pointer equality; extracting that into a production helper would widen the helper surface without centralizing an additional invariant. Both paths intentionally compare the same oldest_open_task slot identity, and the focused tests cover the structural and timeout decisions for each path.

@ChaoZheng109
ChaoZheng109 merged commit db827c1 into hw-native-sys:main Jul 31, 2026
18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants