Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 86 additions & 1 deletion docs/tensormap-and-ringbuffer-a2a3-vs-a5.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ This document describes the substantive differences in the current code under
`src/{a2a3,a5}/runtime/tensormap_and_ringbuffer/`.

> **Maintenance baseline:** The source layout and classifications were verified
> on 2026-08-17. Recompute the counts and update the affected sections whenever
> on 2026-08-31. Recompute the counts and update the affected sections whenever
> the files or constants described here change.

## Comparison Boundary and Classification
Expand Down Expand Up @@ -112,6 +112,7 @@ The functional differences group into the following themes:
| URMA completion | A5-specific implementation and product capability gate | Yes, for now | Retain the A5 path; do not claim that URMA is available in the default build |
| Next-block prefetch | A2/A3-only performance optimization | No | Retain on A2/A3; validate on A5 before considering a port |
| Scheduler progress publication | AICPU topology and measured publication cost | No | Retain A5's 16-task batching; keep per-advance publication on A2/A3, where the portable implementation showed no significant benefit |
| Terminal task release | Measured end-of-run scheduler cost | No | A5 traces show per-task release blocking the tail after task submission has ended, so successful A5 runs close the remaining live interval once; retain incremental release on A2/A3 because no tail release blocking was found there |
| Fatal teardown | Software reliability strategy | No | Retain the current implementations; decide whether to converge after measuring the worst-case A5 teardown time |
| Scheduler trace attribution | Software diagnostic strategy | No | Preserve the current traces; converge only after comparing generated timelines |

Expand Down Expand Up @@ -293,6 +294,90 @@ measurements instead showed lower Effective time in all eight workloads, with
an unweighted mean reduction of `2.81%`. Full A2/A3 measurements are recorded
in the [PR benchmark follow-up](https://github.com/hw-native-sys/simpler/pull/1575#issuecomment-5310909143).

### Terminal Task Release: A5 Seal and Bulk Closure

Task completion and task release are separate scheduler operations. Completion
records the finished AICore work and unlocks dependent tasks. Release later
drops the completed task's retained references, advances the ring's reclaim
head across consumed slots, resets reusable slot state, and publishes reclaim
progress. Both platforms defer this release work in a per-scheduler array with
a capacity of 256 entries.

A2/A3 preserves the incremental protocol for the whole run. It drains the
array when it becomes full, during idle cleanup, and when dispatch exits. Every
completed task therefore reaches `on_task_release()` before the scheduler
returns.

A5 follows the same protocol while the Orchestrator can still submit tasks.
After `orchestrator_done_` seals the graph, however, no new task can require a
reclaimed ring slot. At the next existing full-array, idle-drain, or exit-drain
boundary, A5 discards the deferred-release backlog instead of calling
`on_task_release()` once per entry. The seal is deliberately not loaded on
every scheduler-loop iteration or every completion: completion and dependency
unlocking remain unchanged, and the added acquire loads stay on boundaries
that already perform release bookkeeping.

Skipping incremental release does not leave a successful runtime reusable
state open. After all submitted tasks are complete and every active Scheduler
has left dispatch, the last Scheduler to reach a terminal barrier closes each
ring's live interval `[last_task_alive, current_task_index)` in bulk. It marks
the slots consumed, resets their reusable state, advances the local reclaim
head, force-publishes the final shared watermark, and clears pending
publication masks. The other Schedulers wait for the leader's result. The
barrier uses two 32-bit atomics in an existing eight-byte `SchedulerContext`
tail gap, and the closure routine is kept on a cold, out-of-line path.

This terminal protocol is entered only when orchestration is sealed, the
completed-task count has reached the submitted-task count, and neither the
Orchestrator nor a Scheduler has reported an error. Fatal executions continue
through emergency teardown instead of treating a partial graph as a successful
bulk-close candidate. Initialization, deinitialization, and runtime reuse reset
the barrier state.

This optimization is independent of the A5 K=16 progress-publication policy in
the preceding section. K=16 controls how often an already-advanced reclaim
head is copied to shared memory during the run. Terminal release elision avoids
the per-task reference-count and ring-advance work itself after graph sealing;
its deferred-release array still has capacity 256 and does not impose a
16-task release limit.

| Stage | A2/A3 | A5 |
| ----- | ----- | -- |
| Before graph sealing | Complete tasks, defer release, then incrementally call `on_task_release()` | Same |
| After graph sealing | Continue incremental release | Drop deferred release work at existing release boundaries |
| Successful Scheduler exit | Drain every remaining deferred entry | All Schedulers rendezvous; one closes and publishes all remaining live slots |
| Fatal or partial exit | Emergency teardown after the existing error checks | Emergency teardown; terminal bulk closure is not admitted |
| Profiling | Release phases | Release phases plus a distinct `TerminalClose` phase |

| File | A5-only terminal-release role |
| ---- | ----------------------------- |
| `runtime/async_wait.h`, `runtime/scheduler/scheduler_completion.cpp` | Propagate or read the graph seal at deferred-release capacity boundaries for normal and asynchronous completion |
| `runtime/scheduler/scheduler_dispatch.cpp` | Elide sealed idle/exit backlog drains and enter terminal coordination after dispatch |
| `runtime/scheduler/scheduler.h` | Validate and bulk-close each ring's remaining live interval, then force-publish the terminal watermark |
| `runtime/scheduler/{scheduler_context.h,scheduler_cold_path.cpp}` | Store, reset, and execute the successful-terminal barrier and leader election |
| `platform/include/common/chip_swimlane_profiling.h`, `platform/shared/host/chip_swimlane_collector.cpp`, `simpler_setup/tools/swimlane_converter.py` | Preserve terminal closure as a distinct Scheduler phase in generated timelines |

The motivating experiment was run only on the local A5 system. Against main
commit `bf68bb6c`, the seven non-Qwen workloads improved by `9.195%` in
Effective geometric mean; Qwen3 improved by `0.604%`, and no workload regressed
by 5% or more in Effective time. The largest gains were in the four paged-
attention-unroll cases (`12.944%` to `18.083%`).

The platform scope follows the observed bottleneck. On A5, the motivating
timelines contain a visible tail after the Orchestrator has finished submitting
tasks: Schedulers continue executing per-task release work even though no new
task can consume the reclaimed capacity. That release interval extends the
execution critical path, which gives the seal-and-bulk-close protocol a direct
optimization target. No tail release blocking was found on A2/A3. Its release
work did not appear as the corresponding post-orchestration critical-path
interval, so there is currently no performance evidence that A2/A3 would
benefit from the extra graph-seal observation, terminal barrier, leader
election, and bulk-closure state. A2/A3 therefore keeps the simpler incremental
release protocol, and this experiment does not run an A2/A3 benchmark or port
the implementation there. This is an evidence-based software decision rather
than an A5 hardware requirement; revisit it if a future A2/A3 timeline exposes
the same tail release blocking.

### Fatal Teardown

The A2/A3 scheduler uses a dedicated fatal latch to elect an owner, broadcasts
Expand Down
1 change: 1 addition & 0 deletions simpler_setup/tools/sched_overhead_analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -955,6 +955,7 @@ def _pct(x, denom):
"early_dispatch": "EarlyDispatch (speculative staging)",
"drain": "Drain (sync-start staging)",
"graph_prepare": "GraphPrepare (Definition expansion)",
"terminal_close": "TerminalClose (bulk lifecycle closure)",
"resolve": "Resolve (completion/dependency resolution)",
"idle": "Idle (spinning, no progress — reconstructed from gaps)",
}
Expand Down
5 changes: 4 additions & 1 deletion simpler_setup/tools/scheduler_phase_records.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,12 @@
"early_dispatch",
"drain",
"graph_prepare",
"terminal_close",
)

_SCHEDULER_WORK_PHASES = frozenset({"complete", "dispatch", "release", "early_dispatch", "drain", "graph_prepare"})
_SCHEDULER_WORK_PHASES = frozenset(
{"complete", "dispatch", "release", "early_dispatch", "drain", "graph_prepare", "terminal_close"}
)
_RESOLUTION_WORK_PHASES = frozenset({"resolve", "resolve_standalone", "async_poll", "dummy"})


Expand Down
2 changes: 2 additions & 0 deletions simpler_setup/tools/swimlane_converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -1742,6 +1742,7 @@ def sched_lane_tid(thread_idx, lane=0):
"drain_prepare": "cq_build_attempt_runnable", # inner: cluster scan + build_payload
"drain_publish": "cq_build_attempt_passed", # inner: MMIO write_reg per subtask (the cohort launch)
"graph_prepare": "rail_animation", # bounded Scheduler-side Definition expansion
"terminal_close": "olive", # successful-run bulk lifecycle closure
# Inner in TMR; standalone on HBG's dedicated P thread.
"resolve": "vsync_highlight_color", # on_task_complete: walk consumer list
# Separate-lane (Worker View AICPU_N) — fallback color if it ever lands on Sched
Expand Down Expand Up @@ -1912,6 +1913,7 @@ def _find_containing_complete(thread_idx: int, finish_us: float):
"drain_prepare",
"drain_publish",
"graph_prepare",
"terminal_close",
):
continue
start_us = record["start_time_us"]
Expand Down
3 changes: 2 additions & 1 deletion src/a5/runtime/tensormap_and_ringbuffer/runtime/async_wait.h
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ struct AsyncWaitList {
ChipTaskSlotState **deferred_release_slot_states{nullptr};
int32_t *deferred_release_count{nullptr};
int32_t deferred_release_capacity{0};
const std::atomic<bool> *release_seal{nullptr};
int32_t inline_completed{0};
#if SIMPLER_SCHED_PROFILING
int32_t thread_idx{0};
Expand Down Expand Up @@ -306,7 +307,7 @@ struct AsyncWaitList {
AsyncPollResult poll_and_complete(
AICoreCompletionMailbox *aicore_mailbox, SchedulerState *sched,
ChipTaskSlotState **deferred_release_slot_states, int32_t &deferred_release_count,
int32_t deferred_release_capacity
int32_t deferred_release_capacity, const std::atomic<bool> *release_seal
#if SIMPLER_SCHED_PROFILING
,
int thread_idx
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -637,6 +637,44 @@ struct SchedulerState {
return advanced;
}

// Only the terminal-barrier leader calls this after every scheduler has left dispatch.
int32_t terminal_close_live_slots() {
int32_t current_task_indices[CHIP_MAX_RING_DEPTH];
int32_t total_closed = 0;

for (int32_t ring_id = 0; ring_id < CHIP_MAX_RING_DEPTH; ring_id++) {
auto &ring_sched = ring_sched_states[ring_id];
int32_t current_task_index = ring_sched.ring->fc.current_task_index.load(std::memory_order_acquire);
int32_t live_count = current_task_index - ring_sched.last_task_alive;
if (live_count < 0 || static_cast<uint64_t>(live_count) > ring_sched.ring->task_window_size) {
LOG_ERROR(
"terminal lifecycle close has invalid ring %d interval [%d, %d) for window %" PRIu64, ring_id,
ring_sched.last_task_alive, current_task_index, ring_sched.ring->task_window_size
);
return -1;
}
current_task_indices[ring_id] = current_task_index;
total_closed += live_count;
}

for (int32_t ring_id = 0; ring_id < CHIP_MAX_RING_DEPTH; ring_id++) {
auto &ring_sched = ring_sched_states[ring_id];
int32_t current_task_index = current_task_indices[ring_id];
for (int32_t id = ring_sched.last_task_alive; id < current_task_index; id++) {
ChipTaskSlotState &slot_state = ring_sched.ring->get_slot_state_by_task_id(id);
slot_state.task_state.store(CHIP_TASK_CONSUMED, std::memory_order_relaxed);
slot_state.reset_for_reuse();
}
ring_sched.last_task_alive = current_task_index;
ring_sched.sync_to_sm(true);
}

advance_pending_mask.store(0, std::memory_order_relaxed);
publication_request_mask.store(0, std::memory_order_relaxed);
publication_ack_mask.store(0, std::memory_order_relaxed);
return total_closed;
}

bool try_claim_ready_once(ChipTaskSlotState &slot_state) {
uint8_t flags = slot_state.lifecycle_flags.load(std::memory_order_acquire);
for (;;) {
Expand Down Expand Up @@ -1323,26 +1361,35 @@ AsyncWaitList::try_inline_complete_locked(AsyncWaitList::DrainCompletionSink &si
#else
sink.sched->on_task_complete(slot_state);
#endif
// Read the seal only at a full-buffer boundary; a sealed graph skips per-task release.
bool release_elided = false;
if (*sink.deferred_release_count >= sink.deferred_release_capacity) {
while (*sink.deferred_release_count > 0) {
release_elided = sink.release_seal != nullptr && sink.release_seal->load(std::memory_order_acquire);
if (release_elided) {
*sink.deferred_release_count = 0;
} else {
while (*sink.deferred_release_count > 0) {
#if SIMPLER_SCHED_PROFILING
(void)sink.sched->on_task_release(
*sink.deferred_release_slot_states[--(*sink.deferred_release_count)], sink.thread_idx
);
(void)sink.sched->on_task_release(
*sink.deferred_release_slot_states[--(*sink.deferred_release_count)], sink.thread_idx
);
#else
sink.sched->on_task_release(*sink.deferred_release_slot_states[--(*sink.deferred_release_count)]);
sink.sched->on_task_release(*sink.deferred_release_slot_states[--(*sink.deferred_release_count)]);
#endif
}
}
}
sink.deferred_release_slot_states[(*sink.deferred_release_count)++] = &slot_state;
if (!release_elided) {
sink.deferred_release_slot_states[(*sink.deferred_release_count)++] = &slot_state;
}
sink.inline_completed++;
return true;
}

template <bool Profiling>
inline AsyncPollResult AsyncWaitList::poll_and_complete(
AICoreCompletionMailbox *aicore_mailbox, SchedulerState *sched, ChipTaskSlotState **deferred_release_slot_states,
int32_t &deferred_release_count, int32_t deferred_release_capacity
int32_t &deferred_release_count, int32_t deferred_release_capacity, const std::atomic<bool> *release_seal
#if SIMPLER_SCHED_PROFILING
,
int thread_idx
Expand All @@ -1356,6 +1403,7 @@ inline AsyncPollResult AsyncWaitList::poll_and_complete(
sink.deferred_release_slot_states = deferred_release_slot_states;
sink.deferred_release_count = &deferred_release_count;
sink.deferred_release_capacity = deferred_release_capacity;
sink.release_seal = release_seal;
#if SIMPLER_SCHED_PROFILING
sink.thread_idx = thread_idx;
#endif
Expand Down Expand Up @@ -1394,16 +1442,27 @@ inline AsyncPollResult AsyncWaitList::poll_and_complete(
#else
sched->on_task_complete(*entry.slot_state);
#endif
// The seal is checked only at the full-buffer boundary.
bool release_elided = false;
if (deferred_release_count >= deferred_release_capacity) {
while (deferred_release_count > 0) {
release_elided = release_seal != nullptr && release_seal->load(std::memory_order_acquire);
if (release_elided) {
deferred_release_count = 0;
} else {
while (deferred_release_count > 0) {
#if SIMPLER_SCHED_PROFILING
(void)sched->on_task_release(*deferred_release_slot_states[--deferred_release_count], thread_idx);
(void)sched->on_task_release(
*deferred_release_slot_states[--deferred_release_count], thread_idx
);
#else
sched->on_task_release(*deferred_release_slot_states[--deferred_release_count]);
sched->on_task_release(*deferred_release_slot_states[--deferred_release_count]);
#endif
}
}
}
deferred_release_slot_states[deferred_release_count++] = entry.slot_state;
if (!release_elided) {
deferred_release_slot_states[deferred_release_count++] = entry.slot_state;
}
result.completed++;

int32_t last = count - 1;
Expand Down
Loading
Loading