Llama-3.2-1B on AMD NPU2 — Implementation Guide

A model-first walkthrough: understand what Llama-3.2-1B inference IS, then how this codebase runs it on AMD NPU2 hardware.

How to read this guide: Read Part A first if you're unsure what Llama-3.2-1B inference does at the math level. Part A has no NPU code — just the model itself and its data flow. Then Part B shows how this codebase realizes Part A on AMD NPU2 hardware. Part C is a one-page pointer to the verification subsystem (full design in VERIFICATION.html). Part D lists known optimizations not yet implemented. Part E is reference material to come back to as needed.

Part A — The Model (no NPU yet)

A1. Llama-3.2-1B at a glance

Llama-3.2-1B is a 1.24-billion-parameter decoder-only transformer language model from Meta, released in 2024. Given a sequence of input tokens, it produces a probability distribution over the vocabulary for the next token. Repeated autoregressively, this generates text.

Hyperparameters (defined in LlamaConfig at llama32_1b_weights.py:36)

ParameterValueWhat it means
n_layers16Number of stacked transformer blocks
emb_dim (d_model)2048Hidden dimension everything flows through
n_heads32Number of Q heads in attention
n_kv_heads8Number of K/V heads (GQA: 4 Q heads share each KV head)
head_dim64Per-head dimension. Note: 32 × 64 = 2048 = emb_dim
hidden_dim8192FFN intermediate width (gate/up/down projections expand to this)
vocab_size128256Tokenizer vocabulary size; LM Head outputs this many logits
seq_len2048Fixed prefill length in this implementation (not a model property)
weight dtypebfloat1616-bit brain-float for all weights and activations
RoPE base500000Rotary Position Embedding base frequency

Total parameter accounting (~1.24 B)

ComponentPer layer× 16 layersPer-tensor shape
Attention norm weight2,04832,768(2048,)
Q projection4.19 M67.1 M(2048, 2048)
K projection1.05 M16.8 M(2048, 512)
V projection1.05 M16.8 M(2048, 512)
O projection4.19 M67.1 M(2048, 2048)
FFN norm weight2,04832,768(2048,)
Gate projection16.8 M268 M(2048, 8192)
Up projection16.8 M268 M(2048, 8192)
Down projection16.8 M268 M(8192, 2048)
Per-layer subtotal61.0 M976 M~ 122 MB bf16
Embedding table263 M(128256, 2048)
Final norm2,048(2048,)
LM Head (vocab projection)263 M(128256, 2048)
Grand total≈ 1.50 B~ 3.0 GB bf16

Note: Llama-3.2-1B uses untied embeddings (LM Head is a separate parameter from the embedding table). That's why total is ~1.50 B not ~1.24 B if you sum just the published parameter count. The embedding table is loaded but the embedding lookup is a host-side numpy index, not an NPU kernel.

A2. The transformer block — math and shapes

Llama-3.2-1B is just 16 of these blocks stacked, sandwiched between a token embedding lookup at the start and a final RMSNorm + LM Head at the end. (See A3 for the full top-level pipeline.)

One transformer block is a function block(x) → output where both x and output have the same shape [B, S, H]. The block has two sub-blocks (attention and FFN), each with a residual connection. We diagram them separately to keep each readable.

Symbol convention (used in every shape annotation below)

SymbolMeaningLlama-3.2-1B value
Bbatch size1 (this implementation is single-stream)
Ssequence length2048 (prefill) or 1 (decode)
Hhidden dim (d_model)2048
Lnumber of decoder layers16
N_hquery head count32
N_kvKV head count (GQA)8
GGQA group size = N_h / N_kv4
d_hper-head dim = H / N_h64
D_ffFFN intermediate dim8192
Vvocab size128256

Note: H = N_h · d_h = 32 · 64 = 2048, and the K/V projection output is N_kv · d_h = 8 · 64 = 512 (smaller than H because of GQA).

Linear / matmul / weight-bearing — Q/K/V/O proj, gate/up/down, embedding, LM head
Norm / activation / attention compute — RMSNorm, RoPE, SiLU, scaled dot-product attention
Data / structural — input/output tensors, residual adds

A2.1 — Attention sub-block

From the block's input x, the attention sub-block produces an updated hidden state with cross-position information mixed in (causally — only earlier positions affect later ones). Three weighted projections (Q, K, V) plus RoPE, attention compute, and an output projection. The output is added to a saved copy of x (residual).

Input x [B, S, H] save x for residual [B, S, H] RMSNorm γ: [H], row-wise on H [B, S, H] (broadcast to 3) Q proj W_q: [H, N_h·d_h] K proj W_k: [H, N_kv·d_h] V proj W_v: [H, N_kv·d_h] [B, S, N_h·d_h] [B, S, N_kv·d_h] RoPE on Q cos/sin LUT [S, d_h] RoPE on K cos/sin LUT [S, d_h] V passthrough no rotation q_roped k_roped v Scaled dot-product attention (causal, GQA) S = softmax(Q · K^T / √d_h, causal_mask) · V FlashAttention fuses softmax with the matmuls; GQA = each Q head shares a KV head no learnable weights [B, S, N_h·d_h] = [B, S, H] Output projection W_o: [N_h·d_h, H] [B, S, H] Residual add: out = x + proj [B, S, H]

Per-kernel explanations (attention sub-block)

RMSNorm (input normalization)
Q projection
K projection
V projection
RoPE on Q (Rotary Position Embedding)
RoPE on K
V passthrough
Scaled dot-product attention (causal, GQA)
Output projection
Residual add

A2.2 — FFN sub-block (SwiGLU)

Takes the attention sub-block's output (call it res1) and applies a 3-projection feed-forward network with SwiGLU activation. Like the attention sub-block, the result is added to a saved copy of the input.

Input res1 [B, S, H] save res1 for residual [B, S, H] RMSNorm γ: [H], row-wise on H [B, S, H] (broadcast to 2) Gate projection W_gate: [H, D_ff] Up projection W_up: [H, D_ff] gate: [B, S, D_ff] up: [B, S, D_ff] SiLU(gate) x · σ(x), elementwise up (unchanged) Elementwise mul: SiLU(gate) ⊙ up [B, S, D_ff], no reduction swiglu: [B, S, D_ff] Down projection W_down: [D_ff, H] down: [B, S, H] Residual add: out = res1 + down [B, S, H] — block output

Per-kernel explanations (FFN sub-block)

RMSNorm (FFN)
Gate projection
Up projection
SiLU(gate)
Elementwise multiply: SiLU(gate) ⊙ up
Down projection
Residual add (FFN)

A2.3 — Block-level annotations

Compute-heavy ops (FLOPs ranking, prefill at S=2048)
The three FFN GEMMs dominate FLOPs because D_ff is 4× larger than H. Per-block prefill FLOPs: The 3 FFN projections together = 207 GFLOP per layer ≈ 60% of per-layer compute. × 16 layers × 1.27 s prefill ≈ 2.6 TFLOP/s achieved on the NPU.
Memory-bound ops (bandwidth-limited at small S)
RMSNorm and the elementwise SwiGLU multiply have low arithmetic intensity (~1 FLOP/byte). Attention's softmax + the sub-multiplies inside FlashAttention also become memory-bound when S is small or d_h is small. In decode (S=1), everything except the GEMVs is memory-bound — this is why the per-token decode time is dominated by weight bandwidth, not FLOPs.
Fusable kernel boundaries
Common fusions seen in this and other implementations: The marginal contribution of our specific multi-launch grouping has been validated in internal measurements.
Convention gotchas (where this implementation differs from "vanilla" Llama)
GQA effects on KV cache size
With G = 4 (each KV head shared by 4 Q heads), the KV cache is 4× smaller than it would be without GQA. For Llama-3.2-1B at max_seq=2048:
KV cache size = 2 · L · N_kv · max_seq · d_h · 2 bytes = 2 · 16 · 8 · 2048 · 64 · 2 = ~32 MB
Without GQA (N_kv = N_h = 32), this would be ~128 MB. The savings matter much more for larger models / longer sequences.
Weight sharing
Llama-3.2-1B uses untied embeddings — the LM head W_lm is a separate parameter from the embedding table W_emb. (Some smaller models tie them to save parameters.) Both are [V, H]; together they account for ~526 M of the model's 1.5 B parameters.

A2.4 — Mapping back to our codebase

The 14 ops above map to the production NPU kernels as follows:

Sub-blockModel opsNPU realization
Attention RMSNorm + Q proj + K proj + V proj + RoPE Q + RoPE K rms_gemms_rope.elf — 6 sub-launches stitched into one ELF
Scaled dot-product attention flash_attn.elf — 1 launch (separate ELF; un-mergeable)
(boundary) O proj + Residual #1 First 2 sub-launches of o_ffn.elf
FFN RMSNorm + Gate proj + Up proj + SiLU·mul + Down proj + Residual #2 Remaining 6 sub-launches of o_ffn.elf

So one transformer block = 3 NPU calls (rms_gemms_rope + flash_attn + o_ffn) wrapping a total of 15 sub-launches (6 + 1 + 8). The grouping is not the natural "attention sub-block / FFN sub-block" boundary — instead, the cut is "before FlashAttention" vs "after FlashAttention", because FA must be its own ELF (compile-time scaling issue documented in docs/explain.md). Why this exact grouping is best — and why all 15 sub-launches don't go into one ELF — is the topic of Part B.

One transformer block as math (paraphrased)

Below is one Llama-3.2-1B layer written as plain NumPy — useful as a reference for the math, independent of NPU plumbing. (The actual production NPU pipeline is described in Part B; numerical correctness is gated by make verify against HF transformers bf16 — see VERIFICATION.html.)

def transformer_block(x, lw, rope_lut, config):
    # Attention sub-block
    normed = rms_norm(x, lw.attn_norm)
    q = normed @ lw.wq
    k = normed @ lw.wk
    v = normed @ lw.wv
    q_roped = apply_rope(q, rope_lut)
    k_roped = apply_rope(k, rope_lut)
    attn_out = attention(q_roped, k_roped, v, config)   # GQA, causal mask
    res1 = x + attn_out @ lw.wo

    # FFN sub-block
    normed2 = rms_norm(res1, lw.ffn_norm)
    gate = normed2 @ lw.w_gate
    up = normed2 @ lw.w_up
    swiglu_out = silu(gate) * up
    output = res1 + swiglu_out @ lw.w_down
    return output

A3. Full forward pass — what one inference call does

Top-level pipeline

The diagram below shows the whole inference call as 6 stages. The decoder block is collapsed (×L) — its internals are diagrammed in A2.

Input token IDs [B, S] [B, S] (integer indices) Token embedding W_emb: [V, H] [B, S, H] Decoder block × L attention + FFN (with residuals) L = 16 layers, each = 14 ops (see A2) writes K, V to KV cache (see A4) [B, S, H] Final RMSNorm γ: [H], row-wise on H [B, S, H] LM head W_lm: [V, H], untied [B, S, V] logits argmax over V at last real-token row next_token_id ∈ [0, V)

Per-stage explanations (top-level pipeline)

Token embedding
Decoder block × L
Final RMSNorm
LM head
argmax over V

The two operating modes (model-level)

The forward pass above works for ANY input length. But there are two common usage patterns:

ModeInputWhat we doOutputCost
Prefill The full prompt: token_ids of length S = prompt_len One forward pass with seq=S. Save K, V at every layer for every position into a "KV cache" — we'll need them for decode. Argmax at position S-1 gives the first generated token. 1 token + populated KV cache ~1.27 s for S=2048
Decode One token at a time: x of shape (1, 2048) — embedding of the previous output token One forward pass with seq=1. Use the KV cache in attention — the new K, V for this position get appended. Argmax gives the next token. 1 new token + KV cache extended by 1 position ~92 ms per token

To generate N tokens of text from a prompt: 1 prefill call + N decode calls. The KV cache is built once during prefill and grows by one row per decode step.

A4. KV cache — what it is, why we need it, how it grows

The problem

For a sequence of length T, attention computes:

Q = X @ Wq    # shape (T, n_heads, head_dim)
K = X @ Wk    # shape (T, n_kv_heads, head_dim)
V = X @ Wv    # shape (T, n_kv_heads, head_dim)
attn = softmax(Q @ K.T / √d) @ V   # causal masked

During decode, position T+1 only adds one new query Q[T+1]. But that query needs to attend to all previous K[0..T] and V[0..T]. If we threw those away after the prefill and recomputed them, we'd redo O(T) work per decode step.

The solution: cache K and V

Once K[i] and V[i] are computed for any position i, they never change again (they only depend on x[i] and weights, not on later tokens). So we store them in a per-layer cache and append a new entry per decode step.

Memory layout in our codebase

Allocated in llama32_1b_inference.py:369:

k_cache = np.zeros(
    (config.n_layers, n_kv_heads, max_seq, head_dim),
    dtype=bfloat16,
)
v_cache = np.zeros((config.n_layers, n_kv_heads, max_seq, head_dim), dtype=bfloat16)
DimensionSizeWhy
n_layers16Each layer has its own K, V (different transformations of x)
n_kv_heads8GQA — only 8 distinct heads (vs 32 Q heads)
max_seqprompt_len + n_tokensEnough room for the prompt + every generated token
head_dim64Per-head dimension

Total memory: 16 × 8 × max_seq × 64 × 2 bytes = 16,384 × max_seq bytes ≈ 32 MB at max_seq=2048. Tiny compared to the 3 GB of weights — KV cache is not a memory concern for Llama-1B.

Visual: how the K/V cache grows

Showing one layer's K cache (the V cache has the same structure). Each cell is one position; rows are the 8 KV heads.

State after prefill (prompt_len = 7 tokens, max_seq = 20 in this toy example):

↓ kv_head_idx (8 rows). → position 0, 1, 2, ... 19
Populated by prefill (real prompt position)
Allocated but empty (zero)

State after 4 decode steps (current_pos = 11):

Prefill positions (0..6)
Decode positions (7..10)
Future positions (11..19, not yet written)

The key code points

(1) Cache allocation — once per generate() call:

# llama32_1b_inference.py:369
k_cache = np.zeros((n_layers, n_kv_heads, max_seq, head_dim), dtype=bfloat16)
v_cache = np.zeros((n_layers, n_kv_heads, max_seq, head_dim), dtype=bfloat16)

(2) Prefill writes to the cache — extracts k_roped and v from each layer's intermediates:

# llama32_1b_inference.py:401 — runs after each layer in the prefill loop
k_cache[layer_idx, :, :seq_len, :] = (
    k_roped.astype(bfloat16)
    .reshape(seq_len, n_kv_heads, head_dim)
    .transpose(1, 0, 2)        # layout: (n_kv_heads, seq_len, head_dim)
)
v_cache[layer_idx, :, :seq_len, :] = (
    v_raw.astype(bfloat16).reshape(seq_len, n_kv_heads, head_dim).transpose(1, 0, 2)
)

(3) Decode appends to the cache and reads from it — inside decode_attention_cpu and run_decode_block:

# llama32_1b_decode.py — paraphrased
def run_decode_block(x, lw, cache, config, k_cache_layer, v_cache_layer, current_pos, ...):
    # 1. Compute new k, v from this token (NPU rms_gemv_rope call)
    out = cache.load_and_run("rms_gemv_rope", ...)
    new_k_roped = out[12]   # shape (kv_dim,) = (512,) flat
    new_v       = out[8]    # shape (kv_dim,)

    # 2. Append to cache at current_pos
    k_cache_layer[:, current_pos] = new_k_roped.reshape and transpose
    v_cache_layer[:, current_pos] = new_v.reshape and transpose

    # 3. CPU attention reads positions 0..current_pos
    attn_out = decode_attention_cpu(q_roped, k_cache_layer, v_cache_layer,
                                     current_pos, n_heads, n_kv_heads, head_dim)

# Inside decode_attention_cpu:
seq_len = current_pos + 1
k_cached = k_cache[:, :seq_len, :]    # only positions 0..current_pos
v_cached = v_cache[:, :seq_len, :]
# Then standard QKᵀ V softmax against this slice...
Important sequencing detail: at the start of decode, current_pos = prompt_len (NOT 0). The cache positions 0..prompt_len-1 are populated by the prefill. The first decode step writes the new k, v at position prompt_len and reads positions 0..prompt_len for attention (the new entry plus all the prefill entries).

A5. Padding to fixed seq_len + finding the real prompt

This implementation uses fixed seq_len=2048 because NPU kernels are compiled for one specific shape — recompiling for every prompt length would be prohibitive. So we always pad shorter prompts up to 2048. Let's trace exactly how that works.

Step 1 — Tokenization (host, CPU)

In llama32_1b_inference.py:731:

def _tokenize_prompt(session, prompt_text):
    if session.model_variant == "instruct":
        messages = [{"role": "user", "content": prompt_text}]
        chat_text = session.tokenizer.apply_chat_template(messages, tokenize=False,
                                                            add_generation_prompt=True)
        return session.tokenizer.encode(chat_text)
    return session.tokenizer.encode(prompt_text)

For "What is the capital of France?" with the instruct model, this returns ~30 tokens (the chat template adds system/user role markers).

Step 2 — Padding to seq_len

In llama32_1b_inference.py:754 (run_once):

tokens = _tokenize_prompt(session, prompt_text)   # length = real prompt
prompt_len_actual = len(tokens)                  # save the real length
if len(tokens) < session.seq_len:
    tokens = tokens + [session.tokenizer.eos_token_id] * (session.seq_len - len(tokens))
# Now len(tokens) == 2048 always.

So if the real prompt is 30 tokens long, tokens becomes [real_0, real_1, ..., real_29, EOS, EOS, ..., EOS] with 2018 EOS tokens of padding.

Step 3 — Recovering the real prompt length inside prefill

The prefill function doesn't receive prompt_len_actual directly — it gets only the padded token_ids array. It recovers the real length by counting non-EOS tokens (llama32_1b_inference.py:422):

prompt_len = len([t for t in token_ids if t != tokenizer.eos_token_id])
pred_pos = prompt_len - 1     # index of the last real prompt token
Caveat: this assumes the real prompt does NOT contain any EOS tokens. For typical text inputs that's true. The instruct chat template uses <|begin_of_text|>, <|start_header_id|>, etc. — none of which are EOS — so this works in practice. If a prompt legitimately contained EOS, this counting would be wrong.

Step 4 — Prefill processes ALL 2048 positions but only reads pred_pos's logits

The NPU runs the full forward pass over all 2048 positions including the EOS padding. The padding positions produce garbage k, v values. But we only use the logits at pred_pos = prompt_len - 1, which is BEFORE any padding (llama32_1b_inference.py:427):

# Final RMSNorm + LM Head — only on the last real-token row
last_hidden = np.asarray(x_bf16, dtype=np.float32)[pred_pos:pred_pos + 1]
last_normed_bf16 = _rms_norm(last_hidden, weights.final_norm).flatten().astype(bfloat16)

# NPU LM Head GEMV (8 partitions) on the single normalized row
results = decode_cache.load_and_run("lm_head_gemv", ...)
logits_row = np.concatenate(results, axis=0)[:vocab_size]
prefill_token = int(np.argmax(logits_row))

This is one of the production optimizations: instead of running the LM Head GEMM on all 2048 positions and then taking row pred_pos, we extract just that one row first (CPU RMSNorm in <1 ms) and run a 1×128256 GEMV on the NPU. Saves ~150 ms of pointless compute.

Step 5 — KV cache for decode uses prompt_len, not seq_len

After prefill, the KV cache has positions 0..2047 populated, but only positions 0..prompt_len-1 contain MEANINGFUL k/v (the rest are garbage from EOS padding). Decode starts at current_pos = prompt_len (llama32_1b_inference.py:573):

generated_tokens = [prefill_token]
current_pos = prompt_len            # skip past the garbage padding positions
x_decode = weights.embed_table[prefill_token].astype(bfloat16)

for token_idx in range(n_tokens):
    # Run all 16 transformer blocks in decode mode
    for layer_idx in range(config.n_layers):
        x = run_decode_block(x, ..., k_cache[layer_idx], v_cache[layer_idx],
                              current_pos, ...)
    # LM Head GEMV → next token
    # ...
    current_pos += 1            # cache grows by 1 per token

Inside decode_attention_cpu, the slicing k_cache[:, :current_pos+1, :] ensures we only attend to real prefill positions + actually-decoded positions. The garbage at indices prompt_len..2047 (left over from prefill processing the EOS padding) is never read — those slots are reused by decode if it generates enough tokens to overwrite them.

Cost of padding

For a 30-token prompt padded to 2048, the prefill compute does 2048 / 30 ≈ 68× more work than necessary, because every layer processes 2018 padding positions whose results we throw away. This is a deliberate tradeoff: fixed-shape kernels are vastly easier to compile and faster per-position than dynamic-shape kernels would be on this hardware.

Decode doesn't suffer from this — each decode call only processes ONE token (seq=1), and that token is the real new one.

Visual summary of the prompt+padding+decode lifecycle

Token IDs in the seq=2048 input array, then growing into decode positions:
Real prompt (positions 0..6, prompt_len=7)
EOS padding (E) — prefill processes but we ignore the output
Decode-generated tokens (current_pos=7,8,9,10,11)
In a real run with seq_len=2048, the EOS pad band would be 30 → 2048 positions wide. The decode positions start at index 30 (prompt_len) regardless of where the padding ended.
Note: the prefill's output token (at pred_pos = prompt_len - 1 = 6) is the FIRST generated token. It becomes generated_tokens[0]. Then decode generates tokens 1, 2, 3, ... and writes their k/v at cache positions prompt_len, prompt_len+1, .... The cache positions don't move; the cache just grows in-place into the previously-allocated max_seq array.

A6. Does padding affect the math at real positions?

Short answer: No. The hidden state at pred_pos = prompt_len − 1 is bit-identical to what you'd get if you ran with seq=prompt_len instead of seq=2048. (Same bytes, not just same logits.) This is why padding-with-EOS is a sound workaround, not a numerical approximation.

The reason: of the 14 ops in a transformer block (Part A2), only attention crosses positions. All other ops are per-position: each output row depends ONLY on its own input row. So the only path by which a padding position could contaminate pred_pos's output is through attention — and attention is causally masked, so pred_pos never sees positions later than itself. EOS padding tokens are by construction at indices ≥ prompt_len = pred_pos + 1, all of which the causal mask blocks.

Per-op analysis: which ops cross positions?

Let x[i] denote the hidden state at position i. For each op, the question is: does the output at position pred_pos depend on any x[j] with j ≠ pred_pos?

OpMathCross-position?Why / why not
Embedding lookup x[i] = embed_table[token_ids[i]] No Per-token table lookup. Position i depends only on token_ids[i].
RMSNorm x[i] · rsqrt(mean(x[i]²)+ε) · w No The mean is over the embedding dimension (2048 elements of one row), NOT over positions. RMSNorm at position i depends only on x[i]. Easy to verify: the norm formula has no sum across positions.
Q/K/V projection Q[i] = x[i] @ Wq (etc.) No A matmul (seq, emb) @ (emb, out) is independent matmul per row. Q[i] = x[i] @ Wq.
RoPE rotate Q[i] by angle θ(i) from LUT No RoPE rotates each (position, head) pair by an angle that is a function of position alone. Q_roped[i] depends only on Q[i] and the constant LUT[i].
Attention out[i] = softmax(Q[i] · Kᵀ / √d, mask) · V Yes — but masked The ONLY cross-position op. With the causal mask, out[i] attends to positions 0..i ONLY. Position pred_pos attends to 0..pred_pos — strictly before any padding. Padding positions are at indices pred_pos+1..2047, all blocked.
O projection proj[i] = attn_out[i] @ Wo No Per-row matmul.
Residual add res[i] = x[i] + proj[i] No Elementwise per row.
FFN RMSNorm same as above No Per-row.
Gate / Up GEMMs per-row matmul No Per-row.
SwiGLU SiLU(gate[i]) * up[i] No Elementwise per row.
Down GEMM per-row matmul No Per-row.
Residual add #2 elementwise per row No Per-row.
Final RMSNorm per-row No Per-row.
LM Head logits[i] = x[i] @ W_lm.T No Per-row matmul. (And we only compute row pred_pos — see A7.)
The single-point invariant: attention is the only op that mixes positions, and the causal mask guarantees that the mixing only flows EARLIER → LATER, never the reverse. Since EOS padding is appended at positions LATER than pred_pos, no padding position can leak into pred_pos's output through any pathway.

What about the padding positions themselves?

The padding positions DO produce garbage output. EOS embeddings get RMSNormed, projected, RoPE-rotated, and run through attention (which can attend to real tokens earlier in the sequence — so the garbage is "garbage with prompt context"). But we never USE that garbage:

Subtle case: do dropout, layer norm running stats, etc. matter?

No, because:

How to verify this claim

You can prove the bit-identity empirically: run prefill on a 30-token prompt padded to 2048, then run prefill on the same 30 tokens with seq_len=30 (no padding) — assuming you have kernels compiled for seq=30, which production doesn't but the CPU reference does. Compare x_bf16[pred_pos] from both runs. They should be byte-equal.

This is something you have to script yourself if you ever need to re-prove it (make diagnosis probes the NPU vs HF bf16 per-layer cosine — see VERIFICATION.html — but it does not directly compare seq=30 vs seq=2048 padded).

A7. Single-row LM Head GEMV — workaround or general optimization?

Short answer: general optimization. Always sufficient for autoregressive single-stream generation, regardless of padding. Even a real seq=2048 prompt with no padding would only need the logits at the last position to generate the next token.

Why this is true

Autoregressive language generation has a one-step lookahead: given hidden states for positions 0..T−1, the next token's distribution depends only on logits[T−1]. The logits at positions 0..T−2 would tell you "if I had sampled here, what would the next token be?" — but you've already committed to the actual tokens at those positions (they're the prompt). You don't re-sample them.

So the LM Head's job during inference is always the same: project ONE hidden state row (the last position's) into vocab space, argmax (or sample), produce ONE next token.

Where multi-row LM Head WOULD be needed

Use caseWhy multi-row?Used in this implementation?
Training (computing cross-entropy loss against teacher-forced labels) Loss is summed over all positions; need logits everywhere No — this is inference-only
Speculative decoding (verify a draft model's K-token speculation) Need logits at K positions to score the speculation No — single-stream sampling only
Beam search (track top-K candidate sequences) Need full distributions at each step for multiple beams No — greedy argmax (1 stream)
Dumping logits for analysis / probing Researcher wants per-position logits for downstream analysis No

For the standard autoregressive sampling that this implementation does (greedy or top-k), you only need the last position's logits. This optimization holds whether your prompt fits in 30 tokens or 2048 tokens.

The math savings

ApproachComputeWhy
Naive: full-seq LM Head (2048, 2048) @ (2048, 128256) = (2048, 128256) ≈ 1 TFLOP Computes 2047 rows you'll never look at
This implementation: single-row GEMV (1, 2048) @ (2048, 128256) = (1, 128256) ≈ 0.5 GFLOP Only the row you need; ~2000× less compute

In wall time, this is the "Saves ~150 ms" optimization mentioned in profile.md. Implemented at llama32_1b_inference.py:425-446: extract the single hidden-state row, do RMSNorm on it (CPU, <1 ms because it's one row of 2048 elements), then call the decode-side lm_head_gemv.elf on that single row. The same ELF is reused for both prefill's last-token projection and per-token decode — they're the same operation (1×128256 GEMV).

Padding workaround vs production-grade variable-length support

Now to your bigger question: what's the difference between this implementation's padding-with-EOS and what a real production inference server does?

Our approach is the simplest possible: compile kernels for one fixed shape (seq=2048), pad shorter prompts with EOS. This is appropriate for a research prototype on novel hardware where building a dynamic-shape compiler is itself a research problem.

Production inference servers (vLLM, TensorRT-LLM, SGLang, llama.cpp, etc.) use much more sophisticated approaches:

TechniqueWhat it doesThis implementation?Why production needs it
Dynamic-shape kernels Same kernel handles any seq length, branching at runtime on shape No — fixed seq=2048 Avoids waste on short prompts; supports any prompt length up to a limit
Chunked prefill Split a long prompt (e.g., 32K tokens) into chunks of fixed size (e.g., 512), process sequentially with attention reading the cache for earlier chunks No — single-shot at seq=2048; longer prompts unsupported Supports prompts longer than the kernel's max seq length
Continuous batching Pack multiple users' requests into one batch; add new requests / remove finished ones every step No — single user, single stream Maximize GPU/NPU utilization with multiple concurrent users
Paged KV cache KV cache split into fixed-size pages (like virtual memory pages); attention gathers them at runtime No — contiguous (n_layers, n_kv_heads, max_seq, head_dim) array Avoids fragmentation and overcommit when serving many users with variable sequence lengths
Speculative decoding Use a small draft model to speculate K tokens, verify in one big-model forward pass No — vanilla autoregressive ~2-3× decode speedup at the cost of ~10-30% extra compute
Quantization (INT8/INT4) Compress weights to lower precision, dequantize in kernel No — bf16 throughout ~2-4× speedup, ~2-4× memory reduction
Multi-node tensor/pipeline parallelism Shard model across multiple devices No — single NPU Required for models larger than one device's memory

What our implementation IS vs IS NOT

What this is: a single-user, single-stream, fixed-seq-length, bf16, single-NPU autoregressive LLM inference reference. Optimized for clean code, hardware bring-up, and meaningful end-to-end performance numbers (1.27 s prefill / 92 ms/token decode at seq=2048). Demonstrates that NPU2 + MLIR-AIR can run a real LLM end-to-end.
What this isn't: a production inference server. To deploy this in production, you'd want chunked prefill (or at least multiple compiled seq lengths to avoid the padding waste on short prompts), continuous batching (for multi-user serving), paged KV cache (for memory efficiency), and quantization (for further speedup). The padding workaround is appropriate for the research artifact; it would be replaced with proper variable-length support in a productionization pass.

The "single-row LM Head" optimization is general; the "padding-to-2048" optimization is specific

To return to your distinction: these are two completely separate things.

OptimizationAlways applicable?Why
Single-row LM Head GEMV at the end of prefill Yes, always. Production servers do this too. Autoregressive sampling only needs the last row's logits, regardless of how the prompt was processed.
Pad short prompts with EOS to 2048 No — specific to fixed-shape kernels. Production usually avoids this. It wastes compute (~68× for a 30-token prompt). Only acceptable when dynamic-shape kernels would be even more expensive (e.g., due to compile time, runtime branching cost, or tooling immaturity).

So when you read the LM Head GEMV code, don't think "this is a workaround". Think "this is the right thing to do, and it happens to also dodge an extra 2047 wasted rows that the padding would have created if we used the full-seq GEMM here".

Part B — How we run it on the NPU

Part A was the model. Now we look at how this codebase realizes those ops on AMD NPU2. The translation is not 1-to-1: the model has 14 ops per layer; production runs them as 3 NPU kernel calls per layer (rms_gemms_rope = ops 1-6, flash_attn = op 7, o_ffn = ops 8-15). That's the "multi-launch merging" optimization at work.

B1. End-to-end runtime flow

Implementation overview — prefill

One inference's prefill phase: from the input prompt to the first generated token. The diagram shows which steps run on CPU (gray, host-side numpy) vs which run on NPU (purple, stitched ELFs). FA is its own ELF (pink-purple); the per-layer triple (rms_gemms_rope.elf, flash_attn.elf, o_ffn.elf) is grouped inside the "decoder block × 16" container. KV cache extraction happens on the host after each layer.

Prompt → tokenize + pad CPU; output [B, S=2048] (EOS-padded) Token embedding lookup CPU numpy gather; W_emb: [V, H] x: [B, S, H] = [1, 2048, 2048] bf16 Decoder block × L = 16 (one iteration shown; loop wraps back) rms_gemms_rope.elf — NPU, 1 xrt.run 6 stitched launches: RMSNorm + Q/K/V GEMM + RoPE Q + RoPE K q_roped [S, H]; k_roped [S, kv_H]; v [S, kv_H] flash_attn.elf — NPU, 1 xrt.run (separate ELF) 1 launch; un-mergeable (see B5) attn_out [S, H] extract k_roped, v KV cache write CPU; k_cache[L,:,:S], v_cache[L,:,:S] o_ffn.elf — NPU, 1 xrt.run 8 stitched launches: O + Add + RMSNorm + Gate/Up + SwiGLU + Down + Add x_next [S, H] (= next layer's x_in) (loop back to rms_gemms_rope for layer L+1) x: [B, S, H] after 16 layers Final RMSNorm at row pred_pos CPU; only 1 row (see A7); → [1, H] lm_head_gemv.elf — NPU, 1 xrt.run 8 stitched partitions; W_lm: [V, H] sliced logits [1, V] = [1, 128256] argmax → next_token_id CPU; first generated token next_token_id ∈ [0, V)

Read the colors: gray = CPU/host (numpy, embedding lookup, KV cache management, argmax), purple = NPU stitched ELF, pink = NPU FlashAttention (always its own ELF, never stitched — see B3). The dashed purple outline marks the 16-layer loop boundary.

Implementation overview — decode (per token)

Decode generates ONE token per pass. Per layer it makes 2 NPU calls + 1 CPU step (because attention runs on CPU during decode — see B9 for why). The KV cache is read+appended on each layer.

Previous token id scalar (from prefill or prior decode step) Token embedding lookup CPU numpy gather; single row of W_emb x_decode: [H] = [2048] bf16 (single token) Decoder block × L = 16 (one iteration shown; loop wraps back) rms_gemv_rope.elf — NPU, 1 xrt.run 6 stitched launches (GEMV variants of prefill kernels) q_roped [H]; k_roped [kv_H]; v [kv_H] — single-token decode_attention_cpu — CPU reads k/v_cache[L, :, 0:current_pos]; writes new k/v at current_pos attn_out [H] read 0..pos, append at pos KV cache [16, kv_h, max_seq, d_h] o_gemv_ffn.elf — NPU, 1 xrt.run 8 stitched launches (GEMV variants of o_ffn) (loop back to rms_gemv_rope for layer L+1) x: [H] after 16 layers Final RMSNorm CPU; single-row, <1 ms; → [1, H] lm_head_gemv.elf — NPU, 1 xrt.run SAME ELF reused from prefill (8 partitions) logits [1, V] argmax → next_token_id CPU; → loop back as input to next decode step next_token_id ∈ [0, V)

NPU calls per pass — concrete count

PhaseNPU calls per layerNPU calls totalCPU work per layer
Prefill (1 pass, 16 layers)3 (rms_gemms_rope + flash_attn + o_ffn)48 + 1 (lm_head_gemv) = 49KV cache write (numpy slice assign)
Decode (1 token, 16 layers)2 (rms_gemv_rope + o_gemv_ffn)32 + 1 (lm_head_gemv) = 33decode_attention_cpu (single-query GQA against KV cache)

NPU2 tile array — context

NPU2 (AMD Strix, AIE2P architecture) has a 32-tile compute array arranged as 8 columns × 4 rows. Plus 8 mem-tiles (L2) and shim tiles for DMA. Each compute tile is a VLIW vector core with its own L1 SRAM. Different kernels use different subsets of the 32 tiles depending on parallelism strategy:

Herd shapeTiles usedUsed for (typical)
[8, 4]32 / 32 (full)Prefill GEMMs (Q/K/V/O/Gate/Up/Down). M-dim split 8 ways × N-dim split 4 ways.
[8, 1]8 / 32RMSNorm, RoPE (prefill), SwiGLU, eltwise add, GEMV (decode). Row-parallel across one column of tiles.
[1, 1]1 / 32RoPE (decode) — single tile is enough for the tiny single-token rotation.
Cascade [c_nq, c_ns]variesFlashAttention — uses an internal segment + cascade-stages design (4 stages × per-head segments). Hard to give one number; FA stresses the array more than any other single ELF.

Each kernel's exact tile usage is listed in B2's per-kernel cards. The choice of herd shape is made by the Python builder (passed as herd_x / herd_m / herd_n kwargs) and locked at compile time — it can't change between calls of the same ELF.

The 4 phases of llama32_1b_inference.py:main

From make run to printed output:

Phase 1: build_session llama32_1b_inference.py:669

One-time setup: create KernelCache instances, compile (or load cached) all ELFs, load model weights from HuggingFace, build the RoPE LUT, call prepare_runtime.

Phase 2: prepare_runtime llama32_1b_inference.py:129

Pre-loads ALL weights for ALL 16 layers into per-layer NPU Buffer Objects (BOs), so subsequent inference calls only need to write activations. This is the single biggest cost-amortization in the pipeline (see B7).

Phase 3: run_once / generate llama32_1b_inference.py:742, 523

Tokenize the prompt → pad to seq_len=2048 (see Part A5) → call run_npu_prefill → enter the decode loop.

Phase 4: decode/print

For instruct models, apply chat template; emit tokens incrementally via the streaming callback in interactive mode.

Make targets Makefile:78-99

# One-time compile (~3 min)
make compile

# Run inference
make run
make run PROMPT="..."

# With profiling breakdown
make profile

# Top-k token-level correctness gate vs HF transformers bf16
make verify

# Per-layer ffn_out cosine vs HF bf16 (informational)
make diagnosis

# Interactive REPL
make chat

B2. The kernel building blocks

Before discussing optimizations (multi-launch ELF stitching, BO management), let's see what the basic units are. The codebase has 7 unique compute kernels that together implement every model op from Part A. Each kernel is one of two implementation patterns:

PatternHow it worksUsed for
MLIR-only (codegen) The Python builder constructs an MLIR module that describes the operation in the linalg / scf / air dialects. aircc + aiecc lower it to AIE-tile instructions through standard linalg-vectorize and AIR placement passes. Peano compiles the resulting per-tile LLVM IR. No hand-written C++. RMSNorm, GEMM, eltwise add
MLIR + external C++ kernel The MLIR module declares func.func private @kernel_name { link_with = "kernel.o" } and calls it from inside an air.herd. The .o is a hand-written C++ kernel compiled separately by Peano (LLVM-AIE). aiecc links the .o into the per-tile ELFs. GEMV, RoPE, SwiGLU, FlashAttention

External C++ is used when a hand-tuned implementation beats codegen — typically for kernels with non-trivial vectorization patterns, double-buffering, or tile-level fused operations (FA's softmax + MMA fusion is the canonical example).

The compile pipeline (one ELF, regardless of pattern)

Python
builder
MLIR
module
aircc
(AIR passes)
aiecc
(AIE passes)
Per-tile
ELFs (Peano)
.elf
+ .insts.bin

For external-C++ kernels, the .o file is compiled by Peano in advance (see kernel_builder/external_kernels.py) and placed in the build directory before aircc runs; aiecc finds it via the link_with attribute when packaging per-tile ELFs.

The whole pipeline is invoked by XRTBackend.compile(mlir_module) inside KernelCache.compile_and_cache — see kernel_builder/cache.py:251. (B3 covers stitching multiple kernels into one ELF; this section is just the per-kernel building blocks.)

The 7 kernels — quick index

KernelPatternMaps to model op (Part A)Source builderExternal C++ (if any)
RMSNormMLIR-onlyRMSNorm (attn-norm, ffn-norm, final-norm)weighted_rms_norm/weighted_rms_norm.py
GEMMMLIR-onlyQ/K/V/O proj, Gate/Up/Down proj (prefill, S=2048)kernel_builder/gemm_builder.py
GEMVMLIR + C++Q/K/V/O proj, Gate/Up/Down proj (decode, S=1); LM Headmatrix_vector_multiplication/bf16/matvec.pymv.ccmv.o + mv_k8192.o
RoPEMLIR + C++RoPE Q, RoPE Krope_lut/rope_lut.pykernel_builder/rope_halfsplit.ccrope.o
SwiGLUMLIR + C++SiLU(gate) ⊙ up — fusedkernel_builder/ffn_swiglu/silu_and_mul.pykernel_builder/ffn_swiglu/silu_and_mul.ccsilu_and_mul.o
FlashAttentionMLIR + C++Scaled dot-product attention (causal, GQA)flash_attention/kernel_fusion_based/attn_npu2_seqfirst.pyflash_attention/kernel_fusion_based/attn_npu2.ccattn.o
Eltwise AddMLIR-onlyResidual add #1, Residual add #2eltwise_add/eltwise_add.py

External-C++ .o compilation is centralized in kernel_builder/external_kernels.py, which uses Peano (LLVM-AIE, found via $PEANO_INSTALL_DIR) with --target=aie2p-none-unknown-elf -O2 -std=c++20. Each function (compile_silu_and_mul, compile_rope, etc.) checks if the .o already exists and skips if so.

B2.1 — RMSNorm

Source builderprogramming_examples/weighted_rms_norm/weighted_rms_norm.py
External C++None — pure MLIR/codegen
Maps to model opRMSNorm (Part A2 op #1, #10; final norm in Part A3)
Production usageInside rms_gemms_rope.elf + o_ffn.elf (prefill); rms_gemv_rope.elf + o_gemv_ffn.elf (decode); the final RMSNorm at the end of inference is computed on CPU instead (single row only — see A7)
NPU compute tile usageherd [8, 1] = 8 of 32 tiles. One column of 8 tiles, each tile reducing across one slice of rows. Same shape used in both prefill and decode (the per-row reduction doesn't benefit from row-direction parallelism beyond the column count).

How it's compiled. The Python builder uses FuncOp.from_py_func + @herd to construct an air.herd that does the per-row reduction (sum-of-squares), then the rsqrt + multiply. There's no external C++ — aircc lowers the linalg/scf/arith ops to AIE-tile vector intrinsics, and Peano then turns the per-tile LLVM IR into AIE2P machine code.

The op: y[i] = x[i] · rsqrt(mean(x[i]², dim=-1) + ε) · γ per row. γ (the learned scale) is a per-feature [H]-shaped weight broadcast across rows. The implementation tiles the row dim across an herd_x-tile-tall herd; each tile reduces and normalizes its rows.

Quirk: the builder produces a bare air.herd (not wrapped in air.launch). When stitched into a multi-launch ELF, the stitching code wraps it in air.launch { air.segment { herd } } via _wrap_ir_in_launch from kernel_builder/stitching.py. (See B5 for why this wrapping is needed.)

B2.2 — GEMM (matrix-matrix multiply, prefill)

Source builderprogramming_examples/llama32_1b/kernel_builder/gemm_builder.py (function _build_gemm_module(m, k, n, ...)) — thin wrapper around the upstream BF16 GEMM
Wrapsprogramming_examples/matrix_multiplication/bf16/run.py (function build_module(m, k, n, tile_m, tile_k_l2, tile_k_l1, tile_n, herd_m, herd_n, np_dtype_in, np_dtype_out, arch, direct_codegen)) — the generic BF16 GEMM module builder shared with the standalone GEMM example
External C++None — codegen via aircc's linalg.matmul lowering
Maps to model opsQ proj, K proj, V proj, O proj, Gate proj, Up proj, Down proj (Part A2 ops #2-#4, #8, #11-#12, #14) — during prefill only, where S=2048 makes a true matrix-matrix GEMM
Production usagerms_gemms_rope.elf contains 3 GEMMs (Q, K, V); o_ffn.elf contains 4 GEMMs (O, Gate, Up, Down)
NPU compute tile usageherd [8, 4] = 32 of 32 tiles. Production sets herd_m=8, herd_n=4 — the herd's M dim (8) parallelizes output-row tiles and the N dim (4) parallelizes output-col tiles. This is the only kernel that uses the full NPU2 compute array. Configured per-GEMM in rms_gemms_rope_multi.py:200-209 and o_ffn_multi.py:182-202.

Relationship to the upstream programming_examples GEMM. There is NOT a separate Llama-specific GEMM kernel. gemm_builder.py is a 30-line wrapper that:

  1. Calls the upstream build_module from programming_examples/matrix_multiplication/bf16/run.py with bfloat16 input AND output, arch="aie2p" (NPU2), and direct_codegen=True. This produces a base MLIR module containing one air.herd wrapping a tiled linalg.matmul.
  2. Applies an extra transform IR script (the ~100-line GEMM_TRANSFORM_IR string in gemm_builder.py) on top of that module. The transform script does additional tiling, herd-vectorization, vector-contract → f32 cast lifting, and several rounds of cast-pair hoisting that move arith.extf / arith.truncf ops out of the innermost loops.

Without the transform-IR step, the GEMM compiles but the inner-loop quality is significantly worse (extra bf16↔f32 conversions per MMA iteration). The transform script is what makes the production GEMM competitive with hand-written kernels — but the actual linalg.matmul tiling structure comes from the shared upstream builder, not from the wrapper.

Tile config (prefill default). The wrapper accepts tile_m, tile_k_l2, tile_k_l1, tile_n, herd_m, herd_n. Production uses different configs per GEMM (smaller L2 tiles for the small Q/K/V/O 2048-emb GEMMs, larger for the wider Gate/Up/Down 8192-D_ff GEMMs). All configs come from multi_launch_builder/rms_gemms_rope_multi.py:200-209 and multi_launch_builder/o_ffn_multi.py:182-202.

Why no external C++. The aircc + aiecc pipeline can lower a tiled linalg.matmul with the right transform IR to the same AIE MMA intrinsic that a hand-written kernel would use. There's no measurable win from hand-rolling the matmul C++.

B2.3 — GEMV (matrix-vector multiply, decode)

Source builderprogramming_examples/matrix_vector_multiplication/bf16/matvec.py (function build_module(M, K, tile_m, m_input, herd_m, ...))
External C++programming_examples/matrix_vector_multiplication/bf16/mv.cc → compiled to mv.o (and mv_k8192.o, see below)
Maps to model opsQ/K/V/O/Gate/Up/Down projections — during decode (S=1 makes it M=1 GEMV); also the LM Head (which is structurally a 1×V GEMV regardless of phase, see A7)
Production usagerms_gemv_rope.elf contains 3 GEMVs (Q, K, V); o_gemv_ffn.elf contains 4 GEMVs (O, Gate, Up, Down); lm_head_gemv.elf is an 8-partition GEMV stitched 8 times
NPU compute tile usageherd [8, 1] = 8 of 32 tiles. Production sets tile_m=8, m_input=4, herd_m=8 — the herd's 8 tiles parallelize the M output dim. With M=1 (S=1 in decode) the GEMV gets ZERO M-direction parallelism within a single tile — the 8 tiles instead each handle a slice of the output rows of the projection. The Down GEMV (K=8192) uses a renamed mv_k8192.o variant with tile_m=2 but the same 8-tile herd shape.

How it's compiled. The MLIR builder constructs an air.launch wrapping an air.herd whose body calls the C++ kernel @matvec_vectorized_bf16_bf16 (declared private with link_with = "mv.o"). The C++ in mv.cc implements a hand-vectorized y = W @ x using AIE bf16 MMA intrinsics. Peano compiles this to a .o file via kernel_builder/external_kernels.py:compile_mv:

def compile_mv(tile_m=8):
    src = _PROJ_ROOT / "matrix_vector_multiplication" / "bf16" / "mv.cc"
    _compile_kernel(src, "mv.o", extra_flags=[f"-DDIM_M_OUTPUT={tile_m}"])

The mv_k8192.o trick. The decode o_gemv_ffn.elf needs TWO GEMV variants in one ELF: K=2048 (for O/Gate/Up/normal slots) and K=8192 (for the Down GEMV). MLIR can't have two private functions with the same name and different signatures — so the same mv.cc source is compiled a SECOND time with renamed entry points via -D macros (see kernel_builder/external_kernels.py:155):

def compile_mv_k8192():
    _compile_kernel(src, "mv_k8192.o", extra_flags=[
        "-DDIM_M_OUTPUT=2",
        "-Dmatvec_vectorized_bf16_bf16=dg_matvec_vectorized_bf16_bf16",  # renamed
        "-Dlinalg_fill_bf16=dg_linalg_fill_bf16",
    ])

The renamed function appears in the merged ELF as a separate symbol, side-by-side with the K=2048 version.

B2.4 — RoPE (Rotary Position Embedding)

Source builderprogramming_examples/rope_lut/rope_lut.py (decode/per-row); for prefill multi_launch_builder/rms_gemms_rope_multi.py:_build_rope_2d wraps it for 2D inputs
External C++programming_examples/llama32_1b/kernel_builder/rope_halfsplit.cc → compiled to rope.o
Maps to model opRoPE Q, RoPE K (Part A2 ops #5, #6)
Production usagerms_gemms_rope.elf + rms_gemv_rope.elf (one RoPE for Q-side, one for K-side per ELF)
NPU compute tile usagePrefill: herd [8, 1] = 8 of 32 tiles (rope_herd_x=8, herd_y=1 in rms_gemms_rope_multi.py; the 8 tiles split the seq dim S=2048 across rows). Decode: herd [1, 1] = 1 of 32 tiles (rope_herd_x=1 in rms_gemv_rope_multi.py; only one row to rotate, so single-tile is sufficient and avoids DMA fan-out overhead).

How it's compiled. The MLIR builder constructs an air.herd that DMA-loads one row of (cos, sin) LUT plus one row of input data into L1, then calls @rope (declared with link_with = "rope.o"). The C++ in rope_halfsplit.cc implements the per-position rotation.

The rope_halfsplit.cc story. Two RoPE conventions exist:

Mixing the two produces wrong outputs. The upstream aie_kernels/aie2p/rope.cc uses the interleaved convention. Llama-3.2-1B needs half-split, so this codebase has its own rope_halfsplit.cc compiled to the same rope.o filename → drop-in replacement, no MLIR changes needed. See kernel_builder/external_kernels.py:119 (compile_rope):

def compile_rope():
    src = Path(__file__).resolve().parent / "rope_halfsplit.cc"   # NOT the upstream rope.cc
    _compile_kernel(src, "rope.o")

The LUT (cos/sin table) is precomputed once per session by generate_rope_lut in llama32_1b_weights.py and passed as a kernel input — not compiled into the kernel.

B2.5 — SwiGLU (silu_and_mul, fused activation)

Source builderprogramming_examples/llama32_1b/kernel_builder/ffn_swiglu/silu_and_mul.py
External C++programming_examples/llama32_1b/kernel_builder/ffn_swiglu/silu_and_mul.cc → compiled to silu_and_mul.o
Maps to model opsSiLU(gate) + elementwise multiply (Part A2 ops #13 — fused into one kernel)
Production usageo_ffn.elf + o_gemv_ffn.elf (one fused SwiGLU step between gate/up GEMMs and down GEMM)
NPU compute tile usageherd [8, 1] = 8 of 32 tiles (swiglu_herd_x=8, swiglu_herd_y=1). The 8 tiles split the elementwise work across the row dim. SiLU+multiply is memory-bound at this scale — adding more tiles wouldn't help because L2/L1 DMA bandwidth is already saturated.

How it's compiled. The MLIR builder constructs an air.herd that takes the gate and up tensors as inputs (each [B, S, D_ff]) and produces one output tensor. The herd body calls @silu_and_mul_bf16 (declared with link_with = "silu_and_mul.o"). The C++ implementation does out[i] = SiLU(gate[i]) · up[i] in a vectorized inner loop using AIE bf16 SiLU + multiply intrinsics — fusing the two ops eliminates one full pass over the 8192-wide tensor (vs. doing SiLU and the multiply as two separate kernels).

Compile (with extra include for utils header): see kernel_builder/external_kernels.py:106 (compile_silu_and_mul):

def compile_silu_and_mul():
    src = _PROJ_ROOT / "llama32_1b" / "kernel_builder" / "ffn_swiglu" / "silu_and_mul.cc"
    include_dir = _get_aie_include_dir()
    utils_header = Path(include_dir) / "aie_kernels" / "aie_kernel_utils.h"
    extra = []
    if utils_header.exists():
        extra = ["-include", str(utils_header)]
    _compile_kernel(src, "silu_and_mul.o", extra_flags=extra)

B2.6 — FlashAttention

Source builderprogramming_examples/flash_attention/kernel_fusion_based/attn_npu2_seqfirst.py (function build_module(lk, lkp, lq, lqp, dk, dv, num_q_tiles, num_cascade_stages, num_heads, num_kv_heads, causal))
External C++programming_examples/flash_attention/kernel_fusion_based/attn_npu2.cc → compiled to attn_npu2.o (also copied to attn.o)
Maps to model opScaled dot-product attention (Part A2 op #7) with causal mask + GQA
Production usageflash_attn.elf — its OWN ELF, never stitched with rms_gemms_rope or o_ffn (un-mergeable, see B5)
NPU compute tile usageCascade design — uses ~16-24 tiles depending on config. Production sets num_q_tiles=4, num_cascade_stages=4, num_heads_per_unroll=2. The kernel uses MULTIPLE air.segments (sized [num_heads_per_unroll, 1]) each containing a herd sizes=[c_nq, c_ns]. Effectively the cascade pipelines Q-tile streaming across stages — different from the single-herd pattern of the other 6 kernels. Decode reuses prefill's flash_attn.elf only for full-prefill recomputation (rare); the per-token decode attention runs on CPU instead.

How it's compiled. Of all 7 kernels, FlashAttention is by far the most complex. The MLIR builder produces a multi-tile cascade of air.herds that stream Q tiles through K/V tiles using air.channels for inter-tile DMA. The actual softmax + MMA fusion is in C++ (attn_npu2.cc), which exposes ~16 functions for the FA tile primitives (Q tile load, K tile load, dot-product, online softmax update, V multiply-accumulate, rescale, etc.).

Many compile-time flags. See kernel_builder/external_kernels.py:130 (compile_attn_npu2):

def compile_attn_npu2(head_dim=64):
    src = _PROJ_ROOT / "flash_attention" / "kernel_fusion_based" / "attn_npu2.cc"
    _compile_kernel(src, "attn_npu2.o", extra_flags=[
        "-DBIT_WIDTH=8",
        f"-Dlqp={head_dim}",        # Q-per-tile
        f"-Dlkp={head_dim}",        # K-per-tile
        f"-Ddk={head_dim}",         # head dim, K side
        f"-Ddk_full={head_dim}",
        f"-Ddv={head_dim}",         # head dim, V side
        f"-Ddv_full={head_dim}",
        "-DAIE_API_EMULATE_BFLOAT16_MMUL_WITH_BFP16",
        "-DROUND_CONV_EVEN",
    ])
    # Some link_with attrs use "attn.o", so make a copy
    shutil.copy2("attn_npu2.o", "attn.o")

Most of these -D flags are head_dim parameters that the C++ uses to size internal tile buffers at compile time. head_dim=64 for Llama-3.2-1B; the same kernel works for Llama-3.2-3B with head_dim=128.

Why this can't go in a multi-launch ELF. The cascade design uses many air.channels and stresses the air-opt-shim-dma-bds compiler pass quadratically. With 9+ launches (i.e., FA + the rms_gemms_rope launches) in one ELF, this pass takes >10 minutes. So FA stays as its own single-launch ELF and is invoked between rms_gemms_rope and o_ffn from the host (see B5). This is the main reason production has 3 NPU calls per layer instead of 1.

B2.7 — Eltwise Add (residual)

Source builderprogramming_examples/eltwise_add/eltwise_add.py; specialized 2D and 2D→1D variants are defined locally in multi_launch_builder/o_ffn_multi.py (_build_add_2d_to_2d, _build_add_2d_to_1d)
External C++None — pure MLIR/codegen
Maps to model opResidual #1 (after attention), Residual #2 (after FFN) (Part A2 ops #9, #15)
Production usageTwo adds inside o_ffn.elf (one for each residual); two analogous adds inside o_gemv_ffn.elf
NPU compute tile usageherd [8, 1] = 8 of 32 tiles. The 8 tiles split the row dim. Pure DMA-bound: the add itself is one cycle per element, so total time = DDR↔L1 transfer time. More tiles wouldn't help.

How it's compiled. The simplest kernel: an air.herd with a tiled elementwise loop, lowered by aircc to the AIE add intrinsic. The 2D and 2D→1D variants exist because the residual outputs may be consumed as flat 1D arrays by the next sub-launch (e.g., the final o_ffn output is 1D n_total = seq*emb); the variant just calls memref.collapse_shape internally to handle the type mismatch.

Quirk: like RMSNorm, the simple add builder produces a bare air.herd; multi-launch stitching wraps it via _wrap_ir_in_launch.

B2.8 — Compile-time helpers and orchestration

Two files coordinate the actual external-C++ compilation:

FileWhat it does
kernel_builder/external_kernels.pyPer-kernel compile_* functions (one per .o) + a compile_all_external_kernels(head_dim) top-level that runs all 5 (silu_and_mul, rope, attn, mv, mv_k8192). Each uses Peano via $PEANO_INSTALL_DIR/bin/clang++. Skips compilation if the .o already exists.
kernel_builder/cache.py:prepare_air_projectCalled from compile_and_cache before each ELF compile. Cleans air_project/, calls compile_all_external_kernels, then copies all .o files into air_project/ where aiecc's link_with search path will find them.

So the flow for compiling one ELF is: prepare_air_project → external C++ .o files exist in air_project/backend.compile(mlir_module) runs aircc + aiecc, which links the .os into the per-tile ELFs → output .elf + .insts.bin are copied into cache_dir/.

Bottom line on the building blocks: 7 unique compute kernels. Three are MLIR-only codegen (RMSNorm, GEMM, eltwise add) and four are MLIR + hand-written C++ linked via Peano-compiled .o files (GEMV, RoPE, SwiGLU, FlashAttention). A single ELF can contain one or many of these — see B5 for stitching.

Tile-mapping summary

Side-by-side view of how each of the 7 kernels maps onto the NPU2 8×4 compute array:

KernelPhaseHerd shapeTilesWhy this shape
RMSNormBoth[8, 1]8Per-row reduction; 8-tile column splits rows
GEMMPrefill[8, 4]32Full 2D output-tile parallelism (M and N)
GEMVDecode[8, 1]8M=1 forces output-row-only parallelism
RoPEPrefill[8, 1]8S=2048 rows split across 8 tiles
RoPEDecode[1, 1]1Only 1 row to rotate; multi-tile would just add fan-out overhead
SwiGLUBoth[8, 1]8Memory-bound; more tiles wouldn't help
Eltwise AddBoth[8, 1]8DMA-bound; 1-cycle add
FlashAttentionPrefillcascade [c_nq, c_ns]~16-24Multi-segment Q-tile cascade pipeline

Observation: only the prefill GEMM uses the entire 32-tile array. Most kernels use 8 tiles (one column) — they are limited by either the reduction structure (RMSNorm) or by DMA bandwidth (SwiGLU, eltwise add). For decode, the loss of M-direction parallelism (M=1) means there is simply no work for the additional column dim, so even GEMV drops to 8 tiles. Implication: the M=1 decode path leaves 24/32 = 75% of the compute array idle on every dispatch, which is one reason the per-token throughput is dispatch-overhead-bound.

B3. From standalone kernels to end-to-end inference — the four gaps

B2 covered each kernel as a standalone unit — what it computes, how it's compiled, and how many tiles it uses. But you cannot just chain those 7 kernels together and get a working 1.27 s prefill. Several practical problems sit between "I have a working RMSNorm kernel" and "I have a 16-layer transformer running on the NPU":

GapProblem if unsolvedSolutionSection
#1 — Layout matching Kernel A's output shape/layout doesn't match what kernel B expects to read. Naive chaining produces wrong values or silently misaligned data. CPU pre-transpose of weights, free MLIR reshapes, deliberate physical KV-cache transpose on the host side, mv_k8192 macro-rename trick. B4
#2 — XRT dispatch overhead Each xrt.run() call has ~100 µs fixed overhead. With 49 kernels per prefill pass × 16 layers, dispatch alone would dominate runtime. Stitch multiple air.launchs into one ELF so 6-8 logical kernels run from a single xrt.run() call. Intermediates flow via DDR, host stays out of the loop. B5
#3 — Per-call BO management Naive flow re-allocates and re-uploads every kernel argument on every call. A 14 MB weight tensor uploaded per kernel call would dominate the ~30 ms-per-call budget. Allocate XRT Buffer Objects once, classify each arg as static (write-once), intermediate (no host transfer at all), or output (host-readable). Skip everything that hasn't changed. B6
#4 — Compile time + per-layer state Each ELF compile takes ~30-50 s. Recompiling on every script start costs 3+ minutes. Also: 16 layers × 6 ELFs × N weights each → which BO holds which layer's weights? KernelCache persists compiled ELFs to disk, caches loaded XRT contexts in process, and maintains per-layer BO sets keyed by bo_key="rms_gemms_rope_L{layer}". B7

Sections B4-B7 cover each gap one at a time. Once they're all in place, the prefill (B8) and decode (B9) detail sections show the four gaps working together on real per-layer code paths. B10 is the final code map.

Why this ordering matters. Each gap solution depends on understanding the previous one: layout decisions (B4) constrain what can be stitched into one ELF (B5); the stitched ELF's input layout determines BO classification (B6); BO classification determines what KernelCache needs to track per layer (B7). Skipping ahead leaves you with isolated tricks; reading in order shows why each was necessary.

B4. Gap #1 — Layout matching between kernels

The 7 building-block kernels were each developed in their own standalone programming_examples demo. Their input/output layouts were chosen for that demo's convenience — not for chaining into a transformer. Several layout mismatches show up the moment you try to feed one kernel's output into another:

Mismatch #1 — Weight matrix orientation (GEMV)

HuggingFace stores Llama weights as (out_features, in_features): e.g. wq has shape (2048, 2048) with the FIRST dim being the output. The standalone GEMV kernel, however, expects A[M, K] with M=output, K=input — but reads A contiguously in K-major order (last dim is the contiguous one). HuggingFace storage is output-major. Naive use → reading the wrong elements per MMA, silent garbage output.

Fix: CPU pre-transpose every decode-side weight matrix once, before any timing starts. Implemented in llama32_1b_inference.py:171-197 inside prepare_runtime:

# Pre-transpose all decode GEMV weights (one-time, before timing)
for lw in weights.layers:
    lw._wq_t   = np.ascontiguousarray(lw.wq.astype(bfloat16).reshape(emb_dim, emb_dim).T)
    lw._wk_t   = np.ascontiguousarray(lw.wk.astype(bfloat16).reshape(emb_dim, kv_dim).T)
    lw._wv_t   = np.ascontiguousarray(lw.wv.astype(bfloat16).reshape(emb_dim, kv_dim).T)
    lw._wo_t   = np.ascontiguousarray(lw.wo.astype(bfloat16).reshape(emb_dim, emb_dim).T)
    lw._wgate_t = np.ascontiguousarray(lw.w_gate.astype(bfloat16).reshape(emb_dim, hidden_dim).T)
    lw._wup_t   = np.ascontiguousarray(lw.w_up.astype(bfloat16).reshape(emb_dim, hidden_dim).T)
    lw._wdown_t = np.ascontiguousarray(lw.w_down.astype(bfloat16).reshape(hidden_dim, emb_dim).T)

The .T + ascontiguousarray physically reorders the weight matrix bytes in DDR so the GEMV kernel reads them in K-major order naturally. This costs ~50 ms per layer × 16 layers ≈ 800 ms ONCE at startup, then never again — the transposed buffers live on as _wq_t, _wk_t, etc. and get uploaded to NPU BOs during weight preload.

Why CPU and not on the NPU? The NPU DMA engine has stride=1 mandatory for sub-32-bit types (it can't do a strided BF16 DMA). Doing the transpose during DMA-in would require shape rearrangement that the DMA hardware refuses. So the transpose lives in numpy on the CPU.

Mismatch #2 — KV cache layout (prefill ↔ FlashAttention ↔ decode)

The same physical KV tensor is touched by three different consumers, each with its own preferred layout:

ConsumerWants layout
RoPE K kernel output (prefill)[seq, n_kv_heads, head_dim] — sequence-major
FlashAttention input (prefill)[seq, n_kv_heads, head_dim] — sequence-major (matches RoPE)
KV cache storage (host)[n_kv_heads, max_seq, head_dim] — head-major (so per-head slicing is contiguous)
Decode CPU attention (per-token reads)[n_kv_heads, current_pos+1, head_dim] — needs head-major for fast per-head dot-products

Solution: the prefill kernels keep the seq-major layout that RoPE produces (so RoPE→FlashAttention has a free zero-cost layout match), and the host transposes once after each layer's prefill output to populate the head-major KV cache. From llama32_1b_inference.py:401-410:

k_cache[layer_idx, :, :seq_len, :] = (
    intermediates["k_roped"]
        .astype(bfloat16)
        .reshape(seq_len, n_kv_heads, head_dim)
        .transpose(1, 0, 2)        # seq-major → head-major
)
v_cache[layer_idx, :, :seq_len, :] = (
    intermediates["v"].astype(bfloat16)
        .reshape(seq_len, n_kv_heads, head_dim)
        .transpose(1, 0, 2)
)

This transpose runs on the CPU (~1 ms per layer) for the same DMA-stride reason as Mismatch #1. The bf16 stride=1 hardware limit means you cannot do a layout transpose during NPU DMA-out; the host has to materialize the head-major view itself. (See BF16 DMA stride limitation note in project docs.)

Mismatch #3 — GEMM output flat shape vs. RoPE multi-head input

Q/K GEMM emits [seq, n_heads * head_dim] as a flat 2D tensor. RoPE expects [seq, n_heads, head_dim] so it can apply the per-(head, dim/2) rotation. This one is FREE — it's a pure shape view, no data movement. The MLIR builder uses memref.expand_shape on the L2 buffer between the GEMM air.launch and the RoPE air.launch inside the same stitched ELF (no DDR round-trip, no DMA reshape). Same trick at the eltwise-add → next-RMSNorm boundary.

Mismatch #4 — FFN flat output for the next layer

o_ffn.elf's final output (after the second residual add) is shaped [seq, emb] as far as the math cares, but the next layer's rms_gemms_rope.elf wants its input as a flat 1D [seq * emb] buffer (because that's how the leading RMSNorm's L2 tile shape was specified). The eltwise-add kernel gained a _build_add_2d_to_1d variant that calls memref.collapse_shape internally so the producer and consumer agree on a flat 1D buffer. See multi_launch_builder/o_ffn_multi.py.

Mismatch #5 — Two GEMV variants in one ELF (K=2048 and K=8192)

The decode o_gemv_ffn.elf contains FOUR GEMVs: O, Gate, Up, and Down. Three of them have K=2048 (the embedding dim); the Down GEMV alone has K=8192 (the FFN hidden dim, accumulating back to embedding). MLIR can't have two private functions with the same name and different signatures in one module.

Solution (from kernel_builder/external_kernels.py:155): compile mv.cc a SECOND time with macro renames, producing a separate symbol for the K=8192 variant:

def compile_mv_k8192():
    _compile_kernel(src, "mv_k8192.o", extra_flags=[
        "-DDIM_M_OUTPUT=2",
        "-Dmatvec_vectorized_bf16_bf16=dg_matvec_vectorized_bf16_bf16",  # renamed
        "-Dlinalg_fill_bf16=dg_linalg_fill_bf16",
    ])

Both .o files end up in air_project/ at link time. The MLIR module references each one by its (renamed) symbol, and the linker happily places both into the same ELF.

Bottom line on layout matching: three of the five mismatches are fixed by FREE MLIR reshapes inside stitched ELFs (zero-cost, no data movement). Two require physical CPU work — both are forced by the AIE DMA's stride=1 limitation on sub-32-bit types, which prevents an NPU-side bf16 transpose. Total CPU layout cost: ~800 ms one-time at startup (weight pre-transpose) plus ~1 ms × 16 layers ≈ 16 ms per prefill pass (KV cache transpose). Both are completely outside the timed prefill loop or rounded into negligible cost.

B5. Gap #2 — Multi-launch ELF stitching

The problem. Each xrt.run() call has fixed dispatch overhead (kernel-handle lookup, host↔device synchronization) of ~100 µs. With 7 kernels per layer × 16 layers = 112 NPU calls per prefill pass, dispatch alone is ~11 ms — small relative to a 1.2 s prefill, but devastating for decode where each kernel does only hundreds of µs of NPU work. For decode, raw dispatch overhead can rival the actual compute time.

The fix. Combine multiple kernels into one ELF that runs in one xrt.run() call. The host issues one dispatch; intermediates flow between sub-kernels via DDR using NPU DMA, with no host involvement. From the host's view, "rms_gemms_rope" looks like one kernel even though it's really 6 stitched air.launchs back-to-back.

The mechanism

An MLIR module can contain multiple air.launch operations inside a single func.func. Each air.launch wraps an air.segment wrapping air.herd(s) — i.e., one logical kernel. When that combined module is compiled to one ELF and invoked by one xrt.run(), the launches execute sequentially and intermediates flow between them via DDR using NPU DMA — without CPU involvement.

The Python builders in multi_launch_builder/*_multi.py do this stitching. They take individual MLIR modules (from B2's per-kernel builders) as text strings and concatenate the function bodies into one combined func, with SSA values renamed to avoid collisions.

The 6 production ELFs (stitched products)

The production code stitches the 7 kernel building blocks from B2 into 6 ELFs:

ELFPhaseStitched kernelsBuilderCompile time
rms_gemms_rope.elfPrefill6: RMSNorm + Q GEMM + K GEMM + V GEMM + RoPE Q + RoPE Kmulti_launch_builder/rms_gemms_rope_multi.py:193~33 s
flash_attn.elfPrefill1: FlashAttentionflash_attention/.../attn_npu2_seqfirst.py~46 s
o_ffn.elfPrefill8: O GEMM + Add + RMSNorm + Gate GEMM + Up GEMM + SwiGLU + Down GEMM + Addmulti_launch_builder/o_ffn_multi.py:178~50 s
rms_gemv_rope.elfDecode6: RMSNorm + Q/K/V GEMV + RoPE Q + RoPE K (GEMV variants)multi_launch_builder/rms_gemv_rope_multi.py:369~3 s
o_gemv_ffn.elfDecode8: O GEMV + Add + RMSNorm + Gate/Up GEMV + SwiGLU + Down GEMV + Add (GEMV variants)multi_launch_builder/o_gemv_ffn_multi.py~7 s
lm_head_gemv.elfBoth8: identical 8-partition GEMV stitched 8 timesmulti_launch_builder/lm_head_gemv_multi.py~13 s

So one prefill layer = 3 NPU calls (rms_gemms_rope + flash_attn + o_ffn) covering 15 sub-launches. Without stitching it would be 15 NPU calls per layer × 16 layers = 240 calls per prefill. With stitching it's 48 calls per prefill (16 × 3).

Why FlashAttention is its own ELF (un-mergeable)

FA's MLIR uses many air.channels for its cascade-of-tiles design. The air-opt-shim-dma-bds compiler pass scales super-linearly with the number of channels in a module. With 9+ stitched launches in one ELF (i.e., FA + the rms_gemms_rope launches), this pass takes >10 minutes — empirically prohibitive. So the production split is: FA stays as a 1-launch ELF, called between the stitched rms_gemms_rope and o_ffn. That's why one prefill layer is 3 NPU calls, not 1.

How stitching works (text-based)

All in kernel_builder/stitching.py as text-manipulation utilities. No MLIR Python API for moving operations between modules — every operation belongs to a Context, and you can't lift a region from one func and graft it into another. Text-based stitching sidesteps this.

The algorithm:

  1. Build each sub-kernel as its own complete MLIR module (using B2's per-kernel builders).
  2. Extract each module's func.func body (just the operations between signature and return).
  3. Rename all SSA values, affine maps, and symbols with a unique prefix to avoid collisions.
  4. Remap the original %argN references to the combined function's arg indices (this is what threads the data flow between launches).
  5. Concatenate all bodies into one combined func, surrounded by combined affine map declarations and external function decls.
  6. Parse the resulting text with mlir.ir.Module.parse(...) to validate.

Concrete example: how rms_gemms_rope is stitched

# multi_launch_builder/rms_gemms_rope_multi.py:466-481 (paraphrased)
bodies, maps_all = [], []
for ir, prefix, arg_map in [
    (rms_ir,    "r",  {0:0, 1:1, 2:2}),       # RMSNorm: x_in, norm_w, normed
    (q_ir,      "q",  {0:2, 1:3, 2:4}),       # Q GEMM: normed (=arg2), wq (=arg3), q (=arg4)
    (k_ir,      "k",  {0:2, 1:5, 2:6}),       # K GEMM: normed, wk (=arg5), k (=arg6)
    (v_ir,      "v",  {0:2, 1:7, 2:8}),       # V GEMM: normed, wv (=arg7), v (=arg8)
    (rope_q_ir, "rq", {0:4, 1:9, 2:11}),      # RoPE Q: q (=arg4), lut_q (=arg9), q_roped (=arg11)
    (rope_k_ir, "rk", {0:6, 1:10, 2:12}),     # RoPE K: k (=arg6), lut_k (=arg10), k_roped (=arg12)
]:
    body = _extract_between_func_and_return(ir)
    maps = _extract_affine_maps(ir)
    body = _rename_all_with_externs(body, prefix, _EXTERN_FUNCS)  # prefix all SSA
    maps = [_rename_all_with_externs(m, prefix, _EXTERN_FUNCS) for m in maps]
    body = _fix_launch_func_args(body, prefix, arg_map)             # remap arg refs
    bodies.append(body)
    maps_all.extend(maps)

# Then assemble: module { #maps... func.func @rms_gemms_rope(13 args) { bodies... return } }

The arg_map values are what enable data flow: {0:2, 1:3, 2:4} for Q GEMM means "the Q GEMM's slot 0 (its activation input) connects to the combined func's slot 2 (which is the RMSNorm output, normed)". Same DDR buffer, no host hop between RMSNorm and Q GEMM.

Stitching helpers in kernel_builder/stitching.py

FunctionWhat it does
_extract_between_func_and_return(mlir)Returns the body of the public func.func — everything between signature and return.
_extract_affine_maps(mlir)Returns the #map0 = ..., #map1 = ... declarations from the module header.
_extract_private_funcs(mlir)Returns func.func private declarations (e.g., external C++ kernel decls like @matvec_vectorized_bf16_bf16).
_rename_all(text, prefix)Renames every SSA value (%arg0%q_arg0), every affine map (#map0#q_map0), every symbol (@herd_0@q_herd_0) — but preserves external kernel function names.
_fix_launch_func_args(text, prefix, arg_map)After rename, fixes air.launch args(...) references to point at the COMBINED func's arg slots, not the per-sub-kernel ones.
_wrap_ir_in_launch(mlir)Some sub-builders (RMSNorm, eltwise add) emit a bare air.herd not wrapped in air.launch. This wraps it in air.launch { air.segment { herd } } — required because airrt-to-npu only sees segment_load ops.
What stitching saves vs. what it doesn't: stitching saves XRT dispatch overhead (one xrt.run vs N) and host orchestration (no host round-trip between launches). It does NOT save DDR traffic — intermediates still go through DDR; the launches just read/write that DDR via NPU DMA without involving the host. Per-optimization contributions differ between decode and prefill scales — see Part D for the open headroom.

Intra-ELF vs inter-ELF intermediate flow — what the production design actually does

This is the easiest place to get confused, so it's worth being explicit. The "stay on NPU" property of stitched intermediates applies only inside one ELF. As soon as you cross from one xrt.run() to another (e.g., rms_gemms_ropeflash_attno_ffn), the intermediates go through the host by default.

BoundaryHow intermediates flowCost per transferWhat "production" does
Intra-ELF
between sub-launches inside one merged ELF (e.g., RMSNorm → Q GEMM inside rms_gemms_rope)
NPU DMA reads from / writes to the same DDR-resident BO. Host is completely uninvolved during the xrt.run(). ~µs (NPU-internal DMA, dominated by L2/L1 fan-out) Always uses NPU-only flow. Marked via intermediate_indices so KernelCache neither host-writes on entry nor host-reads on exit.
Inter-ELF
between two separate xrt.run() calls (e.g., rms_gemms_ropeflash_attn)
By default: producer's output BO → sync(FROM_DEVICE) → host numpy view → next call's memcpy + sync(TO_DEVICE) into a SEPARATE BO. Two cache-coherent transfers + a memcpy per intermediate. ~µs/MB at PCIe-equivalent bandwidth; per prefill layer the inter-ELF traffic adds up to ~40 MB round-trip Production uses the host-broker pattern even though BO aliasing is technically possible (the alternative has been validated in development). See D2 for why production accepts this and what it would take to remove.

Concrete prefill numbers per pass (16 layers × 3 ELF dispatches per layer):

WherePer layerPer pass (16 layers)
Inside rms_gemms_rope (6 launches stitched)0 host transport (5 NPU-only handoffs)0
rms_gemms_ropeflash_attn (Q + K + V, host-broker)~12 MB ↓↑ (Q=8 MB, K=2 MB, V=2 MB)~192 MB
flash_attno_ffn (attn_out, host-broker)~8 MB ↓↑~128 MB
Inside o_ffn (8 launches stitched)0 host transport (7 NPU-only handoffs)0
K, V to KV cache (host transpose, B4)~4 MB ↓ each, plus CPU transpose~64 MB ↓ + ~16 ms CPU
Total inter-ELF host↔device traffic per pass~640 MB round-trip

At ~20 GB/s of host↔device bandwidth, ~640 MB ≈ ~32 ms ≈ 3% of the 1.13 s prefill. Decode is much smaller because per-token intermediates are KB-scale: ~10 KB per inter-ELF transfer × 33 NPU calls per token = a few MB, well under measurement noise. So inter-ELF host-broker is a real prefill cost, but tiny in decode.

So what's the design trade-off? Inter-ELF BO aliasing IS technically feasible (validated in development). Production chose the host-broker pattern for code simplicity — managing a cross-ELF BO graph + the MLIR shape conversions + lifetime tracking is non-trivial. The small prefill speedup is left on the table as known optimization headroom; see D2 in the Future work section.

B6. Gap #3 — Anatomy of one NPU call (BOs and host↔device data flow)

The problem. A stitched ELF (B5) hides 6-8 sub-launches behind one xrt.run(). But that single call still has to: get every input from host RAM into NPU-accessible DDR, hand the kernel handles to those buffers, run the kernel, and read outputs back. Done naively, every call would re-allocate buffers and re-upload weights — for a 14 MB wq tensor, that's ~5 ms of PCIe traffic per call, or ~80 ms × 16 layers = 1.3 s extra per prefill pass. The kernel finishes in tens of milliseconds; we cannot afford 5+ ms of host overhead per call.

This section explains what happens during ONE xrt.run() at the BO (Buffer Object) level — the unit of memory the NPU can read and write. Once you understand this anatomy, the per-layer BO trick in B7 (KernelCache) is straightforward.

What is a Buffer Object (BO)?

A BO is an XRT abstraction for a chunk of NPU-accessible memory. Physically it lives in DDR — the same RAM the host uses, but with a NPU-readable mapping. Created by xrt.bo(device, size_bytes, ...). Two operations matter:

OpCostWhat it does
bo.map()~freeReturns a host pointer you can memcpy into. Host writes go to RAM directly.
bo.sync(TO_DEVICE)~µs/MB (cache flush)Flush host CPU caches so the NPU sees the up-to-date bytes when it DMAs from DDR.
bo.sync(FROM_DEVICE)~µs/MB (cache invalidate)Invalidate host CPU caches so the host sees the up-to-date bytes the NPU wrote.

The kernel doesn't get bytes — it gets a list of BOs (one per func.func argument), and the kernel's compiled code uses NPU DMA to stream chunks of those BOs into per-tile L1 / L2 SRAM as it runs.

The five steps of one xrt.run()

StepWhat happensCost (typical)
1. Resolve XRT contextLook up the loaded xclbin for this kernel name; get the device handle and kernel symbol.~µs (cached)
2. Resolve BO listLook up or allocate the BO array for this bo_key. One BO per kernel argument.~µs (cached) or ~ms (first allocation)
3. Write inputsFor each non-static, non-intermediate input: memcpy(bo.map(), input_array) + bo.sync(TO_DEVICE). Static slots (weights) and intermediate slots (kernel-overwritten) are SKIPPED on every call after the first.~µs/MB per slot actually written
4. Submit kernelinvoker.run(*bos) — XRT enqueues the kernel and the call blocks until completion.~100 µs dispatch overhead + actual NPU compute time
5. Read outputsFor each slot in output_indices: bo.sync(FROM_DEVICE) + return a numpy view onto bo.map(). Other slots get a 0-length placeholder.~µs/MB per output

The three index sets — the per-call control knobs

Every load_and_run call (B7) accepts three optional sets that control which slots get host↔device data movement:

SetMeaningEffect
output_indicesSlots the caller wants to read back to host (e.g., q_roped, k_roped).Triggers sync(FROM_DEVICE) for those slots only. Other slots get a 0-length placeholder in the return tuple.
static_input_indicesSlots holding weights/LUTs that are pre-loaded once and never change (e.g., wq, norm_w, RoPE LUT).Skipped by the host write loop on every call after the first. Combined with bo_key, lets per-layer weights persist on device across calls.
intermediate_indicesSlots the kernel will OVERWRITE — entry contents don't matter (e.g., the normed output of RMSNorm that the next launch reads).Skipped by the host write loop on every call after the first. Saves a memcpy + sync for buffers the host never needs to read or initialize.

These sets are what makes per-call cost go from "upload everything" (~ms) to "upload only the new activation" (~µs).

What ONE prefill kernel call actually does (concrete: rms_gemms_rope, layer 5, mid-prefill)

# Argument layout for rms_gemms_rope (13 slots, see B5/B7 for full list):
#   0: x_in           ← layer activation, CHANGES every call
#   1: norm_w         ← layer 5's RMSNorm weight, STATIC
#   2: normed         ← intermediate (RMSNorm → GEMM)
#   3: wq             ← layer 5's Q weight (~14 MB), STATIC
#   4: q              ← intermediate (GEMM → RoPE)
#   5: wk             ← layer 5's K weight (~3.5 MB), STATIC
#   6: k              ← intermediate
#   7: wv             ← layer 5's V weight (~3.5 MB), STATIC
#   8: v              ← intermediate
#   9: rope_lut_q     ← STATIC (LUT)
#  10: rope_lut_k     ← STATIC
#  11: q_roped        ← intermediate, but caller wants to READ it (output_index)
#  12: k_roped        ← intermediate, but caller wants to READ it (output_index)

cache.load_and_run(
    "rms_gemms_rope", RGR_BACKEND,
    x_in_bf16,                              # slot 0 (only this gets written)
    lw.attn_norm,    np.zeros(...),       # slots 1, 2
    lw.wq,           np.zeros(...),       # slots 3, 4
    lw.wk,           np.zeros(...),       # slots 5, 6
    lw.wv,           np.zeros(...),       # slots 7, 8
    rope_lut_q, rope_lut_k,                 # slots 9, 10
    np.zeros(...), np.zeros(...),       # slots 11, 12 (output buffers)
    output_indices=[11, 12],
    static_input_indices={1, 3, 5, 7, 9, 10},
    intermediate_indices={2, 4, 6, 8, 11, 12},
    bo_key=f"rms_gemms_rope_L5",         # this layer's BO set
)

Per-call work: ONE memcpy (slot 0, ~8 KB) + ONE sync(TO_DEVICE) + run + TWO sync(FROM_DEVICE) (slots 11, 12). All 21 MB of weights stay resident on the NPU's BOs — the host doesn't touch them. Without static_input_indices + bo_key, the same call would memcpy and sync ~21 MB of weights every single time.

Bottom line on the per-call anatomy: the BO model lets you separate "what data does the NPU need" from "what does the host need to send THIS call". The three index sets (output / static / intermediate) plus the bo_key are the entire vocabulary for that separation. Whoever owns the load_and_run contract (B7) gets to make every call cheap — even the kernel-call burst inside a tight per-token decode loop.

One important scope note: BOs are per-call, not shared across calls

Each load_and_run call resolves its own BO list via bo_key. Two different kernels (or two calls with different bo_keys) get independent BOs even if they conceptually pass the same intermediate. So:

Production uses (1) for cross-kernel-group transfers — see the per-pass cost breakdown in B5 "Intra-ELF vs inter-ELF intermediate flow". Path (2) is the optimization tracked in D2 (Future work).

B7. Gap #4 — KernelCache: compile-once, per-layer BO sets

The problem. Two costs would otherwise dominate every script start AND every kernel call:

  1. Compile time. Compiling all 6 production ELFs takes ~3 minutes (B5 table). Recompiling on every python llama32_1b_inference.py run is unworkable.
  2. BO management state. 16 layers × 6 ELFs × ~6 weight slots ≈ ~600 weight BOs holding ~1 GB of pre-uploaded weights need to stay alive and be addressable. Naively re-allocating per call would also dominate.

KernelCache (in kernel_builder/cache.py:183) is the single class that solves both. It's the bridge between the per-call BO anatomy (B6) and the realities of running a 16-layer transformer.

Three layers of caching

LayerWhat's cachedLifetimeKey
1. Disk artifactCompiled .elf + .insts.bin + kernel symbol namePersistent (until make clean)name (e.g. "rms_gemms_rope")
2. XRT contextLoaded XRT device + xclbin + kernel handleProcess lifetimename
3. Buffer ObjectsAllocated xrt.bo objects (one per kernel arg)Process lifetimebo_key (defaults to name; overridden per layer)

Layer 1 saves the 3-minute compile. Layer 2 saves the ~100 ms xclbin reload per kernel call. Layer 3 (combined with static_input_indices from B6) saves the per-call weight upload.

Class signature and state

class KernelCache:
    def __init__(self, cache_dir=None, verbose=False, profiler=None):
        self.cache_dir = Path(cache_dir)         # where .elf files persist on disk
        self.profiler = profiler or Profiler()
        self.artifacts = {}      # Layer 1: name → XRTCompileArtifact (paths + symbol)
        self._loaded = {}        # Layer 2: name → (backend, invoker) — XRT handles
        self._cached_bos = {}    # Layer 3: bo_key → list[xrt.bo] — per-session BOs

The two methods

compile_and_cache(name, mlir_module, backend_kwargs) — called ONCE per ELF

# kernel_builder/cache.py:251 (paraphrased)
def compile_and_cache(self, name, mlir_module, backend_kwargs, output_binary_name="air"):
    prepare_air_project()                          # clear air_project/ + compile .o files
    backend = XRTBackend(**backend_kwargs)
    artifact = backend.compile(mlir_module, ...)   # aircc → aiecc → .elf (the slow step)

    cached_binary = self.cache_dir / f"{name}{ext}"
    shutil.copy2(artifact.output_binary, cached_binary)

    self.artifacts[name] = XRTCompileArtifact(str(cached_binary), artifact.kernel, cached_insts)
    backend.unload()

Records name → cached_binary_path in self.artifacts. _save_manifest() writes the dict to cache_dir/manifest.json so a subsequent run with --run-only skips compilation entirely via load_manifest(). This is the difference between a 3-minute startup and a 5-second startup.

load_and_run(name, backend_kwargs, *inputs, ...) — called dozens of times per inference

This is the implementation of the per-NPU-call anatomy from B6. Annotated:

# kernel_builder/cache.py:294 (paraphrased — the contract)
def load_and_run(self, name, backend_kwargs, *inputs,
                 output_indices=None,
                 static_input_indices=None,
                 intermediate_indices=None,
                 bo_key=None):

    # 1. Lookup or load XRT context for this kernel name (Layer 2)
    if name not in self._loaded:
        backend = XRTBackend(**backend_kwargs)
        backend.load(self.artifacts[name])
        self._loaded[name] = (backend, backend.invoker)

    # 2. Lookup or allocate BO list for this bo_key (Layer 3)
    bo_key = bo_key or name             # default: shared BOs per kernel
    if bo_key not in self._cached_bos:
        bos = [allocate_bo(arr.nbytes) for arr in inputs]
        self._cached_bos[bo_key] = bos
        first_call = True
    else:
        bos = self._cached_bos[bo_key]
        first_call = False

    # 3. Write inputs (skipping static + intermediate after first call)
    static = static_input_indices or set()
    intermediate = intermediate_indices or set()
    skip = (static | intermediate) if not first_call else set()

    for i, arr in enumerate(inputs):
        if i in skip:
            continue                       # BO already has the right data
        memcpy(bos[i].map(), arr)
        bos[i].sync(TO_DEVICE)              # host → DDR

    # 4. Run the kernel
    invoker.run(*bos)

    # 5. Read back only the requested outputs
    output_indices = output_indices or [len(inputs) - 1]
    results = []
    for i, arr in enumerate(inputs):
        if i in output_indices:
            bos[i].sync(FROM_DEVICE)         # DDR → host
            results.append(np_view(bos[i].map(), arr.shape, arr.dtype))
        else:
            results.append(np.empty(0, dtype=arr.dtype))   # placeholder
    return tuple(results)
Two crucial properties of this contract:
  1. Return tuple has length len(inputs), not len(output_indices). Slots not in output_indices get an empty placeholder. Callers index by original arg position: out[2], out[14], etc.
  2. static_input_indices and intermediate_indices only kick in after the first call for a given bo_key. The first call must write everything (the BOs have garbage). The pre-load pattern in prepare_runtime exists specifically to make the first call happen during init, not during timed inference.

The bo_key trick — per-layer weight BOs

The single most consequential decision in the whole codebase. In plain language: give each of the 16 transformer layers its own independent set of NPU BOs, pre-load every layer's weights once at startup, then never re-upload weights again during inference.

Why the default is too slow

bo_key defaults to the kernel name (e.g. "rms_gemms_rope") — meaning ALL 16 layers share ONE set of BOs. With 6 weight slots in rms_gemms_rope totaling ~21 MB, the per-layer behavior would be:

That's pure host overhead with zero NPU benefit. For decode, the per-token version of the same problem dominates the entire decode loop.

The trick: encode layer index in bo_key

Override bo_key to f"rms_gemms_rope_L{layer_idx}" so each layer gets its own slot in self._cached_bos. After the one-time preload, _cached_bos looks like this:

# Conceptual view of the cache state after preload
self._cached_bos = {
    "rms_gemms_rope_L0":  [bo_x, bo_norm0,  bo_normed, bo_wq0,  bo_q, ...],   # Layer 0's weights pre-uploaded
    "rms_gemms_rope_L1":  [bo_x, bo_norm1,  bo_normed, bo_wq1,  bo_q, ...],   # Layer 1's weights pre-uploaded
    "rms_gemms_rope_L2":  [bo_x, bo_norm2,  bo_normed, bo_wq2,  bo_q, ...],   # ...
    ...
    "rms_gemms_rope_L15": [bo_x, bo_norm15, bo_normed, bo_wq15, bo_q, ...],
    "o_ffn_L0": [...],   # Same pattern for the other prefill ELF
    ...
}

16 layers × independent BO sets, each holding its own layer's weights resident on the NPU. Now the per-call code:

# preload_prefill_weights — runs ONCE before timing starts
for layer_idx in range(16):
    cache.load_and_run(
        "rms_gemms_rope", RGR_BACKEND,
        np.zeros(...),                                    # slot 0: x_in placeholder
        weights.layers[layer_idx].attn_norm,                  # slot 1
        np.zeros(...),                                    # slot 2
        weights.layers[layer_idx].wq,                         # slot 3 (~14 MB)
        ...                                                   # slots 4-12
        bo_key=f"rms_gemms_rope_L{layer_idx}",             # UNIQUE per layer
    )
# After this loop: 16 separate BO sets are cached, each with its layer's weights uploaded.

# During TIMED inference, exact same call shape but with the real activation in slot 0:
for layer_idx in range(16):
    out = cache.load_and_run(
        "rms_gemms_rope", RGR_BACKEND,
        x_bf16,                                               # slot 0: actual activation
        ...                                                   # slots 1-12 (just placeholders, BOs already have weights)
        static_input_indices={1, 3, 5, 7, 9, 10},  # skip weight write
        intermediate_indices={2, 4, 6, 8, 11, 12},
        bo_key=f"rms_gemms_rope_L{layer_idx}",             # picks layer's pre-loaded BOs
    )

Now the timed call uploads ONLY the activation (slot 0, ~8 KB), even though there are 13 args. The 12 weight/intermediate slots are skipped because (static | intermediate) covers them and the BO list lookup hit the cached entry for that layer's bo_key. Internal measurements indicate this single optimization is the dominant per-token speedup contributor in decode.

Two mechanisms work together: bo_key decides which set of BOs to look up; static_input_indices decides which slots in that set don't need to be re-written. Either alone wouldn't work — without per-layer keys, every layer overwrites every other layer's weights; without the static-skip flag, KernelCache would dutifully re-memcpy every weight slot every call even though the contents are already correct.

Trade-off: memory for speed

This is fundamentally a trade memory for speed design. Concrete numbers:

CostDefault (shared bo_key)Per-layer bo_key
NPU-resident BO memory~120 MB (one set per ELF × 6 ELFs)~1.0 GB (16 layers × 6 ELFs)
Host→device upload per prefill pass~336 MB (16 × 21 MB rewrites)~128 KB (just activations)
One-time preload cost0~200-300 ms (once at startup)

~1 GB of pinned BO memory is acceptable for a 1.24 B-parameter model on a system with 16+ GB of RAM. If memory were tight, you could fall back to shared bo_key and accept the per-call upload cost — the contract would still work, just slower.

Subtle point: aren't CPU and NPU sharing the same DDR?

Yes — NPU2 (Strix) is a unified-memory architecture, so the NPU and CPU share the same physical DDR. So why is there still a memcpy + memory duplication?

Because "shared DDR" doesn't mean "shared allocation". A normal numpy array and an XRT BO live in the same DDR but in different memory regions with different attributes:

Buffer kindAllocatorAttributesWho can read it?
numpy weight arrayPython / glibc mallocPageable, virtual, CPU-cachedCPU only
XRT Buffer Objectxrt.bo(device, size)Physically contiguous, pinned (non-pageable), specific cache attributes, mapped into BOTH CPU and NPU virtual address spacesCPU and NPU

The NPU's DMA engine can ONLY access physically-contiguous, pinned memory — it can't read a random pageable numpy buffer (which is virtually contiguous but physically scattered, and may be swapped out at any moment). So a BO is a special chunk of DDR, requested separately and held alive for the BO's lifetime.

That means the data flow is genuinely:

  1. Weight loaded by HuggingFace → numpy array in pageable RAM (one copy, ~14 MB for wq)
  2. Preload calls memcpy(bo.map(), weight_array) → physical byte copy into the BO's pinned region (~3 ms for 14 MB)
  3. bo.sync(TO_DEVICE) → flushes CPU L1/L2/L3 caches so the NPU's DMA reads the up-to-date DDR contents (NOT a copy — pure cache management)
  4. NPU runs; reads the BO via DMA; writes outputs back
  5. For outputs: bo.sync(FROM_DEVICE) → invalidates CPU caches so a subsequent host read sees what the NPU wrote

So yes — even with shared DDR, the production codebase keeps two physical copies of each weight (the numpy array + the BO), and the preload step really does memcpy them. ~1 GB extra memory + ~200-300 ms one-time preload is the price.

Could it be zero-copy? In principle yes — you could allocate the BO first and then construct a numpy view via np.frombuffer(bo.map(), ...), so the safetensors loader writes directly into the pinned region. The codebase doesn't do this for two reasons:

So the codebase trades the simplicity of standard numpy for a small one-time memory + memcpy cost. "Unified memory" eliminates cross-PCIe DMA (which discrete GPUs suffer); it doesn't eliminate the pinned-vs-pageable distinction or the cache-coherency flush.

Bottom line on KernelCache: three caches with three lifetimes (disk / process / process), one method (load_and_run) implementing the B6 anatomy with the index-set contract, and one trick (bo_key=f"name_L{layer_idx}") that turns "16 layers × ~50 MB of weights to upload per call" into "0 weight uploads per call after preload". The trade is ~1 GB of pinned BO memory for ~hundreds of ms saved per inference. Without this class, the codebase wouldn't be 1.27 s prefill — it would be tens of seconds.

B8. Prefill in NPU detail — putting all four gaps together

Per-layer kernel sequence — 3 NPU calls

Layer N (prefill)

NPU 1
rms_gemms_rope.elf — 6 stitched launches: RMSNorm(x) → Q/K/V projections → RoPE on Q and K. Reads x_in (seq, 2048); writes q_roped (seq, 2048), k_roped (seq, 512), v (seq, 512). Realizes Part A2 ops 1-6.
cache.load_and_run("rms_gemms_rope", ...)
NPU 2
flash_attn.elf — 1 launch: causal GQA flash attention. Reads q_roped, k_roped, v; writes attn_out (seq, 2048). Also extracts k_cache, v_cache for decode. Realizes Part A2 op 7.
cache.load_and_run("flash_attn", ...)
NPU 3
o_ffn.elf — 8 stitched launches: O projection → residual add → RMSNorm → Gate/Up GEMMs → SwiGLU → Down GEMM → second residual add. Reads attn_out, x_residual; writes the layer output. Realizes Part A2 ops 8-15.
cache.load_and_run("o_ffn", ...)

After all 16 layers: CPU RMSNorm on the last token's hidden state (Part A5), then lm_head_gemv.elf (8 partitions, 1 NPU call) → argmax → first generated token.

Tile usage: rms_gemms_rope's GEMMs use the full [8,4] = 32-tile array; its RMSNorm + RoPE use [8,1] = 8 tiles. flash_attn uses a multi-segment cascade ~16-24 tiles. o_ffn's GEMMs use [8,4] = 32 tiles; its add/RMSNorm/SwiGLU use [8,1] = 8 tiles. See B2.8 tile-mapping summary for the full table.

Code walk: run_npu_prefill

# llama32_1b_inference.py:341 — main prefill entry
def run_npu_prefill(token_ids, weights, config, prefill_cache, decode_cache,
                    rope_lut_bf16, max_seq, tokenizer, ...):
    seq_len = len(token_ids)                # 2048

    # Pre-allocate KV cache (16 layers × 8 KV heads × 2048 × 64), see Part A4
    k_cache = np.zeros((config.n_layers, n_kv_heads, max_seq, head_dim), dtype=bfloat16)
    v_cache = np.zeros((config.n_layers, n_kv_heads, max_seq, head_dim), dtype=bfloat16)

    # Token embedding (host-side numpy lookup)
    x_bf16 = weights.embed_table[token_ids].astype(bfloat16)

    # --- TIMED SECTION START ---
    for layer_idx in range(config.n_layers):           # 16 layers
        x_bf16, intermediates = run_transformer_block(
            x_bf16, weights.layers[layer_idx], rope_lut_bf16,
            config, prefill_cache, layer_idx=layer_idx, ...
        )
        # Extract KV cache from this layer's intermediates (see Part A4)
        k_cache[layer_idx, :, :seq_len, :] = intermediates["k_roped"]...
        v_cache[layer_idx, :, :seq_len, :] = intermediates["v"]...

    # Find last real token (see Part A5 padding)
    prompt_len = len([t for t in token_ids if t != tokenizer.eos_token_id])
    pred_pos = prompt_len - 1

    # Final RMSNorm + LM Head — only the last real-token row
    last_normed = _rms_norm(x_bf16[pred_pos:pred_pos+1], weights.final_norm)

    # NPU LM Head GEMV — reuse decode-cache 8-partition GEMV ELF
    results = decode_cache.load_and_run("lm_head_gemv", LM_GEMV_BACKEND, ...)
    logits_row = np.concatenate(results, axis=0)[:vocab_size]
    prefill_token = int(np.argmax(logits_row))

    return prefill_token, k_cache, v_cache, prompt_len

How weights flow into the kernel: prefill preload

Before any timing starts, preload_prefill_weights writes ALL 16 layers' weights into per-layer NPU BOs:

# llama32_1b_prefill.py — preload_prefill_weights (paraphrased)
def preload_prefill_weights(weights, config, cache, seq_len, rope_lut):
    for layer_idx in range(config.n_layers):              # 16 layers
        lw = weights.layers[layer_idx]
        cache.load_and_run(
            "rms_gemms_rope", RMS_GEMMS_ROPE_BACKEND,
            np.zeros((seq_len, emb_dim), dtype=bfloat16),  # slot 0: x_in (placeholder)
            lw.attn_norm.astype(bfloat16),                 # slot 1: norm_w (STATIC)
            np.zeros((seq_len, emb_dim), dtype=bfloat16),  # slot 2: normed (intermediate)
            lw.wq.astype(bfloat16),                        # slot 3: wq (STATIC)
            # ... 9 more args (intermediates + weights + LUTs)
            output_indices=[11, 12],                   # read q_roped, k_roped back
            static_input_indices={1, 3, 5, 7, 9, 10},  # weights/LUTs: written once
            intermediate_indices={2, 4, 6, 8, 11, 12},  # overwritten by kernel
            bo_key=f"rms_gemms_rope_L{layer_idx}",        # per-layer BO set
        )
        # Same pattern for o_ffn ELF — 16 different BO sets, one per layer
The bo_key trick (this is what "per-layer weight BOs" means): KernelCache caches BO objects keyed by bo_key. By using f"rms_gemms_rope_L{layer_idx}", each layer gets its OWN set of NPU BOs. The weights for layer 5 stay in layer 5's BOs and are never overwritten by layer 6. During inference, the timed call uses the same bo_key, so the per-layer weights are already on device — only the x_in activation needs to be host-uploaded.

B9. Decode in NPU detail — putting it all together for per-token generation

Per-token, per-layer kernel sequence

Decode works on one token at a time. Per token, per layer, it makes 3 calls (2 NPU + 1 CPU):

Token T, Layer N (decode)

NPU 1
rms_gemv_rope.elf — 6 stitched launches: RMSNorm(x_decode) → Q/K/V GEMVs (each W·x for the single token) → RoPE Q/K. Reads single-token x_in (2048,); writes single-token q_roped (2048,), k_roped (512,), v (512,).
cache.load_and_run("rms_gemv_rope", ...)
CPU
decode_attention_cpu — Single-query GQA attention against the cumulative KV cache (positions 0 to current_pos). Updates KV cache with new k_roped, v. Why CPU? At head_dim=64 the NPU FA path has overhead; CPU is cheap for single-query.
llama32_1b_decode.py:96
NPU 2
o_gemv_ffn.elf — 8 stitched launches: O GEMV → residual add → RMSNorm → Gate/Up GEMVs → SwiGLU → Down GEMV → second residual add. Output feeds next layer's x_decode.
cache.load_and_run("o_gemv_ffn", ...)

After all 16 layers (per token): CPU RMSNorm on the resulting hidden state, then lm_head_gemv.elf → argmax → next token.

Tile usage: EVERY decode kernel uses ≤ 8 tiles (one column of the 8×4 array): the GEMVs are [8,1], RMSNorm + SwiGLU + add are [8,1], and RoPE drops to [1,1] (only one row to rotate). The decode path leaves at least 24/32 = 75% of the compute array idle on every NPU dispatch — one reason decode is dispatch-overhead-bound (the large per-token speedup we achieved comes from removing dispatch overhead, not from doing more compute).

Code walk: the decode loop

# llama32_1b_inference.py:585 — the decode loop inside generate()
for token_idx in range(n_tokens):
    t_token_start = time.perf_counter()

    x = x_decode.copy()                              # single-token activation (emb_dim,)
    for layer_idx in range(config.n_layers):       # 16 layers
        x = run_decode_block(
            x, weights.layers[layer_idx], decode_cache, config,
            k_cache[layer_idx], v_cache[layer_idx],     # growing each iter
            current_pos, rope_lut_bf16,
        )

    # Final RMSNorm (CPU, <1ms for 2048 elements)
    x_normed = rms_norm(x.astype(np.float32).reshape(1, emb_dim),
                       weights.final_norm.astype(np.float32))

    # LM Head — NPU 8-partition GEMV (single XRT call, 8 launches in one ELF)
    x_lm = x_normed.flatten().astype(bfloat16)
    lm_inputs = [x_lm]                                # slot 0: shared input
    for p in range(_LM_N_PARTITIONS):                # 8 partitions
        lm_inputs.append(weights._lm_weight_parts_gemv[p])  # weight
        lm_inputs.append(np.zeros(_LM_N_PART, dtype=bfloat16))  # output buffer

    lm_results = decode_cache.load_and_run(
        "lm_head_gemv", LM_GEMV_BACKEND, *lm_inputs,
        output_indices=[2 + 2*p for p in range(8)],   # 8 outputs
        static_input_indices={1 + 2*p for p in range(8)},  # weights static
        intermediate_indices={2 + 2*p for p in range(8)},  # skip output writes
    )

    # Concatenate 8 partition outputs into one logits array, argmax
    logits = _assemble_logits(lm_results, vocab_size)
    next_token = int(np.argmax(logits[0]))
    generated_tokens.append(next_token)
    x_decode = weights.embed_table[next_token].astype(bfloat16)
    current_pos += 1

    if next_token in (tokenizer.eos_token_id, 128009):  # <|eot_id|>
        break
Why decode uses CPU attention instead of NPU FA: the production NPU FlashAttention kernel was designed for prefill's seq=2048 batch and has overhead for single-query workloads at head_dim=64. CPU attention is faster for the small single-query case. This is documented in profile.md as a known limitation; an NPU decode FA was added for the larger Llama-3B variant (head_dim=128) but isn't used here.

B10. Code map — where everything lives

Reference section: a top-down map of every file involved in the production runtime, useful for grepping or for finding the right entry point.

Top-level Python files programming_examples/llama32_1b/

FileLinesPurpose
llama32_1b_inference.py975Main entry point. Unified prefill + decode pipeline. main() at the bottom.
llama32_1b_prefill.py514Standalone prefill (with profiler report). compile_all_kernels, run_transformer_block, preload_prefill_weights.
llama32_1b_decode.py286Standalone decode. compile_decode_kernels, run_decode_block, decode_attention_cpu.
llama32_1b_weights.py522HuggingFace safetensors loader. LlamaConfig, LayerWeights, LlamaWeights, load_weights, synthetic_weights, generate_rope_lut.
llama32_1b_cpu_helpers.py~90Small NumPy helpers shared by production + verify: rms_norm (LM-head GEMV final norm), attention_reference (prefill cpu_attn=True fallback), softmax (used by attention_reference). The file used to host a full F32 forward pass + standalone --verify CLI; both became redundant once the verify subsystem started comparing directly against HF transformers bf16.
verify/End-to-end verification subsystem. verify_runner.py orchestrates the top-k token gate (make verify) and the diagnosis lens (make diagnosis). See VERIFICATION.html.
Makefile112Convenience targets: compile, run, profile, chat, verify, diagnosis, clean.

Shared infrastructure kernel_builder/

FileLinesPurpose
cache.py453The KernelCache class. Manages compile, cache, load, run, and BO reuse for all kernels. See B7.
stitching.py206Text-based MLIR stitching utilities for assembling multi-launch ELFs. See B5.
gemm_builder.py137Wraps the upstream matrix_multiplication/bf16/run.py:build_module + applies an additional MLIR transform IR script for prefill GEMMs. See B2.2.
external_kernels.py180Compiles all C++ .o kernel files via Peano (rope, silu_and_mul, mv, mv_k8192, attn).
backend_presets.py65All *_BACKEND kwarg dicts (RGR_BACKEND, OGF_BACKEND, etc.) — XRTBackend init params per kernel.
rope_halfsplit.cc~100Custom RoPE C++ kernel matching HuggingFace's half-split convention.

Multi-launch builders multi_launch_builder/

FilePhaseLaunchesBuilds
rms_gemms_rope_multi.pyPrefill6RMSNorm + Q/K/V GEMM + RoPE Q + RoPE K (Part A2 ops 1-6)
o_ffn_multi.pyPrefill8O GEMM + Add + RMSNorm + Gate/Up GEMM + SiLU×mul + Down GEMM + Add (Part A2 ops 8-15)
rms_gemv_rope_multi.pyDecode6RMSNorm(1D) + Q/K/V GEMV + RoPE Q + RoPE K — single-token version
o_gemv_ffn_multi.pyDecode8GEMV variants of o_ffn — single-token version
lm_head_gemv_multi.pyBoth88-partition vocab GEMV (16384 outputs each)

Other directories

PathPurpose
standalone_kernels/K1..K10/Individual chunk-level kernels for debug; not used by production runtime.
ffn_swiglu/silu_and_mul.ccCustom SwiGLU C++ kernel.
docs/Documentation: profile.md, explain.md, usage.md, plus HTML walkthroughs in docs/detail/.

How model concepts (Part A) map to NPU code (Part B)

Model conceptNPU realizationFile:Function
One transformer block (14 ops)3 NPU calls per layer (rms_gemms_rope + flash_attn + o_ffn)llama32_1b_prefill.py:run_transformer_block
14 ops within a blockStitched into 6+1+8 = 15 sub-launches across 3 ELFs (B5)The multi_launch_builder/*_multi.py files
Token embedding lookupnumpy fancy-indexing on hostllama32_1b_inference.py:373 (embed_table[token_ids])
Final RMSNormHost CPU (1 row only — only the prediction row matters)llama32_1b_inference.py:425-430
LM HeadNPU 8-partition GEMV (1 ELF, 8 launches in 1 xrt.run)multi_launch_builder/lm_head_gemv_multi.py
K cache write (prefill, with transpose)numpy slice assign on host (B4 layout mismatch #2)llama32_1b_inference.py:401
K cache write (decode)numpy slice assign on host inside run_decode_blockllama32_1b_decode.py
Decode attentionCPU (numpy) — single-query GQA against the cache slicellama32_1b_decode.py:96 decode_attention_cpu
Prefill attentionNPU FlashAttention causal GQA (its own ELF, see B5)flash_attention/kernel_fusion_based/attn_npu2_seqfirst.py
Decode GEMV pre-transposed weightsOne-time CPU pre-transpose at startup (B4 layout mismatch #1)llama32_1b_inference.py:171-197

Part C — Verification

The verification subsystem lives in its own subdirectory (verify/) and is documented end-to-end in VERIFICATION.html. This part is a one-page pointer; treat the companion doc as the source of truth.

What runs

Two entry points, both routed through the parent Makefile and both comparing against HuggingFace transformers in bf16 (same dtype as the NPU — fair fight):

TargetWhat it doesPass/fail?
make verify [MODEL=base|instruct]2 prompts × 32 greedy-decoded tokens (CI gate; use make verify-full for the full 8-prompt sweep). At each step both runners' chosen tokens must appear in the OTHER side's top-5 (k=5). Mirrors vLLM's check_logprobs_close. ~2 min (verify-full: ~6 min).Yes. Exits 1 on any FAIL.
make diagnosis [MODEL=...] [PROMPT="..."]Single prompt, prefill only. Per-layer ffn_out cosine + max_abs (NPU vs HF bf16) for all 16 layers. ~3 min.Informational only. Read the table by hand to localize a regression flagged by verify.

How it stays in sync with production

The verify NPU runner (verify/runners/npu_runner.py) is a thin adapter — it imports and invokes the same prepare_runtime, run_npu_prefill, and run_npu_decode_step functions that make run calls. Any change to the production prefill/decode path is automatically tracked by make verify; there is no parallel maintenance.

Why discrete top-k inclusion (and not continuous correlation)

bf16 ULP noise routinely flips per-step top-1 between two mathematically equivalent implementations, so a corr > 0.99-style threshold either trips on noise or sits so loose that real regressions slip through. Discrete top-k inclusion is the escape: bf16 noise can flip top-1 but rarely displaces a token from the top-5, so the gate distinguishes "drift" from "implementation bug" cleanly. See VERIFICATION.html §3 for the full argument.

CI

The LIT test run_npu2_verify.lit runs make verify MODEL=instruct on the NPU2 self-hosted runner and FileCheck-asserts [verify] PASS. REQUIRES: ryzen_ai_npu2, peano, hf_token — local runs without an HF token skip cleanly.

Part D — Future work

A running list of optimizations and design changes that the current production codebase does NOT do, but that we have identified as worth pursuing — typically because they unlock a new capability (larger models, lower latency) or remove a known scalability bottleneck. Each entry captures the motivation, current behavior, proposed change, and rough impact estimate, so a future contributor can pick one up without re-deriving the context.

Format: impact tag (how much it matters), effort tag (rough engineering size), status tag (idea / scoped / in-progress). This section grows over time as new ideas emerge.

D1. Zero-copy weight loading — eliminate CPU↔BO duplication

Make BO the single physical storage for weights (no second numpy copy)

Impact: HIGH (scaling to larger models) Effort: MEDIUM-LARGE Status: identified, not scoped

Why it matters

The current preload pipeline keeps two or three physical copies of each weight tensor in DDR (see B7 "Subtle point: aren't CPU and NPU sharing the same DDR?"):

For Llama-3.2-1B (~2.5 GB of bf16 weights), the per-layer BO trick (~1 GB resident) plus duplicated numpy/transposed copies puts total memory at ~5-6 GB. This is fine on a 16-32 GB host, but it does NOT scale:

ModelBF16 weightsEstimated total RAM with current scheme (rough)
Llama-3.2-1B (current)~2.5 GB~5-6 GB ✓ fits
Llama-3.2-3B~6.4 GB~13-15 GB (tight on 16 GB host)
Llama-3.1-8B~16 GB~32-40 GB (won't fit on most consumer NPU2 systems)
Llama-3.3-70B~140 GB— (impossible without zero-copy)

Memory will become the bottleneck once we move beyond 1-3 B-parameter models. Solving this is a prerequisite for larger model deployment, not a nice-to-have.

Current behavior (what we want to change)

From preload_prefill_weights via cache.load_and_run with static_input_indices:

# Three physical copies in DDR for each weight tensor:
weights.layers[5].wq                      # 1) HuggingFace numpy, ~14 MB pageable
lw._wq_t = np.ascontiguousarray(           # 2) transposed numpy, ~14 MB pageable
    lw.wq.astype(bfloat16)
        .reshape(emb_dim, emb_dim).T
)
memcpy(bo.map(), lw._wq_t)              # 3) XRT BO, ~14 MB pinned
bo.sync(TO_DEVICE)

Proposed change

Use np.frombuffer(bo.map(), ...) to make the BO the only physical storage; numpy is just a view onto it:

# Allocate the destination BO first
bo = xrt.bo(device, weight_size_bytes)

# Construct a numpy view that points INTO the BO's pinned region
weight_view = np.frombuffer(
    bo.map(), dtype=bfloat16, count=weight_n_elements
).reshape(out_dim, in_dim)

# safetensors loader writes directly into the BO via the numpy view
load_safetensors_layer_into(weight_view, layer_idx, "wq")
bo.sync(TO_DEVICE)
# NO memcpy. NO second copy. The BO IS the weight storage.

Engineering cost (why it hasn't been done yet)

  1. safetensors loader needs a "load into existing buffer" API. Today the loader returns a fresh numpy array — caller can't supply the destination buffer. This requires either a custom safetensors reader (~200 LOC) or a pre-allocate-then-copy step that defeats the purpose.
  2. Transpose problem. The B4 weight pre-transpose materializes a NEW array (.T.ascontiguousarray()). For zero-copy to work end-to-end, the transposed result must land directly in the destination BO too. Either:
    • Allocate two BOs per weight (original + transposed), let the transpose write into BO #2, then free BO #1 — but at this point you've used 2× BO memory transiently and have a refcount-management problem
    • Have the safetensors loader perform the transpose during load (read in transposed order from the file format) — requires understanding safetensors' chunk layout
  3. Verify subsystem dependency. verify/runners/npu_runner.py calls prepare_runtime + run_npu_prefill + run_npu_decode_step with the production LlamaWeights object — the same one this BO-aliasing scheme would mutate. If a weight tensor switches from a numpy array to a bf16 BO view mid-call, both verify (HF-bf16 reference, dtype-agnostic) and diagnosis (per-layer ffn_out cosine) need to keep producing the same numbers. Audit the Hf-comparison path before flipping the storage.
  4. BO lifetime + GC. If a numpy view holds a reference to bo.map() but the bo Python object is GC'd, the view becomes a dangling pointer. Need explicit owner-tracking (e.g. attach the BO as an attribute of the numpy view, or maintain a parallel _bo_keepalive list).
  5. Multi-consumer weights. weights.lm_head is sliced into 8 partitions for the LM Head GEMV. If the source is a BO view, all 8 partition views must coexist without anyone freeing the underlying BO.

Estimated impact

SavesAmount
One-time preload memcpy time~200-300 ms (currently amortized; not in critical path)
Pageable RAM (numpy original)~2.5 GB for 1B model, scales with model size
Pageable RAM (transposed copy)~1.3 GB extra (decode-side weights only — prefill GEMM uses original layout)
Total RAM saving for 1B~3.8 GB → roughly halves total memory footprint
UnlocksLlama-8B+ on consumer NPU2 hardware that today can't fit those models

Suggested approach when scoped

  1. Start with a tiny PoC: pick ONE weight tensor (e.g., layer 0's wq), implement the BO-allocate-then-numpy-view path, confirm bit-exact outputs vs. current path on the verify gate.
  2. Extend to all weights for ONE layer; profile real RAM footprint to confirm savings.
  3. Solve the transpose problem (likely: load safetensors in transposed order rather than transpose after).
  4. Roll out across all 16 layers; deprecate the numpy weight reference path; add a flag to fall back for verify.
  5. Validate on 3B model as a stretch test before committing to 8B-class ambitions.

Background discussion: the trade-off and the pinned-vs-pageable subtlety are documented in B7. The reason "shared DDR" doesn't make this problem go away on its own is also there.

D2. Cross-ELF BO aliasing — eliminate inter-ELF host round-trips

Wire producer-output BOs directly to consumer-input BOs across separate xrt.run() calls

Impact: LOW-MEDIUM (~3% prefill, ~0% decode) Effort: MEDIUM Status: validated in development, not in production

Why it matters

As documented in B5 "Intra-ELF vs inter-ELF intermediate flow", production currently routes intermediates between separate ELFs (e.g. rms_gemms_ropeflash_attno_ffn) through the host: producer output is sync'd to host, then memcpy'd + sync'd back into the consumer's input BO. This adds up to ~640 MB host↔device round-trip per prefill pass — about 3% of the 1.13 s prefill wall time. Decode is unaffected (intermediates are KB-scale).

Multi-launch ELF stitching (B5 / Gap #2) eliminates this for sub-launches inside one ELF, but FlashAttention is un-mergeable into the surrounding kernel-groups (compiler pass complexity), so prefill stays as 3 separate ELFs per layer with host-broker round-trips between them. Cross-ELF BO aliasing is the technique that recovers that 3% without merging the ELFs.

Current behavior (what we want to change)

From cells/multi_layer.py / production prefill loop:

for L in range(16):
    rg_out = run_rms_gemms_rope(cache, layer_in, layer_idx=L)
    # rg_out["q_roped"] is a numpy view onto host RAM — sync(FROM_DEVICE) just happened

    q_roped_2d = rg_out["q_roped"].reshape(seq, emb)         # free metadata reshape
    k_roped_2d = rg_out["k_roped"].reshape(seq, kv)
    v_2d = rg_out["v"].reshape(seq, kv)

    fa_out = run_flash_attn(cache, q_roped_2d, k_roped_2d, v_2d, layer_idx=L)
    # ↑ entering FA: memcpy host numpy → FA's BO + sync(TO_DEVICE)
    #   Same data that just left rms_gemms_rope's output BO is now duplicated in FA's input BO

Proposed change — alias the BOs explicitly

Use the same _share_bo helper already validated in development:

# During preload, after both ELFs have allocated their BOs:
_share_bo(cache,
    f"rms_gemms_rope_L{L}", slot=11,        # producer's q_roped output BO
    f"flash_attn_L{L}",       slot=0,         # consumer's Q input BO — now points at same DDR
)
_share_bo(cache, f"rms_gemms_rope_L{L}", 12, f"flash_attn_L{L}", 1)   # K
_share_bo(cache, f"rms_gemms_rope_L{L}",  8, f"flash_attn_L{L}", 2)   # V
_share_bo(cache, f"flash_attn_L{L}", 3, f"o_ffn_L{L}", 0)               # attn_out

# During timed inference, mark these slots intermediate so KernelCache skips host I/O:
fa_out = cache.load_and_run("flash_attn", FA_BACKEND, ...,
    intermediate_indices={0, 1, 2, 3},          # Q, K, V (in), attn_out (out)
    # NO output_indices for attn_out — it stays on device for o_ffn
)

How much can actually be saved

Not all inter-ELF transfers can be 100% eliminated, because the host still needs SOME of them for non-NPU work:

TransferCan fully alias?Reason
Q (rms_gemms_rope → FA)✅ YesHost never touches Q during prefill
K (rms_gemms_rope → FA)⚠️ PartialFA reads it, AND host needs to sync(FROM_DEVICE) + transpose to write KV cache (B4 mismatch #2). Save the host→FA write only
V (rms_gemms_rope → FA)⚠️ PartialSame as K
attn_out (FA → o_ffn)✅ YesHost never touches attn_out
o_ffn output → next layer's rms_gemms_rope's x_in✅ YesPure layer-to-layer activation pass

Best-case saving: drop ~640 MB / pass to ~150 MB / pass (KV cache extraction still needs the device→host read). Wall-time saving: from ~3% to ~0.7% — recovering ~25 ms of the prefill.

Engineering cost (why it hasn't been done yet)

  1. Manual BO graph maintenance. Every cross-ELF data flow requires an explicit _share_bo wiring call during preload. For 16 layers × 4-5 cross-ELF edges, that's ~70 wiring lines that must stay synchronized with the kernel-group load_and_run argument layouts. If a layout changes, every aliasing line has to be audited.
  2. Shape mismatch between producer and consumer. rms_gemms_rope emits 1D flat arrays (q_roped[seq*emb]); FA expects 2D (seq, emb). Today the host does the metadata-only reshape between them. With aliasing the host is no longer in the loop — the shape conversion has to happen on the MLIR side via memref.expand_shape at the FA entry, which means modifying FA's kernel signature or wrapping its launch.
  3. KV cache write coordination. K and V are needed by both the FA (consumer) and the host (KV cache writer). Aliasing means both read from the same BO. The host's sync(FROM_DEVICE) must happen at the right moment — after the producer has finished writing but before/during FA reading. Currently the host-broker pattern enforces this naturally; with aliasing it needs explicit ordering.
  4. FA's internal BO reuse. FlashAttention is un-mergeable partly because of how it uses air.channels and many internal sub-buffers. Aliasing its input BOs needs to verify that FA doesn't internally reuse those slots in a way that would corrupt the producer's data mid-execution.

Estimated impact

SavesAmount
Inter-ELF host↔device round-trip per prefill pass~640 MB → ~150 MB (factor 4× reduction)
Wall time per prefill pass~25 ms (~2.3% of 1.13 s)
Wall time per decode token< 1 ms (negligible — intermediates are KB-scale in decode)
Doesn't change anything forDecode performance, model size scaling, code complexity tradeoffs

Suggested approach when scoped

  1. Start with the easiest edge: alias attn_out (FA → o_ffn). It has no host consumer, so it's a clean win.
  2. Validate output vs. the production path on make verify (top-k token gate) and inspect make diagnosis for unexpected per-layer drift.
  3. Profile to confirm the predicted ~5-10 ms / pass saving is real.
  4. Add Q aliasing next (also no host consumer).
  5. Tackle K/V partial aliasing last — needs the host-readout coordination.
  6. Consider whether the engineering cost is worth ~25 ms / pass at this point. If decode-side or memory-side optimizations (D1) become the priority, this can be deferred indefinitely.

Background: this pattern has been validated in development WITHIN one kernel-group (between separate xrt.run()s of the un-merged baseline). The same _share_bo mechanism would extend to ACROSS kernel-groups in production.

D3. CI: wire up HF_TOKEN so make verify actually runs in CI

The verify gate is shipped but not enforced by CI yet

Impact: MEDIUM (CI cannot catch verify regressions today) Effort: SMALL Status: identified, not done

Why it matters

The whole point of refactoring NpuRunner into a thin adapter over the production prefill/decode functions (VERIFICATION.html) is that any change to production code is automatically tracked by make verify — no parallel maintenance. But that guarantee only pays off if CI actually runs make verify on every PR. Today it does not.

Current behavior

Proposed change

  1. In .github/workflows/buildAndTestRyzenAI.yml, inject HF_TOKEN at the job (or just the lit-test step) level:
    env:
      HF_TOKEN: ${{ secrets.HF_TOKEN }}
  2. In the GitHub repo settings (Settings → Secrets and variables → Actions), add a repository secret named HF_TOKEN with a read token for meta-llama/Llama-3.2-1B-Instruct (and the base model if running the MODEL=base variant in CI). Required on the fork that runs CI; if upstream wants the verify gate too, the same secret needs to be configured there.
  3. (Optional) Cache ~/.cache/huggingface/ in the workflow to avoid re-downloading the 2.5 GB checkpoint on every run. Self-hosted runners typically persist this directory naturally, so this is only needed for ephemeral runners.

What this buys

Every PR runs the 8-prompt × 32-token top-k inclusion gate against HF transformers bf16, end to end through the production prefill + decode kernels. ~4 min added to the existing Ryzen AI CI step. Without it, any regression in run_npu_prefill, run_npu_decode_step, the multi-launch kernel builders, or the external kernels (rope.o, silu_and_mul.o, attn_npu2.o, mv.o, mv_k8192.o) can land if its symptom is “tokens drift outside top-5” rather than a structural breakage caught by other tests.

Risk

Tiny. Adding the env var is one line; missing the secret in the env just keeps the current skip-behavior (the test fails cleanly with “REQUIRES: hf_token” not satisfied, but does not break the rest of CI).

Part E — Reference

E1. Glossary — terms defined in one place

Buffer Object (BO)
An XRT abstraction for a chunk of NPU-accessible memory (in DDR — the same physical RAM the host sees, but with NPU access permissions). Created by xrt.bo(device, size_bytes). Has .map() (returns a host pointer for memcpy) and .sync(direction) (cache flush + barrier). One BO per kernel argument. "Allocating a BO" is cheap; "syncing a BO" is what costs time.
Per-layer weight BO
A BO that holds the weight tensor for a SPECIFIC layer of the transformer. The trick: KernelCache caches BOs keyed by bo_key. When preload_prefill_weights calls load_and_run(..., bo_key="rms_gemms_rope_L5") with layer 5's wq tensor in slot 3, KernelCache allocates a fresh BO list for that key and writes the weights. Later, when inference does the same call with the same bo_key, KernelCache finds the cached BOs (already on device with the right weights), and static_input_indices={3, ...} tells it to skip writing slot 3 from host. 16 layers × 2 kernels × ~6 weight slots ≈ ~200 cached weight BOs holding ~1 GB of weights resident on device.
Static input indices (static_input_indices)
The set of arg slot indices that hold weights/LUTs (data that doesn't change between calls). On any call after the first for a given bo_key, these slots are skipped by the host write loop in load_and_run. The BO already has the right data from the preload call.
Intermediate indices (intermediate_indices)
The set of arg slot indices that hold buffers the kernel will OVERWRITE — it doesn't matter what's in them on entry. The host doesn't need to initialize them; load_and_run skips writing zeros to these slots (saves a memcpy + sync). For a multi-launch ELF, intermediate slots include both internal handoff buffers (like normed) and the final output (until the host reads it back via output_indices).
Shared intermediate BO
NOT a feature of production code (production uses multi-launch merging instead). A development-only pattern: if you have two SEPARATE xrt.run() calls where call N's output is call N+1's input, you can manually alias call N's output BO into call N+1's input BO (via the _share_bo helper), so the data goes from device to device without a host round-trip. Useful for isolating "BO sharing" from "ELF merging" as separate optimizations during analysis.
Multi-launch ELF
One .elf binary that contains multiple air.launch operations stitched into a single func.func. Invoked by ONE xrt.run() call. The launches execute sequentially within the single XRT submission, with intermediates flowing through DDR (NPU DMA reads/writes) without CPU involvement. Saves XRT dispatch overhead and host orchestration cost.
Sub-launch
One air.launch operation. The 6 sub-launches in rms_gemms_rope.elf are the 6 logical kernels (RMSNorm, Q GEMM, K GEMM, V GEMM, RoPE Q, RoPE K) — each was originally a separate air.launch in its own MLIR module before stitching.
Herd
An AIR dialect concept: a 2D array of NPU compute tiles all running the same kernel code in parallel. E.g., air.herd @h tile(%tx, %ty) in (%sx=8, %sy=4) means an 8×4 grid of tiles. Inside an air.launch, each herd is mapped to physical AIE tiles by the air-place-herds compiler pass.
Segment
An AIR dialect concept above the herd: air.segment represents a partition of the NPU array. The wrapping air.launch { air.segment { air.herd { ... } } } is the canonical AIR program structure. Required so that airrt-to-npu emits airrt.segment_load ops.
aircc / aiecc
Two MLIR-AIR compiler drivers. aircc runs the AIR-dialect passes (dependency analysis, broadcast detection, herd placement, AIR→AIE lowering). aiecc runs the AIE-dialect passes (vectorization, routing, generates per-tile ELFs, packages into the final .elf + .insts.bin).
Peano
The AMD fork of LLVM that targets the AIE2P ISA. Used to compile C++ kernels (rope.cc, silu_and_mul.cc, mv.cc) into per-tile object files that get linked into the AIE ELF.
RoPE LUT
Pre-computed cosine/sine table for Rotary Position Embedding. generate_rope_lut in llama32_1b_weights.py builds an array of shape (max_seq, head_dim) = (2048, 64) in bf16. The first half is cos, second half is sin (concatenated, not interleaved — matches the half-split RoPE convention).
GQA (Grouped Query Attention)
Llama-3.2-1B has 32 Q heads but only 8 KV heads. Each KV head is shared by 4 Q heads. Reduces KV cache size 4× without much quality loss. Implemented in both NPU FA and CPU attention by indexing kv_h = h // group_size.
SwiGLU
The FFN activation used by Llama: SwiGLU(gate, up) = SiLU(gate) * up elementwise. Two GEMMs (gate, up) feed it; one GEMM (down) follows. Compared to GELU, requires 1 extra GEMM but learns better.
RMSNorm
Root-Mean-Square layer normalization: RMSNorm(x, w) = x · rsqrt(mean(x²) + ε) · w. Like LayerNorm but without the mean-subtraction and without a bias parameter. Cheaper and works equally well for transformers.
KV cache
Per-layer cache of K and V tensors at every token position seen so far. During decode, attention reads the entire cache (positions 0..current_pos) but only computes one new K and V (for the new token). Without it, decode would be O(N) per token instead of O(1). See Part A4.
Prefill / Decode
Two operating modes of LLM inference. Prefill: process the whole prompt at once (seq=N), populate KV cache. Decode: process one new token (seq=1), append to KV cache, get next token. Repeated decode generates text. See Part A3.
Padding (in this implementation)
NPU kernels are compiled for fixed shapes. Llama-1B's prefill kernels expect seq=2048. Shorter prompts get padded with EOS tokens up to 2048; the prefill processes all 2048 positions but only the logits at pred_pos = prompt_len - 1 are used. See Part A5.

E2. Reading guide — where to start for specific questions

If you want to understand…Read these in this order
The model itself (math, no NPU) 1. Part A2 of this guide
2. Optionally: the original Llama paper for context
The whole pipeline end-to-end 1. Makefile (entry points)
2. llama32_1b_inference.py — start with main() at the bottom, then build_session, run_once, generate, run_npu_prefill
3. llama32_1b_decode.py:run_decode_block
How weights are loaded and pre-staged 1. llama32_1b_weights.pyload_weights()
2. llama32_1b_inference.py:prepare_runtime (line 129)
3. llama32_1b_inference.py:_preload_decode_weights (line 219)
4. llama32_1b_prefill.py:preload_prefill_weights
How a single ELF gets compiled 1. multi_launch_builder/rms_gemms_rope_multi.py:build_rms_gemms_rope_module (line 193) — the highest-level builder
2. kernel_builder/stitching.py — text manipulation helpers
3. kernel_builder/cache.py:compile_and_cache (line 251)
4. kernel_builder/external_kernels.py — C++ .o compilation
How an ELF gets invoked at runtime 1. kernel_builder/cache.py:load_and_run (line 294) — the central dispatch function
2. Any caller in llama32_1b_inference.py or llama32_1b_decode.py
3. kernel_builder/backend_presets.py — the backend kwargs dicts
How multi-launch merging works 1. kernel_builder/stitching.py in full
2. multi_launch_builder/rms_gemms_rope_multi.py lines 466-481 (the stitch loop)
3. docs/explain.md for the design rationale
Why decode uses CPU attention 1. llama32_1b_decode.py:decode_attention_cpu (line 96)
2. docs/profile.md "Decode Breakdown" section
Performance breakdown / where time goes 1. docs/profile.md top-to-bottom — has all the numbers
2. kernel_builder/cache.py:Profiler class (line 54)
3. Run make profile to see live numbers
How to add a new kernel-group 1. Look at any multi_launch_builder/*_multi.py as a template
2. Need a build_module entry point + sub-builder calls + a stitch loop
3. Add a backend preset to kernel_builder/backend_presets.py
4. Add compile + load_and_run wiring in llama32_1b_inference.py

Quick-reference: which file does what when you grep

If you grep for…Meaningful hits in…
load_and_runcache.py (def), llama32_1b_inference.py + llama32_1b_decode.py + llama32_1b_prefill.py (callers)
bo_keycache.py (cache impl), and every preload/run call in inference scripts
static_input_indicesSame as bo_key + load_and_run
compile_and_cachecache.py (def), llama32_1b_prefill.py:compile_all_kernels, llama32_1b_decode.py:compile_decode_kernels
build_moduleEach multi_launch_builder/*_multi.py file's main entry point
_wrap_ir_in_launchstitching.py (def), used by builders that wrap bare herds
RGR_BACKEND / OGF_BACKEND / LM_GEMV_BACKENDbackend_presets.py (def), and at every call site
output_indicesThe contract document for what the caller wants back from each kernel
k_cache / v_cachellama32_1b_inference.py (allocation + prefill writes) and llama32_1b_decode.py:decode_attention_cpu (reads + appends)
pred_posllama32_1b_inference.py:run_npu_prefill — the "find last real prompt token" logic from Part A5