AICore Kernel Programming Guide¶
How to write an AICore kernel for the tensormap_and_ringbuffer runtime
— the SPMD execution-context contract, the supported accessors, and
the things that break silently when ported from native CANN code.
For the broader picture see
hierarchical-level-runtime.md (where
kernels sit in the L0–L6 layering),
task-flow.md (end-to-end task data flow), and
chip-level-arch.md (Host / AICPU / AICore tiers).
The kernel-author contract for the host_build_graph runtime is not
covered here; this guide is tensormap_and_ringbuffer-specific.
1. What a kernel sees¶
Every AICore kernel in this runtime has the signature
args[] is a flat array of 64-bit slots whose meaning is positional
and fixed at compile time:
args layout (tensormap_and_ringbuffer):
[0 .. tensor_count-1] = tensor GM pointers
[tensor_count .. +scalar_count-1] = scalar values
...
[SPMD_LOCAL_CONTEXT_INDEX = 48] = (uint64_t)&LocalContext per-dispatch
[SPMD_GLOBAL_CONTEXT_INDEX = 49] = (uint64_t)&GlobalContext per-core
The trailing two slots are written by the scheduler before each dispatch and hold the SPMD execution context described below. They exist on every dispatch — you can rely on them unconditionally.
The constants live in
src/{a2a3,a5}/runtime/tensormap_and_ringbuffer/common/intrinsic.h;
treat them as private to the runtime and always go through the
accessor functions defined in that header.
2. SPMD execution context¶
The runtime context exposes three topology values plus an optional async-DMA workspace address:
| Accessor (use these) | Returns | Lifetime | Source |
|---|---|---|---|
get_block_idx(args) |
logical block index in [0, block_num) |
per-dispatch | LocalContext.block_idx |
get_block_num(args) |
total logical blocks for this task | per-dispatch | LocalContext.block_num |
get_sub_block_id(args) |
AIV lane in cluster (0 = AIV0, 1 = AIV1) | per-core, init once | GlobalContext.sub_block_id |
get_dma_workspace(args, kind) |
engine workspace GM pointer, or nullptr |
Worker init (SDMA-enabled) | GlobalContext.dma_workspace[kind] |
sub_block_id is only meaningful for AIV kernels in MIX tasks.
AIC kernels and single-AIV tasks should not depend on it. AIV0 is the
"left" lane, AIV1 the "right" lane; they execute the same kernel
binary and use sub_block_id to pick which half of the work they own
(for example: head 0 of a (head0, head1) pair vs head 1).
The scheduler initialises GlobalContext.sub_block_id once per AIV core at
startup, based on each core's position in its cluster
(scheduler_cold_path.cpp::SchedulerContext::init). It also copies the
async-DMA workspace addresses from KernelArgs into
GlobalContext.dma_workspace.
Async-DMA is opt-in per Worker: construct the Worker with enable_sdma=True
and the runtime provisions the SDMA workspace once at init, latches its address
into KernelArgs, and injects it into every run's GlobalContext. A Worker
that does not opt in creates no SDMA streams, and get_dma_workspace returns
nullptr. Provisioning fails fast (Worker init raises) on a platform/runtime
without SDMA support. Every event submitted through the workspace must be waited
before the kernel returns or registered with the runtime's deferred-completion
mechanism.
An invalid kind or unprovisioned slot returns nullptr and must not be used to
submit DMA work. LocalContext is rewritten by build_payload() before each
dispatch.
Logical vs physical block_dim¶
get_block_num(args) returns the logical block count baked into
this task. It is not the same as the physical AICore-block count
that the runtime launches:
| Symbol | Meaning |
|---|---|
rt_available_cluster_count() |
Number of physical AICore blocks this run launches — the whole device; there is no per-call knob. |
get_block_num(args) |
Logical block count the kernel partitions work across. Currently always 1; multi-logical-block (block_num > 1) is not yet implemented. |
When the device reports 24 clusters and your kernel sees
get_block_num(args) == 1, that is by design — every physical block
runs the same kernel and the kernel partitions work however it likes
using get_block_idx() against whatever it expects. Don't conflate
the two.
Each block must write to its own cache line¶
Two AICore blocks running on different cores must never write to the same cache line. This is a hardware constraint, not a software policy.
Each core holds its own copy of a cache line; on dcci (clean+invalidate)
it writes back the entire 64-byte line (16 floats on a2a3), including
the bytes it never touched — which in its copy are stale. When N cores each
write a different element of the same line and flush, the last core to
flush wins and overwrites every other core's element with a stale value.
There is no per-element flush; dcci granularity is one whole cache line.
So a kernel like this is wrong on silicon (it happens to pass on sim,
which models no cache):
// BROKEN: out has block_num elements packed into one cache line; every
// block flushes the whole line -> last-writer-wins -> [0,0,0,last].
out[block_idx] = value;
dcci(&out[block_idx], SINGLE_CACHE_LINE, CACHELINE_OUT);
The fix is to give each block a cache-line-isolated output region — stride
each block's output by at least one cache line (>= 16 floats on a2a3),
or have each block write a full cache-line-aligned tile (the usual case:
real kernels write a head / row / tile per block, which is already aligned):
constexpr int CACHE_LINE_FLOATS = 16; // 64 B / sizeof(float)
out[block_idx * CACHE_LINE_FLOATS] = value; // distinct line per block
dcci(&out[block_idx * CACHE_LINE_FLOATS], SINGLE_CACHE_LINE, CACHELINE_OUT);
See hardware/cache-coherency.md for the full
dcci / cache-line model.
3. Do not use the CCE topology intrinsics¶
The CCE / AscendC headers ship a parallel set of topology intrinsics:
// from kernel_operator.h / tikcfw — DO NOT use in this runtime
get_subblockid();
get_block_idx();
get_block_num();
These read AICore hardware registers that the
tensormap_and_ringbuffer runtime does not program. They were
designed for the native CANN dispatch model, where the OS-level
scheduler sets the registers per launch. simpler's runtime keeps the
same data in software (the LocalContext / GlobalContext
structures in §1) and does not poke the registers.
The consequence is silent miscompute, not an error. Specifically:
get_subblockid()returns whatever stale value the sub-block register holds. In simpler's MIX dispatch that is 0 for both AIV0 and AIV1 of every cluster, so a kernel that partitions heads onsub_block_idparity has AIV1 redo AIV0's work and never writes AIV1's share of the output. This is the partial-zero failure mode in issue #900 / PR #899spmd_paged_attention_highperf: the ported AIV kernel compiled clean, ran without error, and produced 16 correct heads + 16 zero heads out of 32. Resolved by switching the three intrinsics to the(args)accessors above.get_block_idx()/get_block_num()are not redirected either — they reflect physical block topology, not simpler's logical partitioning.
Porting checklist¶
When moving a kernel into this runtime from ascend-transformer-boost / AscendC / any other native-CANN code path:
get_subblockid() → get_sub_block_id(args)
get_block_idx() → get_block_idx(args)
get_block_num() → get_block_num(args)
Plumb args (or just block_idx, block_num, sub_block_id as
plain uint32_t arguments) down through whichever templates,
class methods, or static helpers the kernel uses internally. Do not
leave a single CCE-intrinsic call in the AICore code path; otherwise
the silent-miscompute mode will resurface the next time someone
refactors the call graph.
PR #899's resolution
(commit 0964b4)
is a worked example — the AIC and AIV classes grew pto_block_idx,
pto_block_num, pto_sub_block_id parameters threaded all the way
down from kernel_entry into UnpadAttentionDecoderAic::SetArgs and
UnpadAttentionDecoderAiv::SetArgs.
When the no-arg call is inside the pto-isa tile-pipe library¶
The porting checklist above assumes you own every get_subblockid() call
site. You do not when the kernel drives the pto-isa tile-pipe library
(TPUSH / TPOP / TFREE with TileSplitAxis::TILE_UP_DOWN or
TILE_LEFT_RIGHT): those templates compute per-AIV FIFO offset from the
no-arg get_subblockid() internally
(TPush.hpp::pushVec2GMFiFo / popVecTileFromGMFiFo), and you cannot
thread args into a third-party library template.
Recommended usage (see docs/tpush-tpop.md):
- Call
TPUSH/TPOP/TFREEdirectly — record, back-pressure, andTILE_UP_DOWNlane offset are already implemented inside those templates. - Do not add manual
pipe.prod.record()or batchsetRecordStatus(false)/setAllocateStatus(false)/setFreeStatus(false)unless a reviewed pipeline analysis requires it. - Do not
setEntryOffset(get_sub_block_id(args) * …)for lane split whenget_subblockid()is correct — the library already addsget_subblockid() * tile_bytes_per_lane. - For non-tile-pipe GM addressing (output rows, head partitioning), keep
using
get_sub_block_id(args)from this header.
Do not bridge get_subblockid() with a file-scope cache:
// WRONG — hides per-core state from the orchestrator.
[[block_local]] static int32_t lane; // per-core static
#define get_subblockid() lane // redirect the library's no-arg call
// ... lane = get_sub_block_id(args); once in kernel_entry
The link step resolves the .text relocation such a static needs (§4), so this
loads — but it is still the wrong shape: it hides per-core state the
orchestrator cannot see, behind a macro that silently changes what a library
template computes. If onboard get_subblockid() does not match
get_sub_block_id(args), prefer fixing platform/launch identity; until then add
the lane split explicitly with setEntryOffset computed inline from
get_sub_block_id(args) (see the run_aiv setEntryOffset call sites in
spmd_paged_attention/kernels/mix/paged_attention_parallel.cpp).
4. The AICore loader runs a linked .text¶
simpler loads a kernel by copying the literal .text section bytes and
jumping to offset 0. To make those bytes self-contained,
KernelCompiler.compile_incore links
each compiled object with ld.lld (-e kernel_entry) before
elf_parser.extract_text_section takes the
payload. Linking is what:
- applies ELF relocations (
.rela.text), and - folds out-of-line template instantiations (
.text._Z*COMDAT groups) into the single output.text.
The linked image is position-independent — --image-base does not change the
emitted .text — so the loader can place it anywhere.
extract_text_section still refuses an image that carries either, and also one
whose kernel_entry is not at the start of .text (the loader would enter the
wrong function). Reaching one of those now means the link left the image
incomplete — an undefined symbol kept as a relocation, or out-of-line code the
linker placed ahead of the entry point — not that a kernel merely needs
inlining. The alternative, loading a binary whose BL/B targets are left as
imm26 = 0, branches to garbage on device: CANN 507018 watchdog timeouts or
silently-wrong partial output (issue #900, PR #830 / issue #831).
What linking does and does not rescue:
| Cause | Why it relocates | Status |
|---|---|---|
Out-of-line call to another function (a non-inlined static helper, or a template instantiation emitted to its own section) |
BL <fn> needs an R_AARCH64_CALL26 relocation |
Resolved by the link, provided the linker keeps kernel_entry first. __attribute__((always_inline)) on the call chain still avoids the question entirely and keeps the payload smaller |
Reading a non-const global / static / [[block_local]] variable |
The address load needs a relocation against the data symbol | Resolved by the link: block-locals merge into one .bl_uninit region and each reference gets its true offset. Still prefer passing the value as an argument down from kernel_entry — a per-core global is state the orchestrator cannot see |
That second row is why CANN AscendC kernels load at all: AscendC declares
g_vecTPipePtr / g_cubeTPipePtr and g_kfcClient as block-local globals in
separate .bl.uninit.* sections, and they cannot all sit at offset 0. Without
the link, whichever one is not first silently aliases onto another's slot.
Verify a kernel image before chasing a device hang:
readelf -SW kernel.elf | grep -E '\.text' # want only ".text"
readelf -rW kernel.elf # want: no relocation entries
readelf -sW kernel.elf | grep kernel_entry # want: value == .text address
5. Hard constraints — what AICore physically cannot do¶
These are not design preferences; the hardware refuses or the chip
hangs. They cap what protocol the kernel author can ask of the AICore
side. Confirmed on a3 silicon — see
docs/hardware/mmio-performance.md
for the measurements and
docs/investigations/2026-06-aicore-mmio-to-spr.md
for the verdict trail.
- No SPR-write to
DATA_MAIN_BASE.MOV DATA_MAIN_BASE, xis rejected at compile time — the CCEC backend has no destination encoding for that SPR. OnlyMOV %0, DATA_MAIN_BASE(read self) is available. Use the=lconstraint to accept either uint32 or uint64;=rrejects uint64. - No load or store into the SPR MMIO window. Issuing a
LDR/STRfrom inside an AICore atpeer.reg_addr + offset(or your ownreg_addr + offset) hangs the AICore. The CCECPU monitor killsaicpu-sd50 s later. This applies symmetrically to peer cores' DMB, peer cores' COND, and any other AIC_CTRL register — the chip only accepts SPR-window transactions from AICPU. - DMB is hardware-unidirectional. Combining the two above, an
AICore has no path to mutate any DATA_MAIN_BASE — its own or a
peer's. If a protocol needs an AICore to publish a value into DMB,
route through GM (write field +
dcci, seecache-coherency.md) and let an AICPU thread forward the value into DMB by MMIO STR. - COND is the only AICPU-visible per-task signal an AICore can
emit.
write_reg(RegId::COND, MAKE_FIN_VALUE(task_id))(orMAKE_ACK_VALUE) is the production path — the SPR write retires in ~5–10 ns and lands at the COND MMIO register that AICPU's scheduler polls.
If a kernel needs to publish anything other than a per-task COND
update — for example, a counter, a profiling slot, or a
ring-buffer-style record — it must go through GM with dcci. There
is no "fast direct register" alternative on the AICore side.
5.1 AIC vs AIV on SPR self-access — single-run measurements¶
These were sampled on a3 silicon during the experiment that produced
mmio-performance.md. They are
single-run readings; treat as "the direction of the surprise is solid,
the exact magnitude needs verification before you optimise on it."
- AIC and AIV are indistinguishable on a hot SPR self-cadence path.
A tight loop of
set_cond(FIN) + read_reg(DMB)runs at ~9.7 ns / iter on both AIC and AIV. This refutes the commonly-stated hypothesis that AIC SPR writes trigger a pipeline flush that AIV avoids. If you've seen a kernel slowdown attributed to "AIC SPR pipeline penalty," look for the real cause elsewhere — the bare set_cond + read_reg cost is the same on both engines. - AIC tolerates rotating SPR write targets; AIV does not. A
tight loop of
STRcycling across N distinct SPR target registers costs ~5 ns / STR on AIC (no extra cost vs same-target). On AIV the same loop costs ~26 ns / STR (about 5× slower than same-target). Direction is opposite of the obvious guess ("AIC has more cube state, so target switching should hurt more"). Practical implication: AIV kernels that publish across multiple SPRs in a tight loop pay a per-switch tax that AIC kernels don't; if you can batch writes to one SPR before moving to the next, do so on AIV.
Both findings come from one sample. Re-verify before relying on
either as a design constraint. The
aicore-notification-perf
tool's producer kernel is the closest scaffolding — its producer.cce
already has a mode-aware tight-loop runs on AICore with AICPU-side
tick capture. Extending it to time the AICore-internal
set_cond + read_reg cadence (Phase 5) or rotating-target SPR writes
(Phase 6) is the minimum work — add a new NotifPerfMode value, a
matching branch in producer.cce, and result fields the consumer
sums into the existing NotifPerfResult. Build the producer once
with -DCCE_AICORE_ARCH=dav-c220-cube for AIC and once with
-DCCE_AICORE_ARCH=dav-c220-vec for AIV; both findings need the
AIC/AIV comparison to be meaningful.
6. Related¶
src/a2a3/runtime/tensormap_and_ringbuffer/common/intrinsic.h— declarations of the args-based accessors and theLocalContext/GlobalContextlayout. Same file for a5 undersrc/a5/runtime/tensormap_and_ringbuffer/common/intrinsic.h.src/a2a3/runtime/tensormap_and_ringbuffer/docs/SUBMIT_BY_CLUSTER.md— how the orchestration side dispatches AIC + AIV0 + AIV1 as a single MIX task (the producer of thesub_block_iddistinction).docs/scheduler.md— how the scheduler turns a submitted task into a per-core dispatch payload (the writer ofLocalContext).- Examples worth reading as templates:
tests/st/a2a3/tensormap_and_ringbuffer/spmd_paged_attention/(single-AIV SPMD) andtests/st/a2a3/tensormap_and_ringbuffer/spmd_multiblock_mix/(MIX with both AIV lanes).