Skip to content
Merged
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
14 changes: 14 additions & 0 deletions src/a5/platform/onboard/aicore/inner_kernel.h
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,20 @@ __aicore__ inline uint64_t read_reg(RegId reg) {
}
}

/**
* Read the high 32 bits of DATA_MAIN_BASE.
*
* AICore reads the full 64-bit SPR via MOV; the high half is the
* early-dispatch doorbell written by AICPU (low half stays the dispatch
* token). Read-only on the AICore side, so this is always valid (unlike writes
* to DATA_MAIN_BASE, which the SPR-write port rejects).
*/
__aicore__ inline uint32_t read_dmb_high32() {
uint64_t v;
__asm__ volatile("MOV %0, DATA_MAIN_BASE\n" : "=l"(v));
return static_cast<uint32_t>(v >> 32);
}

/**
* Write to an AICore register
*
Expand Down
13 changes: 13 additions & 0 deletions src/a5/platform/sim/aicore/inner_kernel.h
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,19 @@ inline uint64_t read_reg(RegId reg) {
return static_cast<uint64_t>(__atomic_load_n(ptr, __ATOMIC_ACQUIRE));
}

/**
* Read the high 32 bits of DATA_MAIN_BASE (early-dispatch doorbell).
* The high word lives one 32-bit slot above the dispatch token.
*
* Same atomic-acquire rule as read_reg(): in sim the cell is plain host memory
* shared with the AICPU thread that rings via a 64-bit STR of (token<<32)|token.
*/
inline uint32_t read_dmb_high32() {
uint32_t offset = reg_offset(RegId::DATA_MAIN_BASE);
volatile uint32_t *ptr = reinterpret_cast<volatile uint32_t *>(sparse_reg_ptr(sim_get_reg_base(), offset + 4));
return __atomic_load_n(ptr, __ATOMIC_ACQUIRE);
}
Comment thread
yanghaoran29 marked this conversation as resolved.

/**
* Write to an AICore register in simulated register memory
*
Expand Down
46 changes: 46 additions & 0 deletions src/a5/runtime/tensormap_and_ringbuffer/aicore/aicore_executor.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ __aicore__ __attribute__((weak)) void aicore_execute(__gm__ Runtime *runtime, in
// Register encoding: AICPU_IDLE_TASK_ID=idle, task_id=task, AICORE_EXIT_SIGNAL=exit
uint32_t reg_val = AICPU_IDLE_TASK_ID;
uint32_t last_reg_val = AICPU_IDLE_TASK_ID;
bool exiting = false;

while (true) {
reg_val = static_cast<uint32_t>(read_reg(RegId::DATA_MAIN_BASE));
Expand All @@ -142,6 +143,9 @@ __aicore__ __attribute__((weak)) void aicore_execute(__gm__ Runtime *runtime, in
// hardware-bound) and the AICore-local dcci+ack cost
// (receive_time → start_time, software-tunable). Stored in the
// record as a 32-bit delta `start_time - receive_time`.
//
// Early-dispatch (src_payload != 0): receive_time stays at pickup —
// before the doorbell wait — so it may precede the producer's end.
uint64_t receive_time = get_sys_cnt_aicore();

uint32_t task_id = reg_val; // Decode: register holds task_id directly
Expand All @@ -157,6 +161,48 @@ __aicore__ __attribute__((weak)) void aicore_execute(__gm__ Runtime *runtime, in
// Invalidate payload buffer (AICPU updates its content each dispatch)
dcci(exec_payload, ENTIRE_DATA_CACHE);

// Early-dispatch gate. A gated task was staged on this core before its
// dependencies resolved; wait until AICPU rings the doorbell
// (DATA_MAIN_BASE high 32 == task_id) before executing. The ACK is
// deferred until AFTER the gate so the scheduler keeps the core
// off-limits (pending_occupied stays set) while the task is gated.
// src_payload == 0 (ready path) skips this; a non-zero src_payload is
// both the gate flag and the source PTO2TaskPayload.
if (exec_payload->src_payload != 0) {
__gm__ char *src = reinterpret_cast<__gm__ char *>(exec_payload->src_payload);
int32_t tensor_count = *reinterpret_cast<__gm__ int32_t *>(src + PTO2_TASKPAYLOAD_TENSOR_COUNT_OFFSET);
int32_t scalar_count = *reinterpret_cast<__gm__ int32_t *>(src + PTO2_TASKPAYLOAD_SCALAR_COUNT_OFFSET);
__gm__ uint64_t *src_scalars =
reinterpret_cast<__gm__ uint64_t *>(src + PTO2_TASKPAYLOAD_SCALARS_OFFSET);
int n = 0;
for (int32_t i = 0; i < tensor_count; i++) {
exec_payload->args[n++] = reinterpret_cast<uint64_t>(
src + PTO2_TASKPAYLOAD_TENSORS_OFFSET + i * PTO2_TASKPAYLOAD_TENSOR_STRIDE
);
}
for (int32_t i = 0; i < scalar_count; i++) {
exec_payload->args[n++] = src_scalars[i];
}
OUT_OF_ORDER_STORE_BARRIER();
while (true) {
if (read_dmb_high32() == task_id) {
if (static_cast<uint32_t>(read_reg(RegId::DATA_MAIN_BASE)) == AICORE_EXIT_SIGNAL) {
exiting = true;
}
break;
}
if (static_cast<uint32_t>(read_reg(RegId::DATA_MAIN_BASE)) == AICORE_EXIT_SIGNAL) {
exiting = true;
break;
}
SPIN_WAIT_HINT();
}
if (exiting) {
write_reg(RegId::COND, AICORE_EXITED_VALUE);
break;
}
}

write_reg(RegId::COND, MAKE_ACK_VALUE(task_id));

// Performance profiling: record start time
Expand Down
102 changes: 93 additions & 9 deletions src/a5/runtime/tensormap_and_ringbuffer/docs/RUNTIME_LOGIC.md
Original file line number Diff line number Diff line change
Expand Up @@ -546,24 +546,27 @@ Each scheduler thread runs a tight loop with two main phases:
- Poll register `COND` on each managed core
- When `TASK_FIN_STATE` detected: record completion timestamps, call `on_subtask_complete(task_id, subslot)` to increment the completion counter; when `completed_subtasks == total_required_subtasks`, trigger `on_task_complete(task_id)` which marks `task_state[slot] = COMPLETED`, acquires fanout lock, traverses fanout list (incrementing consumers' `fanin_refcount`), marks `task_state[slot] = CONSUMED`, and advances `last_task_alive` watermark

**Phase 2 — Dispatch**:
**Phase 2 — Dispatch** (full model in §8.6):

- For each idle core: pop a task from the matching shape-based ready queue (lock-free MPMC Vyukov queue, one per resource shape)
- Build `PTO2DispatchPayload` from `TaskDescriptor` with `task_id`, `subslot`, `kernel_id`, and `core_type`
- Write task pointer to `Handshake.task`, signal AICore via register `DATA_MAIN_BASE`
- Write task pointer to `Handshake.task`, signal AICore via register `DATA_MAIN_BASE` (DMB offset `0xD0` on a5)
- After normal ready queues are empty, Phase **4b** may stage speculative early-dispatch candidates onto spare slots (`early_dispatch_queues[]` / `early_sync_start_queue`)

After these phases, the scheduler updates profiling headers and checks for termination (all tasks completed and orchestrator done).

### 8.3 Ready Queue Design

Ready queues use a lock-free bounded MPMC (Vyukov) design:

- One `PTO2ReadyQueue` per resource shape (5 shapes: `AIC_ONLY`, `AIV_X1`, `AIV_X2`, `AIC_AIV_X1`, `AIC_AIV_X2`)
- **Push**: any thread (orchestrator via `init_task`, or scheduler on completion) pushes newly-ready tasks to the queue matching `task->active_mask.to_shape()`
- One `PTO2ReadyQueue` per resource shape (`MIX` / `AIC` / `AIV` in the production tensormap path)
- **Push**: any thread (orchestrator via wiring, or scheduler on completion) pushes newly-ready tasks to the queue matching `task->active_mask.to_shape()`
- **Pop**: scheduler threads pop from the queue matching the idle core's resource shape
- Per-slot sequence counters prevent ABA problems
- `enqueue_pos` and `dequeue_pos` are on separate cache lines to avoid false sharing

Unlike a2a3, a5 does **not** keep a separate `ready_sync_queues[]` tier: ready `require_sync_start` cohorts share `ready_queues[]`. Speculative sync_start early candidates still use the dedicated `early_sync_start_queue` (see §8.6).

### 8.4 Watermark Advancement (last_task_alive)

After a task reaches state CONSUMED (4), the scheduler tries to advance `last_task_alive`:
Expand Down Expand Up @@ -605,6 +608,84 @@ Private internals are split across three .cpp files by responsibility:

`AicpuExecutor` calls neither `handshake_*`, `assign_*`, `reassign_*`, nor `emergency_shutdown` directly — they are private, invoked only by `init` and `on_orchestration_done`.

### 8.6 Dispatch model — two sources, sync tiers, occupancy order

`resolve_and_dispatch` places ready and speculative work onto AICore cores under one
occupancy model (ported from a2a3 early-dispatch; a5 specifics called out below). Two
orthogonal axes decide *what* runs and *where*:

- **Source** — `NORMAL` (all producers done; the task sits in a ready queue and launches on
pickup) vs `EARLY` (a *speculative* pre-stage of a not-yet-released task; its dispatch
payload carries a non-zero `src_payload` gate and launches later by a high-32 doorbell on
`DATA_MAIN_BASE`). Normal strictly precedes early.
- **Cohort** — `SYNC_START` (an SPMD cohort that must launch atomically) vs `REGULAR` (each
block launches independently). "is it ready" (source) and "does it need a rendezvous"
(cohort) are orthogonal.

Within each source the occupancy order is **`sync_start` ▸ MIX ▸ AIC/AIV`** (shape), and per
shape **idle ▸ pending** (an idle core takes its running slot; a busy core takes its gated
pending slot, promoted on completion). a5 implements this order inline in
`dispatch_ready_tasks` / `try_early_dispatch` (no separate `run_staging_order` helper).

#### Queues

| Source | Regular lanes | sync_start lane |
| ------ | ------------- | --------------- |
| NORMAL (ready) | `ready_queues[MIX\|AIC\|AIV]` | *(same `ready_queues[]` — a5 has no `ready_sync_queues[]`)* |
| EARLY (speculative) | `early_dispatch_queues[MIX\|AIC\|AIV]` | `early_sync_start_queue` (single) |

A task routes to the early sync lane iff `active_mask.requires_sync_start()`. Early
dispatch runs only once normal `ready_queues[]` are empty **and** the local
`CoreTracker` has a spare slot (`has_any_free_slot`, a2a3 #1288).

**Direct-only eligibility (a2a3 #1285/#1292):** a consumer is an early candidate only when
every *direct* producer is flagged `allow_early_resolve` (slot-state hint from Arg, or
unconditional true for hidden alloc creators). There is no auto-chain inheritance.

#### sync_start drain + rendezvous

A sync_start cohort of `block_num` cores must occupy all its cores before any of them run.
When it cannot fit inline, `enter_drain_mode` arms a stop-the-world drain:

1. **Single election** — a CAS on `sync_start_pending` makes drains mutually exclusive.
2. **All-or-nothing** — the elected thread checks global available capacity ≥ `block_num`
before staging; if short it aborts and retries after completions free cores.
3. **Parallel stage** — threads barrier, then each CAS-claims a block range and stages its
own cores with a non-zero `src_payload` gate (`drain_stage_cores`): idle → running,
busy → pending (`pending_gated` when still waiting for the doorbell).
4. **Rendezvous launch** — `running_slot_count` counts staged running-slot cores; when it
reaches `popcount(staged_core_mask)` **and** the producer has released,
`maybe_rendezvous_ring` rings every gated core's doorbell together — the cohort starts as one.

Doorbell ownership is exclusive (`claim_all_staged_doorbell_bits` /
`claim_late_staged_doorbell_bits`); launch is latched via `early_dispatch_launch_state`.

#### Early-candidate gate: producer must publish every block

Producer-side `propagate_dispatch_fanin` no-ops until the producer is **fully published**:
`published_block_count == logical_block_num` (a2a3 #1326). Publication is recorded after
prepare/publish makes payload + low-32 dispatch tokens visible. Early queue entries carry a
`task_id_snapshot` generation tag (#1336) so recycled slots cannot revive stale candidates.

Wiring and propagation serialize under `fanout_lock` with `PTO2_DISPATCH_PROPAGATED` so
late-wired consumers still receive exactly one early-candidate contribution per eligible
edge (#1405).

#### a5 platform notes

- `RUNTIME_MAX_WORKER = 108` — doorbell table / `staged_core_mask` words must cover 108 cores.
- AICore index fields are `s_block_idx` / `s_block_num` (not a2a3 `block_idx` / `block_num`).
- DMB MMIO offset is **`0xD0`** (a2a3 uses `0xA0`). Ready dispatch writes the low 32 bits;
early release rings high 32 via 64-bit STR `(token<<32)|token`. AICore gated path spins on
`read_dmb_high32() == task_id` before ACK.
- Ready path keeps `src_payload == 0` and ACK-then-execute behavior unchanged.

#### MIX per-core placement

A MIX task spans a cluster (1 AIC + 2 AIV). Gated MIX may place **per core**
(`to_pending && !is_core_idle`): idle cores → running, busy cores → pending. Cross-core start
skew within a block is tolerated by AICore incore synchronization.

---

## 9. AICore Worker Interaction
Expand Down Expand Up @@ -632,11 +713,13 @@ Instead of polling a shared-memory status flag, the production protocol uses har

**AICore execution loop**:

1. Poll `DATA_MAIN_BASE` for value != AICPU_IDLE_TASK_ID
1. Poll `DATA_MAIN_BASE` (low 32) for value != AICPU_IDLE_TASK_ID
2. Read payload from `Handshake.task`
3. Write ACK to `COND`
4. Execute kernel function via `func_id_to_addr` lookup
5. Write FIN to `COND`
3. If `src_payload != 0` (early/gated): materialize args from `src_payload`, spin until
`read_dmb_high32() == task_id`, **then** ACK
4. Else (ready path): ACK immediately
5. Execute kernel function via `func_id_to_addr` lookup
6. Write FIN to `COND`

### 9.3 PTO2DispatchPayload

Expand All @@ -649,8 +732,9 @@ Built by the scheduler from `PTO2TaskDescriptor`:
| `kernel_id` | Function ID for this subtask slot |
| `core_type` | AIC or AIV |
| `function_bin_addr` | GM address of compiled kernel binary |
| `src_payload` | Non-zero ⇒ gated early path (AICore materializes args + waits high-32 doorbell) |
| `num_args` | Number of arguments |
| `args[]` | Tensor addresses and scalar values |
| `args[]` | Tensor addresses and scalar values (filled by AICPU on ready path; by AICore when gated) |

---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,15 +54,17 @@ class L0TaskArgsWithDeps : private L0TaskArgs {
using L0TaskArgs::add_scalar;
using L0TaskArgs::add_scalars;
using L0TaskArgs::add_scalars_i32;
using L0TaskArgs::allow_early_resolve; // early-dispatch hint (getter)
using L0TaskArgs::copy_scalars_from;
using L0TaskArgs::set_allow_early_resolve; // early-dispatch hint (setter)
using L0TaskArgs::set_task_timing_slot; // selective task-timing slot (setter)
using L0TaskArgs::task_timing_slot; // selective task-timing slot (getter)

// Error / status — forward to Arg
using L0TaskArgs::error_msg;
using L0TaskArgs::has_error;
using L0TaskArgs::launch_spec;
using L0TaskArgs::set_error;
using L0TaskArgs::set_task_timing_slot; // selective task-timing slot (setter)
using L0TaskArgs::task_timing_slot; // selective task-timing slot (getter)

// NOT exposed: set_dependencies, explicit_dep_count, explicit_dep,
// explicit_deps_data — these are the primitive-layer dep API. Users of
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
* GlobalContext (sub_block_id) is initialized once at runtime startup via
* init_global_context() and never modified afterwards.
*
* LocalContext (block_idx, block_num) and args[] are rebuilt by build_payload()
* LocalContext (s_block_idx, s_block_num) and args[] are rebuilt by build_payload()
* before each dispatch. Both context struct pointers are written into the
* args[] suffix on every dispatch (since args[] is rebuilt entirely each time).
*
Expand All @@ -34,6 +34,7 @@

#pragma once

#include <stddef.h>
#include <stdint.h>

#include "arg_direction.h"
Expand All @@ -57,6 +58,19 @@ static_assert(
"GLOBAL_CONTEXT_INDEX out of sync with intrinsic.h"
);

// Byte offsets into PTO2TaskPayload used by AICore to materialize args[] on the
// not_ready (early-dispatch) path. The AICore .o does not include
// pto_runtime2_types.h, so it reads tensor_count / scalar_count / tensors[] /
// scalars[] through these constants. The AICPU side (scheduler_dispatch.cpp)
// static_asserts them against offsetof where the full struct is visible, so any
// PTO2TaskPayload layout drift fails the build rather than corrupting args[].
constexpr uint32_t PTO2_TASKPAYLOAD_TENSOR_COUNT_OFFSET = 0;
constexpr uint32_t PTO2_TASKPAYLOAD_SCALAR_COUNT_OFFSET = 4;
// Cache line 9 (byte 576) holds the AICPU-only DispatchPredicate; tensors follow it.
constexpr uint32_t PTO2_TASKPAYLOAD_TENSORS_OFFSET = 640;
constexpr uint32_t PTO2_TASKPAYLOAD_SCALARS_OFFSET = 4736;
constexpr uint32_t PTO2_TASKPAYLOAD_TENSOR_STRIDE = 128; // sizeof(Tensor)

/**
* Per-core dispatch payload: function address + args[] + SPMD context.
*
Expand All @@ -68,20 +82,40 @@ static_assert(
* concurrently dispatched cores.
*/
struct alignas(64) PTO2DispatchPayload {
uint64_t function_bin_addr; /**< Kernel entry address in GM (set by Scheduler) */
uint64_t args[PTO2_DISPATCH_MAX_ARGS]; /**< Kernel arguments (GM pointers + scalars + ext params) */
// === Cache line 0 (64B): control block, the only line written per dispatch ===
// function_bin_addr, local_context.{s_block_idx,s_block_num,async_ctx.task_token}
// and src_payload are the per-dispatch writes; async_ctx's slab pointers +
// capacity are cold (prefilled once at init / rebuilt per a5 dispatch) but
// ride this hot line for free.
// Sized to exactly 64B so both dispatch paths write one control line: the
// ready path (src_payload = 0) then also fills args[0..num_args); the gated
// path (src_payload = &PTO2TaskPayload) leaves args[] to the idle AICore.
uint64_t function_bin_addr; /**< Kernel entry address in GM (set by Scheduler). */

/** Per-dispatch context: block_idx and block_num.
* Written by build_payload() before each dispatch.
* args[SPMD_LOCAL_CONTEXT_INDEX] points here. */
/** Per-dispatch context: s_block_idx/s_block_num (hot) + async_ctx (task_token hot,
* slab pointers + capacity). args[SPMD_LOCAL_CONTEXT_INDEX] points here. */
LocalContext local_context;

/** Per-core global context: sub_block_id (AIV lane identity).
* Initialized once by init_global_context() at runtime startup.
* args[SPMD_GLOBAL_CONTEXT_INDEX] points here. */
GlobalContext global_context;
/** Early-dispatch gate AND source pointer, folded into one field. 0 = ready:
* AICore executes on pickup (args[] already filled by the AICPU). Non-zero =
* gated: the value is the source PTO2TaskPayload address; the AICore fills
* args[0..num_args) from it, then waits for the doorbell (DATA_MAIN_BASE
* high 32 == this dispatch's reg_task_id) before executing. A payload address
* is never 0, so the gate flag is lossless. */
volatile uint64_t src_payload;

uint8_t reserved_payload_abi_pad[8];
// === Cache lines 1..7: kernel argument vector ===
/** [0..num_args) = GM tensor pointers + scalar values; [SPMD_LOCAL_CONTEXT_INDEX]
* = &local_context, [SPMD_GLOBAL_CONTEXT_INDEX] = &global_context (both written
* each dispatch on a5). On the gated path the AICPU leaves [0..num_args)
* unwritten and the idle AICore fills them from src_payload during its gate wait. */
uint64_t args[PTO2_DISPATCH_MAX_ARGS];

/** Per-core global context: sub_block_id (AIV lane identity). Cold: written once
* at init, never per dispatch, so it lives in the tail (not the CL0 control
* block). args[SPMD_GLOBAL_CONTEXT_INDEX] points here. */
GlobalContext global_context;
// No explicit tail padding: alignas(64) rounds sizeof up to 512 (8 cache lines).

static_assert(sizeof(args[0]) == 8);
static_assert(
Expand All @@ -91,3 +125,5 @@ struct alignas(64) PTO2DispatchPayload {
};

static_assert(sizeof(PTO2DispatchPayload) == 512, "PTO2DispatchPayload hardware ABI size drift");
static_assert(offsetof(PTO2DispatchPayload, args) == 64, "args[] must start at cache line 1 (control block = CL0)");
static_assert(offsetof(PTO2DispatchPayload, src_payload) < 64, "src_payload (gate) must live on the CL0 control block");
Loading
Loading