[Optimization] Parallelize AICore handshake across AICPU threads -- 3~22% Device Speedup #1279
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 Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
📝 WalkthroughWalkthroughThe PR refactors AICPU/scheduler initialization from a single-threaded ChangesScheduler Handshake Refactor
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)sequenceDiagram
participant Thread as AICPU Thread
participant SchedulerContext
participant Handshake as Handshake Records
Thread->>SchedulerContext: pre_handshake_init(...)
SchedulerContext-->>Thread: clears handshake_failed_, sets cores_total_num_
Thread->>SchedulerContext: handshake_partition(tidx, nthreads)
SchedulerContext->>Handshake: write payload pointer, set aicpu_ready=1 for slice
SchedulerContext->>Handshake: wait aicore_regs_ready/aicore_done for slice
alt invalid physical_core_id
SchedulerContext->>SchedulerContext: handshake_failed_.store(true)
end
Thread->>SchedulerContext: post_handshake_init()
alt handshake_failed_
SchedulerContext->>SchedulerContext: emergency_shutdown
else success
SchedulerContext->>Handshake: build AIC/AIV worker-id lists
SchedulerContext->>SchedulerContext: assign_cores_to_threads()
SchedulerContext->>SchedulerContext: profiling/task-count init
end
Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 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.
Code Review
This pull request parallelizes the per-core AICore handshake during initialization to optimize the preamble execution time. It splits the previous single-threaded initialization into three phases: a leader-only pre-handshake setup, a parallelized handshake partition where each thread handles a disjoint slice of cores, and a leader-only post-handshake phase. Feedback suggests that instead of silently falling back to a default index when platform_aicpu_affinity_thread_idx() returns an invalid value, the code should validate the thread index and fail loudly with an error code.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
The AICPU->AICore handshake in scheduler init discovered all cores serially on a single thread while the other AICPU threads spun idle waiting on init_done_. On a2a3 (72 cores) this serial per-core MMIO (reg init + two spin round-trips per core) measured ~217 us of the ~283 us preamble -- a fixed per-round cost that dominates device time for small workloads and is a flat tax on every run. Parallelize it: split SchedulerContext::init into three parts so every AICPU thread handshakes a disjoint slice of cores. - pre_handshake_init (leader): zero per-core state, wire config, init l2-swimlane buffers, set core count -- published before any thread handshakes so the swimlane-before-aicpu_ready ordering still holds. - handshake_partition (all threads): each thread handshakes its contiguous [lo, hi) slice; core writes are race-free (disjoint), and an invalid physical_core_id sets an atomic flag. - post_handshake_init (leader, after a barrier): build the AIC/AIV worker-id lists in core-index order (identical clusters to before, read from the Handshake struct so it stays correct under PTO2_PROFILING), assign cores, init profiling subsystems, payloads. AicpuExecutor::init orchestrates it: the leader (affinity exec_idx 0) publishes hs_setup_done_, all threads handshake their slice, then an hs_arrived_ barrier gates the leader's finish. init_failed_ is stored before hs_setup_done_ (both release) so a pre-handshake failure is always visible before any thread proceeds. Drops the now-dead initialized_ CAS and replaces the preamble spin with a direct init-return check. Measured on a2a3: preamble ~283 us -> ~85 us, a ~187 us/round device reduction (-7.5% on qwen3-14b decode, up to -22% on small workloads where the fixed handshake cost dominated). Compute window unchanged. Golden-verified across vector_example, benchmark_bgemm, paged_attention (65k tasks), and qwen3_14b_decode. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MqeALZTPEnDXTnbYfcnPfq
b0f9314 to
64f6264
Compare
…#1279 - Sim regression (segfault): in a2a3sim, platform_aicpu_affinity_thread_idx() returns -1 during init() (sim gates with platform_aicpu_affinity_gate, which never sets the exec idx; set_thread_idx runs later in run()). The prior `if (tidx<0) tidx=0` made every sim AICPU thread the leader, so all of them ran pre_/post_handshake_init concurrently and raced the shared scheduler state (aic_count_++, worker-list writes, memset) -> memory corruption -> SIGSEGV on the simplest tensormap sim runs (vector_example, per_task_runtime_env). Fix: hand a distinct [0,nthreads) index from a counter (hs_thread_seq_, reset in deinit) when affinity is unavailable, mirroring run()'s thread_idx_++ fallback, so sim gets one leader + disjoint slices like onboard. - Add the upper-bound guard init() was missing vs run(): a tidx >= nthreads would index the partition arrays out of bounds. Fail only that thread (not init_failed_, which would hang the leader at the barrier). - Refresh the now-stale aicpu_execute docstring (init is no longer "first thread only" with an external wait). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ae0484e to
18fee9c
Compare
Collapse the per-core AICPU<->AICore handshake to a single round-trip on a2a3 and a5, and bring a5's handshake up to the parallel multi-thread structure a2a3 already carries from hw-native-sys#1279. Single round-trip (both arches): - AICore reports {physical_core_id, core_type, aicore_done} in one write, then polls its own DATA_MAIN_BASE SPR for the AICPU to open its register window. A kernel launch resets that SPR to 0 (verified on a2a3 silicon); the AICPU writes IDLE (non-zero) as it opens FAST_PATH, so a non-zero read means the window is open. Opening the window IS the acknowledgement -- the aicpu_regs_ready / aicore_regs_ready Handshake fields and the second round-trip are removed. - The AICPU sweeps its core slice (poll aicore_done, service whoever has reported) instead of blocking core-by-core, so per-core wakeups overlap. Parallelize a5: - a5 previously handshaked all cores serially on one AICPU thread while the others spun idle. Split SchedulerContext::init into pre_handshake_init (leader) / handshake_partition (all threads, each a disjoint core slice) / post_handshake_init (leader, after a barrier), mirroring a2a3. AicpuExecutor::init orchestrates the barrier and hands sim threads a distinct index when the affinity gate is inactive. - Worker-id list building is deferred to post_handshake_init (serial) so aic_count_/aiv_count_ are never incremented from more than one thread. handshake_partition and AicpuExecutor::init are now identical across a2a3 and a5. Builds clean on a2a3/a5 (onboard + sim); a2a3sim/a5sim scene tests pass. a2a3/a5 onboard validation relies on CI (no NPU on this box).
…native-sys#1279) Upstream merged the parallel AICore handshake (hw-native-sys#1279, "3~22% Device Speedup") — the shared aicpu_executor.cpp now drives init via pre_handshake_init / handshake_partition / post_handshake_init (leader setup, per-thread core-slice handshake, barrier, leader finalize) instead of the serial sched_ctx_.init() → handshake_all_cores(). The polling squash predates hw-native-sys#1279 and carried the serial handshake, so after rebasing onto upstream the executor referenced methods the polling SchedulerContext did not have. Port the split parallel-handshake methods (the same implementation now upstream) into the polling scheduler_context so it builds against and benefits from the upstream driver. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MqeALZTPEnDXTnbYfcnPfq
…native-sys#1279) Upstream merged the parallel AICore handshake (hw-native-sys#1279, "3~22% Device Speedup") — the shared aicpu_executor.cpp now drives init via pre_handshake_init / handshake_partition / post_handshake_init (leader setup, per-thread core-slice handshake, barrier, leader finalize) instead of the serial sched_ctx_.init() → handshake_all_cores(). The polling squash predates hw-native-sys#1279 and carried the serial handshake, so after rebasing onto upstream the executor referenced methods the polling SchedulerContext did not have. Port the split parallel-handshake methods (the same implementation now upstream) into the polling scheduler_context so it builds against and benefits from the upstream driver. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MqeALZTPEnDXTnbYfcnPfq
Collapse the AICore init handshake from a three-phase ping-pong
(aicore_regs_ready -> aicpu_regs_ready -> aicore_done) to a single round
trip. AICore now publishes {physical_core_id, core_type, aicore_done} in
one write, then polls its own DATA_MAIN_BASE SPR. The AICPU opens the
core's register window (platform_init_aicore_regs writes
DATA_MAIN_BASE=IDLE) only after observing aicore_done, so that
window-open write doubles as the release acknowledgement -- the dedicated
aicpu_regs_ready / aicore_regs_ready fields are removed from struct
Handshake.
a2a3 (already parallel via hw-native-sys#1279): within each thread's core slice,
sweep-poll on aicore_done -- a GM read, not the strictly-serial nGnRE
COND poll -- so per-core wakeups overlap (~max, not sum), and batch the
release barrier (one OUT_OF_ORDER_STORE_BARRIER per slice instead of one
per core).
a5: port the parallel-handshake split (pre_handshake_init /
handshake_partition / post_handshake_init) alongside the single-round-trip
protocol; a5 previously handshaked serially on one thread.
Reproduces the approach of upstream PR hw-native-sys#1214 on a fresh branch off main.
Verified a2a3 onboard (device 3): paged_attention Case1 200/200 golden
rounds, zero mismatches; preamble median 27us vs 50us baseline (-46%, device 3, CaseSmall1,
n=100). a5 compiles clean
(onboard + sim); a5 onboard testing deferred to CI.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MqeALZTPEnDXTnbYfcnPfq
) Collapse the AICore init handshake from a three-phase ping-pong (aicore_regs_ready -> aicpu_regs_ready -> aicore_done) to a single round trip. AICore now publishes {physical_core_id, core_type, aicore_done} in one write, then polls its own DATA_MAIN_BASE SPR. The AICPU opens the core's register window (platform_init_aicore_regs writes DATA_MAIN_BASE=IDLE) only after observing aicore_done, so that window-open write doubles as the release acknowledgement -- the dedicated aicpu_regs_ready / aicore_regs_ready fields are removed from struct Handshake. a2a3 (already parallel via #1279): within each thread's core slice, sweep-poll on aicore_done -- a GM read, not the strictly-serial nGnRE COND poll -- so per-core wakeups overlap (~max, not sum), and batch the release barrier (one OUT_OF_ORDER_STORE_BARRIER per slice instead of one per core). a5: port the parallel-handshake split (pre_handshake_init / handshake_partition / post_handshake_init) alongside the single-round-trip protocol; a5 previously handshaked serially on one thread. Reproduces the approach of upstream PR #1214 on a fresh branch off main. Verified a2a3 onboard (device 3): paged_attention Case1 200/200 golden rounds, zero mismatches; preamble median 27us vs 50us baseline (-46%, device 3, CaseSmall1, n=100). a5 compiles clean (onboard + sim); a5 onboard testing deferred to CI. Co-authored-by: ChaoZheng109 <zhengchao47@huawei.com>
…native-sys#1279) Upstream merged the parallel AICore handshake (hw-native-sys#1279, "3~22% Device Speedup") — the shared aicpu_executor.cpp now drives init via pre_handshake_init / handshake_partition / post_handshake_init (leader setup, per-thread core-slice handshake, barrier, leader finalize) instead of the serial sched_ctx_.init() → handshake_all_cores(). The polling squash predates hw-native-sys#1279 and carried the serial handshake, so after rebasing onto upstream the executor referenced methods the polling SchedulerContext did not have. Port the split parallel-handshake methods (the same implementation now upstream) into the polling scheduler_context so it builds against and benefits from the upstream driver. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MqeALZTPEnDXTnbYfcnPfq
…gbuffer hbg's scheduler was a copy of tmr@fb28fd71 and had fallen a full generation behind. This absorbs tmr's post-fork scheduler evolution while preserving hbg's host-orchestration invariants (single ring, no execution-time reclaim, flat Runtime, host-side orchestrator). Absorbed from tmr: - Remove PTO2LocalReadyBuffer local-first dispatch and its local_bufs threading (hw-native-sys#1245); pop/dispatch now take PTO2ReadyQueue* directly. - Ready-claim completion protocol: ready_state / mark_completed / mark_any_subtask_deferred (replaces the any_subtask_deferred bool). - Rename speculative -> early_dispatch and remove the auto-chain (spec_chain_active/depth, PTO2_SPEC_CHAIN_MAX); move allow_early_resolve from payload onto slot_state. - Per-shape early_dispatch_queues[] (was a single queue) + ready_sync_queues[] Tier-0 sync_start staging (hw-native-sys#1319); run_staging_order template; try_early_dispatch / early_dispatch_shape; has_any_free_slot predicates. - MIX subtasks placed per-core instead of uniform cluster placement (hw-native-sys#1308). - Remove the on-device wiring subsystem (PTO2SpscQueue / WiringState / drain_wiring_queue / wire_task, hw-native-sys#1263): the host orchestrator now wires fanout adjacency inline during submit and seeds readiness via the scheduler's push_ready_routed / route_ready_once, with no device-side wiring queue. - Single-round-trip / parallel AICore handshake (hw-native-sys#1279/hw-native-sys#1310): handshake_all_cores -> pre_handshake_init + per-thread handshake_partition + post_handshake_init, with the matching aicpu_executor boot restructure and AICore-worker protocol. - Drop the per-run per-core memset in scheduler init/deinit (hw-native-sys#1212); gate dep_gen init under SIMPLER_DFX; compile out pmu_active when SIMPLER_DFX is off; remove the redundant second completion poll; remove the dead device-orch core transition (handle_core_transition / reassign_cores_for_all_threads / orch_to_sched_). Remove vestigial device-orch synchronization: with host orchestration the orchestrator runs to completion on the host before the device boots, so the device-side orchestrator_done_ flag is always set before the scheduler threads start (they gate on runtime_init_ready_, whose release/acquire already publishes total_tasks_). Removed orchestrator_done_, the always-true completion guard, and the never-called wait_for_orchestration_done_before_dispatch / orchestration_done() carried over from tmr's concurrent device-orch model. Preserved host-orch divergences (not overwritten by tmr): PTO2_MAX_RING_DEPTH==1 with rings[]/ring_id removed; no advance_ring_pointers / reset_for_reuse / check_and_handle_consumed (completion via completed_tasks_, consumer wait keys on fanout_refcount); flat runtime->workers; hbg's swimlane behavior (no on_aicore_ack hook — the just-filled buffer drains via the completion-before-dispatch invariant and the next-rotation / run-end backstop flushes). Skipped as not applicable to hbg: stall-classification publishing (shared-memory lacks the sched_stall_* fields; last_task_alive semantics differ); per-device scheduler timeout (aicpu_device_config.h / get_scheduler_timeout_ms absent); runtime->dev.* Runtime nesting. get_reg_ptr weak host fallback: route_ready_once transitively ODR-uses the early-dispatch doorbell inline (ring_one_doorbell -> get_reg_ptr) in the host lib, but no core is gated during host graph-build so it never fires; a weak hidden fallback satisfies the loader (same pattern as get_sys_cnt_aicpu). Verified: all 8 runtime targets build -Werror; a2a3sim host_build_graph scene suite 10/10; a2a3 onboard host_build_graph suite 10 passed / 1 skipped (paged_attention exercises the new handshake, route_ready_once wiring, and ready-claim path). tensormap_and_ringbuffer and the a5 runtimes are untouched.
…gbuffer hbg's scheduler was a copy of tmr@fb28fd71 and had fallen a full generation behind. This absorbs tmr's post-fork scheduler evolution while preserving hbg's host-orchestration invariants (single ring, no execution-time reclaim, flat Runtime, host-side orchestrator). Absorbed from tmr: - Remove PTO2LocalReadyBuffer local-first dispatch and its local_bufs threading (hw-native-sys#1245); pop/dispatch now take PTO2ReadyQueue* directly. - Ready-claim completion protocol: ready_state / mark_completed / mark_any_subtask_deferred (replaces the any_subtask_deferred bool). - Rename speculative -> early_dispatch and remove the auto-chain (spec_chain_active/depth, PTO2_SPEC_CHAIN_MAX); move allow_early_resolve from payload onto slot_state. - Per-shape early_dispatch_queues[] (was a single queue) + ready_sync_queues[] Tier-0 sync_start staging (hw-native-sys#1319); run_staging_order template; try_early_dispatch / early_dispatch_shape; has_any_free_slot predicates. - MIX subtasks placed per-core instead of uniform cluster placement (hw-native-sys#1308). - Remove the on-device wiring subsystem (PTO2SpscQueue / WiringState / drain_wiring_queue / wire_task, hw-native-sys#1263): the host orchestrator now wires fanout adjacency inline during submit and seeds readiness via the scheduler's push_ready_routed / route_ready_once, with no device-side wiring queue. - Single-round-trip / parallel AICore handshake (hw-native-sys#1279/hw-native-sys#1310): handshake_all_cores -> pre_handshake_init + per-thread handshake_partition + post_handshake_init, with the matching aicpu_executor boot restructure and AICore-worker protocol. Drop the now-dead two-round-trip Handshake fields aicpu_regs_ready / aicore_regs_ready (aligning the struct to tmr's). - Drop the per-run per-core memset in scheduler init/deinit (hw-native-sys#1212); gate dep_gen init under SIMPLER_DFX; compile out pmu_active when SIMPLER_DFX is off; remove the redundant second completion poll; remove the dead device-orch core transition (handle_core_transition / reassign_cores_for_all_threads / orch_to_sched_). Remove vestigial device-orch synchronization: with host orchestration the orchestrator runs to completion on the host before the device boots, so the device-side orchestrator_done_ flag is always set before the scheduler threads start (they gate on runtime_init_ready_, whose release/acquire already publishes total_tasks_). Removed orchestrator_done_, the always-true completion guard, and the never-called wait_for_orchestration_done_before_dispatch / orchestration_done() carried over from tmr's concurrent device-orch model. Preserved host-orch divergences (not overwritten by tmr): PTO2_MAX_RING_DEPTH==1 with rings[]/ring_id removed; no advance_ring_pointers / reset_for_reuse / check_and_handle_consumed (completion via completed_tasks_, consumer wait keys on fanout_refcount); flat runtime->workers; hbg's swimlane behavior (no on_aicore_ack hook — the just-filled buffer drains via the completion-before-dispatch invariant and the next-rotation / run-end backstop flushes). Skipped as not applicable to hbg: stall-classification publishing (shared-memory lacks the sched_stall_* fields; last_task_alive semantics differ); per-device scheduler timeout (aicpu_device_config.h / get_scheduler_timeout_ms absent); runtime->dev.* Runtime nesting. get_reg_ptr weak host fallback: route_ready_once transitively ODR-uses the early-dispatch doorbell inline (ring_one_doorbell -> get_reg_ptr) in the host lib, but no core is gated during host graph-build so it never fires; a weak hidden fallback satisfies the loader (same pattern as get_sys_cnt_aicpu). Verified: all 8 runtime targets build -Werror; a2a3sim host_build_graph scene suite 10/10; a2a3 onboard host_build_graph suite 10 passed / 1 skipped (paged_attention exercises the new handshake, route_ready_once wiring, and ready-claim path). tensormap_and_ringbuffer and the a5 runtimes are untouched.
…gbuffer (#1327) hbg's scheduler was a copy of tmr@fb28fd71 and had fallen a full generation behind. This absorbs tmr's post-fork scheduler evolution while preserving hbg's host-orchestration invariants (single ring, no execution-time reclaim, flat Runtime, host-side orchestrator). Absorbed from tmr: - Remove PTO2LocalReadyBuffer local-first dispatch and its local_bufs threading (#1245); pop/dispatch now take PTO2ReadyQueue* directly. - Ready-claim completion protocol: ready_state / mark_completed / mark_any_subtask_deferred (replaces the any_subtask_deferred bool). - Rename speculative -> early_dispatch and remove the auto-chain (spec_chain_active/depth, PTO2_SPEC_CHAIN_MAX); move allow_early_resolve from payload onto slot_state. - Per-shape early_dispatch_queues[] (was a single queue) + ready_sync_queues[] Tier-0 sync_start staging (#1319); run_staging_order template; try_early_dispatch / early_dispatch_shape; has_any_free_slot predicates. - MIX subtasks placed per-core instead of uniform cluster placement (#1308). - Remove the on-device wiring subsystem (PTO2SpscQueue / WiringState / drain_wiring_queue / wire_task, #1263): the host orchestrator now wires fanout adjacency inline during submit and seeds readiness via the scheduler's push_ready_routed / route_ready_once, with no device-side wiring queue. - Single-round-trip / parallel AICore handshake (#1279/#1310): handshake_all_cores -> pre_handshake_init + per-thread handshake_partition + post_handshake_init, with the matching aicpu_executor boot restructure and AICore-worker protocol. Drop the now-dead two-round-trip Handshake fields aicpu_regs_ready / aicore_regs_ready (aligning the struct to tmr's). - Drop the per-run per-core memset in scheduler init/deinit (#1212); gate dep_gen init under SIMPLER_DFX; compile out pmu_active when SIMPLER_DFX is off; remove the redundant second completion poll; remove the dead device-orch core transition (handle_core_transition / reassign_cores_for_all_threads / orch_to_sched_). Remove vestigial device-orch synchronization: with host orchestration the orchestrator runs to completion on the host before the device boots, so the device-side orchestrator_done_ flag is always set before the scheduler threads start (they gate on runtime_init_ready_, whose release/acquire already publishes total_tasks_). Removed orchestrator_done_, the always-true completion guard, and the never-called wait_for_orchestration_done_before_dispatch / orchestration_done() carried over from tmr's concurrent device-orch model. Preserved host-orch divergences (not overwritten by tmr): PTO2_MAX_RING_DEPTH==1 with rings[]/ring_id removed; no advance_ring_pointers / reset_for_reuse / check_and_handle_consumed (completion via completed_tasks_, consumer wait keys on fanout_refcount); flat runtime->workers; hbg's swimlane behavior (no on_aicore_ack hook — the just-filled buffer drains via the completion-before-dispatch invariant and the next-rotation / run-end backstop flushes). Skipped as not applicable to hbg: stall-classification publishing (shared-memory lacks the sched_stall_* fields; last_task_alive semantics differ); per-device scheduler timeout (aicpu_device_config.h / get_scheduler_timeout_ms absent); runtime->dev.* Runtime nesting. get_reg_ptr weak host fallback: route_ready_once transitively ODR-uses the early-dispatch doorbell inline (ring_one_doorbell -> get_reg_ptr) in the host lib, but no core is gated during host graph-build so it never fires; a weak hidden fallback satisfies the loader (same pattern as get_sys_cnt_aicpu). Verified: all 8 runtime targets build -Werror; a2a3sim host_build_graph scene suite 10/10; a2a3 onboard host_build_graph suite 10 passed / 1 skipped (paged_attention exercises the new handshake, route_ready_once wiring, and ready-claim path). tensormap_and_ringbuffer and the a5 runtimes are untouched.
Human Summary
Currently, upstream/main has a single thread handle the handshake for all compute cores. This is a fixed cost that represents a significant part of the kernel running time. This PR parallelizes the process among the other AICPU cores, obtaining an up to 22% speedup.
The effect is mostly notable on small kernels, whose fixed runtime component is more significant. This improvements is complementary to that of #1137 which improves the dynamic aspect, favoring large kernels with many small tasks.
AI Summary
Every time the runtime starts a run, it does a "roll call" of all 72 AICore
compute units — greeting each one and setting up its registers before any work
can begin. Today one AICPU thread does this roll call one core at a time,
while the other three threads sit idle waiting. That serial roll call takes
~217 µs of the ~283 µs startup on a2a3, and because it's a fixed cost paid
every single run, it hurts short jobs the most.
This PR has all four AICPU threads greet the cores in parallel, each handling
a quarter of them. Startup drops from ~283 µs to ~85 µs, which takes ~172 µs
off the device time of every run — −7.5% on qwen3-14B decode and up to
−22% on small workloads. The actual compute is untouched; only the startup gets
faster. Correctness is unchanged (golden-verified on 8 cases).
The problem
Before any tasks run, the scheduler has to discover the AICore units it will
dispatch to. For each core it: waits for the core to boot and report in, reads
its physical ID, initializes its control registers over MMIO, and waits for the
core to acknowledge. This "handshake" happens fresh on every round (the cores
exit at the end of each round and are relaunched).
The catch: this loop ran serially on a single thread. On the Ascend AICPU,
MMIO register access is strictly serial (~95 ns per access, no pipelining — a
documented hardware constraint), so greeting 72 cores one-by-one is slow by
construction. Meanwhile the other three AICPU threads spun idle, doing nothing
but waiting for the one thread to finish.
We profiled it directly: of the ~283 µs startup ("preamble") on qwen3-14B decode,
~217 µs (82%) was the serial per-core handshake work. Only ~11 µs was
unavoidable (waiting for the first core to physically boot).
Because this cost is fixed per round, it's a small slice of a big job but a
huge slice of a small one — e.g. ~26% of a simple matmul's device time, and it
was a major contributor to why small workloads looked slow.
The fix
The hardware guidance is explicit: the only way to speed up cross-core polling
is to use more threads. So we do exactly that.
SchedulerContext::initis split into three phases:pre_handshake_init(leader thread only): the shared setup — zero theper-core state, wire configuration, initialize profiling/swimlane buffers,
and record the core count. Published to the other threads via a flag.
handshake_partition(all four threads): each thread greets a disjoint,contiguous slice of the cores (18 each for 72). Because the slices don't
overlap, there's no contention — the expensive MMIO work now runs four-wide.
post_handshake_init(leader thread only, after a barrier): the cheapbookkeeping — build the ordered AIC/AIV worker lists, assign cores to
scheduler threads, initialize dispatch payloads.
AicpuExecutor::initorchestrates the three phases with a simple barrier: theleader publishes "setup done", all threads handshake their slice and report in,
and the leader waits for all of them before finishing. Startup timing:
Results
Direct A/B of this PR vs
upstream/main(both the same upstream runtime, sothis isolates the handshake change), back-to-back on the same NPU (device 2),
100 rounds each, trimmed-80. Device time is the metric of record (on-NPU
wall clock):
Every workload improves by a near-constant ~172 µs — the tell-tale signature
of removing a fixed overhead. The relative win scales inversely with job size:
biggest on the small workloads that the fixed cost dominated, smallest on the
long-running ones. The compute window itself is unchanged (−0.3% to +2.0%,
noise), because this only touches startup — not scheduling or kernel execution.
Correctness
Golden-verified (output compared against a PyTorch reference) on a deliberately
diverse spread, all PASSED:
vector_example— tiny, simple graphbenchmark_bgemm— AIC matrix-multiply (Case0, Bgemm64)paged_attention— 65,792-task orchestration-heavy run (Case1) + a small caseqwen3_14b_decode— dense two-layer transformer decodeThe 65k-task and multi-scope cases are the strongest exercise of the new
multi-thread partition + barrier path.
Why it's safe
correct, so it's unconditional.
gap-free partition), so the per-core writes never overlap.
core-index order as before, so core-to-thread assignment is identical.
(both release/acquire), so a failing init can never let a thread proceed. The
swimlane-buffer init still happens before any core is signaled.
PTO2_PROFILINGthe post-pass reads core typeand physical ID from the Handshake struct, so it works whether or not
profiling is compiled in.
Files changed (5)
All in
src/a2a3/runtime/tensormap_and_ringbuffer/:runtime/scheduler/scheduler_cold_path.cpp— splitinitintopre_handshake_init/post_handshake_init; replacehandshake_all_coreswith the sliced
handshake_partition.runtime/scheduler/scheduler_context.h— updated declarations; added thehandshake_failed_flag.aicpu/aicpu_executor.cpp— parallel init orchestration + barrier; removedthe dead single-leader CAS.
aicore/aicore_executor.cpp— comment updated to reference the new function.docs/RUNTIME_LOGIC.md— doc updated to reference the new functions.Notes for reviewers
upstream/main— bothbuilt from the same upstream runtime and run back-to-back on the same NPU — so
it isolates the handshake change with nothing else varying. The ~85 µs startup
and per-phase handshake breakdown come from direct profiling of the same
optimization.
collector produces no data on the
tensormap_and_ringbufferruntime. It isunrelated to this change and tracked separately.