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.
Every AICore kernel in this runtime has the signature
extern "C" __aicore__ void kernel_entry(__gm__ int64_t *args);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.
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.
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.
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.
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.
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.
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_entryThe 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).
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 addressThese 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.
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.
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).